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.
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())
@@ -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