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.
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: circuitpython-synthtools
3
+ Version: 0.5
4
+ Summary: CircuitPython helper library to do help doing synthio
5
+ Author-email: Tod Kurt <tod@todbot.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/todbot/CircuitPython_SynthTools
8
+ Keywords: adafruit,blinka,circuitpython,micropython,synthtools,synth,synthio,synthesizer,waveform,wav,wave,wavetable
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Topic :: Software Development :: Libraries
11
+ Classifier: Topic :: Software Development :: Embedded Systems
12
+ Classifier: Topic :: System :: Hardware
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Description-Content-Type: text/x-rst
16
+ License-File: LICENSE
17
+ Requires-Dist: Adafruit-Blinka
18
+ Provides-Extra: optional
19
+ Dynamic: license-file
20
+
21
+ Introduction
22
+ ============
23
+
24
+
25
+ .. image:: https://readthedocs.org/projects/circuitpython-synthtools/badge/?version=latest
26
+ :target: https://circuitpython-synthtools.readthedocs.io/
27
+ :alt: Documentation Status
28
+
29
+
30
+
31
+ .. image:: https://img.shields.io/discord/327254708534116352.svg
32
+ :target: https://adafru.it/discord
33
+ :alt: Discord
34
+
35
+
36
+ .. image:: https://github.com/todbot/CircuitPython_SynthTools/workflows/Build%20CI/badge.svg
37
+ :target: https://github.com/todbot/CircuitPython_SynthTools/actions
38
+ :alt: Build Status
39
+
40
+
41
+ .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
42
+ :target: https://github.com/astral-sh/ruff
43
+ :alt: Code Style: Ruff
44
+
45
+ CircuitPython library with tools for making synths with synthio
46
+
47
+
48
+ Dependencies
49
+ =============
50
+ This driver depends on:
51
+
52
+ * `Adafruit CircuitPython <https://github.com/adafruit/circuitpython>`_
53
+
54
+ Please ensure all dependencies are available on the CircuitPython filesystem.
55
+ This is easily achieved by downloading
56
+ `the Adafruit library and driver bundle <https://circuitpython.org/libraries>`_
57
+ or individual libraries can be installed using
58
+ `circup <https://github.com/adafruit/circup>`_.
59
+
60
+
61
+ Installing to a Connected CircuitPython Device with Circup
62
+ ==========================================================
63
+
64
+ Make sure that you have ``circup`` installed in your Python environment.
65
+ Install it with the following command if necessary:
66
+
67
+ .. code-block:: shell
68
+
69
+ pip3 install circup
70
+
71
+ With ``circup`` installed and your CircuitPython device connected use the
72
+ following command to install:
73
+
74
+ .. code-block:: shell
75
+
76
+ circup install synthtools
77
+
78
+ Or the following command to update an existing version:
79
+
80
+ .. code-block:: shell
81
+
82
+ circup update
83
+
84
+ Usage Example
85
+ =============
86
+
87
+ .. code-block:: python
88
+
89
+ import time
90
+ from synth_setup import synth as engine
91
+ from synthtools import Patch, SubtractiveSynth
92
+
93
+ patch1 = Patch(name="fat bass", wave="ASAW", detune=1.004,
94
+ filt_type="LPF", filt_f=800, filt_q=1.4,
95
+ amp_env=[0.01, 0.1, 0.8, 0.4],
96
+ vib_rate=5.5, vib_depth=0.0,
97
+ # AHR filter envelope: cutoff swings 800 -> 3800 Hz and back
98
+ fenv_amount=3000, fenv_attack=0.02, fenv_release=0.30,
99
+ # a slow cyclic wobble on top of it (0 = off)
100
+ filt_lfo_rate=0.4, filt_lfo_amount=0.3)
101
+ synth = SubtractiveSynth(engine, patch1)
102
+ arp = (36, 39, 43, 48)
103
+ i = 0
104
+ sweep = 0
105
+ while True:
106
+ synth.note_on(arp[i % len(arp)], velocity=110)
107
+ time.sleep(0.11)
108
+ synth.note_off(arp[i % len(arp)])
109
+ time.sleep(0.02)
110
+ i += 1
111
+ if i % 32 == 0: # flip waveforms now and then
112
+ synth.wave = "ASQU" if synth.wave == "ASAW" else "ASAW"
113
+
114
+
115
+ What's Included
116
+ ===============
117
+
118
+ * ``Synth`` -- synth engine base: shared voice, patch, and modulation handling,
119
+ with ``mono`` mode for a single-voice synth with ``glide_time`` portamento
120
+ * ``SubtractiveSynth`` -- subtractive two-oscillator synth w/ detune
121
+ * ``WavetableSynth`` -- wavetable-playback with adjustable wave_pos
122
+ * ``BasslineSynth`` -- TB-303-style acid bassline: monophonic, one
123
+ oscillator, a decay-only filter sweep, per-step slide and accent
124
+ * ``EffectsChain`` -- post-synth audio effects: extra filter stages that
125
+ track the synth's cutoff for a steeper slope, plus optional distortion
126
+ and tempo-synced echo (needs ``audiofilters`` in the build)
127
+ * ``Patch`` -- inert, JSON-able patch data; save/load with
128
+ ``save_patches()`` / ``load_patches()``
129
+ * ``Wavetable`` -- loads a wavetable WAV file and lerps between frames
130
+ * ``AHREnvelope`` -- shared-block attack/release envelope, used for both
131
+ the filter and pitch envelopes
132
+ * ``Waves`` -- waveform factory (saw, square, sine, triangle, noise, and
133
+ "analog" variants)
134
+ * ``Arpeggiator`` / ``StepSequencer`` / ``TrigSequencer`` -- poll-based
135
+ sequencers with on/off callbacks
136
+ * ``Param`` / ``ParamSet`` -- knob-pickup and scaling for UIs with fewer
137
+ knobs than parameters
138
+ * ``Glider`` -- a standalone pitch-slide block for hand-built
139
+ ``synthio.Note`` graphs (the engines above have their own portamento,
140
+ via ``mono`` + ``glide_time``)
141
+ * ``RollingAverage`` -- moving-average smoothing for noisy knob reads
142
+
143
+ Documentation
144
+ =============
145
+ API documentation for this library can be found on `Read the Docs <https://circuitpython-synthtools.readthedocs.io/>`_.
146
+
147
+ For information on building library documentation, please check out
148
+ `this guide <https://learn.adafruit.com/creating-and-sharing-a-circuitpython-library/sharing-our-docs-on-readthedocs#sphinx-5-1>`_.
149
+
150
+ Contributing
151
+ ============
152
+
153
+ Contributions are welcome! Please read our `Code of Conduct
154
+ <https://github.com/todbot/CircuitPython_SynthTools/blob/HEAD/CODE_OF_CONDUCT.md>`_
155
+ before contributing to help this project stay welcoming.
@@ -0,0 +1,25 @@
1
+ circuitpython_synthtools-0.5.dist-info/licenses/LICENSE,sha256=jhhRyxpqyQxyF_D6SzY2-jCA5v8Z8JXfezCtxBS85CM,1075
2
+ synthtools/__init__.py,sha256=KIHidF7kUN9xokoowebfF_6bNHwsgjeyrF14_1n00h4,1276
3
+ synthtools/ahr_envelope.py,sha256=t5JCgO5iGLVKr4MyKnu0PNV1NOk8YfALUo5q69TrYa0,9968
4
+ synthtools/arpeggiator.py,sha256=n2sPnpZcu9soXsFJCh6OP65_0DuPnvQGUP0ao7V8SXM,3901
5
+ synthtools/audio_fx.py,sha256=xR_1PnPlySlMmQ46DcpBoRfplBmVeYeEKFu22Z55vm0,6929
6
+ synthtools/bassline_synth.py,sha256=pz5KNyR7iyiaLndL7UpSNMxTuMPuz7GNOKd72z6Y4h8,16680
7
+ synthtools/blocks.py,sha256=NXRkNgKoTtPMh5eDJ2eC7aXmtuZVrr17ZDhFLhJ5tqY,1998
8
+ synthtools/paramset.py,sha256=agk7IOr-OI_3g9LoV4-hZaZX7cI93GZe1vDeWiiwnqE,6732
9
+ synthtools/patch.py,sha256=_ueCz9XJDZRRt6j6FboLIbQiikH04GRbsKtnVQr4gHE,5250
10
+ synthtools/pitch_glider.py,sha256=R5h0Lbyq_slo7SKORRM7a0oumVugE15L8hOZJCov0AY,1927
11
+ synthtools/step_sequencer.py,sha256=SkQuDsRtpAdKOXU3OsFmngeDPVQ0OXt3Gj5wqFCkEPM,4202
12
+ synthtools/subtractive_synth.py,sha256=_NOS_P6atTSksPVA1iQxBffGrHvaqddrQYj1KWaHOxE,3552
13
+ synthtools/synth.py,sha256=XFghrQy3To2ImRKYUx3LpmlH4aNfFIfRqheM-rGQvnE,31769
14
+ synthtools/trig_sequencer.py,sha256=PaWgsTqOvuRjFUJ6VjxMLBuRkyeYCML9WQXBzx02Onk,2921
15
+ synthtools/utils.py,sha256=z0gCbt238W-F29kYpRDXpvQTPZv30rghtHjiwgOpBnw,650
16
+ synthtools/waves.py,sha256=j0Suuzd6_CpOaqkq5oEBCfN1NTWQQMGz9sc2dO-ehpg,19128
17
+ synthtools/wavetable.py,sha256=yrJf2bxjGoOq4PlE4904xQHwmDBrLhb_RH_2xhnxGNk,2545
18
+ synthtools/wavetable_synth.py,sha256=lvxpvITDgkOK6AJVIBbLOUa01wjPSvGxHpbTdS2fY2g,2744
19
+ synthtools/ui/gauge_cluster.py,sha256=QnMBLMFgQhNTw7KQHp51ILzWeCrNLV8TwzDoHl_egG4,2900
20
+ synthtools/ui/param.py,sha256=wJwIiaGlDnGPGCAdeA4veJv5BxbeHE8K4HaddeI6qQs,3629
21
+ synthtools/ui/param_scaler.py,sha256=1Ow9lp7WgFauBV5dVFbynXxYd7OqAJhSKphSZNA_GO8,2958
22
+ circuitpython_synthtools-0.5.dist-info/METADATA,sha256=EsgUQzn0mxBjnCEDQC-trA-ulQh9f9icpQDz0Gc4sfw,5698
23
+ circuitpython_synthtools-0.5.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
24
+ circuitpython_synthtools-0.5.dist-info/top_level.txt,sha256=1xt53cH064i7vvFVDFyuJugc1NogReXvzsXbYGlgnKE,11
25
+ circuitpython_synthtools-0.5.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Tod Kurt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ synthtools
synthtools/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ # SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries
2
+ # SPDX-FileCopyrightText: Copyright (c) 2024 Tod Kurt
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+ """
6
+ `synthtools`
7
+ ================================================================================
8
+
9
+ CircuitPython helper library to do help doing synthio
10
+
11
+
12
+ * Author(s): Tod Kurt
13
+
14
+ Implementation Notes
15
+ --------------------
16
+
17
+ **Software and Dependencies:**
18
+
19
+ * Adafruit CircuitPython firmware for the supported boards:
20
+ https://circuitpython.org/downloads
21
+
22
+ * synthio : https://docs.circuitpython.org/en/latest/shared-bindings/synthio/
23
+
24
+ """
25
+
26
+ # imports
27
+
28
+ from .audio_fx import EffectsChain
29
+ from .bassline_synth import BasslineSynth
30
+ from .patch import Patch, load_patches, save_patches
31
+ from .subtractive_synth import SubtractiveSynth
32
+ from .synth import Synth
33
+
34
+ # wavetable_synth needs the adafruit_wave library; don't break the whole
35
+ # package if it isn't installed. Import it directly if you want it:
36
+ # from synthtools.wavetable import Wavetable
37
+ # from synthtools.wavetable_synth import WavetableSynth
38
+ try:
39
+ from .wavetable import Wavetable
40
+ from .wavetable_synth import WavetableSynth
41
+ except ImportError:
42
+ pass
43
+
44
+ __version__ = "0.5"
45
+ __repo__ = "https://github.com/todbot/CircuitPython_SynthTools.git"
@@ -0,0 +1,232 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
2
+ # SPDX-License-Identifier: MIT
3
+ #
4
+ # ahr_envelope.py - Attack-Release envelope for synthio, as a modulation
5
+ # SOURCE: it produces 0 -> amount and knows nothing about where that goes.
6
+ #
7
+ # Per voice, three objects:
8
+ #
9
+ # pos = LFO(waveform = shared shape, rate = shared rate, once = True)
10
+ # env = Math(CONSTRAINED_LERP, 0.0, depth, pos) -> the output block
11
+ # depth = the shared amount block, or PRODUCT(amount, gain)
12
+ #
13
+ # The LFO is only a *position*: it runs 0 -> 1 through a shared, shaped
14
+ # buffer, and CONSTRAINED_LERP decides what that position maps onto. So
15
+ # release is a matter of moving the endpoints -- NOT of swapping the
16
+ # waveform. That matters: synthio.LFO.waveform is read-only,
17
+ # so an implementation that# reassigns it raises
18
+ # AttributeError: can't set attribute 'waveform'
19
+ # at every note-off. Mutating the buffer's contents in place is fine, and
20
+ # is documented synthio behaviour.
21
+ #
22
+ # One consequence is easy to get wrong. Release replays the SAME rising
23
+ # buffer forward, so its output is `V * (1 - s(t))` -- it INVERTS whatever
24
+ # curvature s has. A buffer holding the obvious t^curve therefore makes the
25
+ # release hang near the top and then fall off a cliff (75% of its height
26
+ # still left at the halfway point, for curve=2): a mirrored attack, not a
27
+ # decay. The buffer holds 1-(1-t)^curve instead, which comes back out of
28
+ # that inversion as V*(1-t)^curve. Attack and release curvature are linked
29
+ # as a result -- one buffer, one exponent. See fill_env_rise() in waves.py.
30
+ #
31
+ # --- why this is bigger than the tutorial's version -------------------
32
+ #
33
+ # todsynth/ahr_envelope.py in the synthio tutorial is the same
34
+ # CONSTRAINED_LERP idea, and it is the right size for a monosynth: one
35
+ # object per voice, writing plain floats into env.a / env.b at press. The
36
+ # cost of that is that every global parameter is O(polyphony) -- moving the
37
+ # envelope depth means looping over live voices and writing each one.
38
+ #
39
+ # This is the polyphonic version. ONE instance is a factory for many
40
+ # voices, and the things a knob touches are shared objects nested inside
41
+ # each voice's graph:
42
+ #
43
+ # _amt the depth, nested in every voice's `depth`
44
+ # _rate_a the attack rate, every voice's LFO rate
45
+ # _rate_r the release rate
46
+ # _wave the shape, rewritten in place under sounding voices
47
+ #
48
+ # so amount, attack, release and curve each reach every sounding voice --
49
+ # including ones already in release -- with a SINGLE write. That is the
50
+ # only reason for the extra indirection. The shared buffer also buys
51
+ # arbitrary integer curve exponents for free, where the tutorial's
52
+ # PRODUCT(lerp, lerp, 1) costs one Math per voice and only gives one curve.
53
+
54
+ import synthio
55
+
56
+ from .blocks import product, scalar_block
57
+ from .waves import env_buffer, fill_env_rise
58
+
59
+
60
+ class AHREnvelope:
61
+ """Shared-block attack/release envelope. One instance owns the shape
62
+ buffer, the rate blocks and the depth blocks; make() hands out one small
63
+ block graph per voice that reads them.
64
+
65
+ Both DESTINATION- and DIRECTION-agnostic, so one class covers every
66
+ one-shot envelope in the library:
67
+
68
+ falling=False 0 -> amount, release back to release_amount
69
+ falling=True amount -> 0, release on to release_amount
70
+
71
+ The filter envelope is the rising case added to a cutoff bus; a pitch
72
+ envelope is the falling one added to a bend graph -- it starts off-pitch
73
+ and settles, then on note-off drifts on to release_amount. Nothing here
74
+ knows which.
75
+
76
+ The two differ ONLY in make()'s endpoints. Everything else -- the shared
77
+ amount block, the shared rates, the in-place shape buffer, the
78
+ re-aim-the-endpoints release -- is identical, which is why this is one
79
+ class with a flag rather than two classes."""
80
+
81
+ def __init__(
82
+ self, attack=0.05, release=0.4, amount=0.0, curve=1, falling=False, release_amount=0.0
83
+ ):
84
+ self._attack = attack
85
+ self._release = release
86
+ self._curve = curve
87
+ self._falling = falling
88
+ # Created ONCE and never replaced. Sounding voices hold references
89
+ # to all of these, so identity has to survive patch reloads --
90
+ # assigning a new object here would orphan the live ones.
91
+ self._wave = env_buffer()
92
+ self._rate_a = scalar_block(1.0)
93
+ self._rate_r = scalar_block(1.0)
94
+ self._amt = scalar_block(amount)
95
+ # Where a release lands. A BLOCK even when it only ever holds 0.0
96
+ # (the filter case): that removes the special case from
97
+ # start_release() and keeps the target live for releasing voices.
98
+ self._rel_amt = scalar_block(release_amount)
99
+ self._refresh_shape()
100
+ self._refresh_rates()
101
+
102
+ # --- shape ----------------------------------------------------------
103
+
104
+ def _refresh_shape(self):
105
+ """Rewrite the shared shape in place. Voices already sounding pick
106
+ this up immediately, because they read this very buffer."""
107
+ fill_env_rise(self._wave, self._curve)
108
+
109
+ def _refresh_rates(self):
110
+ # the rise fills the whole buffer, so a rate is just 1/seconds --
111
+ # no fraction to correct for
112
+ self._rate_a.a = 1.0 / max(self._attack, 0.001)
113
+ self._rate_r.a = 1.0 / max(self._release, 0.001)
114
+
115
+ def configure(self, attack, release, amount, curve, release_amount=0.0):
116
+ """Set everything at once with a single shape rebuild. For patch
117
+ loads; the individual properties are the knob path."""
118
+ self._attack = attack
119
+ self._release = release
120
+ self._curve = curve
121
+ self._amt.a = amount
122
+ self._rel_amt.a = release_amount
123
+ self._refresh_shape()
124
+ self._refresh_rates()
125
+
126
+ # --- live parameters, all O(1) --------------------------------------
127
+
128
+ @property
129
+ def attack(self):
130
+ return self._attack
131
+
132
+ @attack.setter
133
+ def attack(self, v):
134
+ # only a rate now: no shape rebuild, so this is cheap on a knob
135
+ self._attack = v
136
+ self._refresh_rates()
137
+
138
+ @property
139
+ def release(self):
140
+ return self._release
141
+
142
+ @release.setter
143
+ def release(self, v):
144
+ self._release = v
145
+ self._refresh_rates()
146
+
147
+ @property
148
+ def curve(self):
149
+ return self._curve
150
+
151
+ @curve.setter
152
+ def curve(self, v):
153
+ # a switch, not a knob: rebuilds the shape, so it allocates
154
+ self._curve = v
155
+ self._refresh_shape()
156
+
157
+ @property
158
+ def amount(self):
159
+ return self._amt.a
160
+
161
+ @amount.setter
162
+ def amount(self, v):
163
+ # one write into the shared block, reaching every voice including
164
+ # ones already in release (it stays live inside each voice's depth)
165
+ self._amt.a = v
166
+
167
+ @property
168
+ def release_amount(self):
169
+ return self._rel_amt.a
170
+
171
+ @release_amount.setter
172
+ def release_amount(self, v):
173
+ # where a release lands. One write, and because start_release()
174
+ # parks the BLOCK in env.b rather than a number, it reaches voices
175
+ # that are already falling.
176
+ self._rel_amt.a = v
177
+
178
+ # --- per-voice ------------------------------------------------------
179
+
180
+ def make(self, gain=1.0):
181
+ """One voice's envelope, or None when it would do nothing at all.
182
+
183
+ Returns the CONSTRAINED_LERP block to add to a destination. It
184
+ carries everything start_release() needs: the position LFO in ``c``.
185
+
186
+ ``gain`` is a per-voice depth scale -- a plain number, or a block
187
+ (e.g. a velocity LERP) so whatever drives it stays live.
188
+
189
+ Returning None is what makes the envelope cost literally nothing
190
+ when switched off. Note the guard tests BOTH amounts: an envelope
191
+ that does nothing during the note but drifts somewhere on release
192
+ (amount 0, release_amount set) still needs its node built at press,
193
+ because note-off has nothing to re-aim otherwise. The flip side:
194
+ a voice pressed while both were 0 has no envelope at all, so
195
+ raising either mid-note only affects NEW notes.
196
+ """
197
+ if not self._amt.a and not self._rel_amt.a:
198
+ return None
199
+ # A plain 1.0 means "nothing is scaling this", and the voice can use
200
+ # the shared block directly -- one Math lighter in the common case.
201
+ # A BLOCK is always wrapped, even if it happens to evaluate to 1.0
202
+ # right now, because it can change while the voice sounds. Hence the
203
+ # isinstance rather than a bare `gain == 1.0`: what a synthio block
204
+ # does under `==` is not ours to assume.
205
+ if isinstance(gain, (int, float)) and gain == 1.0:
206
+ depth = self._amt
207
+ else:
208
+ depth = product(self._amt, gain)
209
+ pos = synthio.LFO(waveform=self._wave, rate=self._rate_a, once=True)
210
+ # The ONLY difference between a filter envelope and a pitch envelope:
211
+ # which end of the lerp the amount sits at.
212
+ if self._falling:
213
+ return synthio.Math(synthio.MathOperation.CONSTRAINED_LERP, depth, 0.0, pos)
214
+ return synthio.Math(synthio.MathOperation.CONSTRAINED_LERP, 0.0, depth, pos)
215
+
216
+ def start_release(self, env):
217
+ """Send a voice's envelope into release. Call at note-off.
218
+
219
+ Re-aims the same position LFO instead of swapping its waveform, so
220
+ the fall starts wherever the envelope actually got to and a key
221
+ lifted mid-attack does not jump to full depth first. Continuity is
222
+ structural: there is no gain to recompute.
223
+
224
+ The target is the shared release-amount BLOCK, not a number, which
225
+ is what lets it stay live for a voice that is already falling -- and
226
+ it means the rising and falling cases need no branch here at all,
227
+ since a filter envelope simply has a block that holds 0.0.
228
+ """
229
+ env.a = env.value # move from here...
230
+ env.b = self._rel_amt # ...to wherever the release lands
231
+ env.c.rate = self._rate_r
232
+ env.c.retrigger()
@@ -0,0 +1,129 @@
1
+ ## pylint: disable=invalid-name
2
+ # SPDX-FileCopyrightText: Copyright (c) 2023 Tod Kurt
3
+ # SPDX-License-Identifier: MIT
4
+ """
5
+ ``arpeggiator``
6
+ ================================================================================
7
+
8
+ ``Arpeggiator`` is a melodic arpeggiator / 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
+ patterns = (
26
+ (0, 4, 7, 12),
27
+ (0, 3, 7, 10),
28
+ (0, 3, 6, 3),
29
+ (0, 5, 7, 12),
30
+ (0, 12, 0, -12),
31
+ (0, -12, -12, 0),
32
+ (0, 0, 0, 0),
33
+ )
34
+
35
+ pattern_names = (
36
+ "major",
37
+ "minor7",
38
+ "diminished",
39
+ "suspend4",
40
+ "octaves",
41
+ "octaves2",
42
+ "root",
43
+ )
44
+
45
+
46
+ class Arpeggiator:
47
+ """Poll-based arpeggiator: call update() as often as possible.
48
+
49
+ Holds a stack of notes added with add_note()/del_note() and steps
50
+ through them at ``step_millis`` (derived from ``rate`` and bpm, set via
51
+ set_bpm()), firing ``on_func``/``off_func`` at each gated step. ``rate`` is
52
+ in notes-per-beat: 1 = quarter notes, 2 = eighths, 4 = sixteenths.
53
+ """
54
+
55
+ def __init__(self, rate, on_func=None, off_func=None):
56
+ self.rate = rate # 1 = 1/4 note, 2 = 1/8th note, 4 = 16th note
57
+ self.set_bpm(120, rate)
58
+ self.oct_distance = 12 # distance between repeats (Ableton nomenclature)
59
+ self.oct_range = 1 # max number of self.distance to do (Ableton nomenclature)
60
+ self.octave = 0 # which arp step we're on, this is confusing with above
61
+ self.on_func = on_func
62
+ self.off_func = off_func
63
+ self.notes = [] # the list of notes currently pressed
64
+ self.transpose = 0
65
+ self.i = 0 # where in the notes list
66
+ self.gate = 0.5
67
+ self.on = False
68
+ self.held_note = None
69
+ self.next_millis = 0
70
+ self.held_millis = 0
71
+
72
+ def set_bpm(self, bpm, rate=None):
73
+ """Set BPM and optionally rate"""
74
+ self.bpm = bpm
75
+ self.rate = rate
76
+ self.step_millis = 60_000 / self.rate / self.bpm
77
+
78
+ def add_note(self, note):
79
+ """Add a note to the arpeggio"""
80
+ if note not in self.notes:
81
+ self.notes.append(note)
82
+
83
+ def del_note(self, note):
84
+ """Remove a note from the arpeggio"""
85
+ if note in self.notes:
86
+ self.notes.remove(note)
87
+ if self.i >= len(self.notes):
88
+ self.i = 0
89
+
90
+ def start(self):
91
+ """Start the arpeggiator running"""
92
+ self.next_millis = ticks_ms()
93
+ self.on = True
94
+
95
+ def stop(self):
96
+ """Stop the arpeggiator, note_offs any held notes"""
97
+ self.on = False
98
+ self.off_func(self.held_note) # turn off any held note
99
+
100
+ def update(self):
101
+ """Update the arpeggiator. Call as frequently as possible"""
102
+
103
+ if not self.on:
104
+ return
105
+ now = ticks_ms()
106
+
107
+ # trigger note-off after gate time
108
+ if self.held_note and now - self.held_millis > 0:
109
+ self.off_func(self.held_note)
110
+ self.held_note = None
111
+
112
+ # trigger note-on on if time to do so
113
+ delta_millis = now - self.next_millis
114
+ if delta_millis >= 0 and len(self.notes) > 0: # time for new note
115
+ note = self.notes[self.i] + self.oct_distance * self.octave
116
+ # print("\t\t\t\t\t", "delta:",delta_millis)
117
+
118
+ # trigger new note
119
+ self.on_func(note)
120
+ self.held_note = note # save for note-off
121
+
122
+ # set up when note off and next note happens
123
+ self.held_millis = now + (self.step_millis * self.gate)
124
+ self.next_millis = now + self.step_millis - delta_millis // 2
125
+
126
+ # go to next note
127
+ self.i = (self.i + 1) % len(self.notes)
128
+ if self.i == 0:
129
+ self.octave = (self.octave + 1) % self.oct_range