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/waves.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
#
|
|
4
|
+
# waves.py - waveform name -> ulab int16 array, built once, cached forever.
|
|
5
|
+
#
|
|
6
|
+
# All notes share these arrays by reference: zero per-note allocation.
|
|
7
|
+
#
|
|
8
|
+
# Also holds `Waves`, a string-keyed factory with a few extra conveniences
|
|
9
|
+
# (WAV loading, LFO shape helpers) for building instruments interactively.
|
|
10
|
+
# `Waves.make_waveform()` delegates to `get_wave()` for the waveform types
|
|
11
|
+
# they share, rather than generating them a second way.
|
|
12
|
+
#
|
|
13
|
+
# adafruit_wave is imported lazily, inside Waves.wav()/wav_info() only: it
|
|
14
|
+
# isn't available under plain CPython (no pip package here), and everything
|
|
15
|
+
# else in this module -- including the CPython test tier -- must not require
|
|
16
|
+
# it just to build a waveform buffer.
|
|
17
|
+
|
|
18
|
+
import random
|
|
19
|
+
|
|
20
|
+
import ulab.numpy as np
|
|
21
|
+
|
|
22
|
+
_cache = {} # (name, size) -> np.array
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _saw(size, vol):
|
|
26
|
+
return np.linspace(vol, -vol, num=size, dtype=np.int16)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _squ(size, vol):
|
|
30
|
+
h = size // 2
|
|
31
|
+
return np.concatenate(
|
|
32
|
+
(np.ones(h, dtype=np.int16) * vol, np.ones(size - h, dtype=np.int16) * -vol)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sin(size, vol):
|
|
37
|
+
return np.array(
|
|
38
|
+
np.sin(np.linspace(0, 2 * np.pi, num=size, endpoint=False)) * vol, dtype=np.int16
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _tri(size, vol):
|
|
43
|
+
h = size // 2
|
|
44
|
+
return np.concatenate(
|
|
45
|
+
(
|
|
46
|
+
np.linspace(-vol, vol, num=h, dtype=np.int16),
|
|
47
|
+
np.linspace(vol, -vol, num=size - h, dtype=np.int16),
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _nze(size, vol):
|
|
53
|
+
return np.array([random.randint(-vol, vol) for _ in range(size)], dtype=np.int16)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- hand-drawn "analog" waveforms, ported from the Mozzi Arduino synth
|
|
57
|
+
# library's tables/ (saw_analogue512_int8.h, triangle_analogue512_int8.h,
|
|
58
|
+
# square_analogue512_int8.h, smoothsquare8192_int8.h). Each is fixed
|
|
59
|
+
# sampled data -- drawn by hand in Audacity, not generated by a formula --
|
|
60
|
+
# so there is no recipe to regenerate them; only the sample data itself
|
|
61
|
+
# can be shrunk. Each native table below was block-averaged offline (real
|
|
62
|
+
# numpy, not on device) from the raw int8 samples, DC-corrected,
|
|
63
|
+
# peak-normalized to +-127, and phase-rotated to start where the other
|
|
64
|
+
# builders in this file start (peak-first for the saws, trough-first for
|
|
65
|
+
# the triangle, plateau-first for the squares), then had its own first
|
|
66
|
+
# sample appended as a cyclic sentinel so _resample_table()'s wraparound
|
|
67
|
+
# from last sample back to first never needs a special case.
|
|
68
|
+
#
|
|
69
|
+
# Stored as a space-separated string, not a Python list/tuple literal:
|
|
70
|
+
# ruff-format lays out a literal collection that doesn't fit on one line
|
|
71
|
+
# one element per line, which turns a 129-number table into 129 source
|
|
72
|
+
# lines. A string is never reflowed that way, so this keeps ~580 numbers
|
|
73
|
+
# total to a few dozen compact lines.
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ints(s):
|
|
77
|
+
return tuple(int(v) for v in s.split())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resample_table(table, size, vol, peak=127):
|
|
81
|
+
"""Lerp a small native ``table`` (length N+1, table[-1] == table[0]) up
|
|
82
|
+
to ``size`` samples, scaled from its own +-127-ish native range to
|
|
83
|
+
+-vol. Plain Python scalar loop, not vectorized ulab ops: the
|
|
84
|
+
MicroPython ulab fallback (tests/stubs/ulab/numpy.py) supports only
|
|
85
|
+
int/slice indexing, so there is no fancy-indexing gather to lean on
|
|
86
|
+
here -- same shape as _nze() above."""
|
|
87
|
+
n = len(table) - 1
|
|
88
|
+
scale = vol / peak
|
|
89
|
+
out = []
|
|
90
|
+
for k in range(size):
|
|
91
|
+
pos = (k / size) * n
|
|
92
|
+
i = int(pos)
|
|
93
|
+
frac = pos - i
|
|
94
|
+
a = table[i]
|
|
95
|
+
b = table[i + 1]
|
|
96
|
+
out.append(int(round((a * (1.0 - frac) + b * frac) * scale)))
|
|
97
|
+
return np.array(out, dtype=np.int16)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# saw_analogue512_int8.h: 512 raw int8 samples -> N=128 native, round-trip
|
|
101
|
+
# RMS error 5.1% of peak at size=256.
|
|
102
|
+
_ASAW_TABLE = _ints(
|
|
103
|
+
"112 103 105 101 100 97 97 94 93 90 89 86 85 82 81 79 78 75 74 71 70 "
|
|
104
|
+
"68 67 64 63 61 59 57 56 53 52 50 49 46 45 43 42 39 38 35 34 32 31 29 "
|
|
105
|
+
"28 25 24 22 21 18 17 15 14 12 11 8 7 5 4 2 1 -1 -3 -5 -6 -8 -9 -11 "
|
|
106
|
+
"-12 -15 -15 -18 -19 -21 -22 -24 -25 -27 -28 -30 -31 -33 -34 -36 -37 "
|
|
107
|
+
"-39 -40 -42 -43 -45 -46 -48 -49 -51 -52 -54 -54 -57 -57 -60 -60 -62 "
|
|
108
|
+
"-63 -65 -66 -67 -68 -71 -71 -73 -74 -76 -76 -78 -79 -81 -82 -84 -84 "
|
|
109
|
+
"-87 -87 -89 -89 -92 -91 -96 -73 81 112"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _asaw(size, vol):
|
|
114
|
+
return _resample_table(_ASAW_TABLE, size, vol)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# triangle_analogue512_int8.h: 512 raw int8 samples -> N=64 native,
|
|
118
|
+
# round-trip RMS error 2.6% of peak at size=256.
|
|
119
|
+
_ATRI_TABLE = _ints(
|
|
120
|
+
"-106 -101 -95 -89 -84 -78 -71 -65 -58 -52 -45 -38 -31 -24 -17 -10 -2 "
|
|
121
|
+
"5 13 21 29 37 45 53 62 71 78 87 96 105 114 123 125 116 107 98 89 80 "
|
|
122
|
+
"73 64 55 47 39 31 23 15 7 0 -8 -15 -23 -30 -37 -43 -50 -57 -63 -70 "
|
|
123
|
+
"-76 -82 -88 -94 -99 -105 -106"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _atri(size, vol):
|
|
128
|
+
return _resample_table(_ATRI_TABLE, size, vol)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# square_analogue512_int8.h: 512 raw int8 samples -> N=128 native,
|
|
132
|
+
# round-trip RMS error 7.5% of peak at size=256.
|
|
133
|
+
_ASQU_TABLE = _ints(
|
|
134
|
+
"107 104 110 105 109 106 109 106 109 106 109 106 109 107 108 107 108 "
|
|
135
|
+
"107 108 107 108 107 108 107 108 107 108 107 108 107 108 107 108 107 "
|
|
136
|
+
"108 107 108 107 108 107 108 107 108 107 108 107 108 107 108 107 109 "
|
|
137
|
+
"106 109 106 109 106 109 106 110 104 111 102 119 38 -106 -105 -109 "
|
|
138
|
+
"-106 -109 -106 -108 -107 -108 -107 -108 -107 -108 -107 -108 -107 "
|
|
139
|
+
"-108 -107 -108 -107 -108 -107 -108 -107 -108 -107 -108 -107 -108 "
|
|
140
|
+
"-107 -108 -107 -108 -107 -108 -107 -108 -107 -108 -107 -108 -107 "
|
|
141
|
+
"-108 -107 -108 -107 -108 -107 -108 -107 -108 -107 -108 -107 -109 "
|
|
142
|
+
"-107 -109 -106 -109 -106 -110 -103 -114 -51 107"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _asqu(size, vol):
|
|
147
|
+
return _resample_table(_ASQU_TABLE, size, vol)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# smoothsquare8192_int8.h: 8192 raw int8 samples -> N=256 native,
|
|
151
|
+
# round-trip RMS error 2.4% of peak at size=256. DC-centering an
|
|
152
|
+
# asymmetric hand-drawn wave means the two plateaus don't land at equal
|
|
153
|
+
# +-magnitude after normalizing to +-127 (one side is ~117 here) --
|
|
154
|
+
# inaudible, and expected for any non-zero-mean source.
|
|
155
|
+
_SSQU_TABLE = _ints(
|
|
156
|
+
"-5 -5 -5 -5 -5 -5 -5 -5 -4 -2 1 5 10 17 27 38 52 67 83 100 115 117 "
|
|
157
|
+
"117 117 117 117 117 117 117 117 117 117 117 117 117 113 104 96 89 85 "
|
|
158
|
+
"83 83 85 88 93 98 104 110 116 117 117 117 117 117 117 117 117 117 "
|
|
159
|
+
"117 117 117 117 117 114 113 111 110 110 110 110 111 112 114 115 116 "
|
|
160
|
+
"117 117 117 117 117 117 117 117 117 117 117 117 117 117 117 117 116 "
|
|
161
|
+
"116 116 116 116 116 116 117 117 117 117 117 117 117 117 117 117 117 "
|
|
162
|
+
"117 117 117 117 117 117 117 117 117 117 117 117 117 117 117 117 117 "
|
|
163
|
+
"117 117 117 117 117 115 113 109 103 93 81 64 44 19 -9 -40 -73 -108 "
|
|
164
|
+
"-127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 "
|
|
165
|
+
"-127 -126 -112 -93 -79 -68 -61 -59 -60 -65 -72 -82 -94 -106 -117 "
|
|
166
|
+
"-126 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 "
|
|
167
|
+
"-127 -124 -120 -116 -114 -113 -113 -113 -114 -116 -118 -121 -124 "
|
|
168
|
+
"-126 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 "
|
|
169
|
+
"-127 -127 -126 -125 -125 -124 -124 -124 -124 -125 -125 -126 -126 "
|
|
170
|
+
"-127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 "
|
|
171
|
+
"-127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 -127 "
|
|
172
|
+
"-127 -127 -127 -127 -127 -127 -5"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _ssqu(size, vol):
|
|
177
|
+
return _resample_table(_SSQU_TABLE, size, vol)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
_builders = {
|
|
181
|
+
"SAW": _saw, # plain formula sawtooth
|
|
182
|
+
"SQU": _squ, # plain formula square
|
|
183
|
+
"SIN": _sin, # plain formula sine
|
|
184
|
+
"TRI": _tri, # plain formula triangle
|
|
185
|
+
"NZE": _nze, # white noise, resampled fresh every call
|
|
186
|
+
"ASAW": _asaw, # Mozzi hand-drawn "analog" saw, wobbly decay
|
|
187
|
+
"ATRI": _atri, # Mozzi hand-drawn "analog" triangle
|
|
188
|
+
"ASQU": _asqu, # Mozzi hand-drawn "analog" square, rippled plateaus
|
|
189
|
+
"SSQU": _ssqu, # Mozzi hand-drawn square, soft rounded corners
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_wave(name, size=256, volume=28000):
|
|
194
|
+
"""Return a cached int16 waveform array for ``name``. Builds on first use."""
|
|
195
|
+
key = (name, size)
|
|
196
|
+
w = _cache.get(key)
|
|
197
|
+
if w is None:
|
|
198
|
+
w = _builders[name](size, volume)
|
|
199
|
+
_cache[key] = w
|
|
200
|
+
return w
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def wave_names():
|
|
204
|
+
return list(_builders.keys())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
_2x_cache = {} # (name, size) -> np.array, two cycles of get_wave() back to back
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_wave_2x(name, size=256, volume=28000):
|
|
211
|
+
"""Two cycles of get_wave(name), concatenated. Built once and cached,
|
|
212
|
+
like get_wave() itself -- not a per-voice or per-note cost. This is the
|
|
213
|
+
shared source buffer random_phase_wave() slices from: doubling the
|
|
214
|
+
length means any offset in [0, size) has a full cycle available without
|
|
215
|
+
having to wrap around the end."""
|
|
216
|
+
key = (name, size)
|
|
217
|
+
w = _2x_cache.get(key)
|
|
218
|
+
if w is None:
|
|
219
|
+
base = get_wave(name, size, volume)
|
|
220
|
+
w = np.concatenate((base, base))
|
|
221
|
+
_2x_cache[key] = w
|
|
222
|
+
return w
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def random_phase_wave(name, size=256, volume=28000):
|
|
226
|
+
"""One cycle of ``name``, starting at a random point in its cycle.
|
|
227
|
+
|
|
228
|
+
Unlike get_wave()/get_wave_2x(), this is NOT cached -- every call slices
|
|
229
|
+
a fresh ``size``-sample window out of the shared 2x buffer at a random
|
|
230
|
+
offset, so the returned array differs call to call. That makes it a
|
|
231
|
+
per-note-on cost: one array copy, the same tier as building a fresh
|
|
232
|
+
synthio.Note or Biquad at press time, not a per-sample one. Call it once
|
|
233
|
+
per oscillator per note-on, not from a hot path."""
|
|
234
|
+
wave2x = get_wave_2x(name, size, volume)
|
|
235
|
+
start = random.randint(0, size - 1)
|
|
236
|
+
return wave2x[start : start + size]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --- envelope shapes for one-shot LFOs -------------------------------
|
|
240
|
+
# A synthio.LFO with once=True runs its waveform once and then holds the
|
|
241
|
+
# final sample forever. So a buffer holding nothing but a rise 0 -> peak
|
|
242
|
+
# already IS attack-then-hold: there is no need to write a plateau after
|
|
243
|
+
# the rise, because the LFO supplies one for free and for as long as the
|
|
244
|
+
# key is down. Release re-runs the same rise through a CONSTRAINED_LERP
|
|
245
|
+
# with swapped endpoints -- see ahr_envelope.py.
|
|
246
|
+
|
|
247
|
+
ENV_SIZE = 64
|
|
248
|
+
ENV_PEAK = 32767
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def env_buffer():
|
|
252
|
+
"""A writable buffer for an AHR envelope shape."""
|
|
253
|
+
return np.zeros(ENV_SIZE, dtype=np.int16)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
_ramp = None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def ramp_wave():
|
|
260
|
+
"""A cached 0 -> peak ramp, for one-shot LFOs used as a POSITION.
|
|
261
|
+
|
|
262
|
+
Two samples is all it takes: synthio interpolates between waveform
|
|
263
|
+
entries, so (0, ENV_PEAK) with once=True is a clean linear ramp that
|
|
264
|
+
holds at the top -- the tutorial's idiom for fade-ins and bends.
|
|
265
|
+
|
|
266
|
+
Note this is NOT interchangeable with LFO(waveform=None): the default
|
|
267
|
+
waveform is a zero-centred triangle that would come back down again.
|
|
268
|
+
A ramp has to be spelled out.
|
|
269
|
+
|
|
270
|
+
Read-only and shared by every user, unlike the envelope shape buffers,
|
|
271
|
+
which are per-instance because they get rewritten in place."""
|
|
272
|
+
global _ramp
|
|
273
|
+
if _ramp is None:
|
|
274
|
+
_ramp = np.array((0, ENV_PEAK), dtype=np.int16)
|
|
275
|
+
return _ramp
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _curve_ramp(start, stop, n, curve):
|
|
279
|
+
"""Normalized ramp start -> stop over n points, raised to the integer
|
|
280
|
+
power ``curve``. Repeated multiply rather than ``**``: elementwise float
|
|
281
|
+
multiply is already proven on ulab, ``**`` is not.
|
|
282
|
+
|
|
283
|
+
For start/stop in [0,1] the result stays in [0,1], so scaling by
|
|
284
|
+
ENV_PEAK cannot leave int16 range. Call it descending (1.0 -> 0) to get
|
|
285
|
+
(1-t)^curve; ulab has no negative strides, so a descending linspace is
|
|
286
|
+
the way to reverse a ramp, never a [::-1] slice.
|
|
287
|
+
|
|
288
|
+
Allocates one array per multiply, so curve > 1 costs curve-1 extra
|
|
289
|
+
temporaries. Fine for a patch load or a switch, not for a knob path."""
|
|
290
|
+
t = np.linspace(start, stop, num=n)
|
|
291
|
+
y = t
|
|
292
|
+
for _ in range(curve - 1):
|
|
293
|
+
y = y * t
|
|
294
|
+
return y
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _clamp_curve(curve):
|
|
298
|
+
curve = int(curve)
|
|
299
|
+
return 1 if curve < 1 else curve
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def fill_env_rise(buf, curve=1):
|
|
303
|
+
"""Rise 0 -> ENV_PEAK across the WHOLE buffer, shaped 1 - (1-t)^curve.
|
|
304
|
+
|
|
305
|
+
``curve`` is an integer exponent: 1 = linear, 2+ = increasingly
|
|
306
|
+
fast-start, easing into the peak. That shape is chosen for what it does
|
|
307
|
+
at the OTHER end. The release reruns this same buffer through a
|
|
308
|
+
CONSTRAINED_LERP with swapped endpoints, i.e. ``V * (1 - s(t))``, so
|
|
309
|
+
|
|
310
|
+
s(t) = 1 - (1-t)^curve => release = V * (1-t)^curve
|
|
311
|
+
|
|
312
|
+
which is the conventional decay: quick initial drop, long tail. The
|
|
313
|
+
obvious alternative, s(t) = t^curve, makes the release ``V * (1 - t^curve)``
|
|
314
|
+
-- still at 75% of its value halfway through at curve=2, hanging near the
|
|
315
|
+
top and then falling off a cliff. A mirrored attack, not a decay.
|
|
316
|
+
|
|
317
|
+
NOTE this is a deliberate divergence from the synthio tutorial, whose
|
|
318
|
+
PRODUCT(lerp, lerp, 1) is t^2, the shape described above. The tutorial
|
|
319
|
+
keeps attack and release shapes independent, so it can afford t^2 for the
|
|
320
|
+
rise; sharing one buffer means the release gets the casting vote.
|
|
321
|
+
|
|
322
|
+
There is deliberately no hold segment and no second, falling buffer:
|
|
323
|
+
- the hold is what ``once=True`` already does after the last sample
|
|
324
|
+
(measured on device: a one-shot LFO reads 0.9999 at both 0.5s and
|
|
325
|
+
1.5s after a 0.2s rise), so writing a plateau here would only shorten
|
|
326
|
+
the rise and force the release rate to compensate for it;
|
|
327
|
+
- a second buffer is unreachable mid-note anyway, because
|
|
328
|
+
synthio.LFO.waveform is read-only.
|
|
329
|
+
|
|
330
|
+
Written IN PLACE, so every voice sharing this array morphs live."""
|
|
331
|
+
curve = _clamp_curve(curve)
|
|
332
|
+
n = len(buf)
|
|
333
|
+
if curve == 1:
|
|
334
|
+
# 1 - (1-t)^1 is just t, so take the integer linspace directly:
|
|
335
|
+
# bit-identical to a plain linear ramp and one float array cheaper
|
|
336
|
+
buf[:] = np.linspace(0, ENV_PEAK, num=n, dtype=np.int16)
|
|
337
|
+
else:
|
|
338
|
+
# `* -ENV_PEAK + ENV_PEAK`, NOT `ENV_PEAK - arr`: the pure-Python
|
|
339
|
+
# ulab fallback implements __sub__ but not __rsub__, so
|
|
340
|
+
# scalar-minus-array raises TypeError on the MicroPython tier.
|
|
341
|
+
# Array-times-scalar and array-plus-scalar are both fine.
|
|
342
|
+
# Endpoints stay exact for any curve because linspace pins its last
|
|
343
|
+
# element: (1-t) is exactly 1.0 at index 0 and exactly 0.0 at the end.
|
|
344
|
+
buf[:] = np.array(_curve_ramp(1.0, 0.0, n, curve) * (-ENV_PEAK) + ENV_PEAK, dtype=np.int16)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# --- Waves: string-keyed factory + WAV loading, for interactive use ---
|
|
348
|
+
|
|
349
|
+
_NAME_ALIASES = {
|
|
350
|
+
"SIN": "SIN",
|
|
351
|
+
"SINE": "SIN",
|
|
352
|
+
"SQU": "SQU",
|
|
353
|
+
"SQUARE": "SQU",
|
|
354
|
+
"SAW": "SAW",
|
|
355
|
+
"TRI": "TRI",
|
|
356
|
+
"TRIANGLE": "TRI",
|
|
357
|
+
"NZE": "NZE",
|
|
358
|
+
"NOISE": "NZE",
|
|
359
|
+
"ASAW": "ASAW",
|
|
360
|
+
"ANALOG_SAW": "ASAW",
|
|
361
|
+
"ATRI": "ATRI",
|
|
362
|
+
"ANALOG_TRI": "ATRI",
|
|
363
|
+
"ASQU": "ASQU",
|
|
364
|
+
"ANALOG_SQU": "ASQU",
|
|
365
|
+
"SSQU": "SSQU",
|
|
366
|
+
"SMOOTH_SQU": "SSQU",
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class Waves:
|
|
371
|
+
"""
|
|
372
|
+
Generate waveforms for either oscillator or LFO use.
|
|
373
|
+
By default, size is 256, volume is max +-32767
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
waveform_types = ("SIN", "SQU", "SAW", "TRI", "SIL", "NZE", "ASAW", "ATRI", "ASQU", "SSQU")
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def make_waveform(waveid, size=256, volume=32767):
|
|
380
|
+
"""Return a waveform by string name, one of ``waveform_types``.
|
|
381
|
+
|
|
382
|
+
Delegates to ``get_wave()`` for the types it also builds, rather than
|
|
383
|
+
generating them a second way; SIL has no ``get_wave`` equivalent and
|
|
384
|
+
stays local.
|
|
385
|
+
"""
|
|
386
|
+
waveid = waveid.upper()
|
|
387
|
+
if waveid in ("SIL", "SILENCE"):
|
|
388
|
+
return Waves.silence(size)
|
|
389
|
+
canonical = _NAME_ALIASES.get(waveid)
|
|
390
|
+
if canonical is None:
|
|
391
|
+
print("unknown wave type", waveid)
|
|
392
|
+
return None
|
|
393
|
+
return get_wave(canonical, size, volume)
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
def sine(size, volume):
|
|
397
|
+
"""Sine waveform"""
|
|
398
|
+
return get_wave("SIN", size, volume)
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def square(size, volume):
|
|
402
|
+
"""Square waveform"""
|
|
403
|
+
return get_wave("SQU", size, volume)
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def triangle(size, min_vol, max_vol):
|
|
407
|
+
"""Triangle waveform. ``get_wave``'s TRI is symmetric about 0, so this
|
|
408
|
+
only delegates when min_vol/max_vol are the usual +-volume pair."""
|
|
409
|
+
if min_vol == -max_vol:
|
|
410
|
+
return get_wave("TRI", size, max_vol)
|
|
411
|
+
return np.concatenate(
|
|
412
|
+
(
|
|
413
|
+
np.linspace(min_vol, max_vol, num=size // 2, dtype=np.int16),
|
|
414
|
+
np.linspace(max_vol, min_vol, num=size // 2, dtype=np.int16),
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def saw(size, volume):
|
|
420
|
+
"""Saw (aka Ramp) waveform"""
|
|
421
|
+
return Waves.saw_down(size, volume)
|
|
422
|
+
|
|
423
|
+
@staticmethod
|
|
424
|
+
def saw_down(size, volume):
|
|
425
|
+
"""Saw waveform from max to min"""
|
|
426
|
+
return get_wave("SAW", size, volume)
|
|
427
|
+
|
|
428
|
+
@staticmethod
|
|
429
|
+
def saw_up(size, volume):
|
|
430
|
+
"""Saw waveform from min to max"""
|
|
431
|
+
return np.linspace(-volume, volume, num=size, dtype=np.int16)
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def silence(size):
|
|
435
|
+
"""All zeros waveform"""
|
|
436
|
+
return np.zeros(size, dtype=np.int16)
|
|
437
|
+
|
|
438
|
+
@staticmethod
|
|
439
|
+
def noise(size, volume):
|
|
440
|
+
"""White noise waveform (from random.randint)"""
|
|
441
|
+
return get_wave("NZE", size, volume)
|
|
442
|
+
|
|
443
|
+
@staticmethod
|
|
444
|
+
def from_list(vals):
|
|
445
|
+
"""Waveform from a list of values, useful for LFOs"""
|
|
446
|
+
return np.array([int(v) for v in vals], dtype=np.int16)
|
|
447
|
+
|
|
448
|
+
@staticmethod
|
|
449
|
+
def lfo_ramp_up_pos():
|
|
450
|
+
"""Simple two-element ramp-up waveform for synthio.LFO (which does interpolation)"""
|
|
451
|
+
return np.array((0, 32767), dtype=np.int16)
|
|
452
|
+
|
|
453
|
+
@staticmethod
|
|
454
|
+
def lfo_ramp_down_pos():
|
|
455
|
+
"""Simple two-element row-downwaveform for synthio.LFO (which does interpolation)"""
|
|
456
|
+
return np.array((32767, 0), dtype=np.int16)
|
|
457
|
+
|
|
458
|
+
@staticmethod
|
|
459
|
+
def lfo_triangle_pos():
|
|
460
|
+
"""Simple three-element triangle waveform for synthio.LFO (which does interpolation)"""
|
|
461
|
+
return np.array((0, 32767, 0), dtype=np.int16)
|
|
462
|
+
|
|
463
|
+
@staticmethod
|
|
464
|
+
def lfo_triangle():
|
|
465
|
+
"""Simple four-element triangle waveform for synthio.LFO (which does interpolation)"""
|
|
466
|
+
return np.array((0, 32767, 0, -32767), dtype=np.int16)
|
|
467
|
+
|
|
468
|
+
@staticmethod
|
|
469
|
+
def from_ar_times(attack_time=1, release_time=1):
|
|
470
|
+
"""
|
|
471
|
+
Generate a fake Attack/Release 'Envelope' using an LFO waveform.
|
|
472
|
+
This is a dumb way of doing it, but since we cannot get .value()
|
|
473
|
+
out of Envelope, we have to fake it with an LFO.
|
|
474
|
+
"""
|
|
475
|
+
a10 = int(attack_time * 10)
|
|
476
|
+
r10 = int(release_time * 10)
|
|
477
|
+
a = [i * 65535 // a10 - 32767 for i in range(a10)]
|
|
478
|
+
r = [32767 - i * 65535 // r10 for i in range(r10)]
|
|
479
|
+
return Waves.from_list(a + [32767] + r)
|
|
480
|
+
|
|
481
|
+
@staticmethod
|
|
482
|
+
def wav(filepath, size=256, pos=0):
|
|
483
|
+
"""Create a waveform from a WAV file using adafruit_wave"""
|
|
484
|
+
import adafruit_wave
|
|
485
|
+
|
|
486
|
+
with adafruit_wave.open(filepath) as w:
|
|
487
|
+
if w.getsampwidth() != 2 or w.getnchannels() != 1:
|
|
488
|
+
raise ValueError("unsupported format")
|
|
489
|
+
n = size
|
|
490
|
+
w.setpos(pos)
|
|
491
|
+
return np.frombuffer(w.readframes(n), dtype=np.int16)
|
|
492
|
+
|
|
493
|
+
@staticmethod
|
|
494
|
+
def wav_info(filepath):
|
|
495
|
+
"""return (nframes,nchannels,sampwidth) from a WAV filename"""
|
|
496
|
+
import adafruit_wave
|
|
497
|
+
|
|
498
|
+
with adafruit_wave.open(filepath) as w:
|
|
499
|
+
return (w.getnframes(), w.getnchannels(), w.getsampwidth())
|
synthtools/wavetable.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
#
|
|
4
|
+
# wavetable.py - reads Serum-style single-cycle WAV wavetables (16-bit mono,
|
|
5
|
+
# waves of `size` samples back to back) into a reusable waveform buffer.
|
|
6
|
+
# Requires the adafruit_wave library.
|
|
7
|
+
#
|
|
8
|
+
# This is the tool: a waveform-buffer helper for use as a synthio.Note's
|
|
9
|
+
# `waveform`. For the polyphonic synth instrument built on top of it, see
|
|
10
|
+
# wavetable_synth.py.
|
|
11
|
+
|
|
12
|
+
import adafruit_wave
|
|
13
|
+
import ulab.numpy as np
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Wavetable:
|
|
17
|
+
"""Reads one wave (or a lerp between two adjacent waves) out of a
|
|
18
|
+
wavetable WAV file into a fixed, reusable buffer."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, filepath, size=256):
|
|
21
|
+
self.w = adafruit_wave.open(filepath)
|
|
22
|
+
if self.w.getsampwidth() != 2 or self.w.getnchannels() != 1:
|
|
23
|
+
raise ValueError("16-bit mono WAV required")
|
|
24
|
+
self.size = size
|
|
25
|
+
self.num_waves = self.w.getnframes() // size
|
|
26
|
+
self.waveform = np.zeros(size, dtype=np.int16) # the shared buffer
|
|
27
|
+
|
|
28
|
+
def _read_wave(self, i):
|
|
29
|
+
self.w.setpos(i * self.size)
|
|
30
|
+
return np.frombuffer(self.w.readframes(self.size), dtype=np.int16)
|
|
31
|
+
|
|
32
|
+
def set_wave_pos(self, pos):
|
|
33
|
+
"""pos is fractional: 3.25 = 25% between wave 3 and wave 4."""
|
|
34
|
+
n = self.num_waves
|
|
35
|
+
if n < 2: # single-wave file: nothing to blend
|
|
36
|
+
self.waveform[:] = self._read_wave(0)
|
|
37
|
+
return
|
|
38
|
+
if pos < 0:
|
|
39
|
+
pos = 0.0
|
|
40
|
+
elif pos > n - 1:
|
|
41
|
+
pos = n - 1.0
|
|
42
|
+
i = int(pos)
|
|
43
|
+
if i > n - 2: # at the very top, blend the last pair
|
|
44
|
+
i = n - 2
|
|
45
|
+
frac = pos - i
|
|
46
|
+
|
|
47
|
+
wave_a = self._read_wave(i)
|
|
48
|
+
if frac <= 0.0: # exact wave: skip the read and the math
|
|
49
|
+
self.waveform[:] = wave_a
|
|
50
|
+
return
|
|
51
|
+
wave_b = self._read_wave(i + 1)
|
|
52
|
+
|
|
53
|
+
# Convex combination, evaluated in float. Do NOT write this as the
|
|
54
|
+
# usual wave_a + frac * (wave_b - wave_a) -- that subtraction is
|
|
55
|
+
# performed in int16 and wraps whenever the two samples straddle
|
|
56
|
+
# zero at high amplitude (32000 - -32000 = 64000 -> +1536), which
|
|
57
|
+
# then pushes the result past 32767 and raises
|
|
58
|
+
# OverflowError: value must fit in 2 byte(s)
|
|
59
|
+
# on the store back into the int16 buffer. Multiplying by the float
|
|
60
|
+
# weights first promotes to float, and since frac is in [0,1] the
|
|
61
|
+
# result is bounded by the two inputs, so it always fits.
|
|
62
|
+
self.waveform[:] = np.array(wave_a * (1.0 - frac) + wave_b * frac, dtype=np.int16)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
#
|
|
4
|
+
# wavetable_synth.py - polyphonic wavetable synth instrument, built on the
|
|
5
|
+
# Synth engine (synth.py) and the Wavetable waveform-buffer tool
|
|
6
|
+
# (wavetable.py).
|
|
7
|
+
#
|
|
8
|
+
# All sounding notes share self._wavetable.waveform by reference, so
|
|
9
|
+
# setting wave_pos morphs already-sounding notes live -- the lerp writes
|
|
10
|
+
# into the one buffer synthio is reading from. O(1) in polyphony.
|
|
11
|
+
|
|
12
|
+
import synthio
|
|
13
|
+
|
|
14
|
+
from .synth import Synth
|
|
15
|
+
from .wavetable import Wavetable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WavetableSynth(Synth):
|
|
19
|
+
"""Polyphonic wavetable synth: one Note per key, its waveform a
|
|
20
|
+
position (wave_pos) lerped between two adjacent frames of a loaded
|
|
21
|
+
wavetable WAV file (wave_file), through the shared Synth
|
|
22
|
+
filter/envelope graph.
|
|
23
|
+
|
|
24
|
+
All sounding notes share the Wavetable's waveform buffer by
|
|
25
|
+
reference, so moving wave_pos morphs already-sounding notes live --
|
|
26
|
+
the lerp writes into the one buffer synthio is reading from, O(1) in
|
|
27
|
+
polyphony regardless of how many notes are held.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_PARAMS = Synth._PARAMS + ("wave_pos", "wave_file")
|
|
31
|
+
|
|
32
|
+
# class attrs: base __init__ calls _recompile() before subclass setup
|
|
33
|
+
_wavetable = None
|
|
34
|
+
_wt_path = None
|
|
35
|
+
_wave_pos = 0
|
|
36
|
+
|
|
37
|
+
def _recompile(self):
|
|
38
|
+
super()._recompile()
|
|
39
|
+
p = self.patch
|
|
40
|
+
if p.wave_file != self._wt_path: # heavy: only on file change
|
|
41
|
+
self._wavetable = Wavetable(p.wave_file)
|
|
42
|
+
self._wt_path = p.wave_file
|
|
43
|
+
self._wave_pos = getattr(p, "wave_pos", 0)
|
|
44
|
+
self._wavetable.set_wave_pos(self._wave_pos)
|
|
45
|
+
self._wave = self._wavetable.waveform
|
|
46
|
+
|
|
47
|
+
def _decompile(self):
|
|
48
|
+
super()._decompile()
|
|
49
|
+
self.patch.wave_file = self._wt_path
|
|
50
|
+
self.patch.wave_pos = self._wave_pos
|
|
51
|
+
|
|
52
|
+
def _make_notes(self, midi_note, velocity):
|
|
53
|
+
f = synthio.midi_to_hz(midi_note)
|
|
54
|
+
# fmt: off
|
|
55
|
+
return (synthio.Note(f, waveform=self._wave, envelope=self._env,
|
|
56
|
+
amplitude=velocity / 127,
|
|
57
|
+
filter=self._make_filter(),
|
|
58
|
+
bend=self._bend_cur),)
|
|
59
|
+
# fmt: on
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def wave_pos(self):
|
|
63
|
+
return self._wave_pos
|
|
64
|
+
|
|
65
|
+
@wave_pos.setter
|
|
66
|
+
def wave_pos(self, v):
|
|
67
|
+
self._wave_pos = v
|
|
68
|
+
self._wavetable.set_wave_pos(v) # in-place: morphs sounding notes
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def wave_file(self):
|
|
72
|
+
return self._wt_path
|
|
73
|
+
|
|
74
|
+
@wave_file.setter
|
|
75
|
+
def wave_file(self, v):
|
|
76
|
+
if v != self._wt_path: # reopens file; next note-on uses it
|
|
77
|
+
self._wavetable = Wavetable(v)
|
|
78
|
+
self._wt_path = v
|
|
79
|
+
self._wavetable.set_wave_pos(self._wave_pos)
|
|
80
|
+
self._wave = self._wavetable.waveform
|