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
|
@@ -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,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
|