#synthio feedback and questions
1 messages Β· Page 1 of 1 (latest)
π
@left glen excellent, thanks, this is great. One idea @floral pulsar had that would be good to discuss is the notion of having an LFO in synthio that could be directed at other parameters for modulation.
So, for example, the depth of tremelo could modulated by a free-running LFO with a particular frequency and waveshape.
I'm less then even a newbie at most of this but I wonder does it make sense to come up with an interface all filters can use so we could feed A->B->C->D and allow people to mix and match and order as they wish?
(spending my weekend deep in YT and other resources now that I'm sucked in)
here's a simple example of introducing an LFO to modulate the volume of a voice
@velvet thunder are you referring to low pass/high pass filters and the like?
Yeah, I've been looking at a few plug in synths and the various options they have and it seems in general a lot is built upon feeding outputs to inputs to get the end effect.
@velvet thunder right on, this is one of the great things about creating and using synths. The signal chain is exposed and re-configurable in varying degrees -- from "very" in modular synths, to "somewhat" in semi-modulars, to "not at all" in synths that only offer preset configurations.
I've started working on a more general LFO concept. So far it's not actually hooked to the audio synthesis, but here's the gist and some testing...
import array
import synthio
import ulab.numpy as np
SAMPLE_SIZE = 1024
VOLUME = 32767
ramp = np.linspace(-VOLUME, VOLUME, SAMPLE_SIZE, endpoint=False, dtype=np.int16)
sine = np.array(
np.sin(np.linspace(0, 2 * np.pi, SAMPLE_SIZE, endpoint=False)) * VOLUME,
dtype=np.int16,
)
l = synthio.LFO(ramp, rate=7, offset=1)
m = synthio.LFO(sine, rate=8, offset=l, scale=8)
n = synthio.LFO(sine, rate=m, offset=-2, scale=l)
lfos = [l, m, n]
for i in range(200):
print(" ".join(str(v) for v in synthio.lfo_tick(*lfos)))
lfo_tick is just for testing. the 3 graph traces are l, m, and n, which are just a nonsense combination. In reality, you'd say note.bend = n, assigning it an LFO directly to bend the pitch of a note.
BlockInput = Union("LFO", float)
class LFO:
"""A low-frequency oscillator
Every `rate` seconds, the output of the LFO cycles through its `waveform`.
The output at any particular moment is ``waveform[idx] * scale + offset``.
Internally, the calculation takes place in fixed point for speed.
`rate`, `offset`, `scale`, and `once` can be changed at run-time.
An LFO only updates if it is actually associated with a playing Note,
including if it is indirectly associated with the Note via an intermediate
LFO.
Using the same LFO as an input to multiple other LFOs or Notes is OK, but
the result if an LFO is tied to multiple Synthtesizer objects is undefined."""
def __init__(
self,
waveform: ReadableBuffer,
*,
rate: BlockInput = 1.0,
scale: BlockInput = 1.0,
offset: BlockInput = 0,
once=False,
):
pass
waveform: Optional[ReadableBuffer]
"""The waveform of this lfo. (read-only, but the values in the buffer may be modified dynamically)"""
rate: BlockInput
"""The rate (in Hz) at which the LFO cycles through its waveform"""
offset: BlockInput
"""An additive value applied to the LFO's output"""
scale: BlockInput
"""An additive value applied to the LFO's output"""
once: bool
"""True if the waveform should stop when it reaches its last output value, false if it should re-start at the beginning of its waveform"""
phase: float
"""The phase of the oscillator, in the range 0 to 1 (read-only)"""
value: float
"""The value of the oscillator (read-only)"""
def retrigger():
"""Reset the LFO's internal index to the start of the waveform. Most useful when it its `once` property is `True`."""
This is great! Multiple thumbs up. And the value property is perfect for letting us do wavetable sweeps or emulating filter sweeps via wave mixing (e.g. waveform[:] = lerp(wave1, wave2, lfo.value) (something I really like doing)
I would love the ability to have a free-running LFO, instead of having its phase reset on note on. This would allow beat-sync'd effects for the LFO that is independent of the cadence of the played notes. (this concept would be useful for all modulators like tremolo & vibrato too) But I can understand if that's not possible.
in the current design, an LFO wouldn't advance unless it's associated with a playing note. this limitation might be lifted. right now no LFOs are reset except on a manual retrigger but they would stop when not attached to a playing note.
(hypotehtically a synthesizer could have a list of "associated LFOs" that you maintain by hand, and they would advance merely by this association)
let me see if I can get the basics to work again first π
It's alive
Question if it isn't to long, what did you use to generate the FIR filter? been reading a lot about them but haven't looked too in detail about creating them yet
one website I found is https://fiiir.com/
sadly their "python script that generates the coefficients" doesn't work on ulab, several functions are missing.
@left glen the LFO demo is great, modulating the modulator is super helpful for keeping things sounding alive, love it
you can even chain an lfo to itself and use it (e.g., l.offset=l), you just can't print() it without an error.
So I dug into the numpy sourcecode and it wasn't missing much. I have only tried this with one example but it matches the site you gave (some small values aren't technically zero but close enough).
import ulab.numpy as np
def sinc(x):
#x = np.asanyarray(x)
y = np.pi * np.where(x == 0, 1.0e-20, x)
return np.sin(y)/y
def blackman(M):
values = np.array([0.0, M])
M = values[1]
if M < 1:
return np.array([], dtype=values.dtype)
if M == 1:
return np.ones(1, dtype=values.dtype)
n = np.arange(1-M, M, 2)
return 0.42 + 0.5*np.cos(np.pi*n/(M-1)) + 0.08*np.cos(2.0*np.pi*n/(M-1))
# Configuration.
fS = 20000 # Sampling rate.
fL = 5000 # Cutoff frequency.
N = 47 # Filter length, must be odd.
# Compute sinc filter.
h = sinc(2 * fL / fS * (np.arange(N) - (N - 1) / 2))
# Apply window.
h *= blackman(N)
# Normalize to get unity gain.
h /= np.sum(h)
print(h.tolist())```
That should (hopefully) let someone calculate FIR filter coefficients at runtime
Just noticed that the high pass/band pass filters have slightly different code but should be pretty easy to make something to handle all cases. I'll take a look at it soon if no one else does before. (If no one thinks this adds value then please let me know too π )
Cool! I was working on something similar, but didn't finish before the day was done
here's my version, with mark's implementation of sinc: https://gist.github.com/jepler/2ad54d4cb80825435d166a44928c6d7e
try:
from ulab import numpy as np
except ImportError:
import numpy as np
def sinc(x):
y = np.pi * np.where(x == 0, 1.0e-20, x)
return np.sin(y)/y
def lpf(fS, f, N):
if not (N & 1):
raise ValueError('filter length must be odd')
h = np.sinc(2 * f / fS * (np.arange(N) - (N - 1) / 2))
h[(N-1)//2] = 1 # repair bad sinc() function
return h * (1/np.sum(h))
def hpf(fS, f, N):
if not (N & 1):
raise ValueError('filter length must be odd')
h = -lpf(fS, f, N)
h[(N - 1) // 2] += 1
return h
def brf(fS, fL, NL, fH, NH):
hlpf = lpf(fS, fL, NL)
hhpf = hpf(fS, fH, NH)
if NH > NL:
h = hhpf
h[(NH-NL) // 2 : (NH-NL) // 2 + NL] += hlpf
else:
h = hlpf
h[(NL-NH) // 2 : (NL-NH) // 2 + NH] += hhpf
return h
def bpf(fS, fL, NL, fH, NH):
hlpf = lpf(fS, fL, NL)
hhpf = hpf(fS, fH, NH)
return np.convolve(hlpf, hhpf)
if __name__ == '__main__':
print("lpf(24000, 2040, 13) # 1920Hz transition window")
print(list(lpf(24000, 2040, 13)))
print("hpf(24000, 9600, 13) # 960Hz transition window")
print(list(hpf(24000, 9600, 23)))
print("bpf(24000, 1200, 11, 3960, 15) # 2400Hz, 1600Hz transition windows")
print(list(bpf(24000, 1200, 11, 3960, 15)))
print("brf(24000, 960, 19, 2400, 13) # 1200, 1800Hz transition windows")
brf_tst = brf(24000, 960, 19, 2400, 13)
print(brf_tst)
print("brf(24000, 960, 13, 2400, 19) # 1200, 1800Hz transition windows")
brf_tst = brf(24000, 960, 13, 2400, 19)
print(brf_tst)
Is there worth in using the Window type functions? I don't know enough to know the value of it. I used blackman just because that was what the filter setting was on.
and I used "rectangular" because it gave the smallest number of coefficients for any given settings
bold suggestion: listen to a rectangular and then a blackman filter and decide for yourself if the difference is important to your ear
I also don't understand the relationship between N and transition bandwidth
Good idea, have to try to hook them up later to listen.
1/32767 is -45dB. The default filter with blackman window is all lower than -45dB past about 0.137Hz (top transition bandwidth nominally 0.140Hz) using 59 coefficients. With rectangular window it only gets down to about -25dB but requires just 13 coefficients.
I haven't really pushed the boundaries as far as how many coefficients are practical; processing time goes up with more coefficients (surprise!) and will eventually begin skipping.
my gist updated to allow calculating blackman window
first is no filter, 2nd is rectangular window 13 coefficients, 3rd is blackman window 59 coefficients. tbh I dont hear much difference between 2 & 3
Maybe a bit in the high end but my work headphones arenβt exactly quality. For a lot of people it may be a trade off based on the microcontroller chosen. I have to try to push the M7 later today
and of course we can overclock the rp2040 now in circuitpython! was wondering how long it took to calculate a filter ... (times in seconds for 100 repetitions) ```py
microcontroller.cpu.frequency = 125000000
t0 = time.monotonic_ns(); _=[mkfilter.brf(48000,4800,17,3600,27) for i in range(100)]; t1= time.monotonic_ns(); (t1-t0) / 1e9 / 100
0.0040625
microcontroller.cpu.frequency = 180000000
t0 = time.monotonic_ns(); _=[mkfilter.brf(48000,4800,17,3600,27) for i in range(100)]; t1= time.monotonic_ns(); (t1-t0) / 1e9 / 100
0.00282227
microcontroller.cpu.frequency = 250000000
t0 = time.monotonic_ns(); _=[mkfilter.brf(48000,4800,17,3600,27) for i in range(100)]; t1= time.monotonic_ns(); (t1-t0) / 1e9 / 100
0.00203125
changes I'll be pushing in a bit just pushed:
Synthesizer.changeis replacingrelease_then_press. The old name will be supported as a compatibility alias for now.changecan accept single notes (e.g.,synth.change(press=37)to start a MIDI note)changehas a newretrigger=kwarg for retriggering LFOs (single LFO or sequence of LFOs)Synthesizer.lfosis a list of LFOs that are advanced regardless of whether they're associated with an active note, so you can now have free-running LFOs as long as you have a playing synthesizer to hang them off of.
hum I thought a synthio.LFO was configurable so as to compute A*B or A+B but now I can only figure out how to compute B-A (waveform=CONSTANT_MINUS_ONE, scale=A, offset=B)
Should I introduce additional kinds of arithmetic nodes, or how is this traditionally done?
Just FYI I did try a 127 tap FIR filter across 12 simultaneous notes on the M7 and noticed zero delay in anything going on. (not sure if the number of notes mattered but figured I'd try).
Nice! The FIR is applied to all filtered notes together, so the number of playing notes has a modest effect
One strange behavior that I cannot attribute yet (maybe my filter coefficients) is once I surpassed around 10Khz the sound came back. It was a low pass filter at 2500Hz with 500Hz transition. Between 3Kz to around 10Khz it worked great. But beyond that back to normal. I'll keep poking at that though.
that's interesting. what sample rate?
It's of course possible (nay, likely) there's an error in my FIR implementation
Sorry was out for a bit. I used sample rate 10K, with 2KHz frequency and 127 coefficients
i hear the same -- the 3rd one cut out more high end than 2nd, they both sound good and useful to me
continuing to play with filters. here's audacity's plot of a square wave sweep no filter (but with envelope); folowed by 13-tap-rectangular, 59-tap-blackman, and last is 59-tap-rectangular.
filter_rectangular = mkfilter.LPF(48000,800, 13)
filter_rectangular_big = mkfilter.LPF(48000,800, 59)
filter_blackman = mkfilter.LPF(48000, 800, 59, win=mkfilter.blackman)
all have some high frequency content, but 13-tap-rectangular has the most. oh and the sweep frequency should have topped out at 3840Hz which is 4x the filter's nominal cut-off frequency
Everything else aside actually seeing the "rectangle" steps in the 13-tap filter is pretty cool.
I was looking briefly to see how that website calculated the number of taps based on transition bandwidth. Would be interesting to specify I want a 1Khz vs 5Khz transition. Though not sure it is useful or not.
the related post suggests "b ~= 4/N" https://tomroelandts.com/articles/how-to-create-a-simple-low-pass-filter#transition-bandwidth
function C4(e, d, c, a) {
if (e === 'Kaiser') {
var g = Math.ceil((c - 8) / (2.285 * 2 * Math.PI * (d / a))) + 1;
if (!(g % 2)) {
g++
}
return g
}
var f;
switch (e) {
case 'Rectangular':
f = 0.91;
break;
case 'Hamming':
f = 3.1;
break;
case 'Blackman':
f = 4.6;
break
}
var g = Math.ceil(f / (d / a));
if (!(g % 2)) {
g++
}```the page actually uses 4.6 for blackman
>>> def nsamples(fS, fB, factor=4.6):
... b = fB/fS
... return round(factor/b) | 1
...
>>> nsamples(48000, 800) # fiiir website uses 277
277
I assume the synthio filter has any detectable filter response at higher frequencies with blackman window, because the filter coefficients have been rounded off
soon there will be a Math block that can perform one of a range of math operations. You can use it everywhere you used an LFO, so for instance you can use a Math block to add together an overall bend value plus a vibrato amount from an LFO. You can also use it for a source of a control value you want to distribute to multiple other blocks.
class MathOperation:
"""Operation for a Math block"""
SUM: "MathOperation"
"""Computes ``a+b+c``. For 2-input sum, set one argument to ``0.0``"""
ADD_SUB: "MathOperation"
"""Computes ``a+b-c``. For 2-input subtraction, set ``b`` to ``0.0``"""
PRODUCT: "MathOperation"
"""Computes ``a*b*c``. For 2-input product, set one argument to ``1.0``"""
MUL_DIV: "MathOperation"
"""Computes ``a*b/c``. If ``c`` is zero, the output is ``1.0``."""
SCALE_OFFSET: "MathOperation"
"""Computes ``(a*b)+c``"""
OFFSET_SCALE: "MathOperation"
"""Computes ``(a+b)*c``. For 2-input multiplication, set ``b`` to 0."""
LERP: "MathOperation"
"""Computes ``a * (1-c) + b * c``."""
CONSTRAINED_LERP: "MathOperation"
"""Computes ``a * (1-c') + b * c'``, where ``c'`` is constrained to be between ``0.0`` and ``1.0``."""
DIV_ADD: "MathOperation"
"""Computes ``a/b+c``. If ``b`` is zero, the output is ``c``."""
ADD_DIV: "MathOperation"
"""Computes ``(a+b)/c``. For 2-input product, set ``b`` to ``0.0``."""
MID: "MathOperation"
"""Returns the middle of the 3 input values"""
MAX: "MathOperation"
"""Returns the biggest of the 3 input values"""
MIN: "MathOperation"
"""Returns the smallest of the 3 input values"""
ABS: "MathOperation"
"""Returns the absolute value of ``a``"""
class Math:
"""An arithmetic block
Performs an arithmetic operation on up to 3 inputs. See the
documentation of ``MathOperation`` for the specific functions available.
The properties can all be changed at run-time.
An Math only updates if it is actually associated with a playing `Synthesizer`,
including indirectly via a `Note` or another intermediate Math.
Using the same Math as an input to multiple other Maths or Notes is OK, but
the result if an Math is tied to multiple Synthtesizer objects is undefined.
In the current implementation, Maths are updated every 256 samples. This
should be considered an implementation detail.
"""
def __init__(
self,
operation: MathOperation,
a: BlockInput,
b: BlockInput = 0.0,
c: BlockInput = 1.0,
):
pass
a: BlockInput
"""The first input to the operation"""
b: BlockInput
"""The second input to the operation"""
c: BlockInput
"""The third input to the operation"""
operation: MathFunction
"""The function to compute"""
value: float
"""The value of the oscillator (read-only)"""
and this also opens the field for more types of blocks with less work. not that I know other kinds of blocks I want to make right now.
integrator?
this is pretty cool! An integrator could be fun. In some synths they call it a "lag processor" (lol!) and it was often used to take a stepped random value (i.e. "sample & hold") and smooth out the transitions between the random values
I am very happy lerp() is a base operation. I've been copying my lerp function around into almost all my Python & Arduino programs
I've not done much with FIR filters for synths. Usually I do IIR style filters because the memory requirements are lower. Like, my homemade filters have looked a lot like the bottom of this page: https://basicsynth.com/index.php?page=filters
So I guess my question is: how do I make an adjustable frequency FIR filter so I can do filter sweeps?
I don't know that there's an effective way to create frequency filter sweeps with FIR filters.
Initially I drew an incorrect conclusion that IIRs couldn't do high pass but based on that page it seems they can. If they can do high, low, band, and notch then maybe it makes sense to change..
You would have to recalculate the coefficients every time you change filters. Not 100% sure how that changes the internals.
yeah, if it's glitchless (and recalc is fairly cheap) then it doesn't really matter
also, the topic of "synthesis filters" is so varied and crazy that I think there's no one right way
a big deal when the "virtual analog" synths were coming out in the 90s was that they had DSP algorithms that emulated the wonky filter circuits from cherished 70s synths. So not IIR nor FIR but something else entirely
Managed to load a raw audio waveform into synthio without much trouble:
with open('bass2.raw','rb') as file:
raw = file.read()
bass = np.zeros(len(raw)//2, dtype=np.int16)
for i in range(0,len(raw),2):
bass[i//2] = raw[i+1] << 8 | raw[i]
raw = None # free memory```
oh in case it didn't get mentioned here, https://github.com/adafruit/Adafruit_CircuitPython_wave reads .wav files
the memoryview().cast() technique might help get the result you need for raw files too, with less code
Yeah that's where my lack of python experience shows, I'll have to look at it. I knew it was messy
Yup: bass = np.array(memoryview(raw).cast('h'), dtype=np.int16). Thanks I knew it could be cleaner
I've tested a fair amount of the new PR functionality but more on a "does it work" basis, I'm not an expert on how to use it to sound good. I know it is in draft I can go through the code more thoroughly when it's good to go
did you have some success with drum sounds as synthio notes? I'm wondering if that needs any special support.
appreciate any and all testing. just knowing you're not getting crashes or ear-splitting bad sounds is a help. or at least not ear-spitting bad sounds you think are attributable to bugs in synthio
I had a bass sound going well. Trying a piano note now. Haven't tried a drum sound. Wondering if just playing it as a wav file is best
Yeah I've had a few of the later sounds π
playing as a wav file is good if you don't want to read the whole thing into RAM, but of course you can't change the pitch..
or pan it, if you have stereo output
shoot if we can pipe WAVs into synthio, next thing may be to support arbitrary loop points in WAV, and then we're a quick jump to using the huge number of SoundFonts
I don't want to get into WAVs that don't fit within RAM but mmmmaybe adding arbitrary loop points would be doable
we are trying to wrap up the major synthio work soon so this might be a contribution for someone else to make
yes
agreed
I finally got time to play with synthio.LFOs. Very fun! Question: is there a way to run an LFO without connecting it to a synth parameter? Right now I'm connecting it to ring_bend with ring_freq=1 so I can use the LFO as a visual indicator to let you adjust it before bringing it in to the sound, like this:
note = synthio.Note( frequency=110 ) # 110 Hz = A2
wave_sine = np.array(np.sin(np.linspace(0, 2*np.pi, SAMPLE_SIZE, endpoint=False)) * SAMPLE_VOLUME, dtype=np.int16)
lfo1 = synthio.LFO(rate=0.3, waveform=wave_sine)
note.ring_frequency = 1 # inadible
note.ring_bend = lfo1 # now we can get LFO values
synth.press( (note,) ) # turn on the note and the LFO
led = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=1.0) # visual indicator
while True:
print("lfo", lfo1.value, lfo1.phase)
led.fill( (map_range(lfo1.value, -1,1, 0,255), 0,0 ) )
time.sleep(0.05)
yes, Synthesizer has this property:
lfos: List[LFO]
"""A list of LFOs to advance whether or not they are associated with a playing note.
This can be used to implement 'free-running' LFOs. LFOs associated with playing notes are advanced whether or not they are in this list.
This attribute is read-only but its contents may be modified by e.g., calling append() or remove(). It is initially an empty list."""
(but the synthesizer itself has to be playing)
ahh!
hmm, I do not see that in #7985. Am I on the wrong PR?
oh I see, it's only constructed after the synth is running. nevermind!
hum which has the effect that it's not shown in the repr of a synthesizer
However, I am unclear how to use it. This prints an empty list:
note = synthio.Note( frequency=110 ) # 110 Hz = A2
lfo1 = synthio.LFO(rate=0.3, waveform=wave_sine)
synth.press( (note,) )
while True:
print(synth.lfos)
time.sleep(0.05)
synth.lfos.append(lfo1)
ahah!
AttributeError: 'Synthesizer' object has no attribute 'append' π¦ no wait I am dumb
(oh a synthesizer doesn't have a useful repr anyay)
yeah no problem there. but I do like tabbing through in the REPL to see the properties of an object
and yes, synth.lfos.append(lfo1) works as expected
one thing to be aware of is if you ever don't need lfo1 again you have to manually remove it from synth.lfos
right
I'm just going to keep adding LFOs and never remove them. I also never reboot my linux server box. 1035 days uptime!
This attribute is read-only but its contents may be modified by e.g., calling `synth.lfos.append()` or `synth.lfos.remove()`. It is initially an empty list.
would this be better in the docs?
or lfos.append()?
hmmm. this is where my inexperience with "the Python way" is a hindrance. It is not immediately obvious that synth.lfos is a list I can modify. So either that paragraph you have above or a small example (synth.lfos.append(synth.LFO(rate=1)); while True: print(synth.lfos[0].value)) would be good
you joke but knowing when you can remove one is the trick
true
at some point we'll start doing synthio guides and that'll have more examples
and it is okay to do?
lfo1 = synthio.LFO(rate=1)
note = synthio.Note(110)
synth.lfos.append(lfo1)
note.amplitude = lfo1
you mean, can an lfo be associated with a note AND in the LFOs list? Yes. It could be associated with 12 notes, or indirectly 3 times -- it'll still advance at 1Hz
if not it's a bug
I'm going to head out, but I'll read back if you have any other questions or suggestions .. hope you'll do another video!
yes! working on some hardware to let me stretch out a bit more. Will also be doing a MacroPad RP2040 demo or two using my MacroPadSynthPlug (if you or anyone on this thread wants one, send me a DM with a shipping address and I'll mail it off)
π
No promises but if there is a list/issue with these I would take a look at them. I've been poking through the code as the PRs come in so have a vague idea how its set up.
I don't really have a master list of stuff to potentially do
@floral pulsar A couple of specific requests, depending on the time you can devote to it:
- if any of the naming is bad, tell me and we can come up with better naming together. secondarily, the same for the docs, any huge omissions that would be good to rectify.
- It would be great if you would collaborate with me on a guide for synthio. if it helps, there is budget to pay an external author. I could write the dry "here are the base capabilities" parts and you could write "here are some ways to achieve common effects or evoke sounds" to help people with a synth background learn to translate their ideas into synthio. a basic 'live midi to synthio synthesizer' program would be cool too. This would be separate from any project guides using synthio, which might happen in the future.
- identifying any items related to synthio that could go in an adafruit-maintained or community-maintained library and/or contributing to such a library
8.1.0 will likely go out WITHOUT the current PR in it, which will go in 8.1.1 or 8.2.0 (the next release)
hopefully after the current PR we can label the API as stable
- Will do. I'll give it a look over tomorrow and give responses by Tues end. (today is mostly non-CirPy meetings for me) So far it makes sense with the APIs I've used. I've not yet touched filters as that doesn't have an immediately recognizable of an API like
synthio.setFilter(LOWPASS, frequency=1234, q=1.3) - Yes, I would love to help out. I was already thinking of making a "synthio tricks" repo showing common setups & techniques & gotchas. I think your tests in
tests/circuitpython/synth*&tests/circuitpython-manual/synthioare very interesting. I've been cribbing from them. Maybe collate them into a "reference" section at the back of the guide? - Synthio already has so much! A month ago I was thinking "audio rate" stuff would live in synthio and anything "control rate" would be user libraries. But some control-rate like LFOs & envelopes really straddle that arbitrary distinction, so I am glad they are in the core.
One user-level library could be a version of envelopes that allows more complicated & looping envelopes. (and may just do clever twiddling of synthio.LFO)
If thesynthio.filterAPI stays the way it is, another good user library would be a translator from filterType/cutoff/resonance -> FIR coeffs
I have a filtering library but it's not "cooked" yet. it's awful, in fact. also from what I've ready, adjusting FIR parameters in realtime is unsatisfactory. https://gist.github.com/jepler/2ad54d4cb80825435d166a44928c6d7e
the reason for having LFOs in the core is so that they can continue advancing at the right rate no matter what 'blocking' things a user might do with the rest of their circuitpython code (right up until you overload the CPU and it can't keep up no matter what)
Here's my notes on the API thus far. I have been having so much fun with it.
well darn discord markdown renderer is weirder than github's
@floral pulsar Is 4.75V = A4 a 'standard'?
kinda? it seems not well defined what the "base note" is. but I believe integer voltages are C (e.g. 0V = C2, 1V = C3, 3V = C4, etc)
the people to ask about this may be @cobalt lance or Stargirl
I know I am forever having to tune various oscillators in my rack to get them all on the same note, but once that's done, the 1V/Oct signals keeps them running in parallel, melody-wise
oh it's a simple bug
it seemed like it π
Iβve always referenced this table: http://notebook.zoeblade.com/Pitches.html
>>> voct_to_hz(5)
261.625
>>> voct_to_hz(4.75)
219.9995246407531
```but now it's off by 2 octaves from that table..
oh yeah! I had that bookmarked I see. I shoulda used it. thanks!
everything is off by octaves in eurorack lol
(261.625, 261.625)
>>> synthio.midi_to_hz(61), synthio.voct_to_hz(2.0833)
(277.1820320617506, 277.1756278709341)
nice
- Attaching an LFO to
bendseems to disable ringmod. Is this intended?
Can you enter a bug with this? My testing tried to cover each thing in isolation but not always everything in combination. there are lots of combinations.
Sorta unrelated adafruit/circuitpython process question for you: if something like this change to voct_to_hz(), which presumably is just two or so lines of functionality and then a few files to change for name refactoring, would you create a PR, submit it, get it merged, or would you commit straight to main?
Yes, I will make a test case and submit it
It'd go through the PR process, everything does
right, cool thanks!
what'd be the purpose of adding a restriction to bend range? Would a MID math operation let you do that in a more general way?
okay maybe I am thinking too literally of mapping synth config to synthio. Synths typically have a parameter that is "bend range" for a patch, that defaults to +/- one octave but lots of people change it to +/- two semitones because bending a whole octave is only for the heavy metalers of the world. but yes, since we can tune the value sent into .bend we can just do that. And if we need more than that, just adjust .frequency
so nix that one
I think making frequency (&ring_frequency) into BlockInputs will be too much work to dash out right away, so please feel free to file a feature request issue about that. It makes me wonder if the "bend"s should go away to be replaced by a BEND math op instead...
I was pretty sure +-12 octaves of bending was unreasonable but I worried +-1 octave wasn't enough. what do I know.
hahaha yeah there is no right answer
synthio.Note.frequency is noted as BlockInput in the RTD parameter list but not in the constructor
yeah I'll fix the docs to reflect float in both places
similarly, an integrator (whether it's a Math or a third kind of block) will need to be a feature request
@floral pulsar similarly for changing filters away from the FIR approach to something else, and especially for making them per-note rather than synth-wide, it'll need to be a feature request.
totally
I figured many of my suggestions would be unimplementable or future feature request
I appreciate the detailed feedback! I'm happy there were some very easy and actionable items. The feature requests are very specific and actionable too, it seems quite possible that other folks could pick up and implement some of those things.
I'll leave envelope & waveform as properties on the synthesizer for now; they do have the advantage of making some code simpler, when all the sounds it produces are the same. but it's also a leftover from when a note was just a number... and that was barbaric
synthesizer.press(60)
I was wondering if that was why. It makes it very quick to make a "simple tune player" sort of thing
It also relates to the evolution -- synthio was contributed by a community member, to simply play canned MIDI style tunes .. and it evolved by steps after I started working on it in march
so much!
the waveform was square, there was no envelope, only 2 notes were supported, etc. It was deliberately minimal.
"the waveform was square" will now be on a tshirt with a little _|-|_|-|_ graphic
half tempted to riff on creation myth at this point, but I'd end up making myself g-d so that's not cool
π
I think I am wrong. note.bend=lfo works with ringmod in a simple test case. I will take apart the program that had the problem to see what I was doing wrong
oh btw @left glen thank you for making midi_to_hz() take a float too. It's handy
perhaps someday a person will be able to play https://www.youtube.com/watch?v=sxbporF6GCw on circuitpython
From They Might Be Giantsβ album My Murdered Remains. Get it direct from the band http://bit.ly/2EiIG60
Heβs the kind of dog
when presented with his heartβs desire
will turn his nose away
Heβs the kind of dog
who sees a smiling friend and says
βI need no smiling friend todayβ
Heβs a dog who made his money
selling troubled assetsΒ
Now he s...
Composed using a set of "meticulously retuned piano samples", with about 31 tones to the octave, according to John Linnell
Going to probably need more memory for that! π
@floral pulsar I have to still look over your notes (pun now intended!) but if there are some features you'd like first let me know, I wouldn't mind taking a run at a couple improvements if Jeff has to move onto other priorities. Always easier to learn how the code works doing something worthwhile at the same time.
Thanks! I'll have to think about that. And figure out more out to learn synthio
but two things I am very interested in: 1. LFO or Envelope-based wavemixing 2. real-time adjustable filter
if you have two Notes that start in the same change() call and they have the same frequency & bend, they should match forever. so you could use the two notes amplitude inputs (one via a math block configured to compute 1-x) to fade between different waves.
filters I do not have an easy answer for.
I wonder if any radio projects have a solution for filters. Same basic problem just different frequencies. (More a thought to myself on somewhere to look). The delay to recompute may just be okay in radio though
For wave mixing you mean for example having 33% saw and 66% sine and changing those percentages via an LFO or similar?
Exactly so! We can approximate some types of filter movements by mixing between a high-harmonic waveform and a filtered version of that waveform. (e.g. in a certain sense, a sine wave is just a heavily filtered saw wave). So to approximate a filter envelope I have been doing things like:
waveform[:] = lerp( wave_sine, wave_saw, lfo_filter.value )
It would be neat if the was some built-in feature for wave mixing. Not sure what form that would take though
And if anyone would like to have a go at doing simple IIR/delayline filters, here's some example pseudo code showing how few lines it can be: https://beammyselfintothefuture.wordpress.com/2015/02/16/simple-c-code-for-resonant-lpf-hpf-filters-and-high-low-shelving-eqs/ If I have some time next week I may try playing with this in the CirPy core myself
I was looking at Pitch Envelopes (in general) and was wondering is this a feature worth having or could it be done just using the Bend property to change frequency with an LFO to that? (leaning to thinking yes?)
I think between the very complete Bend stuff + LFOs you could do a lot (remember, LFOs essentially become envelopes because you can specify their waveform and told to play only once)
[1982] http://www.bitsavers.org/pdf/computerFaire/SiliconGulchGazette/29b_Mar82.pdf
Playing back 12-bit 25kHz audio from a floppy in the 80s?
[1977, already doing synthesis with envelope!]
From the bitsavers.org collection, a scanned-in computer-related document.computerFaire :: Proceedings of the First West Coast Computer Faire 1977
I was just joking today with JP that we put that floppy featherwing on a tripler to let us save synthio patches π
Anyone had any thoughts on the FIR filters per note? Not sure if it would be too intensive (or have tried it yet) but playing about making a drum kit and wondering.
Probably switch to a different filter algorithm entirely, one that can be tuned or swept. FIR doesn't seem to be amenable to changing dynamically like that from my readings
Makes sense. The filter really makes a difference getting a good kick drum sound but it is fairly low frequency. So then a high hat would be lost
I wasn't going to work on it, but I did. https://github.com/adafruit/circuitpython/pull/8048
Not tested on hardware yet, but gives plausible results for the new manual host computer test "synthio/note/biquad.py"
My main reference has been https://www.w3.org/TR/audio-eq-cookbook/
...
weird, it worked a minute ago π
ok I made a bug when fixing it to do stereo filter.
This audio file has a bunch of simple scenarios inside it:
- frequency-swept sine wave tests
- unfiltered
- lpf, cutoff #1
- lpf, cutoff #2
- hpf, cutoff #1
- hpf, cutoff #2
- bpf, cutoff #1
- bpf, cutoff #2
- frequency-swept square wave, same 7 scenarios as above
- frequency-swept noise wave, same 7 scenarios as above
- frequency-swept filter tests
- static frequency sine wave
- static frequency square wave
- static frequency noise wave
Dang beat me to it! (But it actually got done, which I probably would not be able to do). Testing this later today!
Awesome. I'll hopefully get a chance to test it out tomorrow.
there seem to be some new pops at the start/end of a note. especially of notes that are all super attenuated by the filter.
I'm suspecting there's a problem around the initialization of the history values, called "x" and "y". but it could also be an arithmetic problem with fixed point math as well.
oh and a bug that stops synthesizing all the notes but one
I still don't understand why there's a pop-pop-pop at the start of an 'almost fully filtered' note.
zero-initializing the history values must be wrong, but then what is right?
Does anything show up that may be a clue if you look at the sounds waveform in audacity?
the result of my biquad filtering has a DC offset! ouch.
so when you get out to the end the note is filtered but the DC offset remains so you hear pops
there are still some artifacts at the end; I use an envelope that goes instantly to 0, so maybe it's expected.
zero-time envelope rates are a common cause of clicks/pops in other synths, if that helps.
but the DC offset sounds like the real culprit
for me the big pops at the start are gone but small pops at the end remain.
I am having issues with the bandpass filter not working.
bpf = synth.band_pass_filter(100,10000)
note1 = synthio.Note(frequency=200, waveform=sinwave1, filter=bpf)
synth.press(note1)```
I didn't try the band pass filter so it may well be wrong
On a KB RP2040
does low pass work?
Low and high work
I probably copied the equations wrong. do you mind checking the source against https://www.w3.org/TR/audio-eq-cookbook/ ?
I thought my coefficients matched https://arachnoid.com/BiQuadDesigner/ but I might have missed a + / - or something
A rewritten Java/JavaScript biquadratic filter designer.
I'll double check it, no problem. May be later tonight.
I didn't notice any real pops on my kick drum sound I was working on with a LPF but it was meant to slightly "pop" at the start even without
this is a pop at an end of an almost entirely filtered note. 2nd image is a pop at the end of a not almost entirely filtered note
Ahhh nm I see what I did. I was assuming the two arguments were the bottom/top ends of the filter. Not the middle/Q. So I was giving it impossible values.
unfiltered versions have visible steps, because envelope is only calculated at what is analagous to webaudio "k-rate", 256 samples I think in the case of circuitpython
but that ending linear slide followed by a cut to 0 when the note truly stops is .. weird
I had a highly filtered note and saw the following at the end:
Almost like the filter stopped but it kept playing the sound for a fraction
there's clearly something .. interesting .. happening when a note releases.
visually it's much nicer when I filter then apply envelope & panning.. which makes sense if it's the step changes due to envelope that make the end wonky
haven't listened to it yet
frustratingly there are a lot more crackles with it re-organized like this. not just at the start or end of a note. hum, I'm becoming convinced I'm hearing the steps of the envelope now, when I wasn't before.
I haven't noticed any crackling in anything I have done yet (admittingly not a ton). I'm going from the 2040 via a PCM5102 directly to my mic in port to listen from that
I should figure out what specific parameters I'm hearing little pop/clicks with. My test is on the prop feather prototype, with my midi keyboard playing arpeggios. I'm using a low-pass filter which is set by two of the sliders on my keyboard. When the corner frequency is low, I hear this artifact on high pitched notes that are almost fully filtered, during their attack & decay phases. It's slow enough to feel like distinct pops, which made me think it might be the steps of attack & decay (which are only recalculated every 256(?) samples, and are calculated as 8-bit values so the steps are pretty big)
OK here's where I hear it on my rp2040:
that's the filter Q & frequency and the resulting calculated filter..
```the note involves a bend but removing that doesn't change the outcome. It's also using a specific waveform, I didn't test if that was critical (AKWF_overtone_0011.wav)
I don't hear it on my host computer test harness so either it's a 32 vs 64 bit calculation difference, or .. idk. I will put my concern about it aside for now.
I think I missed how to go from WAV to BufferProtocol used by synthio? I think I do not understand all the various "array of values" types in Python. I have tried things like:
import synthio, adafruit_wave
import ulab.numpy as np
w = adafruit_wave.open("AKWF_granular_0001.wav")
n = w.getnframes()
try1 = list(memoryview(w.readframes(n)).cast("h"))
try2 = np.array( try1, dtype=np.int16)
note = synthio.Note( 220, waveform=try1) # or try2
import adafruit_wave as wave
def read_waveform(filename):
with wave.open(filename) as w:
if w.getsampwidth() != 2 or w.getnchannels() != 1:
raise ValueError("unsupported format")
return memoryview(w.readframes(w.getnframes())).cast('h')
waveform = read_waveform('AKWF_overtone_0011.wav')
that gives you a "memoryview" form, which should be OK for a note's waveform property but doesn't support the arithmetic like lerp
this gives the lerp'able ulab numpy array: ```py
from ulab import numpy as np
def read_waveform_ulab(filename):
with wave.open(filename) as w:
if w.getsampwidth() != 2 or w.getnchannels() != 1:
raise ValueError("unsupported format")
return np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16)
>>> read_waveform_ulab('AKWF_overtone_0011.wav')
array([647, 1810, 2791, ..., -2157, -1234, -12], dtype=int16)
Awesome thanks. That does work. is 'memoryview' in this case a map on the filesystem or an overlay in RAM? if I wanted to plop it into an np array... ahhh yes
thank you
it does read it into RAM, there's not currently a way to "memory map" the data from CIRCUITPY. (and it's not foreseeable that there will be)
gotcha. I want to try scrubbing through a WAV with synthio and with the info in those two functions I can do that
interesting concept!
if it can work, then synthio can be a granular synth too. it probably will not work at first because there is cleverness to "feather" the start & end of the grains so it's not glitchy. but we may be able to do the feathering a little
you won't have control over how many times a buffer gets used .. does it matter?
or is that exactly the thing you're referring to
yes, if I understand your question. granular synths basically take a long sample and chop it up into hundreds or thousands of tiny partially-overlapping chunks (called "grains"), then it plays each grain essentially as synthio does now for a waveform. If your "grain index" increments linearly, picking grains one by one, you'll hopefully get a recognizable recreation of the original. but the fun comes when you move that "grain index" in non-linear ways
I have low expectations any of my experiments in this will work. Even the best granular synths sound a little like... fuzzy underwater stream rebuffering
got a very janky version of grain-like WAV playback working. it's hilariously bad but fun. This is attempting to play part of "StreetChicken.wav" (https://cdn-learn.adafruit.com/assets/assets/000/057/463/original/StreetChicken.wav)
I have no idea what I'm hearing right now but I'm into it
ikr
oops shoulda posted my comments here instead of in the PR. apologies.
either is fine, I appreciate the feedback
like Envelope, the biquad filter is a read-only object. Even more so than envelope the consequence of "running" the filter with partially updated values could be bad. A Note can have its entire filter property re-assigned at runtime, though.
so to be clear: the use case of changing filter parameters on a playing note is supported, but it is done by assigning to the Note's filter property, not assigning to the BIquad's properties. This does present some challenges for updating the filter of multiple notes simultaneously.
If this feature should be held until there's a filter object with a Block-settable 'frequency' and 'q_factor' property, then we can put it on hold.
right okay I get the thinking now
lemme try some tests
also I think I'm hearing some of the glitching you were talking about when a filter is engaged (no attempts at changing it). I cannot reproduce it reliably yet
I wonder if the limited precision of the coefficients & intermediate calculations is just too low in some cases.
That would make sense. I also seem to recall talk about having to massage filter models to behave less ideally at various edge cases; and the positive-feedback / self-oscillating characteristics of hiQ filters could definitely be a place where that could be true
I have been trying to think of ways to have changing filters and/or chained filters/effects (like reverb) but haven't had any time to experiment. But I don't think that should hold up pushing this out.
I will try to do a code review tomorrow if no one else beats me there. I did while it was in draft and nothing stood out though.
Yeah this design doesn't allow for topologies of filters either.
Filter is working well for me. Filter frequency cutoff works very nicely, resonance is pretty subtle. I am able to consistently get clicking artifacts if i sweep cutoff to 0.0 and leave it there.
i'm able to prevent this if i keep the filter cutoff above 17.
The filters can sound pretty good! Here's a quicky example using bandpass (the most well-behaved filter imo) https://www.youtube.com/watch?v=wIc6rovZ4aA it emits some farts and burps every now and then (perhaps related to that 256-sample-block-to-block state issue that was causing popping?)
Testing out brand new filter capability in synthio.
The filters are still pretty untamed and emit some burps and farts along the way, but they're pretty rad.
code: https://gist.github.com/todbot/c4cad5f17dfe643f4d796ef91cd24036
Random thought I've had for filters and potential effects. Would it make sense to add to a note a chain of effect->filter->something->output to process?
Not saying it is easy, but I can sorta see in the code where it may be possible to process a chain. How biquads work but instead of just one filter, many. And if it is a set interface other effects could be written.
Speed is a potential issue too... just random thoughts I had during the code review and last night thinking about it.
If I understand correctly you're suggesting a polyphonic approach that includes per-voice signal paths, yes? A lot of synths are paraphonic (multiple notes/voices at once, but all use the same waveform/voice, filter, and envelope signal path) due to processing constraints or size constraints for analog synths, some are polyphonic (multiple notes, each can be a different voice running through it's own filter and envelope signal path). I haven't run across polyphonic synths that have effects available on a per voice basis other than if you wanted to patch that with a modular synth.
I guess that is what I was thinking. I hadn't really thought of a global signal path (as the filters were global and then they were per note). But for processing there may be something to sharing LFOs and other effects. That would be another change I believe.
Just thinking about ideas really still for when time permits me to look again.
@floral pulsar have you encountered anything like this? https://forums.adafruit.com/viewtopic.php?t=202163
yes. and it's because the audio object isn't being saved by the FeatherSynth instance, so eventually GC eats it
oooohhhhhhh
self.audio = audio should solve it
do you want to jump into the thread on the forum or would you like me to follow up?
I can do it, yes
you rock, thank you
np
hey @left glen (or anyone else who may know), what's the status on loadable native modules in a CircuitPython mpy? I'd love to try different filter algorithms with synthio (notably a Moog-style ladder filter emulation), and these compute-only synth algorithms would be perfect to try with native modules
it's not enabled with any builds by default.
I'd have to try to remember the details but I did have an example where I put an AHRS class in a native module. You also have to ensure you compile it for the right class of CPU for the board
yep, that's not a problem to me, a different MPY for each arch
New low-priority question for @left glen: Does synthio implement any kind of voice-stealing algorithm, and if so, what? E.g. if max_polyphony is 11 and 11 voices are .press()ed, what happens when the 12th voice is .press()ed?
If there's no idle voice, but there is a voice in "decay" phase, the one with the lowest envelope volume is chosen. But a playing (attack/decay/sustain phase) note is never replaced. Happy to entertain replacing this algorithm with a better one.
in the source code, find_channel_with_note if-block on note == SYNTHIO_SILENCE is where the decision would be made. The info to know the "oldest note" currently playing would have to be tracked, it's not available now.
Cool thanks. That's a pretty good algorithm. Voice-stealing choices are another one of these black arts and no one choice is right for every situation. e.g. oldest note is another common strategy but it really messes up if you have a long-duration held bass note and a high pitch arpeggio steals it away, destroying the low-end of a song
Another question for @left glen: how does one get the current value of a synthio.Envelope? There does not appear to be a .value . Alternatively, I can't find anything in synthio.Note that indicates current note volume
I think you're right that this is missing.
feel free to file an issue about that
I think it would have to be a property of a Note, since the Envelope just holds the information about the times & levels
right. I'll look into it. it may be simple enough I can even submit a PR. bwa ha ha
at a quick look it may be a bit involved ...
drat. okay I'll file an issue in any case. I can approximate knowing when a note is silent from note_off and release_time
it's not a big deal
is that what you actually need, an is_playing property?
For the particular case I was dealing with, yes, a Note.is_playing or Envelope.is_playing would work. Specifically, I am creating filter modulation envelopes that need to roughly track what the amplitude envelope is doing so they know when to stop modulating after key release. But I can dead reckon it by knowing key release time and amplitude envelope release_time
@floral pulsar Feel free to tag me on the issue if you aren't working on it. I may have a bit more time soon and looking for something to poke at. From the sounds of it having something that returns where in the envelope (phase and how far into it) you are for a note is what is needed?
expanding a bit on the mention of voice stealing -- there's probably more to be done before that's 100% possible, but the idea is that python code armed with the new note state info can decide on a voice to release if it knows too many are already playing... and then pass that info into .change()
@left glen is the note signal flow oscillator > envelope > filter or oscillator > filter > envelope?
(both are valid and common in typical synth workflows)
The way it's coded is more like oscillator > filter > envelope. The flow in the C code is synth_note_into_buffer, then synthio_biquad_filter_samples, then sum_with_loudness
Gotcha, thanks @left glen
okay so I'm not suggesting that this has to be the logo for synthio...
Blinka may wanna hit the dumbell curls a little bit