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.
- {circuitpython_synthtools-0.5.dist-info → circuitpython_synthtools-0.6.0.dist-info}/METADATA +28 -5
- circuitpython_synthtools-0.6.0.dist-info/RECORD +26 -0
- synthtools/__init__.py +58 -19
- synthtools/ahr_envelope.py +32 -45
- synthtools/audio_fx.py +129 -154
- synthtools/bassline_synth.py +348 -62
- synthtools/blocks.py +4 -4
- synthtools/fm_synth.py +150 -0
- synthtools/{ui/gauge_cluster.py → gauge_cluster.py} +4 -4
- synthtools/param_scaler.py +144 -0
- synthtools/paramset.py +90 -30
- synthtools/patch.py +26 -13
- synthtools/subtractive_synth.py +9 -11
- synthtools/swarm_synth.py +253 -0
- synthtools/synth.py +211 -114
- synthtools/utils.py +1 -1
- synthtools/waves.py +132 -60
- synthtools/wavetable.py +9 -10
- synthtools/wavetable_synth.py +229 -6
- circuitpython_synthtools-0.5.dist-info/RECORD +0 -25
- synthtools/ui/param.py +0 -108
- synthtools/ui/param_scaler.py +0 -83
- {circuitpython_synthtools-0.5.dist-info → circuitpython_synthtools-0.6.0.dist-info}/WHEEL +0 -0
- {circuitpython_synthtools-0.5.dist-info → circuitpython_synthtools-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {circuitpython_synthtools-0.5.dist-info → circuitpython_synthtools-0.6.0.dist-info}/top_level.txt +0 -0
{circuitpython_synthtools-0.5.dist-info → circuitpython_synthtools-0.6.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: circuitpython-synthtools
|
|
3
|
-
Version: 0.
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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)
|
synthtools/ahr_envelope.py
CHANGED
|
@@ -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
|
|
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
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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))
|
|
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
|
|
26
|
-
# still left at the halfway point
|
|
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
|
|
27
|
+
# as a result: one buffer, one exponent. See fill_env_rise() in waves.py.
|
|
30
28
|
#
|
|
31
|
-
#
|
|
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
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
90
|
-
#
|
|
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
|
|
164
|
-
#
|
|
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
|
|
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
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
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
|
|
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
|
|
5
|
-
# synth
|
|
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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
|
19
|
-
#
|
|
20
|
-
# extra
|
|
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
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
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
|
-
"""
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
``
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
139
|
-
"""
|
|
140
|
-
`
|
|
141
|
-
|
|
142
|
-
return self.
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|