Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Duty cycle modulation for synthio Note object (AKA PWM, commonly used on square wave oscillators) #9780

Closed
kevinjwalters opened this issue Nov 1, 2024 · 8 comments

Comments

@kevinjwalters
Copy link

kevinjwalters commented Nov 1, 2024

A common modulation techniques for synthesizers is modulation of the duty cycle on one or more oscillators. A simple example is a triangle wave LFO modulating a square or approximate square wave. It would be useful to have some efficient way of varying the duty cycle on at least a square wave and having that controlled by the usual myriad of synthio options.

Workarounds

I've been doing this by reading the value from an LFO and manually changing the waveform in python code. It's not perfect but better than I thought it would be. I raised it as part of Adafruit Forums: synthio mysteries - high frequency notes, envelope release, LFO mod of square wave duty cycle (PWM) and lpf making noise and @jepler mentioned another technique using ulab to (re)create the arrays using np.where.

I did a quick bit of benchmarking and for a 2048 element waveform (fairly large to get a nice sound on the changing duty cycle) it's actually more efficient to change what's needed in python code than make a whole new array in ulab. So here you can see the python code takes about 1-2ms to just update what needs to be changed in the waveform (for rapid updates) vs 8ms for ulab code. The latter is constant, the former varies with degree of change but the nature of duty cycle modulation is it's highly unlikely to be desirable to make a big change. There's some plus 7-8ms every now and again which I'm guessing is GC. Perhaps surprisingly the python code is much quicker on a (normal 125MHz clocked) Pi Pico WH and won't be allocating any new objects/causing GC.

Adafruit CircuitPython 9.1.4 on 2024-09-17; Cytron EDU PICO W with rp2040
>>>
soft reboot

Auto-reload is on. Simply save files over USB to run them or enter REPL to disable.
code.py output:
DELTA [416, 44, 11, 17, 11, 28, 44]
CP (ms) [51.9104, 3.1128, 1.00708, 1.34278, 1.09863, 2.13623, 4.1809]
ULAB (ms) [7.96509, 14.5569, 14.8926, 7.78198, 14.2822, 13.7024, 13.5193]
DELTA [22, 6, 17, 11, 16, 23, 50]
CP (ms) [1.83105, 0.671396, 1.4038, 2.13622, 1.40381, 2.80761, 4.24195]
ULAB (ms) [8.05664, 14.9841, 13.6719, 6.7444, 14.3738, 13.855, 14.2822]
DELTA [22, 5, 11, 11, 23, 22, 6]
CP (ms) [1.83105, 0.671383, 2.16675, 2.13623, 1.70898, 1.70898, 0.793455]
ULAB (ms) [8.39234, 15.0757, 7.84302, 13.3362, 14.0991, 15.0147, 7.93457]
DELTA [11, 5, 11, 17, 11, 28, 44]
CP (ms) [1.12915, 2.13623, 7.65991, 1.31225, 7.35474, 2.0752, 3.05176]
ULAB (ms) [7.96509, 7.99561, 7.99561, 7.84302, 8.1787, 13.7634, 14.4348]
DELTA [23, 5, 11, 11, 17, 22, 50]
CP (ms) [1.83105, 0.640878, 1.00708, 0.976562, 1.31225, 8.1482, 3.41797]
ULAB (ms) [7.96509, 14.9536, 9.24682, 14.3127, 7.75146, 8.05663, 14.4043]
DELTA [17, 11, 11, 16, 12, 22, 50]
CP (ms) [1.49535, 1.06812, 1.0376, 1.31226, 1.15967, 2.74658, 3.50952]
ULAB (ms) [7.93457, 13.7329, 14.8315, 7.75146, 13.9465, 13.7634, 14.4653]
DELTA [16, 11, 11, 17, 11, 28, 44]
CP (ms) [2.59399, 1.06812, 1.06812, 1.34276, 1.12915, 2.10571, 4.11987]
ULAB (ms) [7.78199, 14.7095, 14.8315, 7.72095, 13.7939, 15.0452, 13.6108]
DELTA [22, 6, 17, 11, 16, 23, 44]
CP (ms) [1.83105, 0.671382, 1.34276, 1.00708, 1.34278, 1.7395, 3.17382]
ULAB (ms) [7.87354, 14.8621, 13.855, 7.75146, 14.2517, 14.7705, 14.3128]
DELTA [22, 11, 11, 17, 11, 22, 6]
CP (ms) [1.77002, 1.12915, 1.43432, 1.34276, 1.06812, 1.64796, 0.762936]
ULAB (ms) [7.87354, 13.3362, 14.3738, 7.65991, 14.2517, 13.7024, 14.5874]
DELTA [17, 11, 11, 17, 11, 27, 45]
CP (ms) [1.49537, 1.15967, 1.34278, 1.37328, 1.12915, 2.07518, 3.14331]
ULAB (ms) [7.78198, 13.2752, 14.4653, 7.65992, 14.1296, 13.5803, 14.6484]

