circuitpython-synthtools 0.5__py3-none-any.whl → 0.5.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: circuitpython-synthtools
3
- Version: 0.5
3
+ Version: 0.5.1
4
4
  Summary: CircuitPython helper library to do help doing synthio
5
5
  Author-email: Tod Kurt <tod@todbot.com>
6
6
  License: MIT
@@ -44,6 +44,15 @@ Introduction
44
44
 
45
45
  CircuitPython library with tools for making synths with synthio
46
46
 
47
+ This library is a collection of tools derived from my many years of playing
48
+ with CircuitPython, ``synthio``, and building synthesizers in general.
49
+ Concepts pulled from these projects and others:
50
+
51
+ - `CircuitPython Synthio Tutorial <https://todbot.github.io/CircuitPython_Synthio_Tutorial/>`_
52
+ - `circuitpython synthio tricks <https://github.com/todbot/circuitpython-synthio-tricks>`_
53
+ - `pico_test_synth <https://github.com/todbot/pico_test_synth>`_
54
+ - `picotouch_synth <https://github.com/todbot/picotouch_synth>`_
55
+ - `picostepseq <https://github.com/todbot/picostepseq>`_
47
56
 
48
57
  Dependencies
49
58
  =============
@@ -120,10 +129,14 @@ What's Included
120
129
  * ``SubtractiveSynth`` -- subtractive two-oscillator synth w/ detune
121
130
  * ``WavetableSynth`` -- wavetable-playback with adjustable wave_pos
122
131
  * ``BasslineSynth`` -- TB-303-style acid bassline: monophonic, one
123
- oscillator, a decay-only filter sweep, per-step slide and accent
124
- * ``EffectsChain`` -- post-synth audio effects: extra filter stages that
125
- track the synth's cutoff for a steeper slope, plus optional distortion
126
- and tempo-synced echo (needs ``audiofilters`` in the build)
132
+ oscillator, a decay-only filter sweep, per-step slide and accent. Can
133
+ own its own filter/distortion/echo effects chain via ``fx_*`` patch
134
+ fields
135
+ * ``EffectsChain`` -- a generic post-synth effects chain: add, insert, or
136
+ remove any ``audiofilters``/``audiodelays`` effect and it stays wired.
137
+ ``tracking_filter()`` builds extra filter stages that follow the synth's
138
+ own cutoff and resonance for a steeper slope (needs ``audiofilters`` in
139
+ the build)
127
140
  * ``Patch`` -- inert, JSON-able patch data; save/load with
128
141
  ``save_patches()`` / ``load_patches()``
129
142
  * ``Wavetable`` -- loads a wavetable WAV file and lerps between frames
@@ -1,9 +1,9 @@
1
- circuitpython_synthtools-0.5.dist-info/licenses/LICENSE,sha256=jhhRyxpqyQxyF_D6SzY2-jCA5v8Z8JXfezCtxBS85CM,1075
2
- synthtools/__init__.py,sha256=KIHidF7kUN9xokoowebfF_6bNHwsgjeyrF14_1n00h4,1276
1
+ circuitpython_synthtools-0.5.1.dist-info/licenses/LICENSE,sha256=jhhRyxpqyQxyF_D6SzY2-jCA5v8Z8JXfezCtxBS85CM,1075
2
+ synthtools/__init__.py,sha256=Hb5mohpSOi6Aba8IM48r8xxmJWd3NL7HqQVJ3F0xRHw,1318
3
3
  synthtools/ahr_envelope.py,sha256=t5JCgO5iGLVKr4MyKnu0PNV1NOk8YfALUo5q69TrYa0,9968
4
4
  synthtools/arpeggiator.py,sha256=n2sPnpZcu9soXsFJCh6OP65_0DuPnvQGUP0ao7V8SXM,3901
