circuitpython-synthtools 0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
synthtools/synth.py ADDED
@@ -0,0 +1,767 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
2
+ # SPDX-License-Identifier: MIT
3
+ #
4
+ # synth.py - Synth base class.
5
+ #
6
+ # Owns: voice bookkeeping, patch load/save, live parameter updates.
7
+ # Subclasses override _recompile() / _decompile() and _make_notes(), and
8
+ # extend _PARAMS.
9
+ #
10
+ # Requires CircuitPython 10+
11
+ #
12
+ # --- two ideas run through this file ---------------------------------
13
+ #
14
+ # 1. THE PATCH IS NOT LIVE STATE. A Patch is inert JSON-able data. It is
15
+ # read once by _recompile() and then left alone -- turning a knob does
16
+ # NOT write to it. save_patch() is the only thing that pushes live
17
+ # state back, via _decompile(). So "the patch on disk" and "what I am
18
+ # hearing" are separate things, and reloading the patch reverts.
19
+ #
20
+ # 2. PARAMETERS ARE BLOCKS, NOT NUMBERS. Anywhere synthio accepts a
21
+ # BlockInput, a shared synthio.Math can stand in for a float and stay
22
+ # writable. Every voice nests the same shared block, so one write
23
+ # reaches all of them inside the C renderer -- O(1) in polyphony,
24
+ # regardless of how many notes are sounding. Doing that arithmetic in
25
+ # the block graph rather than in Python is also what lets a knob reach
26
+ # a note that is ALREADY playing.
27
+ #
28
+ # Live-parameter cost, fastest to slowest:
29
+ # 1. shared block write - one assignment. O(1). filt_f, filt_q,
30
+ # fenv_amount, filt_vel, fenv_vel, the LFO rates and depths.
31
+ # 2. in-place buffer - rewrite the shared envelope shape. O(1), but
32
+ # it allocates temporaries: a switch, not a knob. fenv_curve.
33
+ # 3. cached-object swap - rebuild once, next note-on picks it up. O(1).
34
+ # amp_env, filt_type, wave.
35
+ # 4. per-voice loop - only for genuinely per-note values (detune).
36
+ # O(polyphony), so avoid in anything a knob drives.
37
+ #
38
+ # Every param is a property, so `synth.filt_f = 1000` is the fast path and
39
+ # set_param() is just a string front-end for MIDI CC / UI code.
40
+
41
+ import synthio
42
+
43
+ from .ahr_envelope import AHREnvelope
44
+ from .blocks import clamp, constrained_lerp, lerp, product, scalar_block, sum3
45
+ from .waves import ramp_wave
46
+
47
+ FILTER_MODES = {
48
+ "LPF": synthio.FilterMode.LOW_PASS,
49
+ "HPF": synthio.FilterMode.HIGH_PASS,
50
+ "BPF": synthio.FilterMode.BAND_PASS,
51
+ "NOTCH": synthio.FilterMode.NOTCH,
52
+ }
53
+
54
+
55
+ class Synth:
56
+ """Base synth engine wrapping one synthio.Synthesizer.
57
+
58
+ Owns voice bookkeeping (note_on()/note_off()), patch load/save, and
59
+ live parameter updates. A style subclass overrides _make_notes() (what
60
+ a key sounds like) and typically _recompile()/_decompile() (how the
61
+ subclass's own Patch fields compile to/from live state), extending
62
+ _PARAMS with its own settable names. SubtractiveSynth and
63
+ WavetableSynth are the two worked examples.
64
+
65
+ Every parameter is a plain property (``synth.filt_f = 2000``), each
66
+ backed by a shared synthio block so one write reaches every sounding
67
+ voice in O(1) regardless of polyphony -- see the module docstring
68
+ above for the full cost model. set_param(name, val) is a string
69
+ front-end onto the same properties, for MIDI CC / UI code.
70
+
71
+ A Patch is inert JSON-able data, compiled into live blocks once by
72
+ load_patch() -> _recompile(); nothing after that writes back to it
73
+ until save_patch() explicitly snapshots live state via _decompile().
74
+ """
75
+
76
+ # names settable via set_param(); also what a UI can enumerate
77
+ # fmt: off
78
+ _PARAMS = ("filt_f", "filt_q", "filt_type", "amp_env",
79
+ "attack_time", "decay_time", "sustain_level", "release_time",
80
+ "vib_depth", "vib_rate", "vib_delay",
81
+ "penv_amount", "penv_time", "penv_out_amount", "penv_out_time",
82
+ "filt_lfo_rate", "filt_lfo_amount",
83
+ "fenv_amount", "fenv_attack", "fenv_release", "fenv_curve",
84
+ "filt_vel", "fenv_vel", "glide_time")
85
+ # fmt: on
86
+
87
+ #: One voice at a time: a note-on steals whatever is sounding, whichever
88
+ #: note it was, and ``glide_time`` slides into it from the previous note.
89
+ #: Flip it on any style to get a monosynth::
90
+ #:
91
+ #: lead = SubtractiveSynth(synthesizer, patch)
92
+ #: lead.mono = True
93
+ #: lead.glide_time = 0.08
94
+ #:
95
+ #: Glide is meaningless in poly -- one shared bend would drag every
96
+ #: sounding voice -- so it is only applied while this is set. A plain
97
+ #: attribute rather than a patch field, like ``push_env``.
98
+ mono = False
99
+
100
+ # The cutoff bus can now be driven below zero -- a downward fenv_amount,
101
+ # a negative filt_vel, a big filt_lfo_amount -- none of which was
102
+ # reachable when the cutoff was just a scalar. So it is clamped, in one
103
+ # MID block, since the middle of three values is exactly a clamp.
104
+ #
105
+ # A negative Biquad.frequency does NOT raise or crash (measured on
106
+ # CircuitPython 10.3.0-alpha.3 / rp2040), so this is defensive rather
107
+ # than mandatory: what such a filter *sounds* like is undefined, and a
108
+ # downward sweep hitting 0 Hz is an ordinary patch, not an edge case.
109
+ # Cost is one shared block plus one per modulated voice.
110
+ FILT_F_MIN = 20.0
111
+ FILT_F_MAX = 20000.0
112
+
113
+ def __init__(self, synthesizer, patch=None):
114
+ self.synthio = synthesizer
115
+ self.voices = {} # midi_note -> tuple of synthio.Note
116
+ self.patch = None
117
+ # Every shared block below is created ONCE and never replaced:
118
+ # sounding voices hold references to them, so identity must survive
119
+ # patch reloads. _recompile() writes into them.
120
+ self._filt_f_blk = scalar_block(2000.0)
121
+ self._filt_q_blk = scalar_block(1.0)
122
+ self._filt_vel_blk = scalar_block(0.0)
123
+ self._fenv_vel_blk = scalar_block(0.0)
124
+ # --- the bend graph ------------------------------------------
125
+ #
126
+ # SHARED: bend = SUM(vib_lfo, bend_blk)
127
+ # vib_lfo.scale = PRODUCT(vib_depth, vib_fade)
128
+ # VOICE: note.bend = SUM(bend, penv) -- only when a pitch
129
+ # envelope exists
130
+ #
131
+ # The vibrato fade-in lives INSIDE the LFO's scale rather than on
132
+ # the bend path, because LFO.scale is a BlockInput. That keeps the
133
+ # whole of vib_delay in the shared half: nothing per voice, and
134
+ # vib_depth stays one write into its own block.
135
+ self._vib_depth_blk = scalar_block(0.0)
136
+ # A one-shot 0 -> 1 ramp. rate = 1/vib_delay, so vib_delay = 0 needs
137
+ # no special case at all -- the ramp just finishes in a millisecond.
138
+ # The graph stays static, which is what the identity rule wants.
139
+ self._vib_fade = synthio.LFO(waveform=ramp_wave(), rate=1000.0, once=True)
140
+ self._vib_lfo = synthio.LFO(rate=5.0, scale=product(self._vib_depth_blk, self._vib_fade))
141
+ self._bend_blk = scalar_block(0.0)
142
+ # --- glide, the third input of the bend SUM ------------------
143
+ # Portamento needs a per-voice pitch offset ONLY when there are
144
+ # several voices. In mono there is one, so the glide is shared
145
+ # like everything else here and just occupies the third input
146
+ # sum3() already takes and nothing else was using.
147
+ #
148
+ # It is a POSITION lerp: the bend runs from the previous note's
149
+ # pitch to zero while the Note itself is created at the new pitch.
150
+ # Aimed the other way (start at the new note, bend toward the old)
151
+ # successive glides would compound.
152
+ #
153
+ # In poly mode nothing ever writes _glide.a, so this sits at 0.0
154
+ # and is inert -- two objects, no arithmetic. And because _bend is
155
+ # rooted in synthesizer.blocks below, the LFO ticks without being
156
+ # rooted itself, exactly like _vib_fade inside _vib_lfo.scale.
157
+ self._glide_pos = synthio.LFO(waveform=ramp_wave(), rate=1000.0, once=True)
158
+ self._glide = constrained_lerp(0.0, 0.0, self._glide_pos)
159
+ self._glide_time = 0.0
160
+ self._last_midi = None # where the next glide starts from
161
+ self._bend = sum3(self._vib_lfo, self._bend_blk, self._glide)
162
+ # --- the filter cutoff modulation bus ------------------------
163
+ # Four sources sum onto one destination:
164
+ #
165
+ # SHARED: filt_base = MID(SUM(filt_f, filt_lfo), MIN, MAX)
166
+ # VOICE: cutoff = SUM(filt_base, fenv, vel_hz)
167
+ # vel_hz = PRODUCT(filt_vel, vel/127)
168
+ # fenv = AHREnvelope.make(gain)
169
+ # gain = LERP(1, vel/127, fenv_vel)
170
+ #
171
+ # SUM takes three inputs, so base+LFO, envelope and velocity all
172
+ # land in ONE per-voice node. Every knob in there is a shared block
173
+ # nested inside it, so each stays a single write no matter how many
174
+ # voices are sounding -- including voices already in release.
175
+ #
176
+ # Every one of the three modulations is ADDITIVE and one-sided:
177
+ # filt_f is the floor and they open upward from it. synthio's LFO
178
+ # outputs `waveform[idx] * scale + offset` and its default waveform
179
+ # is a triangle centred on zero, so scale ALONE would swing
180
+ # +/-amount about filt_f and push the bottom half into the
181
+ # FILT_F_MIN clamp. Setting scale and offset both to amount/2 shifts
182
+ # the whole swing up into 0..amount -- see filt_lfo_amount below.
183
+ self._filt_lfo = synthio.LFO(rate=0.5, scale=0.0, offset=0.0)
184
+ self._filt_sum = sum3(self._filt_f_blk, self._filt_lfo)
185
+ self._filt_base = clamp(self._filt_sum, self.FILT_F_MIN, self.FILT_F_MAX)
186
+ # Anything not reachable from a sounding Note has to be rooted here
187
+ # or synthio never updates it -- and that applies to Math blocks,
188
+ # not just LFOs.
189
+ #
190
+ # The LFOs are the obvious case: unattached, they do not advance, so
191
+ # vibrato and the filter sweep would jump phase at every note-on.
192
+ #
193
+ # `_filt_base` and `_bend` are the non-obvious one. Both are only
194
+ # reachable through a voice, so with nothing sounding they freeze --
195
+ # and on a freshly built Synth they have never been evaluated at
196
+ # all. Measured on hardware: the first note-on after construction
197
+ # read its cutoff as 0.0 Hz (a filter slammed shut for a block, i.e.
198
+ # a click), and later notes after a silence read a stale cutoff
199
+ # frozen at wherever the LFO was when the last voice died. Rooting
200
+ # them here makes the shared half of the graph always-live, so a
201
+ # note-on inherits a correct cutoff and bend on its very first
202
+ # update. Costs two list entries.
203
+ synthesizer.blocks.append(self._vib_lfo)
204
+ synthesizer.blocks.append(self._filt_lfo)
205
+ synthesizer.blocks.append(self._filt_base)
206
+ synthesizer.blocks.append(self._bend)
207
+ # The envelope is a modulation SOURCE: it produces 0 -> amount and
208
+ # knows nothing about filters. Created once and never replaced --
209
+ # sounding voices hold references to its buffer and blocks.
210
+ self._fenv = AHREnvelope()
211
+ self._fenvs = {} # midi_note -> env block, for held notes
212
+ # The pitch envelope is the SAME class, falling instead of rising:
213
+ # it starts at penv_amount and settles to 0 (true pitch), then on
214
+ # note-off drifts on to penv_out_amount. Its own instance, so its
215
+ # shape buffer and rates are independent of the filter's.
216
+ self._penv = AHREnvelope(falling=True)
217
+ self._penvs = {} # midi_note -> pitch env block
218
+ # "voice under construction" temporaries, set by note_on around the
219
+ # call to _make_notes so _make_filter and the subclasses can pick
220
+ # them up
221
+ self._fenv_cur = None
222
+ self._cutoff_cur = None
223
+ self._penv_cur = None
224
+ self._bend_cur = None
225
+ # live mirrors of patch values that have nowhere else to live
226
+ self._filt_type = None
227
+ self._filt_mode = None
228
+ self._amp_env = [0.01, 0.10, 0.8, 0.35]
229
+ # a mirror rather than 1/_vib_fade.rate, to avoid a reciprocal
230
+ # round-trip through save/load
231
+ self._vib_delay = 0.0
232
+ self._env = self._make_env()
233
+ if patch:
234
+ self.load_patch(patch)
235
+
236
+ # --- patch <-> live state -------------------------------------------
237
+ # _recompile() reads the patch; _decompile() writes it. Nothing else
238
+ # touches self.patch.
239
+
240
+ def load_patch(self, patch):
241
+ self.patch = patch
242
+ self._recompile()
243
+
244
+ def _recompile(self):
245
+ """Compile patch data into live blocks and cached natives. Once per
246
+ patch load, never per note. Subclasses call super()._recompile()."""
247
+ p = self.patch
248
+ self._filt_type = p.filt_type
249
+ self._filt_mode = FILTER_MODES.get(p.filt_type) # None = no filter
250
+ # list(), NOT the patch's own list: attack_time & friends mutate
251
+ # self._amp_env element-wise, and aliasing it would let a knob turn
252
+ # write straight through into the loaded patch.
253
+ self._amp_env = list(p.amp_env)
254
+ self._env = self._make_env()
255
+ self._filt_f_blk.a = p.filt_f # write, don't replace
256
+ self._filt_q_blk.a = p.filt_q
257
+ self._filt_vel_blk.a = p.filt_vel
258
+ self._fenv_vel_blk.a = p.fenv_vel
259
+ self._filt_lfo.rate = p.filt_lfo_rate
260
+ # via the property: the amount is a half-swing plus a matching
261
+ # offset, and writing .scale alone here would leave a stale offset
262
+ self.filt_lfo_amount = p.filt_lfo_amount
263
+ self._vib_lfo.rate = p.vib_rate
264
+ self._vib_depth_blk.a = p.vib_depth
265
+ self.vib_delay = p.vib_delay # via the property: sets a rate
266
+ self.glide_time = getattr(p, "glide_time", 0.0) # ditto
267
+ # written into the existing envelopes, never new ones, and in one
268
+ # call each so the shape is rebuilt once rather than per parameter
269
+ self._fenv.configure(p.fenv_attack, p.fenv_release, p.fenv_amount, p.fenv_curve)
270
+ # curve 1: penv_curve is not a patch field, though the class takes one
271
+ self._penv.configure(p.penv_time, p.penv_out_time, p.penv_amount, 1, p.penv_out_amount)
272
+
273
+ def _decompile(self):
274
+ """Push live state back into self.patch. The opposite of
275
+ _recompile(). Subclasses call super()._decompile()."""
276
+ p = self.patch
277
+ p.filt_type = self._filt_type
278
+ p.amp_env = list(self._amp_env) # copy out, so later knob turns
279
+ # do not leak into the patch
280
+ p.filt_f = self._filt_f_blk.a
281
+ p.filt_q = self._filt_q_blk.a
282
+ p.filt_vel = self._filt_vel_blk.a
283
+ p.fenv_vel = self._fenv_vel_blk.a
284
+ p.filt_lfo_rate = self._filt_lfo.rate
285
+ p.filt_lfo_amount = self.filt_lfo_amount # undoes the half-swing
286
+ p.vib_rate = self._vib_lfo.rate
287
+ p.vib_depth = self._vib_depth_blk.a
288
+ p.vib_delay = self._vib_delay
289
+ p.glide_time = self._glide_time
290
+ p.penv_amount = self._penv.amount
291
+ p.penv_time = self._penv.attack
292
+ p.penv_out_amount = self._penv.release_amount
293
+ p.penv_out_time = self._penv.release
294
+ p.fenv_amount = self._fenv.amount
295
+ p.fenv_attack = self._fenv.attack
296
+ p.fenv_release = self._fenv.release
297
+ p.fenv_curve = self._fenv.curve
298
+
299
+ def save_patch(self):
300
+ """Snapshot what is currently being heard into the Patch, and return
301
+ it. Knob turns do not reach the patch on their own, so call this
302
+ before Patch.save():
303
+
304
+ synth.save_patch().save("/patch.json")
305
+
306
+ Mutates and returns the patch that was loaded -- it is the same
307
+ object, not a copy.
308
+ """
309
+ self._decompile()
310
+ return self.patch
311
+
312
+ def _make_env(self):
313
+ a, d, s, r = self._amp_env
314
+ return synthio.Envelope(attack_time=a, decay_time=d, sustain_level=s, release_time=r)
315
+
316
+ # --- per-voice construction -----------------------------------------
317
+
318
+ def _voice_fenv_gain(self, velocity):
319
+ """This voice's envelope depth scale.
320
+
321
+ Plain 1.0 when fenv_vel is off -- there is no arithmetic to avoid in
322
+ that case, and it lets AHREnvelope skip a block. Otherwise a LERP
323
+ block, which is exactly `1 - fenv_vel + fenv_vel * vel_norm` but
324
+ with fenv_vel still LIVE inside it, so the knob keeps reaching the
325
+ voice after it has been pressed.
326
+ """
327
+ if not self._fenv_vel_blk.a:
328
+ return 1.0
329
+ return lerp(1.0, velocity / 127.0, self._fenv_vel_blk)
330
+
331
+ def _voice_cutoff(self, velocity):
332
+ """This voice's node on the cutoff bus: SUM(base, envelope, vel).
333
+
334
+ Returns the SHARED base unchanged when neither the envelope nor
335
+ velocity is in play, so the ordinary case allocates nothing at all.
336
+ Either way filt_f reaches this voice with one write, because the
337
+ shared base is nested inside.
338
+ """
339
+ if self._filt_mode is None:
340
+ return None
341
+ vel_hz = None
342
+ if self._filt_vel_blk.a:
343
+ # signed: a negative filt_vel closes the filter as you play
344
+ # harder. filt_vel stays live inside the PRODUCT.
345
+ vel_hz = product(self._filt_vel_blk, velocity / 127.0)
346
+ if self._fenv_cur is None and vel_hz is None:
347
+ return self._filt_base # already clamped
348
+ # `is None` rather than truthiness throughout: these are synthio
349
+ # blocks, and whether one is falsy is not ours to assume.
350
+ # Clamped again here, not just on the shared base: a downward
351
+ # fenv_amount (a normal patch) or a negative filt_vel can drive
352
+ # this sum below zero all on its own.
353
+ return clamp(
354
+ sum3(
355
+ self._filt_base,
356
+ self._fenv_cur if self._fenv_cur is not None else 0.0,
357
+ vel_hz if vel_hz is not None else 0.0,
358
+ ),
359
+ self.FILT_F_MIN,
360
+ self.FILT_F_MAX,
361
+ )
362
+
363
+ def _voice_bend(self):
364
+ """This voice's bend input, shared by all its Notes.
365
+
366
+ Returns the SHARED bend graph unchanged when no pitch envelope is
367
+ in play, so the ordinary case allocates nothing and vibrato and the
368
+ pitch wheel keep reaching every voice with one write. When there is
369
+ one, the shared graph is still nested inside, so that stays true.
370
+
371
+ Only meaningful during note_on: it reads the temporaries note_on
372
+ sets up around _make_notes.
373
+ """
374
+ if self._penv_cur is None:
375
+ return self._bend
376
+ return sum3(self._bend, self._penv_cur)
377
+
378
+ def _make_filter(self):
379
+ """Per-note Biquad (filters hold state, so they cannot be shared).
380
+ Every Note of one voice shares one cutoff graph.
381
+
382
+ Only meaningful during note_on: it reads the temporaries note_on
383
+ sets up around _make_notes.
384
+ """
385
+ if self._filt_mode is None:
386
+ return None
387
+ # fmt: off
388
+ return synthio.Biquad(self._filt_mode, frequency=self._cutoff_cur,
389
+ Q=self._filt_q_blk)
390
+ # fmt: on
391
+
392
+ # --- real-time path -------------------------------------------------
393
+
394
+ def _make_notes(self, midi_note, velocity):
395
+ """Return a tuple of synthio.Note for this key. Override me."""
396
+ raise NotImplementedError
397
+
398
+ def note_on(self, midi_note, velocity=127, glide=None):
399
+ """Press a note.
400
+
401
+ ``glide`` overrides glide_time in seconds for this note only, and
402
+ only matters in mono -- a per-step slide flag needs that, because
403
+ writing glide_time itself would leak into the patch.
404
+ """
405
+ if self.mono:
406
+ # one voice: steal whatever is sounding, whichever note it is
407
+ self.all_notes_off()
408
+ self._aim_glide(midi_note, glide)
409
+ elif midi_note in self.voices:
410
+ self.note_off(midi_note)
411
+ # Restart the vibrato fade only when starting from silence, so
412
+ # adding a note to a held chord does not duck everyone's vibrato
413
+ # back to zero. After the steal above, so a mono retrigger still
414
+ # counts as starting from silence.
415
+ if not self.voices:
416
+ self._vib_fade.retrigger()
417
+ # Each envelope is an INPUT to the thing it modulates, so both have
418
+ # to exist first. All four temporaries are set before _make_notes so
419
+ # every Note of this voice shares one cutoff and one bend graph.
420
+ if self._filt_mode is not None:
421
+ self._fenv_cur = self._fenv.make(self._voice_fenv_gain(velocity))
422
+ self._cutoff_cur = self._voice_cutoff(velocity)
423
+ self._penv_cur = self._penv.make()
424
+ self._bend_cur = self._voice_bend()
425
+ notes = self._make_notes(midi_note, velocity)
426
+ if self._fenv_cur is not None:
427
+ self._fenvs[midi_note] = self._fenv_cur
428
+ if self._penv_cur is not None:
429
+ self._penvs[midi_note] = self._penv_cur
430
+ self._fenv_cur = None
431
+ self._cutoff_cur = None
432
+ self._penv_cur = None
433
+ self._bend_cur = None
434
+ self.voices[midi_note] = notes
435
+ self.synthio.press(notes)
436
+
437
+ def note_off(self, midi_note):
438
+ notes = self.voices.pop(midi_note, None)
439
+ if notes:
440
+ # the Note keeps these alive while it rings out
441
+ env = self._fenvs.pop(midi_note, None)
442
+ if env is not None:
443
+ self._fenv.start_release(env)
444
+ penv = self._penvs.pop(midi_note, None)
445
+ if penv is not None:
446
+ self._penv.start_release(penv)
447
+ self.synthio.release(notes)
448
+
449
+ def all_notes_off(self):
450
+ for midi_note in list(self.voices.keys()):
451
+ self.note_off(midi_note)
452
+
453
+ def pitch_bend(self, amount):
454
+ """+/-1.0 = one octave. One write into the shared bend graph, O(1)."""
455
+ self._bend_blk.a = amount # bend is performance state, not patch state
456
+
457
+ def _aim_glide(self, midi_note, seconds=None):
458
+ """Point the shared glide block at ``midi_note`` and start it.
459
+
460
+ Bend units are octaves, so a semitone is 1/12. The offset is where
461
+ the pitch STARTS; it always ends at 0, i.e. the note's own pitch.
462
+
463
+ Called from note_on() in mono only. Note the previous note is
464
+ still releasing at this point and shares this bend, so a glide
465
+ drags its tail along too -- inherent to a shared bend, and only
466
+ audible with a long amp release and a long glide together.
467
+ """
468
+ prev = self._last_midi
469
+ self._last_midi = midi_note
470
+ secs = self._glide_time if seconds is None else seconds
471
+ self._glide_pos.rate = 1.0 / max(secs, 0.001)
472
+ if prev is None:
473
+ self._glide.a = 0.0 # first note ever: nothing to glide from
474
+ else:
475
+ # Plus whatever glide is still in flight, so interrupting one
476
+ # mid-slide starts the next from where the pitch actually IS.
477
+ # Same structural continuity as AHREnvelope.start_release()'s
478
+ # `env.a = env.value` -- there is no rate to recompute.
479
+ self._glide.a = (prev - midi_note) / 12.0 + self._glide.value
480
+ self._glide_pos.retrigger()
481
+
482
+ # --- live parameters ------------------------------------------------
483
+ # Setters write live state ONLY. Getters read it back. The patch is not
484
+ # involved until save_patch().
485
+
486
+ @property
487
+ def filt_f(self):
488
+ return self._filt_f_blk.a
489
+
490
+ @filt_f.setter
491
+ def filt_f(self, v):
492
+ self._filt_f_blk.a = v # reaches every sounding voice, O(1)
493
+
494
+ @property
495
+ def filt_q(self):
496
+ return self._filt_q_blk.a
497
+
498
+ @filt_q.setter
499
+ def filt_q(self, v):
500
+ self._filt_q_blk.a = v
501
+
502
+ @property
503
+ def filt_type(self):
504
+ return self._filt_type
505
+
506
+ @filt_type.setter
507
+ def filt_type(self, v):
508
+ self._filt_type = v
509
+ self._filt_mode = FILTER_MODES.get(v) # applies at next note-on
510
+
511
+ # --- amp envelope ---------------------------------------------------
512
+ # synthio.Envelope is immutable: every ADSR edit means a new object.
513
+ # That is one small allocation per knob movement, so deadband inputs.
514
+ # push_env controls whether *sounding* notes get the new envelope:
515
+ # True - turning release while holding a chord affects that release
516
+ # (what a player expects). Changing sustain_level mid-note
517
+ # steps the level rather than slewing.
518
+ # False - edits only apply from the next note-on. Safest.
519
+ push_env = True
520
+
521
+ def _rebuild_env(self):
522
+ self._env = self._make_env()
523
+ if self.push_env:
524
+ for notes in self.voices.values():
525
+ for n in notes:
526
+ n.envelope = self._env
527
+
528
+ @property
529
+ def amp_env(self):
530
+ return self._amp_env
531
+
532
+ @amp_env.setter
533
+ def amp_env(self, v):
534
+ self._amp_env = list(v)
535
+ self._rebuild_env()
536
+
537
+ @property
538
+ def attack_time(self):
539
+ return self._amp_env[0]
540
+
541
+ @attack_time.setter
542
+ def attack_time(self, v):
543
+ self._amp_env[0] = v # in-place, no list rebuild
544
+ self._rebuild_env()
545
+
546
+ @property
547
+ def decay_time(self):
548
+ return self._amp_env[1]
549
+
550
+ @decay_time.setter
551
+ def decay_time(self, v):
552
+ self._amp_env[1] = v
553
+ self._rebuild_env()
554
+
555
+ @property
556
+ def sustain_level(self):
557
+ return self._amp_env[2]
558
+
559
+ @sustain_level.setter
560
+ def sustain_level(self, v):
561
+ self._amp_env[2] = v
562
+ self._rebuild_env()
563
+
564
+ @property
565
+ def release_time(self):
566
+ return self._amp_env[3]
567
+
568
+ @release_time.setter
569
+ def release_time(self, v):
570
+ self._amp_env[3] = v
571
+ self._rebuild_env()
572
+
573
+ # --- vibrato: both are single scalar writes into the shared graph ----
574
+
575
+ @property
576
+ def vib_depth(self):
577
+ return self._vib_depth_blk.a
578
+
579
+ @vib_depth.setter
580
+ def vib_depth(self, v):
581
+ # into its own block rather than onto LFO.scale, because scale now
582
+ # holds PRODUCT(depth, fade). Still one write, still O(1).
583
+ self._vib_depth_blk.a = v
584
+
585
+ @property
586
+ def vib_rate(self):
587
+ return self._vib_lfo.rate
588
+
589
+ @vib_rate.setter
590
+ def vib_rate(self, v):
591
+ self._vib_lfo.rate = v
592
+
593
+ @property
594
+ def vib_delay(self):
595
+ """Seconds for the vibrato to fade in from nothing, restarted
596
+ whenever playing begins from silence.
597
+
598
+ Only a rate on the shared fade ramp, so it is cheap on a knob. It
599
+ takes effect at the NEXT retrigger, not mid-fade.
600
+ """
601
+ return self._vib_delay
602
+
603
+ @vib_delay.setter
604
+ def vib_delay(self, v):
605
+ self._vib_delay = v
606
+ self._vib_fade.rate = 1.0 / max(v, 0.001)
607
+
608
+ @property
609
+ def glide_time(self):
610
+ """Seconds to slide from the previous note into a new one.
611
+
612
+ Portamento. Only applies while ``mono`` is set -- one shared bend
613
+ would drag every sounding voice otherwise. Only a rate on the
614
+ shared ramp, so it is cheap on a knob, and it takes effect at the
615
+ next note-on rather than mid-glide.
616
+ """
617
+ return self._glide_time
618
+
619
+ @glide_time.setter
620
+ def glide_time(self, v):
621
+ self._glide_time = v
622
+ self._glide_pos.rate = 1.0 / max(v, 0.001)
623
+
624
+ # --- pitch envelope --------------------------------------------------
625
+ # Thin delegates onto self._penv, which is an AHREnvelope running
626
+ # falling: penv_amount -> 0 on the way in, then on to penv_out_amount
627
+ # on the way out. Amounts are bend units, 1.0 = one octave.
628
+ # Like fenv_amount, raising either amount from 0 only affects NEW notes,
629
+ # because at 0 no per-voice node is built to write into.
630
+
631
+ @property
632
+ def penv_amount(self):
633
+ return self._penv.amount
634
+
635
+ @penv_amount.setter
636
+ def penv_amount(self, v):
637
+ self._penv.amount = v
638
+
639
+ @property
640
+ def penv_time(self):
641
+ return self._penv.attack
642
+
643
+ @penv_time.setter
644
+ def penv_time(self, v):
645
+ self._penv.attack = v
646
+
647
+ @property
648
+ def penv_out_amount(self):
649
+ return self._penv.release_amount
650
+
651
+ @penv_out_amount.setter
652
+ def penv_out_amount(self, v):
653
+ self._penv.release_amount = v
654
+
655
+ @property
656
+ def penv_out_time(self):
657
+ return self._penv.release
658
+
659
+ @penv_out_time.setter
660
+ def penv_out_time(self, v):
661
+ self._penv.release = v
662
+
663
+ # --- filter LFO: shared, so both are one write ----------------------
664
+
665
+ @property
666
+ def filt_lfo_rate(self):
667
+ return self._filt_lfo.rate
668
+
669
+ @filt_lfo_rate.setter
670
+ def filt_lfo_rate(self, v):
671
+ self._filt_lfo.rate = v
672
+
673
+ @property
674
+ def filt_lfo_amount(self):
675
+ """Hz ADDED above filt_f: the cutoff swings 0..v, never below it.
676
+
677
+ synthio's LFO is ``waveform[idx] * scale + offset``, and the default
678
+ waveform is a triangle centred on zero, so ``scale`` on its own is a
679
+ HALF-swing about zero. Writing scale and offset to the same v/2
680
+ recentres it: -v/2..+v/2 shifted up by v/2 is 0..v. This is the
681
+ min/max-to-midpoint/range conversion from README-2-Modulation.md,
682
+ with lmin fixed at 0.
683
+
684
+ Doing it this way rather than with a custom unipolar waveform keeps
685
+ the LFO on synthio's internal 16-bit resolution -- a hand-built
686
+ 64-sample buffer would be measurably steppier.
687
+
688
+ Two writes, but both land on the ONE shared LFO, so this is still
689
+ O(1) in polyphony: there is no per-voice copy to walk.
690
+ """
691
+ return self._filt_lfo.scale * 2.0 # stored as half-swing, see below
692
+
693
+ @filt_lfo_amount.setter
694
+ def filt_lfo_amount(self, v):
695
+ half = v * 0.5
696
+ self._filt_lfo.scale = half
697
+ self._filt_lfo.offset = half
698
+
699
+ # --- filter envelope params -----------------------------------------
700
+ # Thin delegates onto self._fenv, which owns the blocks and buffers.
701
+
702
+ @property
703
+ def fenv_amount(self):
704
+ return self._fenv.amount
705
+
706
+ @fenv_amount.setter
707
+ def fenv_amount(self, v):
708
+ # one write into the shared block; reaches every voice, including
709
+ # ones already in release. Note: if amount was 0 at note-on no
710
+ # envelope was built for that voice, so raising it from 0 only
711
+ # affects new notes.
712
+ self._fenv.amount = v
713
+
714
+ @property
715
+ def fenv_attack(self):
716
+ return self._fenv.attack
717
+
718
+ @fenv_attack.setter
719
+ def fenv_attack(self, v):
720
+ self._fenv.attack = v # a rate write: cheap on a knob
721
+
722
+ @property
723
+ def fenv_release(self):
724
+ return self._fenv.release
725
+
726
+ @fenv_release.setter
727
+ def fenv_release(self, v):
728
+ self._fenv.release = v
729
+
730
+ @property
731
+ def fenv_curve(self):
732
+ return self._fenv.curve
733
+
734
+ @fenv_curve.setter
735
+ def fenv_curve(self, v):
736
+ # the shape is rewritten in place, so sounding voices -- including
737
+ # ones already in release -- morph immediately. O(1), but it does
738
+ # allocate temporaries: this is a switch, not a knob.
739
+ self._fenv.curve = v
740
+
741
+ # --- velocity -------------------------------------------------------
742
+ # Both are shared blocks nested in each voice's graph, so they stay live
743
+ # for voices that already have the relevant node. Turning one on from
744
+ # zero still only affects new notes: at zero no node is built at all.
745
+
746
+ @property
747
+ def filt_vel(self):
748
+ return self._filt_vel_blk.a
749
+
750
+ @filt_vel.setter
751
+ def filt_vel(self, v):
752
+ self._filt_vel_blk.a = v
753
+
754
+ @property
755
+ def fenv_vel(self):
756
+ return self._fenv_vel_blk.a
757
+
758
+ @fenv_vel.setter
759
+ def fenv_vel(self, v):
760
+ self._fenv_vel_blk.a = v
761
+
762
+ def set_param(self, name, val):
763
+ """String front-end for MIDI CC / UI / patch-editor code.
764
+ Prefer ``synth.filt_f = v`` in hot loops."""
765
+ if name not in self._PARAMS:
766
+ raise KeyError(name)
767
+ setattr(self, name, val)