BTW, I gave up using the value from a synthio.LFO object in my code as the update every 256 samples (at 32k = 8ms) was too coarse.

@relic-se
Copy link

relic-se commented Nov 4, 2024

Hi Kevin, I recommend looking into the waveform_loop_start and waveform_loop_end properties of a synthio.Note object (https://docs.circuitpython.org/en/latest/shared-bindings/synthio/index.html#synthio.Note.waveform_loop_start).

Say you want to have a range of 1% to 99% duty cycle, you could generate a square wave array (bigger will provide better detail as you get closer to 99%) to load into waveform. If your array is of 1024 elements, you could get 99% duty cycle with a value of 111 on waveform_loop_end (see set_duty below for calculation) and 1% with a value of 1024.

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

SIZE = 1024
MIN_DUTY = 1 # percentage
VOLUME = 32000

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

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

note = synthio.Note(
    frequency=220,
    waveform=waveform,
)
synth.press(note)

def set_duty(value :int) -> None: # percentage
    value = min(max(value, MIN_DUTY), 100 - MIN_DUTY)
    note.waveform_loop_end = math.ceil(size_high + size_low * value / (100 - MIN_DUTY * 2))

while True:
    for duty in range(100 - MIN_DUTY, MIN_DUTY, -1):
        set_duty(duty)
        time.sleep(0.01)
    for duty in range(MIN_DUTY, 100 - MIN_DUTY):
        set_duty(duty)
        time.sleep(0.01)

Currently, these properties don't support synthio.BlockInput to allow them to be controlled by a synthio.LFO object, but that's something that could be looked into. However, you mention being able to control them more often than every 256 samples.

If you want more granularity than every 8ms, I recommend looking into a DAC that handle sample rates higher than 32khz. I believe the UDA1334 can do 96kHz which would result in LFO updates every 2.6ms.

@jepler
Copy link
Member

jepler commented Nov 4, 2024

Thanks, I'd forgotten about the waveform start/end offsets. This does seem to be a good way to accomplish modulating the duty cycle and I agree that the idea of having them act as BlockInputs is interesting.

I don't think modifying a property such as waveform or waveform_loop_end from Python code can give an effective update rate of more than the number of samples generated at one go, because Python code is not being evaluated while samples are being calculated.

@kevinjwalters
Copy link
Author

kevinjwalters commented Nov 4, 2024

@dcooperdalrymple I'll give it a go. I had thought about ulab slices with moving around a big array as I think they are implemented with views rather than copies.

For LFOs it would be nice to be able to override the default update rate of "every 256 samples". I have been running at 64k for both AudioMixer and Synth with PWM audio out so it's not as coarse as the example I cited. I did notice it so perhaps I started with some code at 16k as I was doing more bassy stuff and then noted the issue.

I haven't yet explored I2S but I'll get there eventually. I see there's a Adafruit I2S Stereo Decoder - UDA1334A Breakout.

@kevinjwalters
Copy link
Author

Is there a way to modify both waveform_loop_start and waveform_loop_end atomically? My instinct is to maintain the same sample length if possible.

I just inspected the intial values and end looks wrong?

>>> len(waveform) ; (note.waveform_loop_start, note.waveform_loop_end)
1024
(0, 16384)

Oh, maybe the waveform_max_length is a static value and doesn't represent the actual waveform length?

>>> note.waveform_loop_end = 512
>>> (note.waveform_loop_start, note.waveform_loop_end)
(0, 512)
>>> note.waveform_loop_end = 1024
>>> (note.waveform_loop_start, note.waveform_loop_end)
(0, 1024)
>>> note.waveform_loop_end = 1025
>>> (note.waveform_loop_start, note.waveform_loop_end)
(0, 1025)
>>> note.waveform_loop_end = 16384
>>> (note.waveform_loop_start, note.waveform_loop_end)
(0, 16384)
>>> note.waveform_loop_end = 16385
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: waveform_loop_end must be 1-16384

@dcooperdalrymple For set_duty() which only modifies end should it be more like this?

def set_duty2(value):
    value = min(max(value, MIN_DUTY), 100 - MIN_DUTY)
    note.waveform_loop_end = round(100 * size_high / value)

@relic-se
Copy link

relic-se commented Nov 4, 2024

Hi Kevin, I realized this morning that I was thinking about the problem wrong. I've added an updated example within the PR I created last night that demonstrates full PWM control in a much better fashion. #9788 (comment)

Essentially, you'll just need a standard 50/50 square waveform and create a "window" of half of the waveform data. Then, you can move that "window" around left or right to get different duty cycles. Here's a terrible visualization of that in paint.

pwm_demo

The PR I shared above also includes synthio.BlockInput support for these values which is working quite well so far.

Without synthio.BlockInput support, it would be more like the following:

def set_duty(value: float) -> None:
    value = min(max(value, 0.01), 0.99) # 0.0 and 1.0 will create silence
    index = int((1.0 - value) * SIZE / 2)
    note.waveform_loop_start = index
    note.waveform_loop_end = index + SIZE // 2

@relic-se
Copy link

relic-se commented Nov 4, 2024

Here's the updated example (without synthio.BlockInput) using the logic above:

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

SIZE = 2048
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 // 2, VOLUME, dtype=np.int16),
    np.full(SIZE // 2, -VOLUME, dtype=np.int16)
))

