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