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/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