-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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
Comments
Hi Kevin, I recommend looking into the 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 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 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. |
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 |
@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. |
Is there a way to modify both I just inspected the intial values and end looks wrong?
Oh, maybe the
@dcooperdalrymple For
|
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. The PR I shared above also includes Without 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 |
Here's the updated example (without 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) |
Waveform data cannot be greater than 16384 |
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 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 . |
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 usingnp.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.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.The text was updated successfully, but these errors were encountered: