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