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/audio_fx.py ADDED
@@ -0,0 +1,180 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
2
+ # SPDX-License-Identifier: MIT
3
+ #
4
+ # audio_fx.py - post-synth effects: extra filter stages that track the
5
+ # synth's cutoff, plus optional distortion and echo.
6
+ #
7
+ # A synthio.Note takes ONE Biquad, so 12 dB/octave is all a voice can do.
8
+ # Steeper means cascading more biquads downstream, and those need
9
+ # somewhere stable to point their `frequency`: Synth rebuilds the voice's
10
+ # cutoff node at every note-on, and it freezes once the Note is freed.
11
+ # BasslineSynth is mono, so it keeps ONE Biquad over one stable cutoff
12
+ # node -- copy `synth.filter`'s frequency and you track it for good.
13
+ #
14
+ # Slope: every Biquad is 12 dB/oct including the synth's own, so `stages`
15
+ # gives 12*(stages+1). Default 1 = 24 dB/oct. (A 303 is usually called
16
+ # ~18 dB/oct, which cascaded 2-pole sections cannot make at all.)
17
+ #
18
+ # Q tracks too, on every stage, matching the synth this was ported from
19
+ # (its `resonance` setter pushes the same Q into the voice filter and both
20
+ # extra ones by hand). Here it is automatic: `src.Q` is the synth's live
21
+ # `filt_q` block, the same object the voice's own Biquad reads, so wiring
22
+ # an extra stage's Q to it needs no propagation code at all -- one knob
23
+ # turn reaches every stage the instant it reaches the voice.
24
+ #
25
+ # Identical resonant sections stacked like this do pile up gain at the
26
+ # cutoff faster than one section alone -- each stage adds its own peak on
27
+ # top of the last. At a squelchy filt_q that is real headroom to watch for;
28
+ # it is also most of why a cascaded resonant filter reads as more
29
+ # aggressive than a single one, which is the point here.
30
+
31
+ import synthio
32
+
33
+ try:
34
+ import audiodelays
35
+ import audiofilters
36
+ except ImportError: # not in every CircuitPython build
37
+ audiodelays = None
38
+ audiofilters = None
39
+
40
+
41
+ class EffectsChain:
42
+ """Post-synth effects: filter stages tracking the synth's cutoff, plus
43
+ optional distortion and echo. Hand ``output`` to a mixer voice::
44
+
45
+ fx = EffectsChain(BasslineSynth(engine, patch), stages=1)
46
+ mixer.voice[0].play(fx.output)
47
+
48
+ ``stages`` extra 12 dB/octave sections give 12*(stages+1) overall,
49
+ counting the synth's own, so the default is 24 dB/octave. They track
50
+ the synth's cutoff AND resonance -- see the module comment.
51
+
52
+ ``distortion`` and ``echo`` are opt-in; each costs a buffer and real
53
+ CPU, and distortion is reportedly too slow to use on an rp2040.
54
+
55
+ Needs a CircuitPython build with ``audiofilters`` (and ``audiodelays``
56
+ for echo). Raises ImportError when constructed rather than when
57
+ imported, so the rest of the package still loads without them.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ synth,
63
+ stages=1,
64
+ distortion=False,
65
+ echo=False,
66
+ buffer_size=1024,
67
+ delay_ms=500,
68
+ max_delay_ms=500,
69
+ decay=0.1,
70
+ ):
71
+ if audiofilters is None:
72
+ raise ImportError(
73
+ "audiofilters is not in this CircuitPython build; "
74
+ "EffectsChain needs it (audiodelays too, for echo)"
75
+ )
76
+ self.synth = synth
77
+ synthesizer = synth.synthio
78
+ cfg = {
79
+ "sample_rate": synthesizer.sample_rate,
80
+ "channel_count": synthesizer.channel_count,
81
+ "buffer_size": buffer_size,
82
+ }
83
+
84
+ stages = max(0, int(stages))
85
+ self.filter = None
86
+ if stages:
87
+ src = synth.filter # the synth's own Biquad, built once
88
+ if src is None:
89
+ raise ValueError("synth has no filter (filt_type is None) to track")
90
+ # Copies sharing src's frequency AND Q blocks -- both live, so
91
+ # both track the synth (sweep, accent, and a filt_q knob turn)
92
+ # with nothing to keep in sync by hand. One Filter holding a
93
+ # tuple, not a Filter each: `filter` runs the sample through
94
+ # them in order, saving a buffer and a pass per stage.
95
+ biquads = tuple(
96
+ synthio.Biquad(src.mode, frequency=src.frequency, Q=src.Q) for _ in range(stages)
97
+ )
98
+ self.filter = audiofilters.Filter(filter=biquads, mix=1.0, **cfg)
99
+
100
+ self.distortion = None
101
+ if distortion:
102
+ # fmt: off
103
+ self.distortion = audiofilters.Distortion(
104
+ mode=audiofilters.DistortionMode.LOFI, mix=0.0, drive=0.5,
105
+ soft_clip=True, pre_gain=0, post_gain=0, **cfg)
106
+ # fmt: on
107
+
108
+ self.echo = None
109
+ if echo:
110
+ if audiodelays is None:
111
+ raise ImportError("audiodelays is not in this CircuitPython build")
112
+ # fmt: off
113
+ self.echo = audiodelays.Echo(
114
+ mix=0.0, max_delay_ms=max_delay_ms, delay_ms=delay_ms,
115
+ decay=decay, freq_shift=False, **cfg)
116
+ # fmt: on
117
+
118
+ # wire whatever exists, in order, and remember the tail
119
+ self.output = synthesizer
120
+ for fx in (self.filter, self.distortion, self.echo):
121
+ if fx is not None:
122
+ fx.play(self.output)
123
+ self.output = fx
124
+
125
+ # --- knobs, all no-ops when the effect was not built ----------------
126
+
127
+ @property
128
+ def filter_mix(self):
129
+ """Dry/wet for the extra filter stages. 1.0 = fully filtered."""
130
+ return self.filter.mix if self.filter is not None else 0.0
131
+
132
+ @filter_mix.setter
133
+ def filter_mix(self, v):
134
+ if self.filter is not None:
135
+ self.filter.mix = v
136
+
137
+ @property
138
+ def drive(self):
139
+ """Distortion amount, 0..1. Driven through pre_gain, not the
140
+ `drive` parameter, which does not do what its name suggests in LOFI
141
+ mode; post_gain pulls back the level pre_gain adds."""
142
+ return self.distortion.pre_gain / 50.0 if self.distortion is not None else 0.0
143
+
144
+ @drive.setter
145
+ def drive(self, v):
146
+ if self.distortion is not None:
147
+ self.distortion.pre_gain = v * 50.0
148
+ self.distortion.post_gain = v * -25.0
149
+
150
+ @property
151
+ def drive_mix(self):
152
+ return self.distortion.mix if self.distortion is not None else 0.0
153
+
154
+ @drive_mix.setter
155
+ def drive_mix(self, v):
156
+ if self.distortion is not None:
157
+ self.distortion.mix = v
158
+
159
+ @property
160
+ def delay_mix(self):
161
+ return self.echo.mix if self.echo is not None else 0.0
162
+
163
+ @delay_mix.setter
164
+ def delay_mix(self, v):
165
+ if self.echo is not None:
166
+ self.echo.mix = v
167
+
168
+ @property
169
+ def delay_ms(self):
170
+ return self.echo.delay_ms if self.echo is not None else 0.0
171
+
172
+ @delay_ms.setter
173
+ def delay_ms(self, v):
174
+ if self.echo is not None:
175
+ self.echo.delay_ms = v
176
+
177
+ def delay_sync(self, bpm, steps=4, steps_per_beat=4):
178
+ """Set the echo time to ``steps`` sequencer steps at ``bpm``. A
179
+ tempo-synced delay is most of what makes an acid line sit right."""
180
+ self.delay_ms = (60_000.0 / bpm / steps_per_beat) * steps
@@ -0,0 +1,410 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
2
+ # SPDX-License-Identifier: MIT
3
+ #
4
+ # bassline_synth.py - the squelchy acid bassline voice, after the Roland
5
+ # TB-303: monophonic, one oscillator, per-step slide and accent.
6
+ #
7
+ # Built straight on Synth with `mono = True`: the slide is Synth's own
8
+ # glide given a per-step time, and the one-voice stealing comes with the
9
+ # switch. There is no monosynth base class -- there turned out to be
10
+ # nothing left for one to hold.
11
+ #
12
+ # NOTE a slide here glides the pitch but still RETRIGGERS the envelopes.
13
+ # A real 303 holds the gate high across a slide so the two steps tie into
14
+ # one note; doing that means retuning the sounding voice in place instead
15
+ # of re-pressing it, which is a chunk of machinery this does not have yet.
16
+ # The synth this was ported from behaves the same way (its note_on_step
17
+ # carries a "FIXME also do appropriate other actions for slide").
18
+ #
19
+ # --- the two things that make it a 303 --------------------------------
20
+ #
21
+ # 1. A DECAY-ONLY filter envelope. The cutoff jumps to its peak at
22
+ # note-on and falls back while the key is still down. Synth's AHR
23
+ # envelope holds at peak instead of falling, which looks like the wrong
24
+ # shape entirely -- but AHR with a NEGATIVE amount is exactly this:
25
+ #
26
+ # filt_f the peak the sweep starts from
27
+ # fenv_amount -envmod * filt_f, so the sweep runs DOWNWARD
28
+ # fenv_attack the fall time (a decay, despite the name)
29
+ #
30
+ # The shape buffer is 1-(1-t)^curve, fast-then-easing, which run
31
+ # downward is the quick drop and long tail the 303 is known for. No new
32
+ # envelope class, and every one of those is still a shared block, so
33
+ # the whole thing stays O(1) live. FILT_F_MIN earns its keep here:
34
+ # envmod = 1.0 aims the sweep at 0 Hz and the clamp catches it.
35
+ #
36
+ # envmod being a FRACTION of filt_f rather than a number of Hz is the
37
+ # 303's own arrangement, and it is why the envelope tracks the cutoff
38
+ # knob: turning cutoff up makes the sweep proportionally bigger.
39
+ #
40
+ # 2. ACCENT, which must not contaminate the patch. On an accented step a
41
+ # 303 raises the cutoff, the resonance, the envelope depth and the
42
+ # level -- and those are all shared blocks here, so the obvious
43
+ # implementation writes filt_f and filt_q and then save_patch() stores
44
+ # the accented values as if they were the knob positions.
45
+ #
46
+ # So accent writes the SPARE INPUTS of blocks Synth already built,
47
+ # never the ones Synth reads back:
48
+ #
49
+ # cutoff _filt_sum.c sum3()'s unused third input
50
+ # resonance _filt_q_blk.b scalar_block()'s unused second input
51
+ #
52
+ # Both are inside the graph the voice already reads, so an accent is
53
+ # still one write and still reaches a sounding note; and filt_f and
54
+ # filt_q read back clean, because Synth reads .a of each. Only
55
+ # fenv_amount has no spare slot -- it is derived from envmod anyway, so
56
+ # _decompile() re-derives the un-accented value on save.
57
+
58
+ import synthio
59
+
60
+ from .blocks import clamp, product, sum3
61
+ from .synth import Synth
62
+ from .waves import get_wave
63
+
64
+
65
+ class BasslineSynth(Synth):
66
+ """Monophonic acid bassline synth after the Roland TB-303: one
67
+ oscillator, a decay-only filter envelope, and per-step slide and
68
+ accent.
69
+
70
+ The 303's own controls map on as:
71
+
72
+ ========== ====================================================
73
+ 303 knob here
74
+ ========== ====================================================
75
+ tuning ``transpose`` (semitones)
76
+ cutoff ``filt_f`` -- the peak the filter sweep starts from
77
+ resonance ``filt_q``
78
+ env mod ``envmod`` -- sweep depth as a FRACTION of filt_f
79
+ decay ``decay`` -- seconds, both the filter fall and the amp
80
+ accent ``accent`` -- how much an accented step is boosted
81
+ ========== ====================================================
82
+
83
+ Play it a step at a time with note_on_step(), which takes the 303's
84
+ per-step slide and accent flags; note_on() is the MIDI-style front end
85
+ and accents anything at or above ``accent_velocity``.
86
+
87
+ A slide glides the pitch into the step but still retriggers the
88
+ envelopes; a real 303 ties the two steps into one note instead. See
89
+ the module comment. Waveform is one shared buffer with no random
90
+ phase, unlike SubtractiveSynth: a monosynth has no second oscillator
91
+ to beat against, so a note-on allocates no waveform at all.
92
+
93
+ ``envmod`` is the source of truth for the filter envelope's depth.
94
+ ``fenv_amount`` is derived from it and from ``filt_f``, so writing
95
+ ``fenv_amount`` directly is overwritten by the next change to either.
96
+ """
97
+
98
+ # fmt: off
99
+ _PARAMS = Synth._PARAMS + ("wave", "envmod", "decay", "amp_level",
100
+ "accent", "accent_cutoff", "accent_q",
101
+ "slide_time", "transpose")
102
+ # fmt: on
103
+
104
+ #: Inherently monophonic -- accent and glide are both shared state
105
+ #: that assumes a single voice.
106
+ mono = True
107
+
108
+ #: note_on() accents at or above this velocity. The 303's sequencer had
109
+ #: a per-step accent switch rather than a velocity; this is the MIDI
110
+ #: stand-in for it, and note_on_step() bypasses it entirely.
111
+ accent_velocity = 100
112
+
113
+ # class attrs: Synth.__init__ builds its graph, and calls _make_env(),
114
+ # before this subclass has run any setup of its own
115
+ _wave_name = "SAW"
116
+ _transpose = 0
117
+ _envmod = 0.5
118
+ _amp_level = 0.8
119
+ _accent = 0.5
120
+ _accent_cutoff = 4000.0
121
+ _accent_q = 0.6
122
+ _slide_time = 0.10
123
+ _accent_on = False
124
+ _env_accent = None
125
+ _filter = None # the shared Biquad; see _build_filter()
126
+ _cutoff = None # its stable frequency node
127
+
128
+ def __init__(self, synthesizer, patch=None):
129
+ super().__init__(synthesizer, patch)
130
+ # _env_accent has no other home: Synth.__init__ calls _make_env()
131
+ # directly rather than _rebuild_env(), and a patch-less synth never
132
+ # reaches _recompile() at all.
133
+ self._rebuild_env()
134
+
135
+ # --- patch <-> live state -------------------------------------------
136
+
137
+ def _recompile(self):
138
+ super()._recompile()
139
+ p = self.patch
140
+ self._wave_name = getattr(p, "wave", "SAW")
141
+ self._transpose = getattr(p, "transpose", 0)
142
+ self._envmod = getattr(p, "envmod", 0.5)
143
+ self._amp_level = getattr(p, "amp_level", 0.8)
144
+ self._accent = getattr(p, "accent", 0.5)
145
+ self._accent_cutoff = getattr(p, "accent_cutoff", 4000.0)
146
+ self._accent_q = getattr(p, "accent_q", 0.6)
147
+ self._slide_time = getattr(p, "slide_time", 0.10)
148
+ get_wave(self._wave_name) # warm the cache; note-on only reads it
149
+ self._accent_on = False
150
+ self._refresh_accent() # also derives fenv_amount from envmod
151
+ self._rebuild_env()
152
+
153
+ def _decompile(self):
154
+ super()._decompile()
155
+ p = self.patch
156
+ p.wave = self._wave_name
157
+ p.transpose = self._transpose
158
+ p.envmod = self._envmod
159
+ p.amp_level = self._amp_level
160
+ p.accent = self._accent
161
+ p.accent_cutoff = self._accent_cutoff
162
+ p.accent_q = self._accent_q
163
+ p.slide_time = self._slide_time
164
+ # super() saved whatever the last step left in the shared block,
165
+ # which on an accented step is the boosted depth. envmod is the
166
+ # real knob, so re-derive the clean value rather than store that.
167
+ p.fenv_amount = -self._envmod * self._filt_f_blk.a
168
+
169
+ # --- the shared filter ------------------------------------------------
170
+ # Mono, so ONE Biquad and one cutoff node serve every note: built once
171
+ # and re-aimed, not allocated per note. Synth rebuilds both each
172
+ # note-on because in poly it has to. Here it does not, and holding
173
+ # still is what lets audio_fx point extra stages at this cutoff once
174
+ # and have them track forever -- the same trick as the synth this was
175
+ # ported from, which shares one filt_env LFO between its voice filter
176
+ # and its effect filters.
177
+
178
+ def _build_filter(self):
179
+ """Create the shared cutoff node and Biquad, once."""
180
+ if self._filt_mode is None:
181
+ self._filter = None
182
+ return
183
+ if self._cutoff is None:
184
+ # sum3's spare inputs are the envelope and velocity, written
185
+ # per note by _voice_cutoff below. Rooted, so it keeps
186
+ # evaluating between notes rather than freezing on the last.
187
+ self._cutoff = clamp(sum3(self._filt_base), self.FILT_F_MIN, self.FILT_F_MAX)
188
+ self.synthio.blocks.append(self._cutoff)
189
+ if self._filter is None or self._filter.mode != self._filt_mode:
190
+ # only on a filt_type change; _cutoff survives it, so anything
191
+ # tracking the cutoff is undisturbed
192
+ self._filter = synthio.Biquad(
193
+ self._filt_mode, frequency=self._cutoff, Q=self._filt_q_blk
194
+ )
195
+
196
+ @property
197
+ def filter(self):
198
+ """The one Biquad every note plays through.
199
+
200
+ Its ``frequency`` is a stable node carrying the whole cutoff bus --
201
+ filt_f, the filter LFO, the envelope sweep, the accent -- so a
202
+ downstream stage can point at it once and follow all of it. That is
203
+ all ``audio_fx.EffectsChain`` needs.
204
+ """
205
+ self._build_filter()
206
+ return self._filter
207
+
208
+ def _voice_cutoff(self, velocity):
209
+ """One voice, so one cutoff node: re-aim it instead of rebuilding."""
210
+ if self._filt_mode is None:
211
+ return None
212
+ self._build_filter()
213
+ inner = self._cutoff.a # the SUM inside the clamp
214
+ inner.b = self._fenv_cur if self._fenv_cur is not None else 0.0
215
+ inner.c = product(self._filt_vel_blk, velocity / 127.0) if self._filt_vel_blk.a else 0.0
216
+ return self._cutoff
217
+
218
+ def _make_filter(self):
219
+ return self.filter
220
+
221
+ # --- accent ----------------------------------------------------------
222
+
223
+ def _refresh_accent(self):
224
+ """Push the current accent state into the shared blocks.
225
+
226
+ Called on every note-on and whenever a knob it depends on moves, so
227
+ an accented note that is still sounding tracks the knob too. Every
228
+ write here is O(1) and lands on a spare input, so nothing Synth
229
+ reads back is disturbed -- see the module comment.
230
+ """
231
+ if self._accent_on:
232
+ boost = self._accent_cutoff * self._accent
233
+ self._filt_q_blk.b = self._accent_q * self._accent
234
+ # the 303 scales env mod by the ALREADY accented cutoff, so an
235
+ # accented step sweeps a wider range as well as a higher one
236
+ envmod = min(1.0, self._envmod + 0.25 * self._accent)
237
+ else:
238
+ boost = 0.0
239
+ self._filt_q_blk.b = 0.0
240
+ envmod = self._envmod
241
+ self._filt_sum.c = boost
242
+ self._fenv.amount = -envmod * (self._filt_f_blk.a + boost)
243
+
244
+ # --- real-time path ---------------------------------------------------
245
+
246
+ def note_on_step(self, midi_note, slide=False, accent=False, velocity=127):
247
+ """Play one sequencer step, with the 303's two per-step flags.
248
+
249
+ ``slide`` glides from the previous step and ties to it, so the
250
+ envelopes keep running. ``accent`` boosts cutoff, resonance,
251
+ envelope depth and level for this step and every step after it,
252
+ until an un-accented one puts them back -- which is how the
253
+ original behaves, the accent living in shared state rather than in
254
+ the voice.
255
+ """
256
+ self._accent_on = accent
257
+ self._refresh_accent() # BEFORE the press: the envelope depth has
258
+ # to be non-zero already or make() builds no envelope node at all
259
+ super().note_on(midi_note, velocity, glide=self._slide_time if slide else 0.0)
260
+
261
+ def note_on(self, midi_note, velocity=127, glide=None):
262
+ """MIDI-style note-on. Accents at or above ``accent_velocity``."""
263
+ self._accent_on = velocity >= self.accent_velocity
264
+ self._refresh_accent()
265
+ super().note_on(midi_note, velocity, glide=glide)
266
+
267
+ def _make_notes(self, midi_note, velocity):
268
+ f = synthio.midi_to_hz(midi_note + self._transpose)
269
+ # No amplitude: the 303 is a fixed-level instrument and velocity
270
+ # picks the accent instead, which arrives as attack_level.
271
+ # fmt: off
272
+ return (synthio.Note(f, waveform=get_wave(self._wave_name),
273
+ envelope=self._env_accent if self._accent_on else self._env,
274
+ filter=self._make_filter(),
275
+ bend=self._bend_cur),)
276
+ # fmt: on
277
+
278
+ # --- amp envelope -----------------------------------------------------
279
+ # Two cached Envelopes rather than one: synthio.Envelope is immutable,
280
+ # so the accented level would otherwise mean building a new one at
281
+ # every accented note-on.
282
+
283
+ def _env_for(self, level):
284
+ a, d, s, r = self._amp_env
285
+ # fmt: off
286
+ return synthio.Envelope(attack_time=a, decay_time=d, sustain_level=s,
287
+ release_time=r, attack_level=level)
288
+ # fmt: on
289
+
290
+ def _make_env(self):
291
+ return self._env_for(self._amp_level)
292
+
293
+ def _rebuild_env(self):
294
+ super()._rebuild_env() # self._env, plus the push to sounding notes
295
+ self._env_accent = self._env_for(min(1.0, self._amp_level + 0.5 * self._accent))
296
+
297
+ # --- live parameters --------------------------------------------------
298
+
299
+ @property
300
+ def filt_f(self):
301
+ return self._filt_f_blk.a
302
+
303
+ @filt_f.setter
304
+ def filt_f(self, v):
305
+ # Overridden only to re-derive the envelope depth: envmod is a
306
+ # fraction of the cutoff, so moving the cutoff moves the sweep.
307
+ self._filt_f_blk.a = v
308
+ self._refresh_accent()
309
+
310
+ @property
311
+ def envmod(self):
312
+ """Filter sweep depth, 0..1, as a fraction of ``filt_f``.
313
+
314
+ 0 = no sweep, 1.0 = sweep all the way down to the FILT_F_MIN clamp.
315
+ """
316
+ return self._envmod
317
+
318
+ @envmod.setter
319
+ def envmod(self, v):
320
+ self._envmod = v
321
+ self._refresh_accent()
322
+
323
+ @property
324
+ def decay(self):
325
+ """Seconds for the filter sweep to fall. The 303's Decay knob.
326
+
327
+ This drives the FILTER envelope only -- ``amp_env`` (or
328
+ ``decay_time``) is the amp's, and wants to be LONGER than this.
329
+ An earlier version tied the two together, which sounds like one
330
+ knob but makes envmod nearly inaudible: if the note fades out at
331
+ the same rate the cutoff falls, the sweep is masked by the
332
+ amplitude and the whole thing just reads as a pluck. Keep the amp
333
+ alive underneath the sweep and the filter movement is obvious.
334
+
335
+ It also has to be SHORTER than the gate, or the sweep is cut off
336
+ partway and envmod does much less than its number suggests -- see
337
+ the filter-envelope notes in the project docs.
338
+ """
339
+ return self._fenv.attack
340
+
341
+ @decay.setter
342
+ def decay(self, v):
343
+ self._fenv.attack = v # a rate write, cheap on a knob
344
+
345
+ @property
346
+ def accent(self):
347
+ return self._accent
348
+
349
+ @accent.setter
350
+ def accent(self, v):
351
+ self._accent = v
352
+ self._refresh_accent()
353
+ self._rebuild_env() # the accented level changed with it
354
+
355
+ @property
356
+ def accent_cutoff(self):
357
+ """Hz added to the cutoff by a full-strength accent."""
358
+ return self._accent_cutoff
359
+
360
+ @accent_cutoff.setter
361
+ def accent_cutoff(self, v):
362
+ self._accent_cutoff = v
363
+ self._refresh_accent()
364
+
365
+ @property
366
+ def accent_q(self):
367
+ """Resonance added by a full-strength accent."""
368
+ return self._accent_q
369
+
370
+ @accent_q.setter
371
+ def accent_q(self, v):
372
+ self._accent_q = v
373
+ self._refresh_accent()
374
+
375
+ @property
376
+ def amp_level(self):
377
+ """Un-accented note level, 0..1 (synthio.Envelope.attack_level)."""
378
+ return self._amp_level
379
+
380
+ @amp_level.setter
381
+ def amp_level(self, v):
382
+ self._amp_level = v
383
+ self._rebuild_env()
384
+
385
+ @property
386
+ def slide_time(self):
387
+ """Seconds a slide step takes. Applied per step by note_on_step(),
388
+ so it never writes glide_time."""
389
+ return self._slide_time
390
+
391
+ @slide_time.setter
392
+ def slide_time(self, v):
393
+ self._slide_time = v
394
+
395
+ @property
396
+ def transpose(self):
397
+ return self._transpose
398
+
399
+ @transpose.setter
400
+ def transpose(self, v):
401
+ self._transpose = v # applies at the next note-on
402
+
403
+ @property
404
+ def wave(self):
405
+ return self._wave_name
406
+
407
+ @wave.setter
408
+ def wave(self, v):
409
+ self._wave_name = v
410
+ get_wave(v) # O(1); warms the cache for the next note-on
synthtools/blocks.py ADDED
@@ -0,0 +1,62 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
2
+ # SPDX-License-Identifier: MIT
3
+ #
4
+ # blocks.py - tiny constructors for synthio's BlockInput graph.
5
+ #
6
+ # Its own module so synth.py and ahr_envelope.py can share these without a
7
+ # circular import (synth.py imports ahr_envelope).
8
+ #
9
+ # These exist so graph-building code reads as arithmetic instead of
10
+ # synthio.Math(synthio.MathOperation.X, ...) boilerplate, and so the
11
+ # operation names appear in exactly one place. Every argument may be a
12
+ # number OR another block -- that is the whole point.
13
+ #
14
+ # Plain functions rather than synthio's callable-MathOperation shorthand
15
+ # (MathOperation.SUM(a, b, c)): not worth depending on in a library that
16
+ # has to run on whatever CircuitPython build is on the board.
17
+
18
+ import synthio
19
+
20
+ _OP = synthio.MathOperation
21
+
22
+
23
+ def scalar_block(v):
24
+ """A settable scalar as a synthio block: write .a to change it.
25
+
26
+ This is the whole trick behind the O(1) parameter design: anywhere
27
+ synthio takes a BlockInput, one of these can stand in for a number and
28
+ stay writable afterwards, so a single write reaches every voice that
29
+ nested it.
30
+ """
31
+ return synthio.Math(_OP.SUM, v, 0.0, 0.0)
32
+
33
+
34
+ def sum3(a, b=0.0, c=0.0):
35
+ """a + b + c."""
36
+ return synthio.Math(_OP.SUM, a, b, c)
37
+
38
+
39
+ def product(a, b, c=1.0):
40
+ """a * b * c."""
41
+ return synthio.Math(_OP.PRODUCT, a, b, c)
42
+
43
+
44
+ def lerp(a, b, t):
45
+ """a*(1-t) + b*t, t unclamped."""
46
+ return synthio.Math(_OP.LERP, a, b, t)
47
+
48
+
49
+ def constrained_lerp(a, b, t):
50
+ """a*(1-t) + b*t with t clamped to 0..1.
51
+
52
+ The one to reach for when t is a one-shot LFO used as a POSITION: it
53
+ cannot overshoot past b if the LFO reads slightly beyond its last
54
+ sample. See ahr_envelope.py and Synth's glide.
55
+ """
56
+ return synthio.Math(_OP.CONSTRAINED_LERP, a, b, t)
57
+
58
+
59
+ def clamp(x, lo, hi):
60
+ """x limited to [lo, hi] -- the middle of three values IS a clamp, so
61
+ this is one block rather than a MIN of a MAX."""
62
+ return synthio.Math(_OP.MID, x, lo, hi)