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/paramset.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2023 Tod Kurt
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""
|
|
5
|
+
``param_set.py``
|
|
6
|
+
================================================================================
|
|
7
|
+
|
|
8
|
+
``ParamSet`` is a collection of ``Param`` objects that track normalized
|
|
9
|
+
knob positions, especially for the case when there are fewer knobs
|
|
10
|
+
than params.
|
|
11
|
+
|
|
12
|
+
Each ``Param`` is a UI- and implementation-independent way of describing
|
|
13
|
+
a named numerical parameter with a min/max, a display format, and
|
|
14
|
+
(optionally) an object attribute that they represent.
|
|
15
|
+
|
|
16
|
+
20 May 2025 - @todbot / Tod Kurt
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Param:
|
|
24
|
+
"""Params are a UI- and implementation-independent way of describing
|
|
25
|
+
a named numerical parameter with a min/max, a display format, and
|
|
26
|
+
(optionally) an object attribute that they represent.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, name, val, vmin, vmax, fmt, objattr=None):
|
|
30
|
+
self.name = name
|
|
31
|
+
self.val = val
|
|
32
|
+
self.vmin = vmin
|
|
33
|
+
self.vmax = vmax
|
|
34
|
+
self.fmt = fmt
|
|
35
|
+
self.objattr = objattr
|
|
36
|
+
|
|
37
|
+
def __str__(self):
|
|
38
|
+
# fmt: off
|
|
39
|
+
obstr = 'None' if self.objattr is None else "'%s'" % self.objattr
|
|
40
|
+
return("Param('" + self.name + "'," + str(self.val) + "," +
|
|
41
|
+
str(self.vmin) + "," + str(self.vmax) + ",'" + str(self.fmt) +
|
|
42
|
+
"'," + obstr + ")")
|
|
43
|
+
# fmt: on
|
|
44
|
+
|
|
45
|
+
def __repr__(self):
|
|
46
|
+
return self.__str__()
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def span(self):
|
|
50
|
+
return self.vmax - self.vmin
|
|
51
|
+
|
|
52
|
+
def knob_to_val(self, knobval):
|
|
53
|
+
"""Knobval ranges 0.0-1.0"""
|
|
54
|
+
return self.vmin + (self.vmax - self.vmin) * knobval
|
|
55
|
+
|
|
56
|
+
def update(self, new_knob_val):
|
|
57
|
+
"""Set a param val with a knob, bounded by the param's min/max attributes"""
|
|
58
|
+
self.val = self.knob_to_val(new_knob_val)
|
|
59
|
+
return self.val
|
|
60
|
+
|
|
61
|
+
def apply_to_obj(self, o):
|
|
62
|
+
"""Apply a parameter to the given object"""
|
|
63
|
+
if self.objattr:
|
|
64
|
+
setattr(o, self.objattr, self.val)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ParamSet:
|
|
68
|
+
"""ParamSet is a collection of Params that track normalized knob positions,
|
|
69
|
+
especially for the case when there are fewer knobs than Params.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
KNOB_PICKUP = 0
|
|
73
|
+
KNOB_SCALE = 1
|
|
74
|
+
KNOB_RELATIVE = 2
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
params,
|
|
79
|
+
num_knobs,
|
|
80
|
+
min_knob_change=0.05,
|
|
81
|
+
knob_smooth=0.5,
|
|
82
|
+
knob_mode=KNOB_PICKUP,
|
|
83
|
+
):
|
|
84
|
+
self.params = params
|
|
85
|
+
self.knob_mode = knob_mode
|
|
86
|
+
self.nparams = len(params)
|
|
87
|
+
self.nknobs = num_knobs
|
|
88
|
+
self.min_change = min_knob_change
|
|
89
|
+
self.smoothing = knob_smooth
|
|
90
|
+
self.nknobsets = self.nparams // self.nknobs
|
|
91
|
+
self._idx = 0 # which knobset we're modifying
|
|
92
|
+
self.is_tracking = [False] * self.nknobs
|
|
93
|
+
|
|
94
|
+
def next_knobset(self):
|
|
95
|
+
self.idx = (self._idx + 1) % self.nknobsets # calls def idx()
|
|
96
|
+
return self._idx
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def idx(self):
|
|
100
|
+
"""Which knobset is currently being edited"""
|
|
101
|
+
return self._idx
|
|
102
|
+
|
|
103
|
+
@idx.setter
|
|
104
|
+
def idx(self, i):
|
|
105
|
+
"""Set which knobset to edit, resets knob tracking"""
|
|
106
|
+
if i != self._idx:
|
|
107
|
+
self.is_tracking = [False] * self.nknobs # reset tracking
|
|
108
|
+
self._idx = i
|
|
109
|
+
|
|
110
|
+
def update_knobs(self, new_knob_vals):
|
|
111
|
+
if self.knob_mode == ParamSet.KNOB_PICKUP:
|
|
112
|
+
self.update_knobs_pickup(new_knob_vals)
|
|
113
|
+
elif self.knob_mode == ParamSet.KNOB_SCALE:
|
|
114
|
+
self.update_knobs_scale(new_knob_vals)
|
|
115
|
+
|
|
116
|
+
def update_knobs_pickup(self, new_knob_vals):
|
|
117
|
+
"""new_knob_vals is list of new knob vals, each 0.0-1.0"""
|
|
118
|
+
for i in range(self.nknobs):
|
|
119
|
+
param = self.params[(self._idx * self.nknobs) + i]
|
|
120
|
+
new_val = param.knob_to_val(new_knob_vals[i])
|
|
121
|
+
if self.is_tracking[i]:
|
|
122
|
+
# only change param val if difference is big enough FIXME
|
|
123
|
+
if abs(new_val - param.val) >= 0.1 * self.min_change * param.span:
|
|
124
|
+
param.val = new_val
|
|
125
|
+
else:
|
|
126
|
+
delta = param.val - new_val
|
|
127
|
+
if abs(delta) < self.min_change * param.span:
|
|
128
|
+
self.is_tracking[i] = True
|
|
129
|
+
|
|
130
|
+
def update_knobs_scale(self, new_knob_vals):
|
|
131
|
+
"""new_knob_val is list of new knob vals, each normalized 0.0-1.0"""
|
|
132
|
+
# note this sucks currently
|
|
133
|
+
for i in range(self.nknobs):
|
|
134
|
+
param = self.params[(self._idx * self.nknobs) + i]
|
|
135
|
+
new_val = param.knob_to_val(new_knob_vals[i])
|
|
136
|
+
delta_val = new_val - param.val
|
|
137
|
+
|
|
138
|
+
val_min, val_max = param.vmin, param.vmax
|
|
139
|
+
# knob_min, knob_max = 0.0, 1.0
|
|
140
|
+
|
|
141
|
+
val_max_pos_delta = val_max - param.val
|
|
142
|
+
val_min_pos_delta = param.val - val_min
|
|
143
|
+
knob_max_pos_delta = val_max - new_val
|
|
144
|
+
knob_min_pos_delta = new_val - val_min
|
|
145
|
+
|
|
146
|
+
if delta_val > 0 and knob_max_pos_delta != 0:
|
|
147
|
+
val_percent_change = delta_val * val_max_pos_delta / knob_max_pos_delta
|
|
148
|
+
elif delta_val < 0 and knob_min_pos_delta != 0:
|
|
149
|
+
val_percent_change = delta_val * val_min_pos_delta / knob_min_pos_delta
|
|
150
|
+
else:
|
|
151
|
+
val_percent_change = 0
|
|
152
|
+
|
|
153
|
+
param.val = min(max(param.val + val_percent_change, val_min), val_max)
|
|
154
|
+
|
|
155
|
+
def apply_params(self, obj):
|
|
156
|
+
"""Apply all params to given object"""
|
|
157
|
+
for i in range(self.nparams):
|
|
158
|
+
self.params[i].apply_to_obj(obj)
|
|
159
|
+
|
|
160
|
+
def apply_knobset(self, obj):
|
|
161
|
+
"""Apply all vals in a knobset to given object"""
|
|
162
|
+
for i in range(self.nknobs):
|
|
163
|
+
self.params[(self._idx * self.nknobs) + i].apply_to_obj(obj)
|
|
164
|
+
|
|
165
|
+
def param_for_name(self, name):
|
|
166
|
+
for p in filter(lambda p: p.name == name, self.params):
|
|
167
|
+
return p
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
# fmt: off
|
|
171
|
+
def __str__(self):
|
|
172
|
+
return("ParamSet(nknobs="+str(self.nknobs) + ", " +
|
|
173
|
+
"nknobsets="+str(self.nknobsets) +
|
|
174
|
+
", params="+str(self.params)+")")
|
|
175
|
+
# fmt: on
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def load(dumpstr):
|
|
179
|
+
dumpobj = json.loads(dumpstr)
|
|
180
|
+
newparams = [Param(**d) for d in dumpobj["params"]]
|
|
181
|
+
return newparams
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def dump(paramset):
|
|
185
|
+
dumpobj = {"params": [p.__dict__ for p in paramset.params]}
|
|
186
|
+
return json.dumps(dumpobj)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# simple test
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
myparams = [
|
|
193
|
+
Param("cutoff", 8000, 0, 9000, "%4d", "filt_frequency"),
|
|
194
|
+
Param("envmod", 0.5, 0.0, 1.0, "%.2f", "filt_env_depth"),
|
|
195
|
+
Param("resq", 8000, 0, 9000, "%4d", "resonance"),
|
|
196
|
+
Param("decay", 0.5, 0.0, 1.0, "%.2f", "decay"),
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
param_set = ParamSet(myparams, num_knobs=2)
|
|
200
|
+
|
|
201
|
+
print("param_set:", param_set)
|
synthtools/patch.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
#
|
|
4
|
+
# patch.py - pure-data patch objects, trivially JSON serializable.
|
|
5
|
+
# Rule: only JSON-native types live here (str, int, float, bool, list).
|
|
6
|
+
# Never a synthio object, never a ulab array.
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Patch:
|
|
12
|
+
"""A synth patch. Unknown kwargs land in __dict__ and round-trip
|
|
13
|
+
through JSON untouched, so synth-type-specific fields (wave_file,
|
|
14
|
+
wave_pos, ...) need no subclassing."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, **kw):
|
|
17
|
+
self.name = "init"
|
|
18
|
+
self.synth_type = "subtractive" # dispatch hint for patch banks
|
|
19
|
+
self.wave = "SAW" # waveform name, see waves.py
|
|
20
|
+
self.detune = 1.001 # osc2 freq ratio, 1.0 = single osc
|
|
21
|
+
self.filt_type = "LPF" # "LPF" | "HPF" | "BPF" | "NOTCH" | None
|
|
22
|
+
self.filt_f = 2500 # filter cutoff in Hz
|
|
23
|
+
self.filt_q = 1.1 # filter resonance
|
|
24
|
+
# list, not tuple: JSON round-trips it as a list anyway, and a list
|
|
25
|
+
# lets you set one stage in place (amp_env[0] = x) without rebuilding.
|
|
26
|
+
self.amp_env = [0.01, 0.10, 0.8, 0.35] # attack, decay, sustain, release
|
|
27
|
+
self.vib_rate = 5.0 # Hz
|
|
28
|
+
self.vib_depth = 0.0 # in bend units: 1.0 = one octave, 0.006 ~ 10 cents
|
|
29
|
+
self.vib_delay = 0.0 # seconds for vibrato to fade in (0 = immediate)
|
|
30
|
+
# Portamento. Only applies while the synth's `mono` is set; a
|
|
31
|
+
# polyphonic synth ignores it. 0 = jump straight to pitch.
|
|
32
|
+
self.glide_time = 0.0 # seconds to slide from the previous note
|
|
33
|
+
# Pitch envelope, in the same bend units. Bends INTO the note from
|
|
34
|
+
# penv_amount to true pitch, then on note-off drifts OUT to
|
|
35
|
+
# penv_out_amount. Both amounts default to 0 = off, and a voice with
|
|
36
|
+
# both off costs nothing.
|
|
37
|
+
self.penv_amount = 0.0 # bend at note-on; + starts sharp, - flat
|
|
38
|
+
self.penv_time = 0.10 # seconds to settle to true pitch
|
|
39
|
+
self.penv_out_amount = 0.0 # bend drifted to after note-off
|
|
40
|
+
self.penv_out_time = 0.20 # seconds for that drift
|
|
41
|
+
# --- the four filter-cutoff modulations -----------------------
|
|
42
|
+
# cutoff = filt_f + filt_lfo + fenv + velocity, summed in one
|
|
43
|
+
# synthio block graph. See synth.py.
|
|
44
|
+
# 1. filt_f above is the base.
|
|
45
|
+
# 2. cyclic LFO, additive: filt_f is the floor, the LFO opens upward
|
|
46
|
+
self.filt_lfo_rate = 0.5 # Hz
|
|
47
|
+
self.filt_lfo_amount = 0 # Hz added above filt_f, 0..amount; 0 = off
|
|
48
|
+
# 3. AHR envelope, added to the cutoff in Hz. amount 0 = off (and
|
|
49
|
+
# costs nothing: no per-voice blocks are created).
|
|
50
|
+
self.fenv_amount = 0 # Hz of cutoff swing; negative sweeps down
|
|
51
|
+
self.fenv_attack = 0.05 # seconds to reach full depth
|
|
52
|
+
self.fenv_release = 0.40 # seconds to fall back to zero
|
|
53
|
+
# Integer exponent on the envelope shape: 1 = linear, 2+ increasingly
|
|
54
|
+
# curved. The attack rises fast and eases into the peak; the release
|
|
55
|
+
# drops fast and tails off -- the conventional analog feel. Both come
|
|
56
|
+
# from one shared buffer holding 1-(1-t)^curve, so they are linked:
|
|
57
|
+
# see fill_env_rise() in waves.py for why the release decides.
|
|
58
|
+
self.fenv_curve = 1
|
|
59
|
+
# 4. Velocity. The units differ on purpose: filt_vel adds to a
|
|
60
|
+
# cutoff so it is in Hz, fenv_vel scales a depth so it is a
|
|
61
|
+
# 0-1 fraction. Both default to "velocity changes nothing".
|
|
62
|
+
self.filt_vel = 0 # Hz of cutoff at full velocity; negative
|
|
63
|
+
# means hard playing CLOSES the filter
|
|
64
|
+
self.fenv_vel = 0.0 # 0 = uniform depth, 1.0 = depth tracks velocity
|
|
65
|
+
# setattr loop, not self.__dict__.update(kw): CircuitPython's
|
|
66
|
+
# instance __dict__ is a read-only mapping and update() raises
|
|
67
|
+
# TypeError. Reading it (dict(self.__dict__)) is fine.
|
|
68
|
+
for k, v in kw.items():
|
|
69
|
+
setattr(self, k, v)
|
|
70
|
+
|
|
71
|
+
# --- dict / JSON round-trip -------------------------------------
|
|
72
|
+
|
|
73
|
+
def to_dict(self):
|
|
74
|
+
return dict(self.__dict__)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_dict(cls, d):
|
|
78
|
+
return cls(**d)
|
|
79
|
+
|
|
80
|
+
def to_json(self):
|
|
81
|
+
return json.dumps(self.to_dict())
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_json(cls, s):
|
|
85
|
+
return cls(**json.loads(s))
|
|
86
|
+
|
|
87
|
+
# --- filesystem helpers -----------------------------------------
|
|
88
|
+
# note: writing to CIRCUITPY from code requires storage.remount()
|
|
89
|
+
|
|
90
|
+
def save(self, filepath):
|
|
91
|
+
with open(filepath, "w") as f:
|
|
92
|
+
f.write(self.to_json())
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def load(cls, filepath):
|
|
96
|
+
with open(filepath) as f:
|
|
97
|
+
return cls.from_json(f.read())
|
|
98
|
+
|
|
99
|
+
def __repr__(self):
|
|
100
|
+
return "Patch(%s)" % ", ".join("%s=%r" % (k, v) for k, v in sorted(self.__dict__.items()))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- simple patch banks: a JSON list of patch dicts -----------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def save_patches(patches, filepath):
|
|
107
|
+
"""Write a list of Patch objects to filepath as one JSON array."""
|
|
108
|
+
with open(filepath, "w") as f:
|
|
109
|
+
json.dump([p.to_dict() for p in patches], f)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_patches(filepath):
|
|
113
|
+
"""Read a list of Patch objects back from a file written by
|
|
114
|
+
save_patches()."""
|
|
115
|
+
with open(filepath) as f:
|
|
116
|
+
return [Patch.from_dict(d) for d in json.load(f)]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2023 Tod Kurt
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
``pitch_glider``
|
|
5
|
+
================================================================================
|
|
6
|
+
|
|
7
|
+
``Glider`` is a portamento feature for synthio.Notes. Attach to note.bend.
|
|
8
|
+
|
|
9
|
+
10 Feb 2025 - @todbot / Tod Kurt
|
|
10
|
+
|
|
11
|
+
"""
|
|
12
|
+
# pitch_glider.py --
|
|
13
|
+
# part of todbot circuitpython synthio tutorial
|
|
14
|
+
# 10 Feb 2025 - @todbot / Tod Kurt
|
|
15
|
+
|
|
16
|
+
import synthio
|
|
17
|
+
import ulab.numpy as np
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Glider:
|
|
21
|
+
"""Attach a Glider to note.bend to implement portamento"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, glide_time, midi_note):
|
|
24
|
+
glide_time = glide_time or 0.001
|
|
25
|
+
self.pos = synthio.LFO(
|
|
26
|
+
once=True,
|
|
27
|
+
rate=1 / glide_time,
|
|
28
|
+
waveform=np.array((0, 32767), dtype=np.int16),
|
|
29
|
+
)
|
|
30
|
+
self.lerp = synthio.Math(synthio.MathOperation.CONSTRAINED_LERP, 0, 0, self.pos)
|
|
31
|
+
self.midi_note = midi_note
|
|
32
|
+
|
|
33
|
+
def update(self, new_midi_note):
|
|
34
|
+
"""Update the glide destination based on new midi note"""
|
|
35
|
+
self.lerp.a = self.bend_amount(new_midi_note, self.midi_note)
|
|
36
|
+
self.lerp.b = 0 # end on the new note
|
|
37
|
+
self.pos.retrigger() # restart the lerp
|
|
38
|
+
# print("bend_amount:", self.bend_amount(self.midi_note, new_midi_note),
|
|
39
|
+
# "old", self.midi_note, "new:", new_midi_note, self.lerp.a, self.lerp.b)
|
|
40
|
+
self.midi_note = new_midi_note
|
|
41
|
+
|
|
42
|
+
def bend_amount(self, old_midi_note, new_midi_note):
|
|
43
|
+
"""Calculate how much note.bend has to happen between two notes"""
|
|
44
|
+
return (new_midi_note - old_midi_note) * (1 / 12)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def glide_time(self):
|
|
48
|
+
"""Return glide time in seconds"""
|
|
49
|
+
return 1 / self.pos.rate
|
|
50
|
+
|
|
51
|
+
@glide_time.setter
|
|
52
|
+
def glide_time(self, glide_time):
|
|
53
|
+
"""Set glide time in seconds, sets the rate of underlying LFO"""
|
|
54
|
+
glide_time = glide_time or 0.001 # ensure non-zero for division
|
|
55
|
+
self.pos.rate = 1 / glide_time
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
## pylint: disable=invalid-name, too-many-arguments, too-many-instance-attributes
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2023 Tod Kurt
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""
|
|
5
|
+
``step_sequencer``
|
|
6
|
+
================================================================================
|
|
7
|
+
|
|
8
|
+
``StepSequencer`` is a note-based sequencer for musical events.
|
|
9
|
+
|
|
10
|
+
Part of synthtools.
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from supervisor import ticks_ms
|
|
18
|
+
except ImportError:
|
|
19
|
+
|
|
20
|
+
def ticks_ms():
|
|
21
|
+
"""stand-in for supervisor.ticks_ms"""
|
|
22
|
+
return time.monotonic_ns() // 1_000_000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StepSequencer:
|
|
26
|
+
"""
|
|
27
|
+
StepSequencer contains a list of meloci events in list of steps.
|
|
28
|
+
|
|
29
|
+
:param int step_count: how many for all the triggers
|
|
30
|
+
:param int steps_per_beat: number of steps in a beat (1=quarter note, 2=8th note, 4=16th note)
|
|
31
|
+
:param function on_func: function to call on note-on
|
|
32
|
+
:param function off_func: function to call on note-off
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, step_count, steps_per_beat, on_func=None, off_func=None):
|
|
36
|
+
self.steps_per_beat = steps_per_beat # 1 = 1/4 note, 2 = 8th note, 4 = 16th note
|
|
37
|
+
self.step_count = step_count # how big the sequence is
|
|
38
|
+
self.i = 0 # where in the step sequence we currently are
|
|
39
|
+
|
|
40
|
+
# our sequence, list of step "objects": ie. list (notenum, vel, gate, on)
|
|
41
|
+
self.steps = [[0, 127, 0.5, True] for i in range(step_count)]
|
|
42
|
+
|
|
43
|
+
self.on_func = on_func # callback to invoke when 'note on' should be sent
|
|
44
|
+
self.off_func = off_func # callback to invoke when 'note off' should be sent
|
|
45
|
+
self.gate_off_millis = 0 # when in the future our note off should occur
|
|
46
|
+
self.held_note = None # the current note playing
|
|
47
|
+
self.transpose = 0
|
|
48
|
+
self.playing = False # is sequence running or not (but use .start()/.stop())
|
|
49
|
+
self.next_millis = 0
|
|
50
|
+
self.error_millis = 0
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def bpm(self):
|
|
54
|
+
"""Returns bpm, computed"""
|
|
55
|
+
return 60_000 / self.step_millis / self.steps_per_beat
|
|
56
|
+
|
|
57
|
+
@bpm.setter
|
|
58
|
+
def bpm(self, bpm):
|
|
59
|
+
"""Sets the internal tempo. step_millis is time between steps"""
|
|
60
|
+
self.step_millis = 60_000 / self.steps_per_beat / bpm
|
|
61
|
+
# print("stepseq.set_bpm: %6.2f %d" % (self.step_millis, bpm) )
|
|
62
|
+
|
|
63
|
+
def set_gates(self, gate):
|
|
64
|
+
"""Set all gates to a specified percentage 0-1"""
|
|
65
|
+
for i in range(self.step_count):
|
|
66
|
+
self.steps[i][2] = gate
|
|
67
|
+
|
|
68
|
+
def start(self):
|
|
69
|
+
"""Start sequencer going"""
|
|
70
|
+
self.next_millis = ticks_ms()
|
|
71
|
+
self.playing = True
|
|
72
|
+
self.error_millis = 0
|
|
73
|
+
|
|
74
|
+
def stop(self):
|
|
75
|
+
"""Stop sequencer, turning off any currently-sounding note"""
|
|
76
|
+
if self.held_note:
|
|
77
|
+
self.off_func(*self.held_note)
|
|
78
|
+
self.playing = False
|
|
79
|
+
self.i = 0
|
|
80
|
+
|
|
81
|
+
def update(self):
|
|
82
|
+
"""Update the sequencer. Call as frequently as possible"""
|
|
83
|
+
if not self.playing:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
now = ticks_ms()
|
|
87
|
+
delta_millis = now - self.next_millis
|
|
88
|
+
|
|
89
|
+
# trigger note-off after gate time
|
|
90
|
+
if now - self.gate_off_millis >= 0 and self.held_note:
|
|
91
|
+
self.off_func(*self.held_note)
|
|
92
|
+
self.held_note = None
|
|
93
|
+
|
|
94
|
+
if delta_millis >= 0: # if zero or great, time for next step
|
|
95
|
+
# print(" delta_millis:", delta_millis, self.error_millis)
|
|
96
|
+
self.error_millis += delta_millis
|
|
97
|
+
(note, vel, gate, on) = self.steps[self.i] # get new note to play
|
|
98
|
+
note += self.transpose # adjust for transpose
|
|
99
|
+
self.held_note = (note, vel, gate, on) # save it for when we note_off it
|
|
100
|
+
|
|
101
|
+
# trigger new note
|
|
102
|
+
self.on_func(*self.held_note) # held_note = (note,vel,gate,on)
|
|
103
|
+
|
|
104
|
+
# prep for next step in sequence
|
|
105
|
+
self.i = (self.i + 1) % self.step_count
|
|
106
|
+
|
|
107
|
+
# set up when next note on is, with some error correction
|
|
108
|
+
self.next_millis = now + self.step_millis
|
|
109
|
+
if self.error_millis > 1:
|
|
110
|
+
self.next_millis -= self.error_millis
|
|
111
|
+
self.error_millis = 0
|
|
112
|
+
# next note off is some percentage smaller
|
|
113
|
+
self.gate_off_millis = now + self.step_millis * self.held_note[2]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
#
|
|
4
|
+
# subtractive.py - classic two-oscillator subtractive synth.
|
|
5
|
+
#
|
|
6
|
+
# Osc2 is a detuned copy of osc1; detune=1.0 collapses to a single osc.
|
|
7
|
+
|
|
8
|
+
import synthio
|
|
9
|
+
|
|
10
|
+
from .synth import Synth
|
|
11
|
+
from .waves import get_wave_2x, random_phase_wave
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SubtractiveSynth(Synth):
|
|
15
|
+
"""Classic two-oscillator subtractive synth: one waveform, an optional
|
|
16
|
+
detuned second oscillator, through the shared Synth filter/envelope
|
|
17
|
+
graph.
|
|
18
|
+
|
|
19
|
+
``detune=1.0`` (the default) collapses to a single oscillator, so an
|
|
20
|
+
ordinary patch costs one Note per key; any other detune spends TWO
|
|
21
|
+
Notes per key (see the polyphony-budget note in the project docs).
|
|
22
|
+
Each oscillator gets its own random phase per note-on, and osc2 is
|
|
23
|
+
scaled down (60% of osc1) so a detuned voice's peak amplitude does not
|
|
24
|
+
exceed a single-oscillator one.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
_PARAMS = Synth._PARAMS + ("wave", "detune")
|
|
28
|
+
|
|
29
|
+
# class attrs: the base __init__ builds its graph before this subclass
|
|
30
|
+
# has run any setup of its own
|
|
31
|
+
_wave_name = "SAW"
|
|
32
|
+
_detune = 1.0
|
|
33
|
+
|
|
34
|
+
def _recompile(self):
|
|
35
|
+
super()._recompile()
|
|
36
|
+
self._wave_name = self.patch.wave
|
|
37
|
+
self._detune = self.patch.detune
|
|
38
|
+
get_wave_2x(self._wave_name) # warm the cache; note-on only slices
|
|
39
|
+
|
|
40
|
+
def _decompile(self):
|
|
41
|
+
super()._decompile()
|
|
42
|
+
self.patch.wave = self._wave_name
|
|
43
|
+
self.patch.detune = self._detune
|
|
44
|
+
|
|
45
|
+
def _make_notes(self, midi_note, velocity):
|
|
46
|
+
f = synthio.midi_to_hz(midi_note)
|
|
47
|
+
amp = velocity / 127
|
|
48
|
+
detuned = self._detune and self._detune != 1.0
|
|
49
|
+
# Each oscillator gets its OWN random start point in the wave's
|
|
50
|
+
# cycle, rerolled every note-on -- see waves.random_phase_wave().
|
|
51
|
+
# Rebalanced only when osc2 exists: undetuned (single-osc) patches
|
|
52
|
+
# stay at full amp, unchanged. When osc2 is present, split so the
|
|
53
|
+
# two sum to amp * 1.0 instead of amp * 1.6 -- the old worst case
|
|
54
|
+
# (both oscillators in phase, still possible now that phase is
|
|
55
|
+
# random) could exceed int16 range on its own, before the filter
|
|
56
|
+
# even sees it. 0.625/0.375 keeps osc2 at 60% of osc1's level, same
|
|
57
|
+
# blend as before, just scaled so the ceiling is 1.0 instead of 1.6.
|
|
58
|
+
# fmt: off
|
|
59
|
+
n1 = synthio.Note(f, waveform=random_phase_wave(self._wave_name),
|
|
60
|
+
envelope=self._env,
|
|
61
|
+
amplitude=amp * 0.625 if detuned else amp,
|
|
62
|
+
filter=self._make_filter(), bend=self._bend_cur)
|
|
63
|
+
if detuned:
|
|
64
|
+
n2 = synthio.Note(f * self._detune,
|
|
65
|
+
waveform=random_phase_wave(self._wave_name),
|
|
66
|
+
envelope=self._env, amplitude=amp * 0.375,
|
|
67
|
+
filter=self._make_filter(), bend=self._bend_cur)
|
|
68
|
+
return (n1, n2)
|
|
69
|
+
return (n1,)
|
|
70
|
+
# fmt: on
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def wave(self):
|
|
74
|
+
return self._wave_name
|
|
75
|
+
|
|
76
|
+
@wave.setter
|
|
77
|
+
def wave(self, v):
|
|
78
|
+
self._wave_name = v
|
|
79
|
+
get_wave_2x(v) # O(1); warms the cache for the next note-on
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def detune(self):
|
|
83
|
+
return self._detune
|
|
84
|
+
|
|
85
|
+
@detune.setter
|
|
86
|
+
def detune(self, v):
|
|
87
|
+
# genuinely per-note (each osc2 has its own frequency), so this one
|
|
88
|
+
# cannot be a shared block: O(polyphony).
|
|
89
|
+
self._detune = v
|
|
90
|
+
for notes in self.voices.values():
|
|
91
|
+
if len(notes) > 1:
|
|
92
|
+
notes[1].frequency = notes[0].frequency * v
|