adafruit / circuitpython

CircuitPython - a Python implementation for teaching coding with microcontrollers
https://circuitpython.org
Other
4.12k stars 1.22k forks source link

Add `synthio.BlockInput` support to `synthio.Note` waveform loop parameters #9788

Closed relic-se closed 2 weeks ago

relic-se commented 2 weeks ago

The following int properties of synthio.Note have been updated to support synthio.BlockInput:

The most common use-case for this feature is controlling PWM/duty-cycle on a square waveform with a synthio.LFO object (see example below), but it could be utilized for other wave-shaping scenarios.

This PR addresses the following issue: https://github.com/adafruit/circuitpython/issues/9780.

import audiobusio
import board
import math
import synthio
import ulab.numpy as np

MIN_DUTY = 10 # percentage
SIZE = 256
VOLUME = 32000
SAMPLE_RATE = 48000

audio = audiobusio.I2SOut(bit_clock=board.GP0, word_select=board.GP1, data=board.GP2)
synth = synthio.Synthesizer(sample_rate=SAMPLE_RATE)
audio.play(synth)

waveform = np.concatenate((
    np.full(size_hi := math.floor(SIZE * MIN_DUTY / 100), VOLUME, dtype=np.int16),
    np.full(size_lo := SIZE - size_hi, -VOLUME, dtype=np.int16)
))

duty_lo = math.ceil(size_hi + size_lo * MIN_DUTY / (100 - MIN_DUTY * 2))
duty_range = (SIZE - duty_lo) / 2
duty_mid = duty_range + duty_lo

note = synthio.Note(
    frequency=110,
    waveform=waveform,
    waveform_loop_end=synthio.LFO(
        scale=duty_range,
        offset=duty_mid,
    ),
)
synth.press(note)

In this PR, I've also included two small bug fixes that I discovered along the way:

relic-se commented 2 weeks ago

In the example I shared originally, the duty cycle does not change linearly and produces an output that is not the best demonstration of controlling a square wave duty cycle through synthio.

I've modified the example to demonstrate controlling duty cycle with LFO in a linear fashion. This uses a "window" technique by controlling both waveform_loop_start and waveform_loop_end in tandem. The loop "window" should always have a size of SIZE // 2 (this would be affected by any drift between the two synthio.LFO objects).

import audiobusio
import board
import synthio
import ulab.numpy as np

SIZE = 256
VOLUME = 32000
SAMPLE_RATE = 48000
RATE = 0.1

audio = audiobusio.I2SOut(bit_clock=board.GP0, word_select=board.GP1, data=board.GP2)
synth = synthio.Synthesizer(sample_rate=SAMPLE_RATE)
audio.play(synth)

waveform = np.concatenate((
    np.full(SIZE // 2, VOLUME, dtype=np.int16),
    np.full(SIZE // 2, -VOLUME, dtype=np.int16)
))

note = synthio.Note(
    frequency=110,
    waveform=waveform,
    waveform_loop_start=synthio.LFO(
        scale=SIZE // 4,
        offset=SIZE // 4,
        rate=RATE,
    ),
    waveform_loop_end=synthio.LFO(
        scale=SIZE // 4,
        offset=SIZE * 3 // 4,
        rate=RATE,
    ),
)
synth.press(note)
jepler commented 2 weeks ago

The test failure occurs because the repr of a note has changed slightly:

-(Note(frequency=830.6076004423605, panning=0.0, amplitude=1.0, bend=0.0, waveform=None, waveform_loop_start=0, waveform_loop_end=16384, envelope=None, filter=None, ring_frequency=0.0, ring_bend=0.0, ring_waveform=None, ring_waveform_loop_start=0, ring_waveform_loop_end=16384),)
+(Note(frequency=830.6076004423605, panning=0.0, amplitude=1.0, bend=0.0, waveform=None, waveform_loop_start=0.0, waveform_loop_end=16384.0, envelope=None, filter=None, ring_frequency=0.0, ring_bend=0.0, ring_waveform=None, ring_waveform_loop_start=0.0, ring_waveform_loop_end=16384.0),)

notice how e.g., the ring_waveform_loop_start now prints as the float 0.0 instead of the int 0.

Do you know how or want to learn how to run the tests locally and update the expected output files? Otherwise, it looks like I could push that change to this branch, let me know if you'd like

relic-se commented 2 weeks ago

I haven't figured out how to build unix build-standard with synthio support just yet, but I'll dive into that soon. I'm assuming your solution is to update the tests to use floats instead rather than attempt to get integers working with BlockInput?

And I'd rather fix the tests first before merging.

jepler commented 2 weeks ago

yes, failed tests stop everything else from building, so it'll need to be fixed.

In ports/unix make VARIANT=coverage test is the basic way to build & run tests that include synthio.

You can make VARIANT=coverage print-failures. Then in the tests directory there's a script endorse.py which can be used to copy the new output file into an expected (".exp") file, or you can do it manually in the shell or however you like to manipulate/rename files.

The third target to know about is make VARIANT=coverage clean-failures which removes anything in the results directory.

relic-se commented 2 weeks ago

Thank you very much for the instruction, @jepler ! I'll tackle that this afternoon and get the PR ready for merging.