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/synth.py
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
#
|
|
4
|
+
# synth.py - Synth base class.
|
|
5
|
+
#
|
|
6
|
+
# Owns: voice bookkeeping, patch load/save, live parameter updates.
|
|
7
|
+
# Subclasses override _recompile() / _decompile() and _make_notes(), and
|
|
8
|
+
# extend _PARAMS.
|
|
9
|
+
#
|
|
10
|
+
# Requires CircuitPython 10+
|
|
11
|
+
#
|
|
12
|
+
# --- two ideas run through this file ---------------------------------
|
|
13
|
+
#
|
|
14
|
+
# 1. THE PATCH IS NOT LIVE STATE. A Patch is inert JSON-able data. It is
|
|
15
|
+
# read once by _recompile() and then left alone -- turning a knob does
|
|
16
|
+
# NOT write to it. save_patch() is the only thing that pushes live
|
|
17
|
+
# state back, via _decompile(). So "the patch on disk" and "what I am
|
|
18
|
+
# hearing" are separate things, and reloading the patch reverts.
|
|
19
|
+
#
|
|
20
|
+
# 2. PARAMETERS ARE BLOCKS, NOT NUMBERS. Anywhere synthio accepts a
|
|
21
|
+
# BlockInput, a shared synthio.Math can stand in for a float and stay
|
|
22
|
+
# writable. Every voice nests the same shared block, so one write
|
|
23
|
+
# reaches all of them inside the C renderer -- O(1) in polyphony,
|
|
24
|
+
# regardless of how many notes are sounding. Doing that arithmetic in
|
|
25
|
+
# the block graph rather than in Python is also what lets a knob reach
|
|
26
|
+
# a note that is ALREADY playing.
|
|
27
|
+
#
|
|
28
|
+
# Live-parameter cost, fastest to slowest:
|
|
29
|
+
# 1. shared block write - one assignment. O(1). filt_f, filt_q,
|
|
30
|
+
# fenv_amount, filt_vel, fenv_vel, the LFO rates and depths.
|
|
31
|
+
# 2. in-place buffer - rewrite the shared envelope shape. O(1), but
|
|
32
|
+
# it allocates temporaries: a switch, not a knob. fenv_curve.
|
|
33
|
+
# 3. cached-object swap - rebuild once, next note-on picks it up. O(1).
|
|
34
|
+
# amp_env, filt_type, wave.
|
|
35
|
+
# 4. per-voice loop - only for genuinely per-note values (detune).
|
|
36
|
+
# O(polyphony), so avoid in anything a knob drives.
|
|
37
|
+
#
|
|
38
|
+
# Every param is a property, so `synth.filt_f = 1000` is the fast path and
|
|
39
|
+
# set_param() is just a string front-end for MIDI CC / UI code.
|
|
40
|
+
|
|
41
|
+
import synthio
|
|
42
|
+
|
|
43
|
+
from .ahr_envelope import AHREnvelope
|
|
44
|
+
from .blocks import clamp, constrained_lerp, lerp, product, scalar_block, sum3
|
|
45
|
+
from .waves import ramp_wave
|
|
46
|
+
|
|
47
|
+
FILTER_MODES = {
|
|
48
|
+
"LPF": synthio.FilterMode.LOW_PASS,
|
|
49
|
+
"HPF": synthio.FilterMode.HIGH_PASS,
|
|
50
|
+
"BPF": synthio.FilterMode.BAND_PASS,
|
|
51
|
+
"NOTCH": synthio.FilterMode.NOTCH,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Synth:
|
|
56
|
+
"""Base synth engine wrapping one synthio.Synthesizer.
|
|
57
|
+
|
|
58
|
+
Owns voice bookkeeping (note_on()/note_off()), patch load/save, and
|
|
59
|
+
live parameter updates. A style subclass overrides _make_notes() (what
|
|
60
|
+
a key sounds like) and typically _recompile()/_decompile() (how the
|
|
61
|
+
subclass's own Patch fields compile to/from live state), extending
|
|
62
|
+
_PARAMS with its own settable names. SubtractiveSynth and
|
|
63
|
+
WavetableSynth are the two worked examples.
|
|
64
|
+
|
|
65
|
+
Every parameter is a plain property (``synth.filt_f = 2000``), each
|
|
66
|
+
backed by a shared synthio block so one write reaches every sounding
|
|
67
|
+
voice in O(1) regardless of polyphony -- see the module docstring
|
|
68
|
+
above for the full cost model. set_param(name, val) is a string
|
|
69
|
+
front-end onto the same properties, for MIDI CC / UI code.
|
|
70
|
+
|
|
71
|
+
A Patch is inert JSON-able data, compiled into live blocks once by
|
|
72
|
+
load_patch() -> _recompile(); nothing after that writes back to it
|
|
73
|
+
until save_patch() explicitly snapshots live state via _decompile().
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
# names settable via set_param(); also what a UI can enumerate
|
|
77
|
+
# fmt: off
|
|
78
|
+
_PARAMS = ("filt_f", "filt_q", "filt_type", "amp_env",
|
|
79
|
+
"attack_time", "decay_time", "sustain_level", "release_time",
|
|
80
|
+
"vib_depth", "vib_rate", "vib_delay",
|
|
81
|
+
"penv_amount", "penv_time", "penv_out_amount", "penv_out_time",
|
|
82
|
+
"filt_lfo_rate", "filt_lfo_amount",
|
|
83
|
+
"fenv_amount", "fenv_attack", "fenv_release", "fenv_curve",
|
|
84
|
+
"filt_vel", "fenv_vel", "glide_time")
|
|
85
|
+
# fmt: on
|
|
86
|
+
|
|
87
|
+
#: One voice at a time: a note-on steals whatever is sounding, whichever
|
|
88
|
+
#: note it was, and ``glide_time`` slides into it from the previous note.
|
|
89
|
+
#: Flip it on any style to get a monosynth::
|
|
90
|
+
#:
|
|
91
|
+
#: lead = SubtractiveSynth(synthesizer, patch)
|
|
92
|
+
#: lead.mono = True
|
|
93
|
+
#: lead.glide_time = 0.08
|
|
94
|
+
#:
|
|
95
|
+
#: Glide is meaningless in poly -- one shared bend would drag every
|
|
96
|
+
#: sounding voice -- so it is only applied while this is set. A plain
|
|
97
|
+
#: attribute rather than a patch field, like ``push_env``.
|
|
98
|
+
mono = False
|
|
99
|
+
|
|
100
|
+
# The cutoff bus can now be driven below zero -- a downward fenv_amount,
|
|
101
|
+
# a negative filt_vel, a big filt_lfo_amount -- none of which was
|
|
102
|
+
# reachable when the cutoff was just a scalar. So it is clamped, in one
|
|
103
|
+
# MID block, since the middle of three values is exactly a clamp.
|
|
104
|
+
#
|
|
105
|
+
# A negative Biquad.frequency does NOT raise or crash (measured on
|
|
106
|
+
# CircuitPython 10.3.0-alpha.3 / rp2040), so this is defensive rather
|
|
107
|
+
# than mandatory: what such a filter *sounds* like is undefined, and a
|
|
108
|
+
# downward sweep hitting 0 Hz is an ordinary patch, not an edge case.
|
|
109
|
+
# Cost is one shared block plus one per modulated voice.
|
|
110
|
+
FILT_F_MIN = 20.0
|
|
111
|
+
FILT_F_MAX = 20000.0
|
|
112
|
+
|
|
113
|
+
def __init__(self, synthesizer, patch=None):
|
|
114
|
+
self.synthio = synthesizer
|
|
115
|
+
self.voices = {} # midi_note -> tuple of synthio.Note
|
|
116
|
+
self.patch = None
|
|
117
|
+
# Every shared block below is created ONCE and never replaced:
|
|
118
|
+
# sounding voices hold references to them, so identity must survive
|
|
119
|
+
# patch reloads. _recompile() writes into them.
|
|
120
|
+
self._filt_f_blk = scalar_block(2000.0)
|
|
121
|
+
self._filt_q_blk = scalar_block(1.0)
|
|
122
|
+
self._filt_vel_blk = scalar_block(0.0)
|
|
123
|
+
self._fenv_vel_blk = scalar_block(0.0)
|
|
124
|
+
# --- the bend graph ------------------------------------------
|
|
125
|
+
#
|
|
126
|
+
# SHARED: bend = SUM(vib_lfo, bend_blk)
|
|
127
|
+
# vib_lfo.scale = PRODUCT(vib_depth, vib_fade)
|
|
128
|
+
# VOICE: note.bend = SUM(bend, penv) -- only when a pitch
|
|
129
|
+
# envelope exists
|
|
130
|
+
#
|
|
131
|
+
# The vibrato fade-in lives INSIDE the LFO's scale rather than on
|
|
132
|
+
# the bend path, because LFO.scale is a BlockInput. That keeps the
|
|
133
|
+
# whole of vib_delay in the shared half: nothing per voice, and
|
|
134
|
+
# vib_depth stays one write into its own block.
|
|
135
|
+
self._vib_depth_blk = scalar_block(0.0)
|
|
136
|
+
# A one-shot 0 -> 1 ramp. rate = 1/vib_delay, so vib_delay = 0 needs
|
|
137
|
+
# no special case at all -- the ramp just finishes in a millisecond.
|
|
138
|
+
# The graph stays static, which is what the identity rule wants.
|
|
139
|
+
self._vib_fade = synthio.LFO(waveform=ramp_wave(), rate=1000.0, once=True)
|
|
140
|
+
self._vib_lfo = synthio.LFO(rate=5.0, scale=product(self._vib_depth_blk, self._vib_fade))
|
|
141
|
+
self._bend_blk = scalar_block(0.0)
|
|
142
|
+
# --- glide, the third input of the bend SUM ------------------
|
|
143
|
+
# Portamento needs a per-voice pitch offset ONLY when there are
|
|
144
|
+
# several voices. In mono there is one, so the glide is shared
|
|
145
|
+
# like everything else here and just occupies the third input
|
|
146
|
+
# sum3() already takes and nothing else was using.
|
|
147
|
+
#
|
|
148
|
+
# It is a POSITION lerp: the bend runs from the previous note's
|
|
149
|
+
# pitch to zero while the Note itself is created at the new pitch.
|
|
150
|
+
# Aimed the other way (start at the new note, bend toward the old)
|
|
151
|
+
# successive glides would compound.
|
|
152
|
+
#
|
|
153
|
+
# In poly mode nothing ever writes _glide.a, so this sits at 0.0
|
|
154
|
+
# and is inert -- two objects, no arithmetic. And because _bend is
|
|
155
|
+
# rooted in synthesizer.blocks below, the LFO ticks without being
|
|
156
|
+
# rooted itself, exactly like _vib_fade inside _vib_lfo.scale.
|
|
157
|
+
self._glide_pos = synthio.LFO(waveform=ramp_wave(), rate=1000.0, once=True)
|
|
158
|
+
self._glide = constrained_lerp(0.0, 0.0, self._glide_pos)
|
|
159
|
+
self._glide_time = 0.0
|
|
160
|
+
self._last_midi = None # where the next glide starts from
|
|
161
|
+
self._bend = sum3(self._vib_lfo, self._bend_blk, self._glide)
|
|
162
|
+
# --- the filter cutoff modulation bus ------------------------
|
|
163
|
+
# Four sources sum onto one destination:
|
|
164
|
+
#
|
|
165
|
+
# SHARED: filt_base = MID(SUM(filt_f, filt_lfo), MIN, MAX)
|
|
166
|
+
# VOICE: cutoff = SUM(filt_base, fenv, vel_hz)
|
|
167
|
+
# vel_hz = PRODUCT(filt_vel, vel/127)
|
|
168
|
+
# fenv = AHREnvelope.make(gain)
|
|
169
|
+
# gain = LERP(1, vel/127, fenv_vel)
|
|
170
|
+
#
|
|
171
|
+
# SUM takes three inputs, so base+LFO, envelope and velocity all
|
|
172
|
+
# land in ONE per-voice node. Every knob in there is a shared block
|
|
173
|
+
# nested inside it, so each stays a single write no matter how many
|
|
174
|
+
# voices are sounding -- including voices already in release.
|
|
175
|
+
#
|
|
176
|
+
# Every one of the three modulations is ADDITIVE and one-sided:
|
|
177
|
+
# filt_f is the floor and they open upward from it. synthio's LFO
|
|
178
|
+
# outputs `waveform[idx] * scale + offset` and its default waveform
|
|
179
|
+
# is a triangle centred on zero, so scale ALONE would swing
|
|
180
|
+
# +/-amount about filt_f and push the bottom half into the
|
|
181
|
+
# FILT_F_MIN clamp. Setting scale and offset both to amount/2 shifts
|
|
182
|
+
# the whole swing up into 0..amount -- see filt_lfo_amount below.
|
|
183
|
+
self._filt_lfo = synthio.LFO(rate=0.5, scale=0.0, offset=0.0)
|
|
184
|
+
self._filt_sum = sum3(self._filt_f_blk, self._filt_lfo)
|
|
185
|
+
self._filt_base = clamp(self._filt_sum, self.FILT_F_MIN, self.FILT_F_MAX)
|
|
186
|
+
# Anything not reachable from a sounding Note has to be rooted here
|
|
187
|
+
# or synthio never updates it -- and that applies to Math blocks,
|
|
188
|
+
# not just LFOs.
|
|
189
|
+
#
|
|
190
|
+
# The LFOs are the obvious case: unattached, they do not advance, so
|
|
191
|
+
# vibrato and the filter sweep would jump phase at every note-on.
|
|
192
|
+
#
|
|
193
|
+
# `_filt_base` and `_bend` are the non-obvious one. Both are only
|
|
194
|
+
# reachable through a voice, so with nothing sounding they freeze --
|
|
195
|
+
# and on a freshly built Synth they have never been evaluated at
|
|
196
|
+
# all. Measured on hardware: the first note-on after construction
|
|
197
|
+
# read its cutoff as 0.0 Hz (a filter slammed shut for a block, i.e.
|
|
198
|
+
# a click), and later notes after a silence read a stale cutoff
|
|
199
|
+
# frozen at wherever the LFO was when the last voice died. Rooting
|
|
200
|
+
# them here makes the shared half of the graph always-live, so a
|
|
201
|
+
# note-on inherits a correct cutoff and bend on its very first
|
|
202
|
+
# update. Costs two list entries.
|
|
203
|
+
synthesizer.blocks.append(self._vib_lfo)
|
|
204
|
+
synthesizer.blocks.append(self._filt_lfo)
|
|
205
|
+
synthesizer.blocks.append(self._filt_base)
|
|
206
|
+
synthesizer.blocks.append(self._bend)
|
|
207
|
+
# The envelope is a modulation SOURCE: it produces 0 -> amount and
|
|
208
|
+
# knows nothing about filters. Created once and never replaced --
|
|
209
|
+
# sounding voices hold references to its buffer and blocks.
|
|
210
|
+
self._fenv = AHREnvelope()
|
|
211
|
+
self._fenvs = {} # midi_note -> env block, for held notes
|
|
212
|
+
# The pitch envelope is the SAME class, falling instead of rising:
|
|
213
|
+
# it starts at penv_amount and settles to 0 (true pitch), then on
|
|
214
|
+
# note-off drifts on to penv_out_amount. Its own instance, so its
|
|
215
|
+
# shape buffer and rates are independent of the filter's.
|
|
216
|
+
self._penv = AHREnvelope(falling=True)
|
|
217
|
+
self._penvs = {} # midi_note -> pitch env block
|
|
218
|
+
# "voice under construction" temporaries, set by note_on around the
|
|
219
|
+
# call to _make_notes so _make_filter and the subclasses can pick
|
|
220
|
+
# them up
|
|
221
|
+
self._fenv_cur = None
|
|
222
|
+
self._cutoff_cur = None
|
|
223
|
+
self._penv_cur = None
|
|
224
|
+
self._bend_cur = None
|
|
225
|
+
# live mirrors of patch values that have nowhere else to live
|
|
226
|
+
self._filt_type = None
|
|
227
|
+
self._filt_mode = None
|
|
228
|
+
self._amp_env = [0.01, 0.10, 0.8, 0.35]
|
|
229
|
+
# a mirror rather than 1/_vib_fade.rate, to avoid a reciprocal
|
|
230
|
+
# round-trip through save/load
|
|
231
|
+
self._vib_delay = 0.0
|
|
232
|
+
self._env = self._make_env()
|
|
233
|
+
if patch:
|
|
234
|
+
self.load_patch(patch)
|
|
235
|
+
|
|
236
|
+
# --- patch <-> live state -------------------------------------------
|
|
237
|
+
# _recompile() reads the patch; _decompile() writes it. Nothing else
|
|
238
|
+
# touches self.patch.
|
|
239
|
+
|
|
240
|
+
def load_patch(self, patch):
|
|
241
|
+
self.patch = patch
|
|
242
|
+
self._recompile()
|
|
243
|
+
|
|
244
|
+
def _recompile(self):
|
|
245
|
+
"""Compile patch data into live blocks and cached natives. Once per
|
|
246
|
+
patch load, never per note. Subclasses call super()._recompile()."""
|
|
247
|
+
p = self.patch
|
|
248
|
+
self._filt_type = p.filt_type
|
|
249
|
+
self._filt_mode = FILTER_MODES.get(p.filt_type) # None = no filter
|
|
250
|
+
# list(), NOT the patch's own list: attack_time & friends mutate
|
|
251
|
+
# self._amp_env element-wise, and aliasing it would let a knob turn
|
|
252
|
+
# write straight through into the loaded patch.
|
|
253
|
+
self._amp_env = list(p.amp_env)
|
|
254
|
+
self._env = self._make_env()
|
|
255
|
+
self._filt_f_blk.a = p.filt_f # write, don't replace
|
|
256
|
+
self._filt_q_blk.a = p.filt_q
|
|
257
|
+
self._filt_vel_blk.a = p.filt_vel
|
|
258
|
+
self._fenv_vel_blk.a = p.fenv_vel
|
|
259
|
+
self._filt_lfo.rate = p.filt_lfo_rate
|
|
260
|
+
# via the property: the amount is a half-swing plus a matching
|
|
261
|
+
# offset, and writing .scale alone here would leave a stale offset
|
|
262
|
+
self.filt_lfo_amount = p.filt_lfo_amount
|
|
263
|
+
self._vib_lfo.rate = p.vib_rate
|
|
264
|
+
self._vib_depth_blk.a = p.vib_depth
|
|
265
|
+
self.vib_delay = p.vib_delay # via the property: sets a rate
|
|
266
|
+
self.glide_time = getattr(p, "glide_time", 0.0) # ditto
|
|
267
|
+
# written into the existing envelopes, never new ones, and in one
|
|
268
|
+
# call each so the shape is rebuilt once rather than per parameter
|
|
269
|
+
self._fenv.configure(p.fenv_attack, p.fenv_release, p.fenv_amount, p.fenv_curve)
|
|
270
|
+
# curve 1: penv_curve is not a patch field, though the class takes one
|
|
271
|
+
self._penv.configure(p.penv_time, p.penv_out_time, p.penv_amount, 1, p.penv_out_amount)
|
|
272
|
+
|
|
273
|
+
def _decompile(self):
|
|
274
|
+
"""Push live state back into self.patch. The opposite of
|
|
275
|
+
_recompile(). Subclasses call super()._decompile()."""
|
|
276
|
+
p = self.patch
|
|
277
|
+
p.filt_type = self._filt_type
|
|
278
|
+
p.amp_env = list(self._amp_env) # copy out, so later knob turns
|
|
279
|
+
# do not leak into the patch
|
|
280
|
+
p.filt_f = self._filt_f_blk.a
|
|
281
|
+
p.filt_q = self._filt_q_blk.a
|
|
282
|
+
p.filt_vel = self._filt_vel_blk.a
|
|
283
|
+
p.fenv_vel = self._fenv_vel_blk.a
|
|
284
|
+
p.filt_lfo_rate = self._filt_lfo.rate
|
|
285
|
+
p.filt_lfo_amount = self.filt_lfo_amount # undoes the half-swing
|
|
286
|
+
p.vib_rate = self._vib_lfo.rate
|
|
287
|
+
p.vib_depth = self._vib_depth_blk.a
|
|
288
|
+
p.vib_delay = self._vib_delay
|
|
289
|
+
p.glide_time = self._glide_time
|
|
290
|
+
p.penv_amount = self._penv.amount
|
|
291
|
+
p.penv_time = self._penv.attack
|
|
292
|
+
p.penv_out_amount = self._penv.release_amount
|
|
293
|
+
p.penv_out_time = self._penv.release
|
|
294
|
+
p.fenv_amount = self._fenv.amount
|
|
295
|
+
p.fenv_attack = self._fenv.attack
|
|
296
|
+
p.fenv_release = self._fenv.release
|
|
297
|
+
p.fenv_curve = self._fenv.curve
|
|
298
|
+
|
|
299
|
+
def save_patch(self):
|
|
300
|
+
"""Snapshot what is currently being heard into the Patch, and return
|
|
301
|
+
it. Knob turns do not reach the patch on their own, so call this
|
|
302
|
+
before Patch.save():
|
|
303
|
+
|
|
304
|
+
synth.save_patch().save("/patch.json")
|
|
305
|
+
|
|
306
|
+
Mutates and returns the patch that was loaded -- it is the same
|
|
307
|
+
object, not a copy.
|
|
308
|
+
"""
|
|
309
|
+
self._decompile()
|
|
310
|
+
return self.patch
|
|
311
|
+
|
|
312
|
+
def _make_env(self):
|
|
313
|
+
a, d, s, r = self._amp_env
|
|
314
|
+
return synthio.Envelope(attack_time=a, decay_time=d, sustain_level=s, release_time=r)
|
|
315
|
+
|
|
316
|
+
# --- per-voice construction -----------------------------------------
|
|
317
|
+
|
|
318
|
+
def _voice_fenv_gain(self, velocity):
|
|
319
|
+
"""This voice's envelope depth scale.
|
|
320
|
+
|
|
321
|
+
Plain 1.0 when fenv_vel is off -- there is no arithmetic to avoid in
|
|
322
|
+
that case, and it lets AHREnvelope skip a block. Otherwise a LERP
|
|
323
|
+
block, which is exactly `1 - fenv_vel + fenv_vel * vel_norm` but
|
|
324
|
+
with fenv_vel still LIVE inside it, so the knob keeps reaching the
|
|
325
|
+
voice after it has been pressed.
|
|
326
|
+
"""
|
|
327
|
+
if not self._fenv_vel_blk.a:
|
|
328
|
+
return 1.0
|
|
329
|
+
return lerp(1.0, velocity / 127.0, self._fenv_vel_blk)
|
|
330
|
+
|
|
331
|
+
def _voice_cutoff(self, velocity):
|
|
332
|
+
"""This voice's node on the cutoff bus: SUM(base, envelope, vel).
|
|
333
|
+
|
|
334
|
+
Returns the SHARED base unchanged when neither the envelope nor
|
|
335
|
+
velocity is in play, so the ordinary case allocates nothing at all.
|
|
336
|
+
Either way filt_f reaches this voice with one write, because the
|
|
337
|
+
shared base is nested inside.
|
|
338
|
+
"""
|
|
339
|
+
if self._filt_mode is None:
|
|
340
|
+
return None
|
|
341
|
+
vel_hz = None
|
|
342
|
+
if self._filt_vel_blk.a:
|
|
343
|
+
# signed: a negative filt_vel closes the filter as you play
|
|
344
|
+
# harder. filt_vel stays live inside the PRODUCT.
|
|
345
|
+
vel_hz = product(self._filt_vel_blk, velocity / 127.0)
|
|
346
|
+
if self._fenv_cur is None and vel_hz is None:
|
|
347
|
+
return self._filt_base # already clamped
|
|
348
|
+
# `is None` rather than truthiness throughout: these are synthio
|
|
349
|
+
# blocks, and whether one is falsy is not ours to assume.
|
|
350
|
+
# Clamped again here, not just on the shared base: a downward
|
|
351
|
+
# fenv_amount (a normal patch) or a negative filt_vel can drive
|
|
352
|
+
# this sum below zero all on its own.
|
|
353
|
+
return clamp(
|
|
354
|
+
sum3(
|
|
355
|
+
self._filt_base,
|
|
356
|
+
self._fenv_cur if self._fenv_cur is not None else 0.0,
|
|
357
|
+
vel_hz if vel_hz is not None else 0.0,
|
|
358
|
+
),
|
|
359
|
+
self.FILT_F_MIN,
|
|
360
|
+
self.FILT_F_MAX,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def _voice_bend(self):
|
|
364
|
+
"""This voice's bend input, shared by all its Notes.
|
|
365
|
+
|
|
366
|
+
Returns the SHARED bend graph unchanged when no pitch envelope is
|
|
367
|
+
in play, so the ordinary case allocates nothing and vibrato and the
|
|
368
|
+
pitch wheel keep reaching every voice with one write. When there is
|
|
369
|
+
one, the shared graph is still nested inside, so that stays true.
|
|
370
|
+
|
|
371
|
+
Only meaningful during note_on: it reads the temporaries note_on
|
|
372
|
+
sets up around _make_notes.
|
|
373
|
+
"""
|
|
374
|
+
if self._penv_cur is None:
|
|
375
|
+
return self._bend
|
|
376
|
+
return sum3(self._bend, self._penv_cur)
|
|
377
|
+
|
|
378
|
+
def _make_filter(self):
|
|
379
|
+
"""Per-note Biquad (filters hold state, so they cannot be shared).
|
|
380
|
+
Every Note of one voice shares one cutoff graph.
|
|
381
|
+
|
|
382
|
+
Only meaningful during note_on: it reads the temporaries note_on
|
|
383
|
+
sets up around _make_notes.
|
|
384
|
+
"""
|
|
385
|
+
if self._filt_mode is None:
|
|
386
|
+
return None
|
|
387
|
+
# fmt: off
|
|
388
|
+
return synthio.Biquad(self._filt_mode, frequency=self._cutoff_cur,
|
|
389
|
+
Q=self._filt_q_blk)
|
|
390
|
+
# fmt: on
|
|
391
|
+
|
|
392
|
+
# --- real-time path -------------------------------------------------
|
|
393
|
+
|
|
394
|
+
def _make_notes(self, midi_note, velocity):
|
|
395
|
+
"""Return a tuple of synthio.Note for this key. Override me."""
|
|
396
|
+
raise NotImplementedError
|
|
397
|
+
|
|
398
|
+
def note_on(self, midi_note, velocity=127, glide=None):
|
|
399
|
+
"""Press a note.
|
|
400
|
+
|
|
401
|
+
``glide`` overrides glide_time in seconds for this note only, and
|
|
402
|
+
only matters in mono -- a per-step slide flag needs that, because
|
|
403
|
+
writing glide_time itself would leak into the patch.
|
|
404
|
+
"""
|
|
405
|
+
if self.mono:
|
|
406
|
+
# one voice: steal whatever is sounding, whichever note it is
|
|
407
|
+
self.all_notes_off()
|
|
408
|
+
self._aim_glide(midi_note, glide)
|
|
409
|
+
elif midi_note in self.voices:
|
|
410
|
+
self.note_off(midi_note)
|
|
411
|
+
# Restart the vibrato fade only when starting from silence, so
|
|
412
|
+
# adding a note to a held chord does not duck everyone's vibrato
|
|
413
|
+
# back to zero. After the steal above, so a mono retrigger still
|
|
414
|
+
# counts as starting from silence.
|
|
415
|
+
if not self.voices:
|
|
416
|
+
self._vib_fade.retrigger()
|
|
417
|
+
# Each envelope is an INPUT to the thing it modulates, so both have
|
|
418
|
+
# to exist first. All four temporaries are set before _make_notes so
|
|
419
|
+
# every Note of this voice shares one cutoff and one bend graph.
|
|
420
|
+
if self._filt_mode is not None:
|
|
421
|
+
self._fenv_cur = self._fenv.make(self._voice_fenv_gain(velocity))
|
|
422
|
+
self._cutoff_cur = self._voice_cutoff(velocity)
|
|
423
|
+
self._penv_cur = self._penv.make()
|
|
424
|
+
self._bend_cur = self._voice_bend()
|
|
425
|
+
notes = self._make_notes(midi_note, velocity)
|
|
426
|
+
if self._fenv_cur is not None:
|
|
427
|
+
self._fenvs[midi_note] = self._fenv_cur
|
|
428
|
+
if self._penv_cur is not None:
|
|
429
|
+
self._penvs[midi_note] = self._penv_cur
|
|
430
|
+
self._fenv_cur = None
|
|
431
|
+
self._cutoff_cur = None
|
|
432
|
+
self._penv_cur = None
|
|
433
|
+
self._bend_cur = None
|
|
434
|
+
self.voices[midi_note] = notes
|
|
435
|
+
self.synthio.press(notes)
|
|
436
|
+
|
|
437
|
+
def note_off(self, midi_note):
|
|
438
|
+
notes = self.voices.pop(midi_note, None)
|
|
439
|
+
if notes:
|
|
440
|
+
# the Note keeps these alive while it rings out
|
|
441
|
+
env = self._fenvs.pop(midi_note, None)
|
|
442
|
+
if env is not None:
|
|
443
|
+
self._fenv.start_release(env)
|
|
444
|
+
penv = self._penvs.pop(midi_note, None)
|
|
445
|
+
if penv is not None:
|
|
446
|
+
self._penv.start_release(penv)
|
|
447
|
+
self.synthio.release(notes)
|
|
448
|
+
|
|
449
|
+
def all_notes_off(self):
|
|
450
|
+
for midi_note in list(self.voices.keys()):
|
|
451
|
+
self.note_off(midi_note)
|
|
452
|
+
|
|
453
|
+
def pitch_bend(self, amount):
|
|
454
|
+
"""+/-1.0 = one octave. One write into the shared bend graph, O(1)."""
|
|
455
|
+
self._bend_blk.a = amount # bend is performance state, not patch state
|
|
456
|
+
|
|
457
|
+
def _aim_glide(self, midi_note, seconds=None):
|
|
458
|
+
"""Point the shared glide block at ``midi_note`` and start it.
|
|
459
|
+
|
|
460
|
+
Bend units are octaves, so a semitone is 1/12. The offset is where
|
|
461
|
+
the pitch STARTS; it always ends at 0, i.e. the note's own pitch.
|
|
462
|
+
|
|
463
|
+
Called from note_on() in mono only. Note the previous note is
|
|
464
|
+
still releasing at this point and shares this bend, so a glide
|
|
465
|
+
drags its tail along too -- inherent to a shared bend, and only
|
|
466
|
+
audible with a long amp release and a long glide together.
|
|
467
|
+
"""
|
|
468
|
+
prev = self._last_midi
|
|
469
|
+
self._last_midi = midi_note
|
|
470
|
+
secs = self._glide_time if seconds is None else seconds
|
|
471
|
+
self._glide_pos.rate = 1.0 / max(secs, 0.001)
|
|
472
|
+
if prev is None:
|
|
473
|
+
self._glide.a = 0.0 # first note ever: nothing to glide from
|
|
474
|
+
else:
|
|
475
|
+
# Plus whatever glide is still in flight, so interrupting one
|
|
476
|
+
# mid-slide starts the next from where the pitch actually IS.
|
|
477
|
+
# Same structural continuity as AHREnvelope.start_release()'s
|
|
478
|
+
# `env.a = env.value` -- there is no rate to recompute.
|
|
479
|
+
self._glide.a = (prev - midi_note) / 12.0 + self._glide.value
|
|
480
|
+
self._glide_pos.retrigger()
|
|
481
|
+
|
|
482
|
+
# --- live parameters ------------------------------------------------
|
|
483
|
+
# Setters write live state ONLY. Getters read it back. The patch is not
|
|
484
|
+
# involved until save_patch().
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def filt_f(self):
|
|
488
|
+
return self._filt_f_blk.a
|
|
489
|
+
|
|
490
|
+
@filt_f.setter
|
|
491
|
+
def filt_f(self, v):
|
|
492
|
+
self._filt_f_blk.a = v # reaches every sounding voice, O(1)
|
|
493
|
+
|
|
494
|
+
@property
|
|
495
|
+
def filt_q(self):
|
|
496
|
+
return self._filt_q_blk.a
|
|
497
|
+
|
|
498
|
+
@filt_q.setter
|
|
499
|
+
def filt_q(self, v):
|
|
500
|
+
self._filt_q_blk.a = v
|
|
501
|
+
|
|
502
|
+
@property
|
|
503
|
+
def filt_type(self):
|
|
504
|
+
return self._filt_type
|
|
505
|
+
|
|
506
|
+
@filt_type.setter
|
|
507
|
+
def filt_type(self, v):
|
|
508
|
+
self._filt_type = v
|
|
509
|
+
self._filt_mode = FILTER_MODES.get(v) # applies at next note-on
|
|
510
|
+
|
|
511
|
+
# --- amp envelope ---------------------------------------------------
|
|
512
|
+
# synthio.Envelope is immutable: every ADSR edit means a new object.
|
|
513
|
+
# That is one small allocation per knob movement, so deadband inputs.
|
|
514
|
+
# push_env controls whether *sounding* notes get the new envelope:
|
|
515
|
+
# True - turning release while holding a chord affects that release
|
|
516
|
+
# (what a player expects). Changing sustain_level mid-note
|
|
517
|
+
# steps the level rather than slewing.
|
|
518
|
+
# False - edits only apply from the next note-on. Safest.
|
|
519
|
+
push_env = True
|
|
520
|
+
|
|
521
|
+
def _rebuild_env(self):
|
|
522
|
+
self._env = self._make_env()
|
|
523
|
+
if self.push_env:
|
|
524
|
+
for notes in self.voices.values():
|
|
525
|
+
for n in notes:
|
|
526
|
+
n.envelope = self._env
|
|
527
|
+
|
|
528
|
+
@property
|
|
529
|
+
def amp_env(self):
|
|
530
|
+
return self._amp_env
|
|
531
|
+
|
|
532
|
+
@amp_env.setter
|
|
533
|
+
def amp_env(self, v):
|
|
534
|
+
self._amp_env = list(v)
|
|
535
|
+
self._rebuild_env()
|
|
536
|
+
|
|
537
|
+
@property
|
|
538
|
+
def attack_time(self):
|
|
539
|
+
return self._amp_env[0]
|
|
540
|
+
|
|
541
|
+
@attack_time.setter
|
|
542
|
+
def attack_time(self, v):
|
|
543
|
+
self._amp_env[0] = v # in-place, no list rebuild
|
|
544
|
+
self._rebuild_env()
|
|
545
|
+
|
|
546
|
+
@property
|
|
547
|
+
def decay_time(self):
|
|
548
|
+
return self._amp_env[1]
|
|
549
|
+
|
|
550
|
+
@decay_time.setter
|
|
551
|
+
def decay_time(self, v):
|
|
552
|
+
self._amp_env[1] = v
|
|
553
|
+
self._rebuild_env()
|
|
554
|
+
|
|
555
|
+
@property
|
|
556
|
+
def sustain_level(self):
|
|
557
|
+
return self._amp_env[2]
|
|
558
|
+
|
|
559
|
+
@sustain_level.setter
|
|
560
|
+
def sustain_level(self, v):
|
|
561
|
+
self._amp_env[2] = v
|
|
562
|
+
self._rebuild_env()
|
|
563
|
+
|
|
564
|
+
@property
|
|
565
|
+
def release_time(self):
|
|
566
|
+
return self._amp_env[3]
|
|
567
|
+
|
|
568
|
+
@release_time.setter
|
|
569
|
+
def release_time(self, v):
|
|
570
|
+
self._amp_env[3] = v
|
|
571
|
+
self._rebuild_env()
|
|
572
|
+
|
|
573
|
+
# --- vibrato: both are single scalar writes into the shared graph ----
|
|
574
|
+
|
|
575
|
+
@property
|
|
576
|
+
def vib_depth(self):
|
|
577
|
+
return self._vib_depth_blk.a
|
|
578
|
+
|
|
579
|
+
@vib_depth.setter
|
|
580
|
+
def vib_depth(self, v):
|
|
581
|
+
# into its own block rather than onto LFO.scale, because scale now
|
|
582
|
+
# holds PRODUCT(depth, fade). Still one write, still O(1).
|
|
583
|
+
self._vib_depth_blk.a = v
|
|
584
|
+
|
|
585
|
+
@property
|
|
586
|
+
def vib_rate(self):
|
|
587
|
+
return self._vib_lfo.rate
|
|
588
|
+
|
|
589
|
+
@vib_rate.setter
|
|
590
|
+
def vib_rate(self, v):
|
|
591
|
+
self._vib_lfo.rate = v
|
|
592
|
+
|
|
593
|
+
@property
|
|
594
|
+
def vib_delay(self):
|
|
595
|
+
"""Seconds for the vibrato to fade in from nothing, restarted
|
|
596
|
+
whenever playing begins from silence.
|
|
597
|
+
|
|
598
|
+
Only a rate on the shared fade ramp, so it is cheap on a knob. It
|
|
599
|
+
takes effect at the NEXT retrigger, not mid-fade.
|
|
600
|
+
"""
|
|
601
|
+
return self._vib_delay
|
|
602
|
+
|
|
603
|
+
@vib_delay.setter
|
|
604
|
+
def vib_delay(self, v):
|
|
605
|
+
self._vib_delay = v
|
|
606
|
+
self._vib_fade.rate = 1.0 / max(v, 0.001)
|
|
607
|
+
|
|
608
|
+
@property
|
|
609
|
+
def glide_time(self):
|
|
610
|
+
"""Seconds to slide from the previous note into a new one.
|
|
611
|
+
|
|
612
|
+
Portamento. Only applies while ``mono`` is set -- one shared bend
|
|
613
|
+
would drag every sounding voice otherwise. Only a rate on the
|
|
614
|
+
shared ramp, so it is cheap on a knob, and it takes effect at the
|
|
615
|
+
next note-on rather than mid-glide.
|
|
616
|
+
"""
|
|
617
|
+
return self._glide_time
|
|
618
|
+
|
|
619
|
+
@glide_time.setter
|
|
620
|
+
def glide_time(self, v):
|
|
621
|
+
self._glide_time = v
|
|
622
|
+
self._glide_pos.rate = 1.0 / max(v, 0.001)
|
|
623
|
+
|
|
624
|
+
# --- pitch envelope --------------------------------------------------
|
|
625
|
+
# Thin delegates onto self._penv, which is an AHREnvelope running
|
|
626
|
+
# falling: penv_amount -> 0 on the way in, then on to penv_out_amount
|
|
627
|
+
# on the way out. Amounts are bend units, 1.0 = one octave.
|
|
628
|
+
# Like fenv_amount, raising either amount from 0 only affects NEW notes,
|
|
629
|
+
# because at 0 no per-voice node is built to write into.
|
|
630
|
+
|
|
631
|
+
@property
|
|
632
|
+
def penv_amount(self):
|
|
633
|
+
return self._penv.amount
|
|
634
|
+
|
|
635
|
+
@penv_amount.setter
|
|
636
|
+
def penv_amount(self, v):
|
|
637
|
+
self._penv.amount = v
|
|
638
|
+
|
|
639
|
+
@property
|
|
640
|
+
def penv_time(self):
|
|
641
|
+
return self._penv.attack
|
|
642
|
+
|
|
643
|
+
@penv_time.setter
|
|
644
|
+
def penv_time(self, v):
|
|
645
|
+
self._penv.attack = v
|
|
646
|
+
|
|
647
|
+
@property
|
|
648
|
+
def penv_out_amount(self):
|
|
649
|
+
return self._penv.release_amount
|
|
650
|
+
|
|
651
|
+
@penv_out_amount.setter
|
|
652
|
+
def penv_out_amount(self, v):
|
|
653
|
+
self._penv.release_amount = v
|
|
654
|
+
|
|
655
|
+
@property
|
|
656
|
+
def penv_out_time(self):
|
|
657
|
+
return self._penv.release
|
|
658
|
+
|
|
659
|
+
@penv_out_time.setter
|
|
660
|
+
def penv_out_time(self, v):
|
|
661
|
+
self._penv.release = v
|
|
662
|
+
|
|
663
|
+
# --- filter LFO: shared, so both are one write ----------------------
|
|
664
|
+
|
|
665
|
+
@property
|
|
666
|
+
def filt_lfo_rate(self):
|
|
667
|
+
return self._filt_lfo.rate
|
|
668
|
+
|
|
669
|
+
@filt_lfo_rate.setter
|
|
670
|
+
def filt_lfo_rate(self, v):
|
|
671
|
+
self._filt_lfo.rate = v
|
|
672
|
+
|
|
673
|
+
@property
|
|
674
|
+
def filt_lfo_amount(self):
|
|
675
|
+
"""Hz ADDED above filt_f: the cutoff swings 0..v, never below it.
|
|
676
|
+
|
|
677
|
+
synthio's LFO is ``waveform[idx] * scale + offset``, and the default
|
|
678
|
+
waveform is a triangle centred on zero, so ``scale`` on its own is a
|
|
679
|
+
HALF-swing about zero. Writing scale and offset to the same v/2
|
|
680
|
+
recentres it: -v/2..+v/2 shifted up by v/2 is 0..v. This is the
|
|
681
|
+
min/max-to-midpoint/range conversion from README-2-Modulation.md,
|
|
682
|
+
with lmin fixed at 0.
|
|
683
|
+
|
|
684
|
+
Doing it this way rather than with a custom unipolar waveform keeps
|
|
685
|
+
the LFO on synthio's internal 16-bit resolution -- a hand-built
|
|
686
|
+
64-sample buffer would be measurably steppier.
|
|
687
|
+
|
|
688
|
+
Two writes, but both land on the ONE shared LFO, so this is still
|
|
689
|
+
O(1) in polyphony: there is no per-voice copy to walk.
|
|
690
|
+
"""
|
|
691
|
+
return self._filt_lfo.scale * 2.0 # stored as half-swing, see below
|
|
692
|
+
|
|
693
|
+
@filt_lfo_amount.setter
|
|
694
|
+
def filt_lfo_amount(self, v):
|
|
695
|
+
half = v * 0.5
|
|
696
|
+
self._filt_lfo.scale = half
|
|
697
|
+
self._filt_lfo.offset = half
|
|
698
|
+
|
|
699
|
+
# --- filter envelope params -----------------------------------------
|
|
700
|
+
# Thin delegates onto self._fenv, which owns the blocks and buffers.
|
|
701
|
+
|
|
702
|
+
@property
|
|
703
|
+
def fenv_amount(self):
|
|
704
|
+
return self._fenv.amount
|
|
705
|
+
|
|
706
|
+
@fenv_amount.setter
|
|
707
|
+
def fenv_amount(self, v):
|
|
708
|
+
# one write into the shared block; reaches every voice, including
|
|
709
|
+
# ones already in release. Note: if amount was 0 at note-on no
|
|
710
|
+
# envelope was built for that voice, so raising it from 0 only
|
|
711
|
+
# affects new notes.
|
|
712
|
+
self._fenv.amount = v
|
|
713
|
+
|
|
714
|
+
@property
|
|
715
|
+
def fenv_attack(self):
|
|
716
|
+
return self._fenv.attack
|
|
717
|
+
|
|
718
|
+
@fenv_attack.setter
|
|
719
|
+
def fenv_attack(self, v):
|
|
720
|
+
self._fenv.attack = v # a rate write: cheap on a knob
|
|
721
|
+
|
|
722
|
+
@property
|
|
723
|
+
def fenv_release(self):
|
|
724
|
+
return self._fenv.release
|
|
725
|
+
|
|
726
|
+
@fenv_release.setter
|
|
727
|
+
def fenv_release(self, v):
|
|
728
|
+
self._fenv.release = v
|
|
729
|
+
|
|
730
|
+
@property
|
|
731
|
+
def fenv_curve(self):
|
|
732
|
+
return self._fenv.curve
|
|
733
|
+
|
|
734
|
+
@fenv_curve.setter
|
|
735
|
+
def fenv_curve(self, v):
|
|
736
|
+
# the shape is rewritten in place, so sounding voices -- including
|
|
737
|
+
# ones already in release -- morph immediately. O(1), but it does
|
|
738
|
+
# allocate temporaries: this is a switch, not a knob.
|
|
739
|
+
self._fenv.curve = v
|
|
740
|
+
|
|
741
|
+
# --- velocity -------------------------------------------------------
|
|
742
|
+
# Both are shared blocks nested in each voice's graph, so they stay live
|
|
743
|
+
# for voices that already have the relevant node. Turning one on from
|
|
744
|
+
# zero still only affects new notes: at zero no node is built at all.
|
|
745
|
+
|
|
746
|
+
@property
|
|
747
|
+
def filt_vel(self):
|
|
748
|
+
return self._filt_vel_blk.a
|
|
749
|
+
|
|
750
|
+
@filt_vel.setter
|
|
751
|
+
def filt_vel(self, v):
|
|
752
|
+
self._filt_vel_blk.a = v
|
|
753
|
+
|
|
754
|
+
@property
|
|
755
|
+
def fenv_vel(self):
|
|
756
|
+
return self._fenv_vel_blk.a
|
|
757
|
+
|
|
758
|
+
@fenv_vel.setter
|
|
759
|
+
def fenv_vel(self, v):
|
|
760
|
+
self._fenv_vel_blk.a = v
|
|
761
|
+
|
|
762
|
+
def set_param(self, name, val):
|
|
763
|
+
"""String front-end for MIDI CC / UI / patch-editor code.
|
|
764
|
+
Prefer ``synth.filt_f = v`` in hot loops."""
|
|
765
|
+
if name not in self._PARAMS:
|
|
766
|
+
raise KeyError(name)
|
|
767
|
+
setattr(self, name, val)
|