5
- synthtools/audio_fx.py,sha256=xR_1PnPlySlMmQ46DcpBoRfplBmVeYeEKFu22Z55vm0,6929
6
- synthtools/bassline_synth.py,sha256=pz5KNyR7iyiaLndL7UpSNMxTuMPuz7GNOKd72z6Y4h8,16680
5
+ synthtools/audio_fx.py,sha256=YjnwniyqOu7UjCBnS0smY1Xsanbgoz_Aop-6bqJhGes,7085
6
+ synthtools/bassline_synth.py,sha256=pnnGeYWFTtfEXmhTPOpoxFzh6WQIrAMqWWfWWn-rOvY,29287
7
7
  synthtools/blocks.py,sha256=NXRkNgKoTtPMh5eDJ2eC7aXmtuZVrr17ZDhFLhJ5tqY,1998
8
8
  synthtools/paramset.py,sha256=agk7IOr-OI_3g9LoV4-hZaZX7cI93GZe1vDeWiiwnqE,6732
9
9
  synthtools/patch.py,sha256=_ueCz9XJDZRRt6j6FboLIbQiikH04GRbsKtnVQr4gHE,5250
@@ -19,7 +19,7 @@ synthtools/wavetable_synth.py,sha256=lvxpvITDgkOK6AJVIBbLOUa01wjPSvGxHpbTdS2fY2g
19
19
  synthtools/ui/gauge_cluster.py,sha256=QnMBLMFgQhNTw7KQHp51ILzWeCrNLV8TwzDoHl_egG4,2900
20
20
  synthtools/ui/param.py,sha256=wJwIiaGlDnGPGCAdeA4veJv5BxbeHE8K4HaddeI6qQs,3629
21
21
  synthtools/ui/param_scaler.py,sha256=1Ow9lp7WgFauBV5dVFbynXxYd7OqAJhSKphSZNA_GO8,2958
22
- circuitpython_synthtools-0.5.dist-info/METADATA,sha256=EsgUQzn0mxBjnCEDQC-trA-ulQh9f9icpQDz0Gc4sfw,5698
23
- circuitpython_synthtools-0.5.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
24
- circuitpython_synthtools-0.5.dist-info/top_level.txt,sha256=1xt53cH064i7vvFVDFyuJugc1NogReXvzsXbYGlgnKE,11
25
- circuitpython_synthtools-0.5.dist-info/RECORD,,
22
+ circuitpython_synthtools-0.5.1.dist-info/METADATA,sha256=pX5x30A4Uz-N2k0S-KBUdKbD8wH3twsdGWtc0pZ7NbI,6454
23
+ circuitpython_synthtools-0.5.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
24
+ circuitpython_synthtools-0.5.1.dist-info/top_level.txt,sha256=1xt53cH064i7vvFVDFyuJugc1NogReXvzsXbYGlgnKE,11
25
+ circuitpython_synthtools-0.5.1.dist-info/RECORD,,
synthtools/__init__.py CHANGED
@@ -25,7 +25,7 @@ Implementation Notes
25
25
 
26
26
  # imports
27
27
 
28
- from .audio_fx import EffectsChain
28
+ from .audio_fx import EffectsChain, set_drive, sync_delay, tracking_filter
29
29
  from .bassline_synth import BasslineSynth
30
30
  from .patch import Patch, load_patches, save_patches
31
31
  from .subtractive_synth import SubtractiveSynth
@@ -41,5 +41,5 @@ try:
41
41
  except ImportError:
42
42
  pass
43
43
 
44
- __version__ = "0.5"
44
+ __version__ = "0.5.1"
45
45
  __repo__ = "https://github.com/todbot/CircuitPython_SynthTools.git"
synthtools/audio_fx.py CHANGED
@@ -1,8 +1,14 @@
1
1
  # SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
2
2
  # SPDX-License-Identifier: MIT
3
3
  #
4
- # audio_fx.py - post-synth effects: extra filter stages that track the
5
- # synth's cutoff, plus optional distortion and echo.
4
+ # audio_fx.py - a generic post-synth effects chain, plus a factory for the
5
+ # one effect that needs synth-specific knowledge: extra filter stages that
6
+ # track the synth's own cutoff and resonance.
7
+ #
8
+ # EffectsChain itself never imports audiofilters/audiodelays -- it just
9
+ # calls `.play(source)` on whatever effect objects it's handed, so it loads
10
+ # fine even where those modules don't exist. Only tracking_filter() (and
11
+ # whatever Distortion/Echo objects a caller constructs) needs them.
6
12
  #
7
13
  # A synthio.Note takes ONE Biquad, so 12 dB/octave is all a voice can do.
8
14
  # Steeper means cascading more biquads downstream, and those need
@@ -11,9 +17,9 @@
11
17
  # BasslineSynth is mono, so it keeps ONE Biquad over one stable cutoff
12
18
  # node -- copy `synth.filter`'s frequency and you track it for good.
13
19
  #
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.)
20
+ # Slope: every Biquad is 12 dB/oct, so `stages` extra ones plus the
21
+ # synth's own give 12*(stages+1). (A 303 is usually called ~18 dB/oct,
22
+ # which cascaded 2-pole sections cannot make at all.)
17
23
  #
18
24
  # Q tracks too, on every stage, matching the synth this was ported from
19
25
  # (its `resonance` setter pushes the same Q into the voice filter and both
@@ -31,150 +37,126 @@
31
37
  import synthio
32
38
 
33
39
  try:
34
- import audiodelays
35
40
  import audiofilters
36
41
  except ImportError: # not in every CircuitPython build
37
- audiodelays = None
38
42
  audiofilters = None
39
43
 
40
44
 
41
45
  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
+ """A synth's output, plus an ordered chain of playback effects it runs
47
+ through. Add, insert, or remove any ``audiofilters``/``audiodelays``
48
+ effect object; the chain keeps every effect's ``.play(source)`` pointed
49
+ at whatever now precedes it::
50
+
51
+ fx = EffectsChain(synth)
52
+ fx.add(tracking_filter(synth, stages=1))
53
+ fx.add(audiofilters.Distortion(mix=0.0, drive=0.5, ...))
46
54
  mixer.voice[0].play(fx.output)
47
55
 
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.
56
+ ``output`` is always the tail -- the synth itself when the chain is
57
+ empty. Building an ``EffectsChain`` touches no effects module at all,
58
+ so it loads fine even where ``audiofilters`` doesn't exist; only the
59
+ effects you add to it need it.
60
+
61
+ A mixer voice holds whatever object ``output`` *was* at the time you
62
+ called ``play()`` -- it has no way to notice ``output`` changing
63
+ identity later. Any ``add``/``insert``/``remove`` that changes the
64
+ tail (an ``add``, an ``insert`` at the end, or removing the current
65
+ tail) needs ``mixer.voice[0].play(fx.output)`` called again to reach
66
+ the speaker; an insert/remove in the *middle* rewires in place and
67
+ needs nothing further, since the tail object itself doesn't change.
58
68
  """
59
69
 
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
- )
70
+ def __init__(self, synth):
76
71
  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
72
+ self._effects = []
136
73
 
137
74
  @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
75
+ def effects(self):
76
+ """The chain in order, outermost effect last. Read-only -- go
77
+ through :meth:`add`/:meth:`insert`/:meth:`remove` so an effect's
78
+ ``.play()`` source can never drift out of sync with this list."""
79
+ return tuple(self._effects)
149
80
 
150
81
  @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
82
+ def output(self):
83
+ """The tail of the chain: what a mixer voice should play."""
84
+ return self._effects[-1] if self._effects else self.synth.synthio
85
+
86
+ def add(self, effect):
87
+ """Append ``effect`` to the end of the chain and return it."""
88
+ effect.play(self.output)
89
+ self._effects.append(effect)
90
+ return effect
91
+
92
+ def insert(self, index, effect):
93
+ """Insert ``effect`` at ``index`` (Python list semantics, negative
94
+ included), rewiring everything from there on."""
95
+ self._effects.insert(index, effect)
96
+ self._rewire(self._effects.index(effect))
97
+ return effect
98
+
99
+ def remove(self, effect):
100
+ """Take ``effect`` out of the chain and rewire around the gap."""
101
+ index = self._effects.index(effect)
102
+ del self._effects[index]
103
+ self._rewire(index)
104
+
105
+ def _rewire(self, from_index):
106
+ src = self._effects[from_index - 1] if from_index > 0 else self.synth.synthio
107
+ for stage in self._effects[from_index:]:
108
+ stage.play(src)
109
+ src = stage
110
+
111
+
112
+ def tracking_filter(synth, stages=1, buffer_size=1024, mix=1.0):
113
+ """Build one ``audiofilters.Filter`` holding ``stages`` extra Biquads
114
+ that track ``synth.filter``'s cutoff AND resonance -- see the module
115
+ comment for why that's automatic once they share the live blocks.
116
+ ``stages`` extra 12 dB/octave sections plus the synth's own give
117
+ ``12*(stages+1)`` overall.
118
+
119
+ Raises ``ImportError`` if this build has no ``audiofilters``,
120
+ ``ValueError`` if ``stages < 1`` or ``synth`` has no filter to track
121
+ (either a poly synth, which has no ``.filter`` at all, or a mono one
122
+ built with ``filt_type=None``).
123
+ """
124
+ if audiofilters is None:
125
+ raise ImportError("audiofilters is not in this CircuitPython build")
126
+ if stages < 1:
127
+ raise ValueError("stages must be >= 1")
128
+ src = getattr(synth, "filter", None)
129
+ if src is None:
130
+ raise ValueError("synth has no filter to track (mono-only, and filt_type must be set)")
131
+ synthesizer = synth.synthio
132
+ # Copies sharing src's frequency AND Q blocks -- both live, so both
133
+ # track the synth (sweep, accent, and a filt_q knob turn) with nothing
134
+ # to keep in sync by hand. One Filter holding a tuple, not a Filter
135
+ # each: `filter` runs the sample through them in order, saving a
136
+ # buffer and a pass per stage.
137
+ biquads = tuple(
138
+ synthio.Biquad(src.mode, frequency=src.frequency, Q=src.Q) for _ in range(stages)
139
+ )
140
+ return audiofilters.Filter(
141
+ filter=biquads,
142
+ mix=mix,
143
+ sample_rate=synthesizer.sample_rate,
144
+ channel_count=synthesizer.channel_count,
145
+ buffer_size=buffer_size,
146
+ )
147
+
148
+
149
+ def set_drive(distortion, amount):
150
+ """Set a ``Distortion`` effect's drive, 0..1, through ``pre_gain`` and
151
+ ``post_gain`` -- LOFI mode's own ``drive`` parameter does not do what
152
+ its name suggests; ``post_gain`` pulls back the level ``pre_gain``
153
+ adds."""
154
+ distortion.pre_gain = amount * 50.0
155
+ distortion.post_gain = amount * -25.0
156
+
157
+
158
+ def sync_delay(echo, bpm, steps=4, steps_per_beat=4):
159
+ """Set an ``Echo`` effect's ``delay_ms`` to ``steps`` sequencer steps
160
+ at ``bpm`` -- a tempo-synced delay is most of what makes a repeating
161
+ line sit right."""
162
+ echo.delay_ms = (60_000.0 / bpm / steps_per_beat) * steps
@@ -57,10 +57,18 @@
57
57
 
58
58
  import synthio
59
59
 
60
+ from .audio_fx import EffectsChain, set_drive, tracking_filter
60
61
  from .blocks import clamp, product, sum3
61
- from .synth import Synth
62
+ from .synth import FILTER_MODES, Synth
62
63
  from .waves import get_wave
63
64
 
65
+ try:
66
+ import audiodelays
67
+ import audiofilters
68
+ except ImportError: # not in every CircuitPython build
69
+ audiodelays = None
70
+ audiofilters = None
71
+
64
72
 
65
73
  class BasslineSynth(Synth):
