circuitpython-synthtools 0.5__py3-none-any.whl → 0.5.1__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 → circuitpython_synthtools-0.5.1.dist-info}/METADATA +18 -5
- {circuitpython_synthtools-0.5.dist-info → circuitpython_synthtools-0.5.1.dist-info}/RECORD +8 -8
- synthtools/__init__.py +2 -2
- synthtools/audio_fx.py +119 -137
- synthtools/bassline_synth.py +303 -2
- {circuitpython_synthtools-0.5.dist-info → circuitpython_synthtools-0.5.1.dist-info}/WHEEL +0 -0
- {circuitpython_synthtools-0.5.dist-info → circuitpython_synthtools-0.5.1.dist-info}/licenses/LICENSE +0 -0
- {circuitpython_synthtools-0.5.dist-info → circuitpython_synthtools-0.5.1.dist-info}/top_level.txt +0 -0
{circuitpython_synthtools-0.5.dist-info → circuitpython_synthtools-0.5.1.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: circuitpython-synthtools
|
|
3
|
-
Version: 0.5
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: CircuitPython helper library to do help doing synthio
|
|
5
5
|
Author-email: Tod Kurt <tod@todbot.com>
|
|
6
6
|
License: MIT
|
|
@@ -44,6 +44,15 @@ Introduction
|
|
|
44
44
|
|
|
45
45
|
CircuitPython library with tools for making synths with synthio
|
|
46
46
|
|
|
47
|
+
This library is a collection of tools derived from my many years of playing
|
|
48
|
+
with CircuitPython, ``synthio``, and building synthesizers in general.
|
|
49
|
+
Concepts pulled from these projects and others:
|
|
50
|
+
|
|
51
|
+
- `CircuitPython Synthio Tutorial <https://todbot.github.io/CircuitPython_Synthio_Tutorial/>`_
|
|
52
|
+
- `circuitpython synthio tricks <https://github.com/todbot/circuitpython-synthio-tricks>`_
|
|
53
|
+
- `pico_test_synth <https://github.com/todbot/pico_test_synth>`_
|
|
54
|
+
- `picotouch_synth <https://github.com/todbot/picotouch_synth>`_
|
|
55
|
+
- `picostepseq <https://github.com/todbot/picostepseq>`_
|
|
47
56
|
|
|
48
57
|
Dependencies
|
|
49
58
|
=============
|
|
@@ -120,10 +129,14 @@ What's Included
|
|
|
120
129
|
* ``SubtractiveSynth`` -- subtractive two-oscillator synth w/ detune
|
|
121
130
|
* ``WavetableSynth`` -- wavetable-playback with adjustable wave_pos
|
|
122
131
|
* ``BasslineSynth`` -- TB-303-style acid bassline: monophonic, one
|
|
123
|
-
oscillator, a decay-only filter sweep, per-step slide and accent
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
132
|
+
oscillator, a decay-only filter sweep, per-step slide and accent. Can
|
|
133
|
+
own its own filter/distortion/echo effects chain via ``fx_*`` patch
|
|
134
|
+
fields
|
|
135
|
+
* ``EffectsChain`` -- a generic post-synth effects chain: add, insert, or
|
|
136
|
+
remove any ``audiofilters``/``audiodelays`` effect and it stays wired.
|
|
137
|
+
``tracking_filter()`` builds extra filter stages that follow the synth's
|
|
138
|
+
own cutoff and resonance for a steeper slope (needs ``audiofilters`` in
|
|
139
|
+
the build)
|
|
127
140
|
* ``Patch`` -- inert, JSON-able patch data; save/load with
|
|
128
141
|
``save_patches()`` / ``load_patches()``
|
|
129
142
|
* ``Wavetable`` -- loads a wavetable WAV file and lerps between frames
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
circuitpython_synthtools-0.5.dist-info/licenses/LICENSE,sha256=jhhRyxpqyQxyF_D6SzY2-jCA5v8Z8JXfezCtxBS85CM,1075
|
|
2
|
-
synthtools/__init__.py,sha256=
|
|
1
|
+
circuitpython_synthtools-0.5.1.dist-info/licenses/LICENSE,sha256=jhhRyxpqyQxyF_D6SzY2-jCA5v8Z8JXfezCtxBS85CM,1075
|
|
2
|
+
synthtools/__init__.py,sha256=Hb5mohpSOi6Aba8IM48r8xxmJWd3NL7HqQVJ3F0xRHw,1318
|
|
3
3
|
synthtools/ahr_envelope.py,sha256=t5JCgO5iGLVKr4MyKnu0PNV1NOk8YfALUo5q69TrYa0,9968
|
|
4
4
|
synthtools/arpeggiator.py,sha256=n2sPnpZcu9soXsFJCh6OP65_0DuPnvQGUP0ao7V8SXM,3901
|
|
5
|
-
synthtools/audio_fx.py,sha256=
|
|
6
|
-
synthtools/bassline_synth.py,sha256=
|
|
5
|
+
synthtools/audio_fx.py,sha256=YjnwniyqOu7UjCBnS0smY1Xsanbgoz_Aop-6bqJhGes,7085
|
|
6
|
+
synthtools/bassline_synth.py,sha256=pnnGeYWFTtfEXmhTPOpoxFzh6WQIrAMqWWfWWn-rOvY,29287
|
|
7
7
|
synthtools/blocks.py,sha256=NXRkNgKoTtPMh5eDJ2eC7aXmtuZVrr17ZDhFLhJ5tqY,1998
|
|
8
8
|
synthtools/paramset.py,sha256=agk7IOr-OI_3g9LoV4-hZaZX7cI93GZe1vDeWiiwnqE,6732
|
|
9
9
|
synthtools/patch.py,sha256=_ueCz9XJDZRRt6j6FboLIbQiikH04GRbsKtnVQr4gHE,5250
|
|
@@ -19,7 +19,7 @@ synthtools/wavetable_synth.py,sha256=lvxpvITDgkOK6AJVIBbLOUa01wjPSvGxHpbTdS2fY2g
|
|
|
19
19
|
synthtools/ui/gauge_cluster.py,sha256=QnMBLMFgQhNTw7KQHp51ILzWeCrNLV8TwzDoHl_egG4,2900
|
|
20
20
|
synthtools/ui/param.py,sha256=wJwIiaGlDnGPGCAdeA4veJv5BxbeHE8K4HaddeI6qQs,3629
|
|
21
21
|
synthtools/ui/param_scaler.py,sha256=1Ow9lp7WgFauBV5dVFbynXxYd7OqAJhSKphSZNA_GO8,2958
|
|
22
|
-
circuitpython_synthtools-0.5.dist-info/METADATA,sha256=
|
|
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,,
|
|
22
|
+
circuitpython_synthtools-0.5.1.dist-info/METADATA,sha256=pX5x30A4Uz-N2k0S-KBUdKbD8wH3twsdGWtc0pZ7NbI,6454
|
|
23
|
+
circuitpython_synthtools-0.5.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
24
|
+
circuitpython_synthtools-0.5.1.dist-info/top_level.txt,sha256=1xt53cH064i7vvFVDFyuJugc1NogReXvzsXbYGlgnKE,11
|
|
25
|
+
circuitpython_synthtools-0.5.1.dist-info/RECORD,,
|
synthtools/__init__.py
CHANGED
|
@@ -25,7 +25,7 @@ Implementation Notes
|
|
|
25
25
|
|
|
26
26
|
# imports
|
|
27
27
|
|
|
28
|
-
from .audio_fx import EffectsChain
|
|
28
|
+
from .audio_fx import EffectsChain, set_drive, sync_delay, tracking_filter
|
|
29
29
|
from .bassline_synth import BasslineSynth
|
|
30
30
|
from .patch import Patch, load_patches, save_patches
|
|
31
31
|
from .subtractive_synth import SubtractiveSynth
|
|
@@ -41,5 +41,5 @@ try:
|
|
|
41
41
|
except ImportError:
|
|
42
42
|
pass
|
|
43
43
|
|
|
44
|
-
__version__ = "0.5"
|
|
44
|
+
__version__ = "0.5.1"
|
|
45
45
|
__repo__ = "https://github.com/todbot/CircuitPython_SynthTools.git"
|
synthtools/audio_fx.py
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
#
|
|
4
|
-
# audio_fx.py - post-synth effects
|
|
5
|
-
# synth
|
|
4
|
+
# audio_fx.py - a generic post-synth effects chain, plus a factory for the
|
|
5
|
+
# one effect that needs synth-specific knowledge: extra filter stages that
|
|
6
|
+
# track the synth's own cutoff and resonance.
|
|
7
|
+
#
|
|
8
|
+
# EffectsChain itself never imports audiofilters/audiodelays -- it just
|
|
9
|
+
# calls `.play(source)` on whatever effect objects it's handed, so it loads
|
|
10
|
+
# fine even where those modules don't exist. Only tracking_filter() (and
|
|
11
|
+
# whatever Distortion/Echo objects a caller constructs) needs them.
|
|
6
12
|
#
|
|
7
13
|
# A synthio.Note takes ONE Biquad, so 12 dB/octave is all a voice can do.
|
|
8
14
|
# Steeper means cascading more biquads downstream, and those need
|
|
@@ -11,9 +17,9 @@
|
|
|
11
17
|
# BasslineSynth is mono, so it keeps ONE Biquad over one stable cutoff
|
|
12
18
|
# node -- copy `synth.filter`'s frequency and you track it for good.
|
|
13
19
|
#
|
|
14
|
-
# Slope: every Biquad is 12 dB/oct
|
|
15
|
-
#
|
|
16
|
-
#
|
|
20
|
+
# Slope: every Biquad is 12 dB/oct, so `stages` extra ones plus the
|
|
21
|
+
# synth's own give 12*(stages+1). (A 303 is usually called ~18 dB/oct,
|
|
22
|
+
# which cascaded 2-pole sections cannot make at all.)
|
|
17
23
|
#
|
|
18
24
|
# Q tracks too, on every stage, matching the synth this was ported from
|
|
19
25
|
# (its `resonance` setter pushes the same Q into the voice filter and both
|
|
@@ -31,150 +37,126 @@
|
|
|
31
37
|
import synthio
|
|
32
38
|
|
|
33
39
|
try:
|
|
34
|
-
import audiodelays
|
|
35
40
|
import audiofilters
|
|
36
41
|
except ImportError: # not in every CircuitPython build
|
|
37
|
-
audiodelays = None
|
|
38
42
|
audiofilters = None
|
|
39
43
|
|
|
40
44
|
|
|
41
45
|
class EffectsChain:
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
"""A synth's output, plus an ordered chain of playback effects it runs
|
|
47
|
+
through. Add, insert, or remove any ``audiofilters``/``audiodelays``
|
|
48
|
+
effect object; the chain keeps every effect's ``.play(source)`` pointed
|
|
49
|
+
at whatever now precedes it::
|
|
50
|
+
|
|
51
|
+
fx = EffectsChain(synth)
|
|
52
|
+
fx.add(tracking_filter(synth, stages=1))
|
|
53
|
+
fx.add(audiofilters.Distortion(mix=0.0, drive=0.5, ...))
|
|
46
54
|
mixer.voice[0].play(fx.output)
|
|
47
55
|
|
|
48
|
-
``
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
``output`` is always the tail -- the synth itself when the chain is
|
|
57
|
+
empty. Building an ``EffectsChain`` touches no effects module at all,
|
|
58
|
+
so it loads fine even where ``audiofilters`` doesn't exist; only the
|
|
59
|
+
effects you add to it need it.
|
|
60
|
+
|
|
61
|
+
A mixer voice holds whatever object ``output`` *was* at the time you
|
|
62
|
+
called ``play()`` -- it has no way to notice ``output`` changing
|
|
63
|
+
identity later. Any ``add``/``insert``/``remove`` that changes the
|
|
64
|
+
tail (an ``add``, an ``insert`` at the end, or removing the current
|
|
65
|
+
tail) needs ``mixer.voice[0].play(fx.output)`` called again to reach
|
|
66
|
+
the speaker; an insert/remove in the *middle* rewires in place and
|
|
67
|
+
needs nothing further, since the tail object itself doesn't change.
|
|
58
68
|
"""
|
|
59
69
|
|
|
60
|
-
def __init__(
|
|
61
|
-
self,
|
|
62
|
-
synth,
|
|
63
|
-
stages=1,
|
|
64
|
-
distortion=False,
|
|
65
|
-
echo=False,
|
|
66
|
-
buffer_size=1024,
|
|
67
|
-
delay_ms=500,
|
|
68
|
-
max_delay_ms=500,
|
|
69
|
-
decay=0.1,
|
|
70
|
-
):
|
|
71
|
-
if audiofilters is None:
|
|
72
|
-
raise ImportError(
|
|
73
|
-
"audiofilters is not in this CircuitPython build; "
|
|
74
|
-
"EffectsChain needs it (audiodelays too, for echo)"
|
|
75
|
-
)
|
|
70
|
+
def __init__(self, synth):
|
|
76
71
|
self.synth = synth
|
|
77
|
-
|
|
78
|
-
cfg = {
|
|
79
|
-
"sample_rate": synthesizer.sample_rate,
|
|
80
|
-
"channel_count": synthesizer.channel_count,
|
|
81
|
-
"buffer_size": buffer_size,
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
stages = max(0, int(stages))
|
|
85
|
-
self.filter = None
|
|
86
|
-
if stages:
|
|
87
|
-
src = synth.filter # the synth's own Biquad, built once
|
|
88
|
-
if src is None:
|
|
89
|
-
raise ValueError("synth has no filter (filt_type is None) to track")
|
|
90
|
-
# Copies sharing src's frequency AND Q blocks -- both live, so
|
|
91
|
-
# both track the synth (sweep, accent, and a filt_q knob turn)
|
|
92
|
-
# with nothing to keep in sync by hand. One Filter holding a
|
|
93
|
-
# tuple, not a Filter each: `filter` runs the sample through
|
|
94
|
-
# them in order, saving a buffer and a pass per stage.
|
|
95
|
-
biquads = tuple(
|
|
96
|
-
synthio.Biquad(src.mode, frequency=src.frequency, Q=src.Q) for _ in range(stages)
|
|
97
|
-
)
|
|
98
|
-
self.filter = audiofilters.Filter(filter=biquads, mix=1.0, **cfg)
|
|
99
|
-
|
|
100
|
-
self.distortion = None
|
|
101
|
-
if distortion:
|
|
102
|
-
# fmt: off
|
|
103
|
-
self.distortion = audiofilters.Distortion(
|
|
104
|
-
mode=audiofilters.DistortionMode.LOFI, mix=0.0, drive=0.5,
|
|
105
|
-
soft_clip=True, pre_gain=0, post_gain=0, **cfg)
|
|
106
|
-
# fmt: on
|
|
107
|
-
|
|
108
|
-
self.echo = None
|
|
109
|
-
if echo:
|
|
110
|
-
if audiodelays is None:
|
|
111
|
-
raise ImportError("audiodelays is not in this CircuitPython build")
|
|
112
|
-
# fmt: off
|
|
113
|
-
self.echo = audiodelays.Echo(
|
|
114
|
-
mix=0.0, max_delay_ms=max_delay_ms, delay_ms=delay_ms,
|
|
115
|
-
decay=decay, freq_shift=False, **cfg)
|
|
116
|
-
# fmt: on
|
|
117
|
-
|
|
118
|
-
# wire whatever exists, in order, and remember the tail
|
|
119
|
-
self.output = synthesizer
|
|
120
|
-
for fx in (self.filter, self.distortion, self.echo):
|
|
121
|
-
if fx is not None:
|
|
122
|
-
fx.play(self.output)
|
|
123
|
-
self.output = fx
|
|
124
|
-
|
|
125
|
-
# --- knobs, all no-ops when the effect was not built ----------------
|
|
126
|
-
|
|
127
|
-
@property
|
|
128
|
-
def filter_mix(self):
|
|
129
|
-
"""Dry/wet for the extra filter stages. 1.0 = fully filtered."""
|
|
130
|
-
return self.filter.mix if self.filter is not None else 0.0
|
|
131
|
-
|
|
132
|
-
@filter_mix.setter
|
|
133
|
-
def filter_mix(self, v):
|
|
134
|
-
if self.filter is not None:
|
|
135
|
-
self.filter.mix = v
|
|
72
|
+
self._effects = []
|
|
136
73
|
|
|
137
74
|
@property
|
|
138
|
-
def
|
|
139
|
-
"""
|
|
140
|
-
`
|
|
141
|
-
|
|
142
|
-
return self.
|
|
143
|
-
|
|
144
|
-
@drive.setter
|
|
145
|
-
def drive(self, v):
|
|
146
|
-
if self.distortion is not None:
|
|
147
|
-
self.distortion.pre_gain = v * 50.0
|
|
148
|
-
self.distortion.post_gain = v * -25.0
|
|
75
|
+
def effects(self):
|
|
76
|
+
"""The chain in order, outermost effect last. Read-only -- go
|
|
77
|
+
through :meth:`add`/:meth:`insert`/:meth:`remove` so an effect's
|
|
78
|
+
``.play()`` source can never drift out of sync with this list."""
|
|
79
|
+
return tuple(self._effects)
|
|
149
80
|
|
|
150
81
|
@property
|
|
151
|
-
def
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
82
|
+
def output(self):
|
|
83
|
+
"""The tail of the chain: what a mixer voice should play."""
|
|
84
|
+
return self._effects[-1] if self._effects else self.synth.synthio
|
|
85
|
+
|
|
86
|
+
def add(self, effect):
|
|
87
|
+
"""Append ``effect`` to the end of the chain and return it."""
|
|
88
|
+
effect.play(self.output)
|
|
89
|
+
self._effects.append(effect)
|
|
90
|
+
return effect
|
|
91
|
+
|
|
92
|
+
def insert(self, index, effect):
|
|
93
|
+
"""Insert ``effect`` at ``index`` (Python list semantics, negative
|
|
94
|
+
included), rewiring everything from there on."""
|
|
95
|
+
self._effects.insert(index, effect)
|
|
96
|
+
self._rewire(self._effects.index(effect))
|
|
97
|
+
return effect
|
|
98
|
+
|
|
99
|
+
def remove(self, effect):
|
|
100
|
+
"""Take ``effect`` out of the chain and rewire around the gap."""
|
|
101
|
+
index = self._effects.index(effect)
|
|
102
|
+
del self._effects[index]
|
|
103
|
+
self._rewire(index)
|
|
104
|
+
|
|
105
|
+
def _rewire(self, from_index):
|
|
106
|
+
src = self._effects[from_index - 1] if from_index > 0 else self.synth.synthio
|
|
107
|
+
for stage in self._effects[from_index:]:
|
|
108
|
+
stage.play(src)
|
|
109
|
+
src = stage
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def tracking_filter(synth, stages=1, buffer_size=1024, mix=1.0):
|
|
113
|
+
"""Build one ``audiofilters.Filter`` holding ``stages`` extra Biquads
|
|
114
|
+
that track ``synth.filter``'s cutoff AND resonance -- see the module
|
|
115
|
+
comment for why that's automatic once they share the live blocks.
|
|
116
|
+
``stages`` extra 12 dB/octave sections plus the synth's own give
|
|
117
|
+
``12*(stages+1)`` overall.
|
|
118
|
+
|
|
119
|
+
Raises ``ImportError`` if this build has no ``audiofilters``,
|
|
120
|
+
``ValueError`` if ``stages < 1`` or ``synth`` has no filter to track
|
|
121
|
+
(either a poly synth, which has no ``.filter`` at all, or a mono one
|
|
122
|
+
built with ``filt_type=None``).
|
|
123
|
+
"""
|
|
124
|
+
if audiofilters is None:
|
|
125
|
+
raise ImportError("audiofilters is not in this CircuitPython build")
|
|
126
|
+
if stages < 1:
|
|
127
|
+
raise ValueError("stages must be >= 1")
|
|
128
|
+
src = getattr(synth, "filter", None)
|
|
129
|
+
if src is None:
|
|
130
|
+
raise ValueError("synth has no filter to track (mono-only, and filt_type must be set)")
|
|
131
|
+
synthesizer = synth.synthio
|
|
132
|
+
# Copies sharing src's frequency AND Q blocks -- both live, so both
|
|
133
|
+
# track the synth (sweep, accent, and a filt_q knob turn) with nothing
|
|
134
|
+
# to keep in sync by hand. One Filter holding a tuple, not a Filter
|
|
135
|
+
# each: `filter` runs the sample through them in order, saving a
|
|
136
|
+
# buffer and a pass per stage.
|
|
137
|
+
biquads = tuple(
|
|
138
|
+
synthio.Biquad(src.mode, frequency=src.frequency, Q=src.Q) for _ in range(stages)
|
|
139
|
+
)
|
|
140
|
+
return audiofilters.Filter(
|
|
141
|
+
filter=biquads,
|
|
142
|
+
mix=mix,
|
|
143
|
+
sample_rate=synthesizer.sample_rate,
|
|
144
|
+
channel_count=synthesizer.channel_count,
|
|
145
|
+
buffer_size=buffer_size,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def set_drive(distortion, amount):
|
|
150
|
+
"""Set a ``Distortion`` effect's drive, 0..1, through ``pre_gain`` and
|
|
151
|
+
``post_gain`` -- LOFI mode's own ``drive`` parameter does not do what
|
|
152
|
+
its name suggests; ``post_gain`` pulls back the level ``pre_gain``
|
|
153
|
+
adds."""
|
|
154
|
+
distortion.pre_gain = amount * 50.0
|
|
155
|
+
distortion.post_gain = amount * -25.0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def sync_delay(echo, bpm, steps=4, steps_per_beat=4):
|
|
159
|
+
"""Set an ``Echo`` effect's ``delay_ms`` to ``steps`` sequencer steps
|
|
160
|
+
at ``bpm`` -- a tempo-synced delay is most of what makes a repeating
|
|
161
|
+
line sit right."""
|
|
162
|
+
echo.delay_ms = (60_000.0 / bpm / steps_per_beat) * steps
|
synthtools/bassline_synth.py
CHANGED
|
@@ -57,10 +57,18 @@
|
|
|
57
57
|
|
|
58
58
|
import synthio
|
|
59
59
|
|
|
60
|
+
from .audio_fx import EffectsChain, set_drive, tracking_filter
|
|
60
61
|
from .blocks import clamp, product, sum3
|
|
61
|
-
from .synth import Synth
|
|
62
|
+
from .synth import FILTER_MODES, Synth
|
|
62
63
|
from .waves import get_wave
|
|
63
64
|
|
|
65
|
+
try:
|
|
66
|
+
import audiodelays
|
|
67
|
+
import audiofilters
|
|
68
|
+
except ImportError: # not in every CircuitPython build
|
|
69
|
+
audiodelays = None
|
|
70
|
+
audiofilters = None
|
|
71
|
+
|
|
64
72
|
|
|
65
73
|
class BasslineSynth(Synth):
|
|
66
74
|
"""Monophonic acid bassline synth after the Roland TB-303: one
|
|
@@ -98,7 +106,16 @@ class BasslineSynth(Synth):
|
|
|
98
106
|
# fmt: off
|
|
99
107
|
_PARAMS = Synth._PARAMS + ("wave", "envmod", "decay", "amp_level",
|
|
100
108
|
"accent", "accent_cutoff", "accent_q",
|
|
101
|
-
"slide_time", "transpose"
|
|
109
|
+
"slide_time", "transpose",
|
|
110
|
+
# the three STRUCTURAL fx_* fields
|
|
111
|
+
# (fx_filter_stages, fx_distortion_on,
|
|
112
|
+
# fx_echo_on) are deliberately absent: a
|
|
113
|
+
# set_param() from a MIDI CC would silently
|
|
114
|
+
# mute the fx chain, since nothing here can
|
|
115
|
+
# reach into the mixer and re-play() the
|
|
116
|
+
# new tail. See the "effects chain" section.
|
|
117
|
+
"fx_filter_mix", "fx_drive", "fx_drive_mix",
|
|
118
|
+
"fx_delay_ms", "fx_delay_mix", "fx_delay_decay")
|
|
102
119
|
# fmt: on
|
|
103
120
|
|
|
104
121
|
#: Inherently monophonic -- accent and glide are both shared state
|
|
@@ -125,6 +142,23 @@ class BasslineSynth(Synth):
|
|
|
125
142
|
_filter = None # the shared Biquad; see _build_filter()
|
|
126
143
|
_cutoff = None # its stable frequency node
|
|
127
144
|
|
|
145
|
+
# --- the owned effects chain: class attrs; see _build_fx() ----------
|
|
146
|
+
FX_BUFFER_SIZE = 1024
|
|
147
|
+
FX_MAX_DELAY_MS = 1000.0 # buffer sizing only, not a knob -- see fx_delay_ms
|
|
148
|
+
_fx_filter_stages = 0
|
|
149
|
+
_fx_filter_mix = 1.0
|
|
150
|
+
_fx_distortion_on = False
|
|
151
|
+
_fx_drive = 0.0
|
|
152
|
+
_fx_drive_mix = 0.0
|
|
153
|
+
_fx_echo_on = False
|
|
154
|
+
_fx_delay_ms = 300.0
|
|
155
|
+
_fx_delay_mix = 0.0
|
|
156
|
+
_fx_delay_decay = 0.3
|
|
157
|
+
_fx = None # the owned EffectsChain, lazily built by _build_fx()
|
|
158
|
+
_fx_stage = None # tracking_filter()'s Filter, if fx_filter_stages > 0
|
|
159
|
+
_fx_dist = None # audiofilters.Distortion, if fx_distortion_on
|
|
160
|
+
_fx_delay = None # audiodelays.Echo, if fx_echo_on
|
|
161
|
+
|
|
128
162
|
def __init__(self, synthesizer, patch=None):
|
|
129
163
|
super().__init__(synthesizer, patch)
|
|
130
164
|
# _env_accent has no other home: Synth.__init__ calls _make_env()
|
|
@@ -150,6 +184,34 @@ class BasslineSynth(Synth):
|
|
|
150
184
|
self._refresh_accent() # also derives fenv_amount from envmod
|
|
151
185
|
self._rebuild_env()
|
|
152
186
|
|
|
187
|
+
# --- the owned effects chain -------------------------------------
|
|
188
|
+
# Compare the three STRUCTURAL fields against what's already live
|
|
189
|
+
# BEFORE overwriting them: only a real shape change should drop
|
|
190
|
+
# self._fx. A patch load that leaves the fx shape alone must reach
|
|
191
|
+
# the live effects the same as every other param here, not freeze
|
|
192
|
+
# them -- see the "effects chain" section for why a naive
|
|
193
|
+
# unconditional invalidate is a silent wrong-sound bug.
|
|
194
|
+
new_stages = getattr(p, "fx_filter_stages", 0)
|
|
195
|
+
new_distortion_on = getattr(p, "fx_distortion_on", False)
|
|
196
|
+
new_echo_on = getattr(p, "fx_echo_on", False)
|
|
197
|
+
structural_changed = (
|
|
198
|
+
new_stages != self._fx_filter_stages
|
|
199
|
+
or new_distortion_on != self._fx_distortion_on
|
|
200
|
+
or new_echo_on != self._fx_echo_on
|
|
201
|
+
)
|
|
202
|
+
self._fx_filter_stages = new_stages
|
|
203
|
+
self._fx_distortion_on = new_distortion_on
|
|
204
|
+
self._fx_echo_on = new_echo_on
|
|
205
|
+
self._fx_filter_mix = getattr(p, "fx_filter_mix", 1.0)
|
|
206
|
+
self._fx_drive = getattr(p, "fx_drive", 0.0)
|
|
207
|
+
self._fx_drive_mix = getattr(p, "fx_drive_mix", 0.0)
|
|
208
|
+
self._fx_delay_ms = getattr(p, "fx_delay_ms", 300.0)
|
|
209
|
+
self._fx_delay_mix = getattr(p, "fx_delay_mix", 0.0)
|
|
210
|
+
self._fx_delay_decay = getattr(p, "fx_delay_decay", 0.3)
|
|
211
|
+
if structural_changed:
|
|
212
|
+
self._fx = None # rebuilt lazily, next .fx/.output access
|
|
213
|
+
self._push_fx_live()
|
|
214
|
+
|
|
153
215
|
def _decompile(self):
|
|
154
216
|
super()._decompile()
|
|
155
217
|
p = self.patch
|
|
@@ -165,6 +227,15 @@ class BasslineSynth(Synth):
|
|
|
165
227
|
# which on an accented step is the boosted depth. envmod is the
|
|
166
228
|
# real knob, so re-derive the clean value rather than store that.
|
|
167
229
|
p.fenv_amount = -self._envmod * self._filt_f_blk.a
|
|
230
|
+
p.fx_filter_stages = self._fx_filter_stages
|
|
231
|
+
p.fx_filter_mix = self._fx_filter_mix
|
|
232
|
+
p.fx_distortion_on = self._fx_distortion_on
|
|
233
|
+
p.fx_drive = self._fx_drive
|
|
234
|
+
p.fx_drive_mix = self._fx_drive_mix
|
|
235
|
+
p.fx_echo_on = self._fx_echo_on
|
|
236
|
+
p.fx_delay_ms = self._fx_delay_ms
|
|
237
|
+
p.fx_delay_mix = self._fx_delay_mix
|
|
238
|
+
p.fx_delay_decay = self._fx_delay_decay
|
|
168
239
|
|
|
169
240
|
# --- the shared filter ------------------------------------------------
|
|
170
241
|
# Mono, so ONE Biquad and one cutoff node serve every note: built once
|
|
@@ -218,6 +289,108 @@ class BasslineSynth(Synth):
|
|
|
218
289
|
def _make_filter(self):
|
|
219
290
|
return self.filter
|
|
220
291
|
|
|
292
|
+
# --- the owned effects chain (optional) -------------------------------
|
|
293
|
+
# A specialized EffectsChain, not the general-purpose one: fixed order
|
|
294
|
+
# (filter -> distortion -> echo, the acid-bass signal flow), built
|
|
295
|
+
# from the SAME tracking_filter()/set_drive() free functions the demo
|
|
296
|
+
# used to call by hand. The three fx_*_on/fx_filter_stages fields are
|
|
297
|
+
# STRUCTURAL -- they decide what exists -- everything else is a LIVE
|
|
298
|
+
# write into whatever's already built. See fx_drive and friends below.
|
|
299
|
+
|
|
300
|
+
def _fx_cfg(self):
|
|
301
|
+
s = self.synthio
|
|
302
|
+
return {
|
|
303
|
+
"sample_rate": s.sample_rate,
|
|
304
|
+
"channel_count": s.channel_count,
|
|
305
|
+
"buffer_size": self.FX_BUFFER_SIZE,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
def _build_fx(self):
|
|
309
|
+
"""Build the owned chain from the current fx_* fields, once.
|
|
310
|
+
|
|
311
|
+
Atomic: everything lands in locals and self._fx/_fx_stage/_fx_dist/
|
|
312
|
+
_fx_delay are only assigned once every requested piece succeeds.
|
|
313
|
+
Assigning self._fx as each piece is built and then raising partway
|
|
314
|
+
(audiofilters present but audiodelays isn't, say) would leave
|
|
315
|
+
self._fx non-None with an fx_echo_on that never got its Echo --
|
|
316
|
+
later code would treat the chain as already built and never retry.
|
|
317
|
+
"""
|
|
318
|
+
if self._fx is not None:
|
|
319
|
+
return
|
|
320
|
+
chain = EffectsChain(self)
|
|
321
|
+
stage = dist = delay = None
|
|
322
|
+
# No filter to track with filt_type=None -- an ordinary, silent
|
|
323
|
+
# no-op, the same as _voice_cutoff() returning None. tracking_filter
|
|
324
|
+
# itself raises ValueError for external callers who don't already
|
|
325
|
+
# know why; internally we do, so we just skip the stage instead of
|
|
326
|
+
# letting that surface from an `output` property read.
|
|
327
|
+
if self._fx_filter_stages > 0 and self._filt_mode is not None:
|
|
328
|
+
stage = chain.add(
|
|
329
|
+
tracking_filter(self, stages=self._fx_filter_stages, mix=self._fx_filter_mix)
|
|
330
|
+
)
|
|
331
|
+
if self._fx_distortion_on:
|
|
332
|
+
if audiofilters is None:
|
|
333
|
+
raise ImportError("audiofilters is not in this CircuitPython build")
|
|
334
|
+
# fmt: off
|
|
335
|
+
dist = chain.add(audiofilters.Distortion(
|
|
336
|
+
mode=audiofilters.DistortionMode.LOFI, mix=self._fx_drive_mix,
|
|
337
|
+
soft_clip=True, pre_gain=0, post_gain=0, **self._fx_cfg()))
|
|
338
|
+
# fmt: on
|
|
339
|
+
set_drive(dist, self._fx_drive)
|
|
340
|
+
if self._fx_echo_on:
|
|
341
|
+
if audiodelays is None:
|
|
342
|
+
raise ImportError("audiodelays is not in this CircuitPython build")
|
|
343
|
+
# fmt: off
|
|
344
|
+
delay = chain.add(audiodelays.Echo(
|
|
345
|
+
mix=self._fx_delay_mix, delay_ms=self._fx_delay_ms,
|
|
346
|
+
max_delay_ms=self.FX_MAX_DELAY_MS, decay=self._fx_delay_decay,
|
|
347
|
+
freq_shift=False, **self._fx_cfg()))
|
|
348
|
+
# fmt: on
|
|
349
|
+
self._fx, self._fx_stage, self._fx_dist, self._fx_delay = chain, stage, dist, delay
|
|
350
|
+
|
|
351
|
+
def _push_fx_live(self):
|
|
352
|
+
"""Push the current fx_* values into whatever's already built.
|
|
353
|
+
|
|
354
|
+
A no-op for anything not built yet -- called by every live fx_*
|
|
355
|
+
setter AND by _recompile(), so a patch load reaches a chain that's
|
|
356
|
+
already sounding exactly like every other param in this class,
|
|
357
|
+
even mid-transition after a structural change has invalidated
|
|
358
|
+
self._fx but left the still-playing objects in place.
|
|
359
|
+
"""
|
|
360
|
+
if self._fx_stage is not None:
|
|
361
|
+
self._fx_stage.mix = self._fx_filter_mix
|
|
362
|
+
if self._fx_dist is not None:
|
|
363
|
+
set_drive(self._fx_dist, self._fx_drive)
|
|
364
|
+
self._fx_dist.mix = self._fx_drive_mix
|
|
365
|
+
if self._fx_delay is not None:
|
|
366
|
+
self._fx_delay.delay_ms = self._fx_delay_ms
|
|
367
|
+
self._fx_delay.mix = self._fx_delay_mix
|
|
368
|
+
self._fx_delay.decay = self._fx_delay_decay
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def fx(self):
|
|
372
|
+
"""The owned effects chain, built the first time anything needs
|
|
373
|
+
it. Add/insert/remove more effects on it if you want to extend
|
|
374
|
+
past filter+distortion+echo -- it's an ordinary ``EffectsChain``.
|
|
375
|
+
"""
|
|
376
|
+
self._build_fx()
|
|
377
|
+
return self._fx
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def output(self):
|
|
381
|
+
"""What a mixer voice should play: the synth itself, or the tail
|
|
382
|
+
of ``fx`` if any ``fx_*`` field asked for an effect.
|
|
383
|
+
|
|
384
|
+
A mixer voice's ``play()`` captures this object's identity at
|
|
385
|
+
call time. Changing any STRUCTURAL field (``fx_filter_stages``,
|
|
386
|
+
``fx_distortion_on``, ``fx_echo_on`` -- directly, via
|
|
387
|
+
``set_param()``, or via ``load_patch()``) invalidates the owned
|
|
388
|
+
chain, so re-fetch ``output`` and hand it to the mixer voice
|
|
389
|
+
again afterward. The LIVE fx knobs (mix, drive, delay time) need
|
|
390
|
+
no such thing -- they reach whatever's already playing.
|
|
391
|
+
"""
|
|
392
|
+
return self.fx.output
|
|
393
|
+
|
|
221
394
|
# --- accent ----------------------------------------------------------
|
|
222
395
|
|
|
223
396
|
def _refresh_accent(self):
|
|
@@ -307,6 +480,25 @@ class BasslineSynth(Synth):
|
|
|
307
480
|
self._filt_f_blk.a = v
|
|
308
481
|
self._refresh_accent()
|
|
309
482
|
|
|
483
|
+
@property
|
|
484
|
+
def filt_type(self):
|
|
485
|
+
return self._filt_type
|
|
486
|
+
|
|
487
|
+
@filt_type.setter
|
|
488
|
+
def filt_type(self, v):
|
|
489
|
+
# Overridden to also invalidate the owned fx chain on a real mode
|
|
490
|
+
# change. _build_filter() already re-swaps the VOICE Biquad live
|
|
491
|
+
# (its frequency/Q are shared blocks, but `mode` isn't), and
|
|
492
|
+
# tracking_filter() copies that same mode as a plain value into
|
|
493
|
+
# each owned stage -- so left alone, a filt_type change would
|
|
494
|
+
# leave the voice on the new mode and the owned stages stuck on
|
|
495
|
+
# the old one: wrong sound, no exception.
|
|
496
|
+
old_mode = self._filt_mode
|
|
497
|
+
self._filt_type = v
|
|
498
|
+
self._filt_mode = FILTER_MODES.get(v)
|
|
499
|
+
if self._filt_mode != old_mode:
|
|
500
|
+
self._fx = None
|
|
501
|
+
|
|
310
502
|
@property
|
|
311
503
|
def envmod(self):
|
|
312
504
|
"""Filter sweep depth, 0..1, as a fraction of ``filt_f``.
|
|
@@ -408,3 +600,112 @@ class BasslineSynth(Synth):
|
|
|
408
600
|
def wave(self, v):
|
|
409
601
|
self._wave_name = v
|
|
410
602
|
get_wave(v) # O(1); warms the cache for the next note-on
|
|
603
|
+
|
|
604
|
+
# --- owned effects chain: the three STRUCTURAL switches --------------
|
|
605
|
+
# Changing any of these invalidates self._fx (rebuilt lazily, next
|
|
606
|
+
# .fx/.output access) but leaves whatever is currently built alone --
|
|
607
|
+
# it's still what the mixer is playing. See "the owned effects chain"
|
|
608
|
+
# above for why, and output's docstring for the resulting contract.
|
|
609
|
+
|
|
610
|
+
@property
|
|
611
|
+
def fx_filter_stages(self):
|
|
612
|
+
"""Extra 12 dB/octave Biquad stages cascaded after the voice's own
|
|
613
|
+
filter, via ``tracking_filter()``. 0 = none (the default -- costs
|
|
614
|
+
nothing and never touches ``audiofilters``). Has no effect while
|
|
615
|
+
``filt_type`` is ``None``: there is no cutoff to track."""
|
|
616
|
+
return self._fx_filter_stages
|
|
617
|
+
|
|
618
|
+
@fx_filter_stages.setter
|
|
619
|
+
def fx_filter_stages(self, v):
|
|
620
|
+
self._fx_filter_stages = v
|
|
621
|
+
self._fx = None
|
|
622
|
+
|
|
623
|
+
@property
|
|
624
|
+
def fx_distortion_on(self):
|
|
625
|
+
"""Whether a distortion stage exists at all. Separate from
|
|
626
|
+
``fx_drive_mix`` on purpose: even at mix 0 a ``Distortion`` effect
|
|
627
|
+
costs a buffer and real CPU every block, so whether one *exists*
|
|
628
|
+
has to be deliberate."""
|
|
629
|
+
return self._fx_distortion_on
|
|
630
|
+
|
|
631
|
+
@fx_distortion_on.setter
|
|
632
|
+
def fx_distortion_on(self, v):
|
|
633
|
+
self._fx_distortion_on = v
|
|
634
|
+
self._fx = None
|
|
635
|
+
|
|
636
|
+
@property
|
|
637
|
+
def fx_echo_on(self):
|
|
638
|
+
"""Whether an echo stage exists at all -- see ``fx_distortion_on``,
|
|
639
|
+
same reasoning."""
|
|
640
|
+
return self._fx_echo_on
|
|
641
|
+
|
|
642
|
+
@fx_echo_on.setter
|
|
643
|
+
def fx_echo_on(self, v):
|
|
644
|
+
self._fx_echo_on = v
|
|
645
|
+
self._fx = None
|
|
646
|
+
|
|
647
|
+
# --- owned effects chain: the six LIVE knobs --------------------------
|
|
648
|
+
# Each reaches whatever's already built via _push_fx_live(); a no-op
|
|
649
|
+
# until the matching structural switch above turns the effect on.
|
|
650
|
+
|
|
651
|
+
@property
|
|
652
|
+
def fx_filter_mix(self):
|
|
653
|
+
"""Dry/wet for the extra filter stages, 1.0 = fully filtered."""
|
|
654
|
+
return self._fx_filter_mix
|
|
655
|
+
|
|
656
|
+
@fx_filter_mix.setter
|
|
657
|
+
def fx_filter_mix(self, v):
|
|
658
|
+
self._fx_filter_mix = v
|
|
659
|
+
self._push_fx_live()
|
|
660
|
+
|
|
661
|
+
@property
|
|
662
|
+
def fx_drive(self):
|
|
663
|
+
"""Distortion amount, 0..1, via ``set_drive()`` (LOFI mode's own
|
|
664
|
+
``drive`` parameter does not behave as its name suggests)."""
|
|
665
|
+
return self._fx_drive
|
|
666
|
+
|
|
667
|
+
@fx_drive.setter
|
|
668
|
+
def fx_drive(self, v):
|
|
669
|
+
self._fx_drive = v
|
|
670
|
+
self._push_fx_live()
|
|
671
|
+
|
|
672
|
+
@property
|
|
673
|
+
def fx_drive_mix(self):
|
|
674
|
+
"""Dry/wet for the distortion stage."""
|
|
675
|
+
return self._fx_drive_mix
|
|
676
|
+
|
|
677
|
+
@fx_drive_mix.setter
|
|
678
|
+
def fx_drive_mix(self, v):
|
|
679
|
+
self._fx_drive_mix = v
|
|
680
|
+
self._push_fx_live()
|
|
681
|
+
|
|
682
|
+
@property
|
|
683
|
+
def fx_delay_ms(self):
|
|
684
|
+
"""Echo delay time in milliseconds."""
|
|
685
|
+
return self._fx_delay_ms
|
|
686
|
+
|
|
687
|
+
@fx_delay_ms.setter
|
|
688
|
+
def fx_delay_ms(self, v):
|
|
689
|
+
self._fx_delay_ms = v
|
|
690
|
+
self._push_fx_live()
|
|
691
|
+
|
|
692
|
+
@property
|
|
693
|
+
def fx_delay_mix(self):
|
|
694
|
+
"""Dry/wet for the echo stage."""
|
|
695
|
+
return self._fx_delay_mix
|
|
696
|
+
|
|
697
|
+
@fx_delay_mix.setter
|
|
698
|
+
def fx_delay_mix(self, v):
|
|
699
|
+
self._fx_delay_mix = v
|
|
700
|
+
self._push_fx_live()
|
|
701
|
+
|
|
702
|
+
@property
|
|
703
|
+
def fx_delay_decay(self):
|
|
704
|
+
"""Echo feedback, 0..1 -- how much each repeat carries into the
|
|
705
|
+
next. Unrelated to ``decay``, the filter-envelope fall time."""
|
|
706
|
+
return self._fx_delay_decay
|
|
707
|
+
|
|
708
|
+
@fx_delay_decay.setter
|
|
709
|
+
def fx_delay_decay(self, v):
|
|
710
|
+
self._fx_delay_decay = v
|
|
711
|
+
self._push_fx_live()
|
|
File without changes
|
{circuitpython_synthtools-0.5.dist-info → circuitpython_synthtools-0.5.1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{circuitpython_synthtools-0.5.dist-info → circuitpython_synthtools-0.5.1.dist-info}/top_level.txt
RENAMED
|
File without changes
|