note = synthio.Note(
    frequency=220,
    waveform=waveform,
)
synth.press(note)

def set_duty(value: float) -> None:
    value = min(max(value, 0.001), 0.999) # 0.0 and 1.0 will create silence
    index = int((1.0 - value) * SIZE / 2)
    note.waveform_loop_start = index
    note.waveform_loop_end = index + SIZE // 2

while True:
    for duty in range(1000, 0, -1):
        set_duty(duty / 1000)
        time.sleep(0.01)
    for duty in range(0, 1000):
        set_duty(duty / 1000)
        time.sleep(0.01)

@relic-se
Copy link

relic-se commented Nov 4, 2024

I just inspected the intial values and end looks wrong?

>>> len(waveform) ; (note.waveform_loop_start, note.waveform_loop_end)
1024
(0, 16384)

Oh, maybe the waveform_max_length is a static value and doesn't represent the actual waveform length?

waveform_loop_end defaults to the maximum allowed waveform size within synthio. When synthio iterates over the waveform data, it will loop around at the maximum index of the waveform data before waveform_loop_end if it is less than that value. Using the maximum allowed length was a way for us to set a default for the uint32_t value that wouldn't affect existing synthio programs which don't utilize this setting.

>>> note.waveform_loop_end = 16385
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: waveform_loop_end must be 1-16384

Waveform data cannot be greater than 16384 int16_t elements in length. This ValueError is appropriate in this case, but if the aforementioned PR is merged, this error message will no longer occur. In the note output loop, it will still cap waveform_loop_end to the maximum index of the waveform data which is limited to 16384.

@relic-se
Copy link

relic-se commented Nov 4, 2024

The PR (#9788) has been merged into the main branch. This feature should be available within the "Absolute Newest" downloads within https://circuitpython.org/board/raspberry_pi_pico/ in case you aren't building the firmware yourself.

I've got one last quick update to the example using synthio.LFO. To avoid any potential drift between the waveform_loop_start and waveform_loop_end, it would be better to share a single synthio.LFO object between them and use synthio.Math to add SIZE // 2 to waveform_loop_end:

note.waveform_loop_start = synthio.LFO(
    scale=len(note.waveform) // 4,
    offset=len(note.waveform) // 4,
)
note.waveform_loop_end = synthio.Math(
    synthio.MathOperation.SUM,
    note.waveform_loop_start,
    len(note.waveform) // 2,
    0.0 # 1.0 by default
)

Here's a gist of the full example: https://gist.github.com/dcooperdalrymple/90ddfcf5ddf87b6cf370b35b1b213331

Unless there's any else do dive into on this topic, I think we're clear to close this issue, @jepler .

@kevinjwalters kevinjwalters changed the title Duty cycle modulation for synthio Note object (AKA PWM for commonly used on square wave oscillators) Duty cycle modulation for synthio Note object (AKA PWM, commonly used on square wave oscillators) Nov 5, 2024
@jepler jepler closed this as completed Nov 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants