circuitpython-synthtools 0.5__py3-none-any.whl → 0.6.0__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.6.0
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,16 @@ 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
+ - `synthiota <https://github.com/todbot/synthiota>`_
54
+ - `pico_test_synth <https://github.com/todbot/pico_test_synth>`_
55
+ - `picotouch_synth <https://github.com/todbot/picotouch_synth>`_
56
+ - `picostepseq <https://github.com/todbot/picostepseq>`_
47
57
 
48
58
  Dependencies
49
59
  =============
@@ -118,12 +128,22 @@ What's Included
118
128
  * ``Synth`` -- synth engine base: shared voice, patch, and modulation handling,
119
129
  with ``mono`` mode for a single-voice synth with ``glide_time`` portamento
120
130
  * ``SubtractiveSynth`` -- subtractive two-oscillator synth w/ detune
131
+ * ``FMSynth`` -- two-operator phase-modulation voice: a carrier waveform
132
+ pre-rendered from ``sin(theta + fm_index*sin(fm_ratio*theta))``.
133
+ ``fm_ratio`` must be an integer; ``fm_index`` is PM depth in radians,
134
+ 0 = plain single-oscillator. (Not a live audio-rate bend modulator --
135
+ synthio's Math/LFO blocks only update every 256 samples, too slow for
136
+ that; see the module docstring for why.)
121
137
  * ``WavetableSynth`` -- wavetable-playback with adjustable wave_pos
122
138
  * ``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)
139
+ oscillator, a decay-only filter sweep, per-step slide and accent. Can
140
+ own its own filter/distortion/echo effects chain via ``fx_*`` patch
141
+ fields
142
+ * ``EffectsChain`` -- a generic post-synth effects chain: add, insert, or
143
+ remove any ``audiofilters``/``audiodelays`` effect and it stays wired.
144
+ ``tracking_filter()`` builds extra filter stages that follow the synth's
145
+ own cutoff and resonance for a steeper slope (needs ``audiofilters`` in
146
+ the build)
127
147
  * ``Patch`` -- inert, JSON-able patch data; save/load with
128
148
  ``save_patches()`` / ``load_patches()``
129
149
  * ``Wavetable`` -- loads a wavetable WAV file and lerps between frames
@@ -135,6 +155,9 @@ What's Included
135
155
  sequencers with on/off callbacks
136
156
  * ``Param`` / ``ParamSet`` -- knob-pickup and scaling for UIs with fewer
137
157
  knobs than parameters
158
+ * ``ParamScaler`` -- proportional ("scale") knob takeover for a single
159
+ control, when you are not using ``ParamSet``
160
+ * ``GaugeCluster`` -- a bar-graph display of a parameter page
138
161
  * ``Glider`` -- a standalone pitch-slide block for hand-built
139
162
  ``synthio.Note`` graphs (the engines above have their own portamento,
140
163
  via ``mono`` + ``glide_time``)
@@ -0,0 +1,26 @@
1
+ circuitpython_synthtools-0.6.0.dist-info/licenses/LICENSE,sha256=jhhRyxpqyQxyF_D6SzY2-jCA5v8Z8JXfezCtxBS85CM,1075
2
+ synthtools/__init__.py,sha256=WXLJ-chYBMFnvVbSnwyxy2Txv9ixApOKi8vyrKypQlA,2592
3
+ synthtools/ahr_envelope.py,sha256=fWhjp7LahNGjL8qtTgyiAnX5hnviAULuyc9dEJ6t74I,9233
4
+ synthtools/arpeggiator.py,sha256=n2sPnpZcu9soXsFJCh6OP65_0DuPnvQGUP0ao7V8SXM,3901
5
+ synthtools/audio_fx.py,sha256=VzdWT_pCwfQU5GTb8hVGGMwoFV4lxPwWE1Nm7A1a_Fc,6675
6
+ synthtools/bassline_synth.py,sha256=Gkz7MKKUITgND4Se5nAJly9sZLQjHhFdd-aFrOAjeVM,28112
7
+ synthtools/blocks.py,sha256=EsGcDEzIJX_dvBN8CVlDrewtl16x5leU03tum9jrhr0,1995
8
+ synthtools/fm_synth.py,sha256=-r4NcsESjEechkbmV0tZx4pXayHIz4qUfIznKtQoKgQ,6073
9
+ synthtools/gauge_cluster.py,sha256=lcMkRK-db6KV6pu0PUJRWAOJT8IdjgpE_4bAi0lQwxc,2910
10
+ synthtools/param_scaler.py,sha256=dmaFkzRhrfZn54DAgPwZs7P4B4BFd90ZUvMHpWIf2i4,6202
11
+ synthtools/paramset.py,sha256=--V89zHR4pUhkSey1CX7iMLNsF5l1hKmKWrAr3j_cRA,9938
12
+ synthtools/patch.py,sha256=vMr-kdVD66uxY8BrrnB0J6uDLREjuu16I8HzfhAkqHs,6033
13
+ synthtools/pitch_glider.py,sha256=R5h0Lbyq_slo7SKORRM7a0oumVugE15L8hOZJCov0AY,1927
14
+ synthtools/step_sequencer.py,sha256=SkQuDsRtpAdKOXU3OsFmngeDPVQ0OXt3Gj5wqFCkEPM,4202
15
+ synthtools/subtractive_synth.py,sha256=WIpmhbiiLtyQRFV9mHWWM-duST5RfY9HUN4SRf5wTOw,3382
16
+ synthtools/swarm_synth.py,sha256=kCYsdVuaafTKkD06_m22iFaPUMCzeBKX67mkrsEq3pk,10945
17
+ synthtools/synth.py,sha256=ZdmAKWBVzJqPyzU5W1hxnJ6j09D4rnCWm77s5jRW_Ww,36528
18
+ synthtools/trig_sequencer.py,sha256=PaWgsTqOvuRjFUJ6VjxMLBuRkyeYCML9WQXBzx02Onk,2921
19
+ synthtools/utils.py,sha256=deKQbkGXIBdGUOXBOpRCc-UHdPiL8YmybIJ3BBo9aA4,648
20
+ synthtools/waves.py,sha256=lwDka71TsoHGJcyVfmG4t-TEf6TjBwftJEXSS4Qxyx0,22407
21
+ synthtools/wavetable.py,sha256=K1rghAptDYIOwy0PcL9xCllnJH6X3h875MpeuXCm6lQ,2492
22
+ synthtools/wavetable_synth.py,sha256=WUs4FfrKd1hosPiPYnf05I4S8t98ngEMi8qhShkbCps,12896
23
+ circuitpython_synthtools-0.6.0.dist-info/METADATA,sha256=C4HgNUbEFEH2PUe0Pv8sQ9-u7t3P4rSOMQF6upscetw,7086
24
+ circuitpython_synthtools-0.6.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
25
+ circuitpython_synthtools-0.6.0.dist-info/top_level.txt,sha256=1xt53cH064i7vvFVDFyuJugc1NogReXvzsXbYGlgnKE,11
26
+ circuitpython_synthtools-0.6.0.dist-info/RECORD,,
synthtools/__init__.py CHANGED
@@ -21,25 +21,64 @@ Implementation Notes
21
21
 
22
22
  * synthio : https://docs.circuitpython.org/en/latest/shared-bindings/synthio/
23
23
 
24
+ Importing
25
+ ---------
26
+
27
+ Names are resolved LAZILY: ``from synthtools import Patch`` imports
28
+ ``synthtools.patch`` and nothing else. Adding a tool to this library costs
29
+ nothing to anyone who does not use it.
30
+
31
+ Anything not listed below is still importable by its module path::
32
+
33
+ from synthtools.waves import get_wave
34
+ from synthtools.step_sequencer import StepSequencer
24
35
  """
25
36
 
26
- # imports
27
-
28
- from .audio_fx import EffectsChain
29
- from .bassline_synth import BasslineSynth
30
- from .patch import Patch, load_patches, save_patches
31
- from .subtractive_synth import SubtractiveSynth
32
- from .synth import Synth
33
-
34
- # wavetable_synth needs the adafruit_wave library; don't break the whole
35
- # package if it isn't installed. Import it directly if you want it:
36
- # from synthtools.wavetable import Wavetable
37
- # from synthtools.wavetable_synth import WavetableSynth
38
- try:
39
- from .wavetable import Wavetable
40
- from .wavetable_synth import WavetableSynth
41
- except ImportError:
42
- pass
43
-
44
- __version__ = "0.5"
37
+ import sys
38
+
39
+ __version__ = "0.6.0"
45
40
  __repo__ = "https://github.com/todbot/CircuitPython_SynthTools.git"
41
+
42
+ #: Public name -> the submodule that defines it. Every entry is loaded on
43
+ #: first use and cached by the import system, never at package import.
44
+ _LAZY = {
45
+ "EffectsChain": "audio_fx",
46
+ "set_drive": "audio_fx",
47
+ "sync_delay": "audio_fx",
48
+ "tracking_filter": "audio_fx",
49
+ "BasslineSynth": "bassline_synth",
50
+ "FMSynth": "fm_synth",
51
+ "GaugeCluster": "gauge_cluster",
52
+ "ParamScaler": "param_scaler",
53
+ "Patch": "patch",
54
+ "load_patches": "patch",
55
+ "save_patches": "patch",
56
+ "SubtractiveSynth": "subtractive_synth",
57
+ "SwarmSynth": "swarm_synth",
58
+ "Synth": "synth",
59
+ # These two additionally need adafruit_wave. Asking for one without it
60
+ # raises ImportError naming adafruit_wave, rather than the name simply
61
+ # not existing.
62
+ "Wavetable": "wavetable",
63
+ "WavetableSynth": "wavetable_synth",
64
+ }
65
+
66
+ __all__ = tuple(sorted(_LAZY))
67
+
68
+
69
+ def __getattr__(name):
70
+ """Import the submodule that defines ``name`` on first access (PEP 562).
71
+
72
+ Verified on CircuitPython 10.3.0-alpha.3 as well as CPython and
73
+ MicroPython. Note the import must be spelled as an absolute
74
+ ``__import__`` of the dotted path: ``from . import <mod>`` re-enters
75
+ this function on MicroPython (a package-attribute lookup) and
76
+ recurses into AttributeError.
77
+ """
78
+ modname = _LAZY.get(name)
79
+ if modname is None:
80
+ raise AttributeError(name)
81
+ full = __name__ + "." + modname
82
+ if full not in sys.modules:
83
+ __import__(full)
84
+ return getattr(sys.modules[full], name)
@@ -1,7 +1,7 @@
1
1
  # SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
2
2
  # SPDX-License-Identifier: MIT
3
3
  #
4
- # ahr_envelope.py - Attack-Release envelope for synthio, as a modulation
4
+ # ahr_envelope.py -- Attack-Release envelope for synthio, as a modulation
5
5
  # SOURCE: it produces 0 -> amount and knows nothing about where that goes.
6
6
  #
7
7
  # Per voice, three objects:
@@ -12,44 +12,32 @@
12
12
  #
13
13
  # The LFO is only a *position*: it runs 0 -> 1 through a shared, shaped
14
14
  # buffer, and CONSTRAINED_LERP decides what that position maps onto. So
15
- # release is a matter of moving the endpoints -- NOT of swapping the
16
- # waveform. That matters: synthio.LFO.waveform is read-only,
17
- # so an implementation that# reassigns it raises
18
- # AttributeError: can't set attribute 'waveform'
19
- # at every note-off. Mutating the buffer's contents in place is fine, and
20
- # is documented synthio behaviour.
15
+ # release moves the ENDPOINTS rather than swapping the waveform. That
16
+ # matters, because synthio.LFO.waveform is read-only and reassigning it
17
+ # raises `AttributeError: can't set attribute 'waveform'` at every
18
+ # note-off. Mutating the buffer's contents in place is documented and fine.
21
19
  #
22
20
  # One consequence is easy to get wrong. Release replays the SAME rising
23
- # buffer forward, so its output is `V * (1 - s(t))` -- it INVERTS whatever
21
+ # buffer forward, so its output is `V * (1 - s(t))`, INVERTING whatever
24
22
  # curvature s has. A buffer holding the obvious t^curve therefore makes the
25
- # release hang near the top and then fall off a cliff (75% of its height
26
- # still left at the halfway point, for curve=2): a mirrored attack, not a
23
+ # release hang near the top and then fall off a cliff (at curve=2, 75% of
24
+ # its height is still left at the halfway point): a mirrored attack, not a
27
25
  # decay. The buffer holds 1-(1-t)^curve instead, which comes back out of
28
26
  # that inversion as V*(1-t)^curve. Attack and release curvature are linked
29
- # as a result -- one buffer, one exponent. See fill_env_rise() in waves.py.
27
+ # as a result: one buffer, one exponent. See fill_env_rise() in waves.py.
30
28
  #
31
- # --- why this is bigger than the tutorial's version -------------------
32
- #
33
- # todsynth/ahr_envelope.py in the synthio tutorial is the same
34
- # CONSTRAINED_LERP idea, and it is the right size for a monosynth: one
35
- # object per voice, writing plain floats into env.a / env.b at press. The
36
- # cost of that is that every global parameter is O(polyphony) -- moving the
37
- # envelope depth means looping over live voices and writing each one.
38
- #
39
- # This is the polyphonic version. ONE instance is a factory for many
40
- # voices, and the things a knob touches are shared objects nested inside
41
- # each voice's graph:
29
+ # ONE instance is a factory for many voices. Everything a knob touches is a
30
+ # shared object nested inside each voice's graph:
42
31
  #
43
32
  # _amt the depth, nested in every voice's `depth`
44
33
  # _rate_a the attack rate, every voice's LFO rate
45
34
  # _rate_r the release rate
46
35
  # _wave the shape, rewritten in place under sounding voices
47
36
  #
48
- # so amount, attack, release and curve each reach every sounding voice --
49
- # including ones already in release -- with a SINGLE write. That is the
50
- # only reason for the extra indirection. The shared buffer also buys
51
- # arbitrary integer curve exponents for free, where the tutorial's
52
- # PRODUCT(lerp, lerp, 1) costs one Math per voice and only gives one curve.
37
+ # so amount, attack, release and curve each reach every sounding voice,
38
+ # release included, with a SINGLE write. That is the whole reason for the
39
+ # extra indirection over a per-voice envelope, which would be O(polyphony)
40
+ # on every global parameter.
53
41
 
54
42
  import synthio
55
43
 
@@ -69,13 +57,13 @@ class AHREnvelope:
69
57
  falling=True amount -> 0, release on to release_amount
70
58
 
71
59
  The filter envelope is the rising case added to a cutoff bus; a pitch
72
- envelope is the falling one added to a bend graph -- it starts off-pitch
60
+ envelope is the falling one added to a bend graph; it starts off-pitch
73
61
  and settles, then on note-off drifts on to release_amount. Nothing here
74
62
  knows which.
75
63
 
76
- The two differ ONLY in make()'s endpoints. Everything else -- the shared
64
+ The two differ ONLY in make()'s endpoints. Everything else (the shared
77
65
  amount block, the shared rates, the in-place shape buffer, the
78
- re-aim-the-endpoints release -- is identical, which is why this is one
66
+ re-aim-the-endpoints release) is identical, which is why this is one
79
67
  class with a flag rather than two classes."""
80
68
 
81
69
  def __init__(
@@ -85,9 +73,9 @@ class AHREnvelope:
85
73
  self._release = release
86
74
  self._curve = curve
87
75
  self._falling = falling
88
- # Created ONCE and never replaced. Sounding voices hold references
89
- # to all of these, so identity has to survive patch reloads --
90
- # assigning a new object here would orphan the live ones.
76
+ # Created ONCE and never replaced. Sounding voices hold references to
77
+ # all of these, so identity has to survive patch reloads; assigning a
78
+ # new object here would orphan the live ones.
91
79
  self._wave = env_buffer()
92
80
  self._rate_a = scalar_block(1.0)
93
81
  self._rate_r = scalar_block(1.0)
@@ -107,8 +95,7 @@ class AHREnvelope:
107
95
  fill_env_rise(self._wave, self._curve)
108
96
 
109
97
  def _refresh_rates(self):
110
- # the rise fills the whole buffer, so a rate is just 1/seconds --
111
- # no fraction to correct for
98
+ # the rise fills the whole buffer, so a rate is just 1/seconds
112
99
  self._rate_a.a = 1.0 / max(self._attack, 0.001)
113
100
  self._rate_r.a = 1.0 / max(self._release, 0.001)
114
101
 
@@ -160,8 +147,8 @@ class AHREnvelope:
160
147
 
161
148
  @amount.setter
162
149
  def amount(self, v):
163
- # one write into the shared block, reaching every voice including
164
- # ones already in release (it stays live inside each voice's depth)
150
+ # one write into the shared block, reaching every voice, release
151
+ # included, since it stays live inside each voice's depth
165
152
  self._amt.a = v
166
153
 
167
154
  @property
@@ -183,7 +170,7 @@ class AHREnvelope:
183
170
  Returns the CONSTRAINED_LERP block to add to a destination. It
184
171
  carries everything start_release() needs: the position LFO in ``c``.
185
172
 
186
- ``gain`` is a per-voice depth scale -- a plain number, or a block
173
+ ``gain`` is a per-voice depth scale: a plain number, or a block
187
174
  (e.g. a velocity LERP) so whatever drives it stays live.
188
175
 
189
176
  Returning None is what makes the envelope cost literally nothing
@@ -196,12 +183,12 @@ class AHREnvelope:
196
183
  """
197
184
  if not self._amt.a and not self._rel_amt.a:
198
185
  return None
199
- # A plain 1.0 means "nothing is scaling this", and the voice can use
200
- # the shared block directly -- one Math lighter in the common case.
201
- # A BLOCK is always wrapped, even if it happens to evaluate to 1.0
202
- # right now, because it can change while the voice sounds. Hence the
203
- # isinstance rather than a bare `gain == 1.0`: what a synthio block
204
- # does under `==` is not ours to assume.
186
+ # A plain 1.0 means nothing is scaling this, so the voice can use the
187
+ # shared block directly, one Math lighter. A BLOCK is always wrapped
188
+ # even if it evaluates to 1.0 right now, because it can change while
189
+ # the voice sounds. Hence isinstance rather than a bare
190
+ # `gain == 1.0`: what a synthio block does under `==` is not ours to
191
+ # assume.
205
192
  if isinstance(gain, (int, float)) and gain == 1.0:
206
193
  depth = self._amt
207
194
  else:
@@ -222,7 +209,7 @@ class AHREnvelope:
222
209
  structural: there is no gain to recompute.
223
210
 
224
211
  The target is the shared release-amount BLOCK, not a number, which
225
- is what lets it stay live for a voice that is already falling -- and
212
+ is what lets it stay live for a voice that is already falling, and
226
213
  it means the rising and falling cases need no branch here at all,
227
214
  since a filter envelope simply has a block that holds 0.0.
228
215
  """
synthtools/audio_fx.py CHANGED
@@ -1,180 +1,155 @@
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.
6
7
  #
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.
8
+ # EffectsChain itself never imports audiofilters/audiodelays: it only calls
9
+ # `.play(source)` on whatever effect objects it is handed, so it loads fine
10
+ # where those modules don't exist. Only tracking_filter(), and whatever
11
+ # Distortion/Echo a caller constructs, needs them.
13
12
  #
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.)
13
+ # A synthio.Note takes ONE Biquad, so 12 dB/octave is all a voice can do.
14
+ # Steeper means cascading more biquads downstream, and those need somewhere
15
+ # stable to point their `frequency`: Synth rebuilds the voice's cutoff node
16
+ # at every note-on, and it freezes once the Note is freed. BasslineSynth is
17
+ # mono, so it keeps ONE Biquad over one stable cutoff node; copy
18
+ # `synth.filter`'s frequency and you track it for good. Slope is
19
+ # 12*(stages+1). (A 303 is usually called ~18 dB/oct, which cascaded 2-pole
20
+ # sections cannot make at all.)
17
21
  #
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.
22
+ # Q tracks on every stage, and automatically: `src.Q` is the synth's live
23
+ # `filt_q` block, the same object the voice's own Biquad reads, so wiring an
24
+ # extra stage's Q to it needs no propagation code.
24
25
  #
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.
26
+ # Identical resonant sections stacked this way do pile up gain at the cutoff,
27
+ # each stage adding its own peak on top of the last. At a squelchy filt_q
28
+ # that is real headroom to watch, and also most of why a cascaded resonant
29
+ # filter reads as more aggressive than a single one.
30
30
 
31
31
  import synthio
32
32
 
33
33
  try:
34
- import audiodelays
35
34
  import audiofilters
36
35
  except ImportError: # not in every CircuitPython build
37
- audiodelays = None
38
36
  audiofilters = None
39
37
 
40
38
 
41
39
  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)
40
+ """A synth's output, plus an ordered chain of playback effects it runs
41
+ through. Add, insert, or remove any ``audiofilters``/``audiodelays``
42
+ effect object; the chain keeps every effect's ``.play(source)`` pointed
43
+ at whatever now precedes it::
44
+
45
+ fx = EffectsChain(synth)
46
+ fx.add(tracking_filter(synth, stages=1))
47
+ fx.add(audiofilters.Distortion(mix=0.0, drive=0.5, ...))
46
48
  mixer.voice[0].play(fx.output)
47
49
 
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.
50
+ ``output`` is always the tail: the synth itself when the chain is
51
+ empty. Building an ``EffectsChain`` touches no effects module at all,
52
+ so it loads fine even where ``audiofilters`` doesn't exist; only the
53
+ effects you add to it need it.
54
+
55
+ A mixer voice holds whatever object ``output`` *was* at the time you
56
+ called ``play()``: it has no way to notice ``output`` changing
57
+ identity later. Any ``add``/``insert``/``remove`` that changes the
58
+ tail (an ``add``, an ``insert`` at the end, or removing the current
59
+ tail) needs ``mixer.voice[0].play(fx.output)`` called again to reach
60
+ the speaker; an insert/remove in the *middle* rewires in place and
61
+ needs nothing further, since the tail object itself doesn't change.
58
62
  """
59
63
 
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
- )
64
+ def __init__(self, synth):
76
65
  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
66
+ self._effects = []
136
67
 
137
68
  @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
69
+ def effects(self):
70
+ """The chain in order, outermost effect last. Read-only: go
71
+ through :meth:`add`/:meth:`insert`/:meth:`remove` so an effect's
72
+ ``.play()`` source can never drift out of sync with this list."""
73
+ return tuple(self._effects)
149
74
 
150
75
  @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
76
+ def output(self):
77
+ """The tail of the chain: what a mixer voice should play."""
78
+ return self._effects[-1] if self._effects else self.synth.synthio
79
+
80
+ def add(self, effect):
81
+ """Append ``effect`` to the end of the chain and return it."""
82
+ effect.play(self.output)
83
+ self._effects.append(effect)
84
+ return effect
85
+
86
+ def insert(self, index, effect):
87
+ """Insert ``effect`` at ``index`` (Python list semantics, negative
88
+ included), rewiring everything from there on."""
89
+ self._effects.insert(index, effect)
90
+ self._rewire(self._effects.index(effect))
91
+ return effect
92
+
93
+ def remove(self, effect):
94
+ """Take ``effect`` out of the chain and rewire around the gap."""
95
+ index = self._effects.index(effect)
96
+ del self._effects[index]
97
+ self._rewire(index)
98
+
99
+ def _rewire(self, from_index):
100
+ src = self._effects[from_index - 1] if from_index > 0 else self.synth.synthio
101
+ for stage in self._effects[from_index:]:
102
+ stage.play(src)
103
+ src = stage
104
+
105
+
106
+ def tracking_filter(synth, stages=1, buffer_size=1024, mix=1.0):
107
+ """Build one ``audiofilters.Filter`` holding ``stages`` extra Biquads
108
+ that track ``synth.filter``'s cutoff AND resonance; see the module
109
+ comment for why that's automatic once they share the live blocks.
110
+ ``stages`` extra 12 dB/octave sections plus the synth's own give
111
+ ``12*(stages+1)`` overall.
112
+
113
+ Raises ``ImportError`` if this build has no ``audiofilters``,
114
+ ``ValueError`` if ``stages < 1`` or ``synth`` has no filter to track
115
+ (either a poly synth, which has no ``.filter`` at all, or a mono one
116
+ built with ``filt_type=None``).
117
+ """
118
+ if audiofilters is None:
119
+ raise ImportError("audiofilters is not in this CircuitPython build")
120
+ if stages < 1:
121
+ raise ValueError("stages must be >= 1")
122
+ src = getattr(synth, "filter", None)
123
+ if src is None:
124
+ raise ValueError("synth has no filter to track (mono-only, and filt_type must be set)")
125
+ synthesizer = synth.synthio
126
+ # Copies sharing src's frequency AND Q blocks, both live, so both track
127
+ # the synth (sweep, accent, a filt_q turn) with nothing to keep in sync
128
+ # by hand. One Filter holding a tuple, not a Filter each: `filter` runs
129
+ # the sample through them in order, saving a buffer and a pass per stage.
130
+ biquads = tuple(
131
+ synthio.Biquad(src.mode, frequency=src.frequency, Q=src.Q) for _ in range(stages)
132
+ )
133
+ return audiofilters.Filter(
134
+ filter=biquads,
135
+ mix=mix,
136
+ sample_rate=synthesizer.sample_rate,
137
+ channel_count=synthesizer.channel_count,
138
+ buffer_size=buffer_size,
139
+ )
140
+
141
+
142
+ def set_drive(distortion, amount):
143
+ """Set a ``Distortion`` effect's drive, 0..1, through ``pre_gain`` and
144
+ ``post_gain`` (LOFI mode's own ``drive`` parameter does not do what
145
+ its name suggests); ``post_gain`` pulls back the level ``pre_gain``
146
+ adds."""
147
+ distortion.pre_gain = amount * 50.0
148
+ distortion.post_gain = amount * -25.0
149
+
150
+
151
+ def sync_delay(echo, bpm, steps=4, steps_per_beat=4):
152
+ """Set an ``Echo`` effect's ``delay_ms`` to ``steps`` sequencer steps
153
+ at ``bpm``: a tempo-synced delay is most of what makes a repeating
154
+ line sit right."""
155
+ echo.delay_ms = (60_000.0 / bpm / steps_per_beat) * steps