66
74
  """Monophonic acid bassline synth after the Roland TB-303: one
@@ -98,7 +106,16 @@ class BasslineSynth(Synth):
98
106
  # fmt: off
99
107
  _PARAMS = Synth._PARAMS + ("wave", "envmod", "decay", "amp_level",
100
108
  "accent", "accent_cutoff", "accent_q",
101
- "slide_time", "transpose")
109
+ "slide_time", "transpose",
110
+ # the three STRUCTURAL fx_* fields
111
+ # (fx_filter_stages, fx_distortion_on,
112
+ # fx_echo_on) are deliberately absent: a
113
+ # set_param() from a MIDI CC would silently
114
+ # mute the fx chain, since nothing here can
115
+ # reach into the mixer and re-play() the
116
+ # new tail. See the "effects chain" section.
117
+ "fx_filter_mix", "fx_drive", "fx_drive_mix",
118
+ "fx_delay_ms", "fx_delay_mix", "fx_delay_decay")
102
119
  # fmt: on
103
120
 
104
121
  #: Inherently monophonic -- accent and glide are both shared state
@@ -125,6 +142,23 @@ class BasslineSynth(Synth):
125
142
  _filter = None # the shared Biquad; see _build_filter()
126
143
  _cutoff = None # its stable frequency node
127
144
 
145
+ # --- the owned effects chain: class attrs; see _build_fx() ----------
146
+ FX_BUFFER_SIZE = 1024
147
+ FX_MAX_DELAY_MS = 1000.0 # buffer sizing only, not a knob -- see fx_delay_ms
148
+ _fx_filter_stages = 0
149
+ _fx_filter_mix = 1.0
150
+ _fx_distortion_on = False
151
+ _fx_drive = 0.0
152
+ _fx_drive_mix = 0.0
153
+ _fx_echo_on = False
154
+ _fx_delay_ms = 300.0
155
+ _fx_delay_mix = 0.0
156
+ _fx_delay_decay = 0.3
157
+ _fx = None # the owned EffectsChain, lazily built by _build_fx()
158
+ _fx_stage = None # tracking_filter()'s Filter, if fx_filter_stages > 0
159
+ _fx_dist = None # audiofilters.Distortion, if fx_distortion_on
160
+ _fx_delay = None # audiodelays.Echo, if fx_echo_on
161
+
128
162
  def __init__(self, synthesizer, patch=None):
129
163
  super().__init__(synthesizer, patch)
130
164
  # _env_accent has no other home: Synth.__init__ calls _make_env()
@@ -150,6 +184,34 @@ class BasslineSynth(Synth):
150
184
  self._refresh_accent() # also derives fenv_amount from envmod
151
185
  self._rebuild_env()
152
186
 
187
+ # --- the owned effects chain -------------------------------------
188
+ # Compare the three STRUCTURAL fields against what's already live
189
+ # BEFORE overwriting them: only a real shape change should drop
190
+ # self._fx. A patch load that leaves the fx shape alone must reach
191
+ # the live effects the same as every other param here, not freeze
192
+ # them -- see the "effects chain" section for why a naive
193
+ # unconditional invalidate is a silent wrong-sound bug.
194
+ new_stages = getattr(p, "fx_filter_stages", 0)
195
+ new_distortion_on = getattr(p, "fx_distortion_on", False)
196
+ new_echo_on = getattr(p, "fx_echo_on", False)
197
+ structural_changed = (
198
+ new_stages != self._fx_filter_stages
199
+ or new_distortion_on != self._fx_distortion_on
200
+ or new_echo_on != self._fx_echo_on
201
+ )
202
+ self._fx_filter_stages = new_stages
203
+ self._fx_distortion_on = new_distortion_on
204
+ self._fx_echo_on = new_echo_on
205
+ self._fx_filter_mix = getattr(p, "fx_filter_mix", 1.0)
206
+ self._fx_drive = getattr(p, "fx_drive", 0.0)
207
+ self._fx_drive_mix = getattr(p, "fx_drive_mix", 0.0)
208
+ self._fx_delay_ms = getattr(p, "fx_delay_ms", 300.0)
209
+ self._fx_delay_mix = getattr(p, "fx_delay_mix", 0.0)
210
+ self._fx_delay_decay = getattr(p, "fx_delay_decay", 0.3)
211
+ if structural_changed:
212
+ self._fx = None # rebuilt lazily, next .fx/.output access
213
+ self._push_fx_live()
214
+
153
215
  def _decompile(self):
154
216
  super()._decompile()
155
217
  p = self.patch
@@ -165,6 +227,15 @@ class BasslineSynth(Synth):
165
227
  # which on an accented step is the boosted depth. envmod is the
166
228
  # real knob, so re-derive the clean value rather than store that.
167
229
  p.fenv_amount = -self._envmod * self._filt_f_blk.a
230
+ p.fx_filter_stages = self._fx_filter_stages
231
+ p.fx_filter_mix = self._fx_filter_mix
232
+ p.fx_distortion_on = self._fx_distortion_on
233
+ p.fx_drive = self._fx_drive
234
+ p.fx_drive_mix = self._fx_drive_mix
235
+ p.fx_echo_on = self._fx_echo_on
236
+ p.fx_delay_ms = self._fx_delay_ms
237
+ p.fx_delay_mix = self._fx_delay_mix
238
+ p.fx_delay_decay = self._fx_delay_decay
168
239
 
169
240
  # --- the shared filter ------------------------------------------------
170
241
  # Mono, so ONE Biquad and one cutoff node serve every note: built once
@@ -218,6 +289,108 @@ class BasslineSynth(Synth):
218
289
  def _make_filter(self):
219
290
  return self.filter
220
291
 
292
+ # --- the owned effects chain (optional) -------------------------------
293
+ # A specialized EffectsChain, not the general-purpose one: fixed order
294
+ # (filter -> distortion -> echo, the acid-bass signal flow), built
295
+ # from the SAME tracking_filter()/set_drive() free functions the demo
296
+ # used to call by hand. The three fx_*_on/fx_filter_stages fields are
297
+ # STRUCTURAL -- they decide what exists -- everything else is a LIVE
298
+ # write into whatever's already built. See fx_drive and friends below.
299
+
300
+ def _fx_cfg(self):
301
+ s = self.synthio
302
+ return {
303
+ "sample_rate": s.sample_rate,
304
+ "channel_count": s.channel_count,
305
+ "buffer_size": self.FX_BUFFER_SIZE,
306
+ }
307
+
308
+ def _build_fx(self):
309
+ """Build the owned chain from the current fx_* fields, once.
310
+
311
+ Atomic: everything lands in locals and self._fx/_fx_stage/_fx_dist/
312
+ _fx_delay are only assigned once every requested piece succeeds.
313
+ Assigning self._fx as each piece is built and then raising partway
314
+ (audiofilters present but audiodelays isn't, say) would leave
315
+ self._fx non-None with an fx_echo_on that never got its Echo --
316
+ later code would treat the chain as already built and never retry.
317
+ """
318
+ if self._fx is not None:
319
+ return
320
+ chain = EffectsChain(self)
321
+ stage = dist = delay = None
322
+ # No filter to track with filt_type=None -- an ordinary, silent
323
+ # no-op, the same as _voice_cutoff() returning None. tracking_filter
324
+ # itself raises ValueError for external callers who don't already
325
+ # know why; internally we do, so we just skip the stage instead of
326
+ # letting that surface from an `output` property read.
327
+ if self._fx_filter_stages > 0 and self._filt_mode is not None:
328
+ stage = chain.add(
329
+ tracking_filter(self, stages=self._fx_filter_stages, mix=self._fx_filter_mix)
330
+ )
331
+ if self._fx_distortion_on:
332
+ if audiofilters is None:
333
+ raise ImportError("audiofilters is not in this CircuitPython build")
334
+ # fmt: off
335
+ dist = chain.add(audiofilters.Distortion(
336
+ mode=audiofilters.DistortionMode.LOFI, mix=self._fx_drive_mix,
337
+ soft_clip=True, pre_gain=0, post_gain=0, **self._fx_cfg()))
338
+ # fmt: on
339
+ set_drive(dist, self._fx_drive)
340
+ if self._fx_echo_on:
341
+ if audiodelays is None:
342
+ raise ImportError("audiodelays is not in this CircuitPython build")
343
+ # fmt: off
344
+ delay = chain.add(audiodelays.Echo(
345
+ mix=self._fx_delay_mix, delay_ms=self._fx_delay_ms,
346
+ max_delay_ms=self.FX_MAX_DELAY_MS, decay=self._fx_delay_decay,
347
+ freq_shift=False, **self._fx_cfg()))
348
+ # fmt: on
349
+ self._fx, self._fx_stage, self._fx_dist, self._fx_delay = chain, stage, dist, delay
350
+
351
+ def _push_fx_live(self):
352
+ """Push the current fx_* values into whatever's already built.
353
+
354
+ A no-op for anything not built yet -- called by every live fx_*
355
+ setter AND by _recompile(), so a patch load reaches a chain that's
356
+ already sounding exactly like every other param in this class,
357
+ even mid-transition after a structural change has invalidated
358
+ self._fx but left the still-playing objects in place.
359
+ """
360
+ if self._fx_stage is not None:
361
+ self._fx_stage.mix = self._fx_filter_mix
362
+ if self._fx_dist is not None:
363
+ set_drive(self._fx_dist, self._fx_drive)
364
+ self._fx_dist.mix = self._fx_drive_mix
365
+ if self._fx_delay is not None:
366
+ self._fx_delay.delay_ms = self._fx_delay_ms
367
+ self._fx_delay.mix = self._fx_delay_mix
368
+ self._fx_delay.decay = self._fx_delay_decay
369
+
370
+ @property
371
+ def fx(self):
372
+ """The owned effects chain, built the first time anything needs
373
+ it. Add/insert/remove more effects on it if you want to extend
374
+ past filter+distortion+echo -- it's an ordinary ``EffectsChain``.
375
+ """
376
+ self._build_fx()
377
+ return self._fx
378
+
379
+ @property
380
+ def output(self):
381
+ """What a mixer voice should play: the synth itself, or the tail
382
+ of ``fx`` if any ``fx_*`` field asked for an effect.
383
+
384
+ A mixer voice's ``play()`` captures this object's identity at
385
+ call time. Changing any STRUCTURAL field (``fx_filter_stages``,
386
+ ``fx_distortion_on``, ``fx_echo_on`` -- directly, via
387
+ ``set_param()``, or via ``load_patch()``) invalidates the owned
388
+ chain, so re-fetch ``output`` and hand it to the mixer voice
389
+ again afterward. The LIVE fx knobs (mix, drive, delay time) need
390
+ no such thing -- they reach whatever's already playing.
391
+ """
392
+ return self.fx.output
393
+
221
394
  # --- accent ----------------------------------------------------------
222
395
 
223
396
  def _refresh_accent(self):
@@ -307,6 +480,25 @@ class BasslineSynth(Synth):
307
480
  self._filt_f_blk.a = v
308
481
  self._refresh_accent()
309
482
 
483
+ @property
484
+ def filt_type(self):
485
+ return self._filt_type
486
+
487
+ @filt_type.setter
488
+ def filt_type(self, v):
489
+ # Overridden to also invalidate the owned fx chain on a real mode
490
+ # change. _build_filter() already re-swaps the VOICE Biquad live
491
+ # (its frequency/Q are shared blocks, but `mode` isn't), and
492
+ # tracking_filter() copies that same mode as a plain value into
493
+ # each owned stage -- so left alone, a filt_type change would
494
+ # leave the voice on the new mode and the owned stages stuck on
495
+ # the old one: wrong sound, no exception.
496
+ old_mode = self._filt_mode
497
+ self._filt_type = v
498
+ self._filt_mode = FILTER_MODES.get(v)
499
+ if self._filt_mode != old_mode:
500
+ self._fx = None
501
+
310
502
  @property
311
503
  def envmod(self):
312
504
  """Filter sweep depth, 0..1, as a fraction of ``filt_f``.
@@ -408,3 +600,112 @@ class BasslineSynth(Synth):
408
600
  def wave(self, v):
409
601
  self._wave_name = v
410
602
  get_wave(v) # O(1); warms the cache for the next note-on
603
+
604
+ # --- owned effects chain: the three STRUCTURAL switches --------------
605
+ # Changing any of these invalidates self._fx (rebuilt lazily, next
606
+ # .fx/.output access) but leaves whatever is currently built alone --
607
+ # it's still what the mixer is playing. See "the owned effects chain"
608
+ # above for why, and output's docstring for the resulting contract.
609
+
610
+ @property
611
+ def fx_filter_stages(self):
612
+ """Extra 12 dB/octave Biquad stages cascaded after the voice's own
613
+ filter, via ``tracking_filter()``. 0 = none (the default -- costs
614
+ nothing and never touches ``audiofilters``). Has no effect while
615
+ ``filt_type`` is ``None``: there is no cutoff to track."""
616
+ return self._fx_filter_stages
617
+
618
+ @fx_filter_stages.setter
619
+ def fx_filter_stages(self, v):
620
+ self._fx_filter_stages = v
621
+ self._fx = None
622
+
623
+ @property
624
+ def fx_distortion_on(self):
625
+ """Whether a distortion stage exists at all. Separate from
626
+ ``fx_drive_mix`` on purpose: even at mix 0 a ``Distortion`` effect
627
+ costs a buffer and real CPU every block, so whether one *exists*
628
+ has to be deliberate."""
629
+ return self._fx_distortion_on
630
+
631
+ @fx_distortion_on.setter
632
+ def fx_distortion_on(self, v):
633
+ self._fx_distortion_on = v
634
+ self._fx = None
635
+
636
+ @property
637
+ def fx_echo_on(self):
638
+ """Whether an echo stage exists at all -- see ``fx_distortion_on``,
639
+ same reasoning."""
640
+ return self._fx_echo_on
641
+
642
+ @fx_echo_on.setter
643
+ def fx_echo_on(self, v):
644
+ self._fx_echo_on = v
645
+ self._fx = None
646
+
647
+ # --- owned effects chain: the six LIVE knobs --------------------------
648
+ # Each reaches whatever's already built via _push_fx_live(); a no-op
649
+ # until the matching structural switch above turns the effect on.
650
+
651
+ @property
652
+ def fx_filter_mix(self):
653
+ """Dry/wet for the extra filter stages, 1.0 = fully filtered."""
654
+ return self._fx_filter_mix
655
+
656
+ @fx_filter_mix.setter
657
+ def fx_filter_mix(self, v):
658
+ self._fx_filter_mix = v
659
+ self._push_fx_live()
660
+
661
+ @property
662
+ def fx_drive(self):
663
+ """Distortion amount, 0..1, via ``set_drive()`` (LOFI mode's own
664
+ ``drive`` parameter does not behave as its name suggests)."""
665
+ return self._fx_drive
666
+
667
+ @fx_drive.setter
668
+ def fx_drive(self, v):
669
+ self._fx_drive = v
670
+ self._push_fx_live()
671
+
672
+ @property
673
+ def fx_drive_mix(self):
674
+ """Dry/wet for the distortion stage."""
675
+ return self._fx_drive_mix
676
+
677
+ @fx_drive_mix.setter
678
+ def fx_drive_mix(self, v):
679
+ self._fx_drive_mix = v
680
+ self._push_fx_live()
681
+
682
+ @property
683
+ def fx_delay_ms(self):
684
+ """Echo delay time in milliseconds."""
685
+ return self._fx_delay_ms
686
+
687
+ @fx_delay_ms.setter
688
+ def fx_delay_ms(self, v):
689
+ self._fx_delay_ms = v
690
+ self._push_fx_live()
691
+
692
+ @property
693
+ def fx_delay_mix(self):
694
+ """Dry/wet for the echo stage."""
695
+ return self._fx_delay_mix
696
+
697
+ @fx_delay_mix.setter
698
+ def fx_delay_mix(self, v):
699
+ self._fx_delay_mix = v
700
+ self._push_fx_live()
701
+
702
+ @property
703
+ def fx_delay_decay(self):
704
+ """Echo feedback, 0..1 -- how much each repeat carries into the
705
+ next. Unrelated to ``decay``, the filter-envelope fall time."""
706
+ return self._fx_delay_decay
707
+
708
+ @fx_delay_decay.setter
709
+ def fx_delay_decay(self, v):
710
+ self._fx_delay_decay = v
711
+ self._push_fx_live()