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,91 @@
|
|
|
1
|
+
## pylint: disable=invalid-name,too-many-arguments,multiple-statements
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2023 Tod Kurt
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""
|
|
5
|
+
``trig_sequencer``
|
|
6
|
+
================================================================================
|
|
7
|
+
|
|
8
|
+
``TrigSequencer`` is a trigger-based (drum) sequencer for rhythmic 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 TrigSequencer:
|
|
26
|
+
"""
|
|
27
|
+
TrigSequencer contains a list of on/off event triggers in list of steps.
|
|
28
|
+
|
|
29
|
+
:param int trig_count: how many triggers to keep track of
|
|
30
|
+
:param int step_count: how many for all the triggers
|
|
31
|
+
:param int steps_per_beat: number of steps in a beat (1=quarter note, 2=8th note, 4=16th note)
|
|
32
|
+
:param function on_func: function to call on trigger start
|
|
33
|
+
:param function off_func: function to call on trigger end (unused)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, trig_count, step_count, steps_per_beat, on_func=None, off_func=None):
|
|
37
|
+
self.trig_count = trig_count
|
|
38
|
+
self.step_count = step_count
|
|
39
|
+
self.steps_per_beat = steps_per_beat
|
|
40
|
+
self.on_func = on_func
|
|
41
|
+
self.off_func = off_func
|
|
42
|
+
self.trigs = [[0 for t in range(step_count)] for i in range(trig_count)]
|
|
43
|
+
self.i = 0 # where in the step sequence we currently are
|
|
44
|
+
self.playing = False
|
|
45
|
+
self.drum_map = [0] * trig_count
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def bpm(self):
|
|
49
|
+
return 60_000 / self.step_millis / self.steps_per_beat
|
|
50
|
+
|
|
51
|
+
@bpm.setter
|
|
52
|
+
def bpm(self, bpm):
|
|
53
|
+
"""Sets the internal tempo. step_millis is time between steps"""
|
|
54
|
+
self.step_millis = 60_000 / self.steps_per_beat / bpm
|
|
55
|
+
|
|
56
|
+
def start(self):
|
|
57
|
+
"""Start sequencer going"""
|
|
58
|
+
self.next_millis = ticks_ms()
|
|
59
|
+
self.playing = True
|
|
60
|
+
|
|
61
|
+
def stop(self):
|
|
62
|
+
"""Stop sequencer, turning off any currently-sounding note"""
|
|
63
|
+
self.off_func(*self.held_note)
|
|
64
|
+
self.playing = False
|
|
65
|
+
self.i = 0
|
|
66
|
+
|
|
67
|
+
def set_drum_map(self, drum_map):
|
|
68
|
+
self.drum_map = drum_map
|
|
69
|
+
|
|
70
|
+
def set_pattern(self, pattern):
|
|
71
|
+
for i in range(len(pattern)):
|
|
72
|
+
self.trigs[i] = pattern[i]
|
|
73
|
+
|
|
74
|
+
def update(self):
|
|
75
|
+
"""Update the sequencer. Call as frequently as possible"""
|
|
76
|
+
if not self.playing:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
now = ticks_ms()
|
|
80
|
+
delta_millis = now - self.next_millis
|
|
81
|
+
|
|
82
|
+
if delta_millis >= 0: # time to play
|
|
83
|
+
# print(" delta_millis:", delta_millis)
|
|
84
|
+
|
|
85
|
+
for t in range(self.trig_count):
|
|
86
|
+
if self.trigs[t][self.i] == 1:
|
|
87
|
+
self.on_func(t, self.drum_map[t])
|
|
88
|
+
|
|
89
|
+
# prep for next step in sequence
|
|
90
|
+
self.i = (self.i + 1) % self.step_count
|
|
91
|
+
self.next_millis = now + self.step_millis - delta_millis
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
##pylint: disable=invalid-name
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2024 Tod Kurt
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""
|
|
5
|
+
`gauge_cluster`
|
|
6
|
+
================================================================================
|
|
7
|
+
|
|
8
|
+
A group of `displayio` objects that display a list of values graphically.
|
|
9
|
+
|
|
10
|
+
Part of synthtools.
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import displayio
|
|
15
|
+
from vectorio import Rectangle
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GaugeCluster: # (dispalyio.Group) ?
|
|
19
|
+
"""
|
|
20
|
+
GaugeCluster is a group of `displayio` objects that display a list
|
|
21
|
+
of values graphically.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# pylint: disable=too-many-arguments,too-many-locals
|
|
25
|
+
def __init__(self, num_vals, x=2, y=4, width=5, height=40, xstride=3):
|
|
26
|
+
self.gauge_vals = [0] * num_vals # 0-255 is val range
|
|
27
|
+
self.x = x
|
|
28
|
+
self.y = y
|
|
29
|
+
self.w = width
|
|
30
|
+
self.h = height
|
|
31
|
+
palW = displayio.Palette(1)
|
|
32
|
+
palW[0] = 0xFFFFFF
|
|
33
|
+
palB = displayio.Palette(1)
|
|
34
|
+
palB[0] = 0x000000
|
|
35
|
+
xspacing = width + 2 # 7
|
|
36
|
+
xstride = int(xspacing * xstride) # 21 orig
|
|
37
|
+
gauges = displayio.Group()
|
|
38
|
+
select_lines = displayio.Group()
|
|
39
|
+
# fmt: off
|
|
40
|
+
for i in range(num_vals // 2):
|
|
41
|
+
rL = Rectangle(pixel_shader=palW, width=self.w, height=self.h,
|
|
42
|
+
x=self.x + (i * xstride),
|
|
43
|
+
y=self.y)
|
|
44
|
+
rLB = Rectangle(pixel_shader=palB, width=self.w-2, height=self.h,
|
|
45
|
+
x=self.x+1+(i*xstride),
|
|
46
|
+
y=self.y+1)
|
|
47
|
+
rR = Rectangle(pixel_shader=palW, width=self.w, height=self.h,
|
|
48
|
+
x=self.x+xspacing+(i*xstride),
|
|
49
|
+
y=self.y)
|
|
50
|
+
rRB = Rectangle(pixel_shader=palB,width=self.w-2, height=self.h,
|
|
51
|
+
x=self.x + xspacing+1+(i*xstride),
|
|
52
|
+
y=self.y + 1)
|
|
53
|
+
for r in (rL, rLB, rR, rRB):
|
|
54
|
+
gauges.append(r)
|
|
55
|
+
|
|
56
|
+
# add in the select lines, above the actual cluster
|
|
57
|
+
line = Rectangle(pixel_shader=palW, width=self.w*2+2, height=2,
|
|
58
|
+
x=self.x+(i*xstride),
|
|
59
|
+
y=self.y-3)
|
|
60
|
+
line.hidden = True
|
|
61
|
+
select_lines.append(line)
|
|
62
|
+
# fmt: on
|
|
63
|
+
|
|
64
|
+
self.gauges = gauges
|
|
65
|
+
self.select_lines = select_lines
|
|
66
|
+
|
|
67
|
+
def set_gauge_val(self, i, v):
|
|
68
|
+
"""Set gauge `i` with value `v`. v ranges from 0-255"""
|
|
69
|
+
self.gauge_vals[i] = v # 0-255
|
|
70
|
+
self.gauges[1 + (i * 2)].height = self.h - 2 - ((v * (self.h - 2)) // 255)
|
|
71
|
+
|
|
72
|
+
def get_gauge_val(self, i):
|
|
73
|
+
"""Get gauge value of gauge `i`, return value ranges from 0-255"""
|
|
74
|
+
return self.gauge_vals[i]
|
|
75
|
+
|
|
76
|
+
def select_line(self, i, show=True):
|
|
77
|
+
"""Show a bar above two of the gauges, indicating they are the pair
|
|
78
|
+
able to be edited (this should maybe go in synthui)"""
|
|
79
|
+
self.select_lines[i].hidden = not show
|
synthtools/ui/param.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
## pylint: disable=invalid-name,too-many-arguments,multiple-statements
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2024 Tod Kurt
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""
|
|
5
|
+
`param`
|
|
6
|
+
================================================================================
|
|
7
|
+
|
|
8
|
+
A `Param` is a named represention of an on-screen configuration value.
|
|
9
|
+
|
|
10
|
+
A `ParamRange` is a Param with a numeric range and setter function to update
|
|
11
|
+
when the Param is changed.
|
|
12
|
+
|
|
13
|
+
A `ParamChoice` is a Param with a list of options to choose from and
|
|
14
|
+
a setter function to update when the Param is changed.
|
|
15
|
+
|
|
16
|
+
Part of synthtools.
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Param: # pylint: disable=too-few-public-methods
|
|
22
|
+
"""Param is a named representation of an on-screen config value"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, name, fullname, val):
|
|
25
|
+
self.name = name
|
|
26
|
+
self.fullname = fullname
|
|
27
|
+
self.val = val
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ParamRange:
|
|
31
|
+
"""ParamRange is a Param with a numeric range and setter/getter functions
|
|
32
|
+
to update and set the represented value"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, name, fullname, val, fmt, minval, maxval, setter=None, getter=None):
|
|
35
|
+
self.name = name
|
|
36
|
+
self.fullname = fullname
|
|
37
|
+
self.val = val
|
|
38
|
+
self.fmt = fmt
|
|
39
|
+
self.minval = minval
|
|
40
|
+
self.maxval = maxval
|
|
41
|
+
self.valrange = maxval - minval
|
|
42
|
+
self.setter = setter
|
|
43
|
+
self.getter = getter
|
|
44
|
+
|
|
45
|
+
def __repr__(self):
|
|
46
|
+
return "ParamRange('%s', %s, %s,%s)" % (
|
|
47
|
+
self.name,
|
|
48
|
+
self.fmt % self.val,
|
|
49
|
+
self.fmt % self.minval,
|
|
50
|
+
self.fmt % self.maxval,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def update(self):
|
|
54
|
+
"""Update the param's value using the getter"""
|
|
55
|
+
if self.getter:
|
|
56
|
+
self.val = self.getter()
|
|
57
|
+
|
|
58
|
+
def get_text(self):
|
|
59
|
+
"""Return a text version of the param's value, using its fmt"""
|
|
60
|
+
return self.fmt % self.val # text representation
|
|
61
|
+
|
|
62
|
+
def set_by_gauge_val(self, gv): # gv ranges 0-255
|
|
63
|
+
"""Set the param's value (and the underlying value the param is
|
|
64
|
+
representing, using the 0-255 'gauge value' range"""
|
|
65
|
+
self.val = (gv * (self.valrange) / 255) + self.minval
|
|
66
|
+
if self.setter:
|
|
67
|
+
self.setter(self.val)
|
|
68
|
+
|
|
69
|
+
def get_by_gauge_val(self):
|
|
70
|
+
"""Get the param's value in terms of the 0-255 'gauge value'"""
|
|
71
|
+
return (self.val - self.minval) / (self.valrange) * 255
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ParamChoice:
|
|
75
|
+
"""ParamChoice is a Param with a list of options and setter/getter functions
|
|
76
|
+
to update and set the represented value"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, name, fullname, val, choices, setter=None, getter=None):
|
|
79
|
+
self.name = name
|
|
80
|
+
self.fullname = fullname
|
|
81
|
+
self.val = val
|
|
82
|
+
self.choices = choices
|
|
83
|
+
self.num_choices = len(choices)
|
|
84
|
+
self.setter = setter
|
|
85
|
+
self.getter = getter
|
|
86
|
+
|
|
87
|
+
def __repr__(self):
|
|
88
|
+
return "ParamChoice('%s', %s, %s)" % (self.name, self.val, self.choices)
|
|
89
|
+
|
|
90
|
+
def update(self):
|
|
91
|
+
"""Update the param's value using the getter"""
|
|
92
|
+
if self.getter:
|
|
93
|
+
self.val = self.getter()
|
|
94
|
+
|
|
95
|
+
def get_text(self):
|
|
96
|
+
"""Return a text version of the param's value, using its fmt"""
|
|
97
|
+
return self.choices[self.val] # text representation
|
|
98
|
+
|
|
99
|
+
def set_by_gauge_val(self, gv): # gv ranges 0-255
|
|
100
|
+
"""Set the param's value (and the underlying value the param is
|
|
101
|
+
representing, using the 0-255 'gauge value' range"""
|
|
102
|
+
self.val = int(gv * (self.num_choices - 1) / 255)
|
|
103
|
+
if self.setter:
|
|
104
|
+
self.setter(self.val)
|
|
105
|
+
|
|
106
|
+
def get_by_gauge_val(self):
|
|
107
|
+
"""Get the param's value in terms of the 0-255 'gauge value'"""
|
|
108
|
+
return int(self.val * 255 / (self.num_choices - 1))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2024 Tod Kurt
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
`param_scaler`
|
|
5
|
+
================================================================================
|
|
6
|
+
|
|
7
|
+
`ParamScaler` attempts to solve the "knob pickup" problem when a control's
|
|
8
|
+
position does not match the Param position.
|
|
9
|
+
|
|
10
|
+
The scaler will increase/decrease its internal value relative to the change
|
|
11
|
+
of the incoming knob position and the amount of "runway" remaining on the
|
|
12
|
+
value. Once the knob reaches its max or min position, the value will
|
|
13
|
+
move in sync with the knob. The value will always decrease/increase
|
|
14
|
+
in the same direction as the knob.
|
|
15
|
+
This mirrors how the Deluge synth's "SCALE" mode works.
|
|
16
|
+
|
|
17
|
+
Part of synthtools.
|
|
18
|
+
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from micropython import const
|
|
22
|
+
|
|
23
|
+
knob_min, knob_max = const(0), const(255)
|
|
24
|
+
val_min, val_max = const(0), const(255)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ParamScaler:
|
|
28
|
+
"""
|
|
29
|
+
ParamScaler will scale its value based on input knob position, always
|
|
30
|
+
trying to 'do the right thing' no matter where the current value of
|
|
31
|
+
the knob is in relation to its own value
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, val, knob_pos):
|
|
35
|
+
"""val and knob_pos range from 0-255, floating point"""
|
|
36
|
+
self.val = val
|
|
37
|
+
self.knob_pos_last = knob_pos
|
|
38
|
+
self.knob_match = False
|
|
39
|
+
|
|
40
|
+
def reset(self, val=None, knob_pos=None):
|
|
41
|
+
"""Reset the ParamScaler's value and knob_pos memory"""
|
|
42
|
+
if val is not None:
|
|
43
|
+
self.val = val
|
|
44
|
+
if knob_pos is not None:
|
|
45
|
+
self.knob_pos_last = knob_pos
|
|
46
|
+
self.knob_match = False
|
|
47
|
+
|
|
48
|
+
def update(self, knob_pos):
|
|
49
|
+
"""Update the ParamScaler's internal value based on new knob_pos"""
|
|
50
|
+
# print("k:%3d lk:%3d m:%1d" % (knob_pos, self.knob_pos_last, self.knob_match))
|
|
51
|
+
if self.knob_match:
|
|
52
|
+
# print("!! ==")
|
|
53
|
+
self.val = knob_pos
|
|
54
|
+
self.knob_pos_last = knob_pos
|
|
55
|
+
return knob_pos
|
|
56
|
+
|
|
57
|
+
knob_delta = knob_pos - self.knob_pos_last
|
|
58
|
+
self.knob_pos_last = knob_pos
|
|
59
|
+
|
|
60
|
+
if abs(knob_pos - self.val) < 5: # todfixme: make configurable
|
|
61
|
+
# print("!!!!")
|
|
62
|
+
self.knob_match = True
|
|
63
|
+
self.val = knob_pos
|
|
64
|
+
return knob_pos
|
|
65
|
+
|
|
66
|
+
val_max_pos_delta = val_max - self.val
|
|
67
|
+
val_min_pos_delta = self.val - val_min
|
|
68
|
+
knob_max_pos_delta = val_max - knob_pos
|
|
69
|
+
knob_min_pos_delta = knob_pos - val_min
|
|
70
|
+
|
|
71
|
+
if knob_delta > 0 and knob_max_pos_delta != 0:
|
|
72
|
+
# print("+ ", end='')
|
|
73
|
+
val_percent_change = knob_delta * val_max_pos_delta / knob_max_pos_delta
|
|
74
|
+
elif knob_delta < 0 and knob_min_pos_delta != 0:
|
|
75
|
+
# print("- ", end='')
|
|
76
|
+
val_percent_change = knob_delta * val_min_pos_delta / knob_min_pos_delta
|
|
77
|
+
else:
|
|
78
|
+
# print(". ", end='')
|
|
79
|
+
val_percent_change = 0
|
|
80
|
+
|
|
81
|
+
# print("val_percent_change: %2.2f" % val_percent_change)
|
|
82
|
+
self.val = min(max(self.val + val_percent_change, val_min), val_max)
|
|
83
|
+
return self.val
|
synthtools/utils.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2023 Tod Kurt
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from collections import deque
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RollingAverage:
|
|
8
|
+
"""A fixed-size moving average, for smoothing/deadbanding noisy knob
|
|
9
|
+
reads. update() pushes a new value onto a ``window_size``-deep deque and
|
|
10
|
+
returns the mean of whatever is currently in it -- so the window is
|
|
11
|
+
still filling for the first ``window_size`` calls."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, window_size):
|
|
14
|
+
self.d = deque((), window_size)
|
|
15
|
+
self.window_size = window_size
|
|
16
|
+
|
|
17
|
+
def update(self, new_value):
|
|
18
|
+
self.d.append(new_value)
|
|
19
|
+
return sum(self.d) / self.window_size
|