circuitpython-synthtools 0.6.0__py3-none-any.whl → 0.7.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: circuitpython-synthtools
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: CircuitPython helper library to do help doing synthio
5
5
  Author-email: Tod Kurt <tod@todbot.com>
6
6
  License: MIT
@@ -162,6 +162,8 @@ What's Included
162
162
  ``synthio.Note`` graphs (the engines above have their own portamento,
163
163
  via ``mono`` + ``glide_time``)
164
164
  * ``RollingAverage`` -- moving-average smoothing for noisy knob reads
165
+ * ``Scale`` / ``chord`` -- scales and diatonic/chromatic chords for
166
+ mapping pads and MIDI onto pitch
165
167
 
166
168
  Documentation
167
169
  =============
@@ -1,5 +1,5 @@
1
- circuitpython_synthtools-0.6.0.dist-info/licenses/LICENSE,sha256=jhhRyxpqyQxyF_D6SzY2-jCA5v8Z8JXfezCtxBS85CM,1075
2
- synthtools/__init__.py,sha256=WXLJ-chYBMFnvVbSnwyxy2Txv9ixApOKi8vyrKypQlA,2592
1
+ circuitpython_synthtools-0.7.0.dist-info/licenses/LICENSE,sha256=jhhRyxpqyQxyF_D6SzY2-jCA5v8Z8JXfezCtxBS85CM,1075
2
+ synthtools/__init__.py,sha256=n0ZXMz7Uu0pPEqalR51IxkClI9DTwuUbpu5yPj_whn8,2752
3
3
  synthtools/ahr_envelope.py,sha256=fWhjp7LahNGjL8qtTgyiAnX5hnviAULuyc9dEJ6t74I,9233
4
4
  synthtools/arpeggiator.py,sha256=n2sPnpZcu9soXsFJCh6OP65_0DuPnvQGUP0ao7V8SXM,3901
5
5
  synthtools/audio_fx.py,sha256=VzdWT_pCwfQU5GTb8hVGGMwoFV4lxPwWE1Nm7A1a_Fc,6675
@@ -7,6 +7,7 @@ synthtools/bassline_synth.py,sha256=Gkz7MKKUITgND4Se5nAJly9sZLQjHhFdd-aFrOAjeVM,
7
7
  synthtools/blocks.py,sha256=EsGcDEzIJX_dvBN8CVlDrewtl16x5leU03tum9jrhr0,1995
8
8
  synthtools/fm_synth.py,sha256=-r4NcsESjEechkbmV0tZx4pXayHIz4qUfIznKtQoKgQ,6073
9
9
  synthtools/gauge_cluster.py,sha256=lcMkRK-db6KV6pu0PUJRWAOJT8IdjgpE_4bAi0lQwxc,2910
10
+ synthtools/harmony.py,sha256=V_btMIqO-Ep2x64XwM1A1agSeQqwPcbLqpvGjxlnpUE,6206
10
11
  synthtools/param_scaler.py,sha256=dmaFkzRhrfZn54DAgPwZs7P4B4BFd90ZUvMHpWIf2i4,6202
11
12
  synthtools/paramset.py,sha256=--V89zHR4pUhkSey1CX7iMLNsF5l1hKmKWrAr3j_cRA,9938
12
13
  synthtools/patch.py,sha256=vMr-kdVD66uxY8BrrnB0J6uDLREjuu16I8HzfhAkqHs,6033
@@ -20,7 +21,7 @@ synthtools/utils.py,sha256=deKQbkGXIBdGUOXBOpRCc-UHdPiL8YmybIJ3BBo9aA4,648
20
21
  synthtools/waves.py,sha256=lwDka71TsoHGJcyVfmG4t-TEf6TjBwftJEXSS4Qxyx0,22407
21
22
  synthtools/wavetable.py,sha256=K1rghAptDYIOwy0PcL9xCllnJH6X3h875MpeuXCm6lQ,2492
22
23
  synthtools/wavetable_synth.py,sha256=WUs4FfrKd1hosPiPYnf05I4S8t98ngEMi8qhShkbCps,12896
23
- circuitpython_synthtools-0.6.0.dist-info/METADATA,sha256=C4HgNUbEFEH2PUe0Pv8sQ9-u7t3P4rSOMQF6upscetw,7086
24
- circuitpython_synthtools-0.6.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
25
- circuitpython_synthtools-0.6.0.dist-info/top_level.txt,sha256=1xt53cH064i7vvFVDFyuJugc1NogReXvzsXbYGlgnKE,11
26
- circuitpython_synthtools-0.6.0.dist-info/RECORD,,
24
+ circuitpython_synthtools-0.7.0.dist-info/METADATA,sha256=WY049mABTbxBi_qkmr_a_Dh3AZ9ImrnVP0w81veUu0M,7189
25
+ circuitpython_synthtools-0.7.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
26
+ circuitpython_synthtools-0.7.0.dist-info/top_level.txt,sha256=1xt53cH064i7vvFVDFyuJugc1NogReXvzsXbYGlgnKE,11
27
+ circuitpython_synthtools-0.7.0.dist-info/RECORD,,
synthtools/__init__.py CHANGED
@@ -36,7 +36,7 @@ Anything not listed below is still importable by its module path::
36
36
 
37
37
  import sys
38
38
 
39
- __version__ = "0.6.0"
39
+ __version__ = "0.7.0"
40
40
  __repo__ = "https://github.com/todbot/CircuitPython_SynthTools.git"
41
41
 
42
42
  #: Public name -> the submodule that defines it. Every entry is loaded on
@@ -49,6 +49,12 @@ _LAZY = {
49
49
  "BasslineSynth": "bassline_synth",
50
50
  "FMSynth": "fm_synth",
51
51
  "GaugeCluster": "gauge_cluster",
52
+ "Scale": "harmony",
53
+ "chord": "harmony",
54
+ "note_name": "harmony",
55
+ "SCALES": "harmony",
56
+ "CHORDS": "harmony",
57
+ "DIATONIC_SHAPES": "harmony",
52
58
  "ParamScaler": "param_scaler",
53
59
  "Patch": "patch",
54
60
  "load_patches": "patch",
synthtools/harmony.py ADDED
@@ -0,0 +1,198 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
2
+ # SPDX-License-Identifier: MIT
3
+ """
4
+ ``harmony``
5
+ ================================================================================
6
+
7
+ Scales and chords for mapping controllers onto musical pitch.
8
+
9
+ A ``Scale`` is a root MIDI note plus a set of semitone offsets. Index it
10
+ by scale degree to get MIDI notes; degrees past the end (or negative)
11
+ wrap with octave shifts, so a row of pads maps straight onto the scale
12
+ without bounds checks. ``Scale.chord()`` stacks in-key chords by degree;
13
+ the module-level ``chord()`` builds chromatic chords from an interval
14
+ table.
15
+
16
+ Pure Python, no dependencies. Part of synthtools.
17
+
18
+ """
19
+
20
+ # The *_NAMES tuples are written out rather than `tuple(SCALES)` because
21
+ # MicroPython does not guarantee dict iteration order (CPython 3.7+ does),
22
+ # and a UI that builds a selector from the order and stores an index needs
23
+ # it stable across interpreters. Keep each tuple in step with its dict.
24
+
25
+ #: name -> ascending semitone offsets from the root, one octave. The root
26
+ #: (0) is always the first entry; the octave (12) is implied, not listed.
27
+ SCALES = {
28
+ "major": (0, 2, 4, 5, 7, 9, 11),
29
+ "minor": (0, 2, 3, 5, 7, 8, 10), # natural minor / aeolian
30
+ "dorian": (0, 2, 3, 5, 7, 9, 10),
31
+ "phrygian": (0, 1, 3, 5, 7, 8, 10),
32
+ "lydian": (0, 2, 4, 6, 7, 9, 11),
33
+ "mixolydian": (0, 2, 4, 5, 7, 9, 10),
34
+ "locrian": (0, 1, 3, 5, 6, 8, 10),
35
+ "harmonic_minor": (0, 2, 3, 5, 7, 8, 11),
36
+ "melodic_minor": (0, 2, 3, 5, 7, 9, 11),
37
+ "major_pentatonic": (0, 2, 4, 7, 9),
38
+ "minor_pentatonic": (0, 3, 5, 7, 10),
39
+ "blues": (0, 3, 5, 6, 7, 10),
40
+ "whole_tone": (0, 2, 4, 6, 8, 10),
41
+ "chromatic": (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11),
42
+ }
43
+
44
+ SCALE_NAMES = (
45
+ "major",
46
+ "minor",
47
+ "dorian",
48
+ "phrygian",
49
+ "lydian",
50
+ "mixolydian",
51
+ "locrian",
52
+ "harmonic_minor",
53
+ "melodic_minor",
54
+ "major_pentatonic",
55
+ "minor_pentatonic",
56
+ "blues",
57
+ "whole_tone",
58
+ "chromatic",
59
+ )
60
+
61
+ #: name -> semitone offsets from a root note. Chromatic, key-independent.
62
+ CHORDS = {
63
+ "1": (0,),
64
+ "5": (0, 7),
65
+ "oct": (0, 12),
66
+ "maj": (0, 4, 7),
67
+ "min": (0, 3, 7),
68
+ "dim": (0, 3, 6),
69
+ "aug": (0, 4, 8),
70
+ "sus2": (0, 2, 7),
71
+ "sus4": (0, 5, 7),
72
+ "6": (0, 4, 7, 9),
73
+ "min6": (0, 3, 7, 9),
74
+ "maj7": (0, 4, 7, 11),
75
+ "min7": (0, 3, 7, 10),
76
+ "dom7": (0, 4, 7, 10),
77
+ "dim7": (0, 3, 6, 9),
78
+ "add9": (0, 4, 7, 14),
79
+ "maj9": (0, 4, 7, 11, 14),
80
+ "min9": (0, 3, 7, 10, 14),
81
+ }
82
+
83
+ CHORD_NAMES = (
84
+ "1",
85
+ "5",
86
+ "oct",
87
+ "maj",
88
+ "min",
89
+ "dim",
90
+ "aug",
91
+ "sus2",
92
+ "sus4",
93
+ "6",
94
+ "min6",
95
+ "maj7",
96
+ "min7",
97
+ "dom7",
98
+ "dim7",
99
+ "add9",
100
+ "maj9",
101
+ "min9",
102
+ )
103
+
104
+ #: name -> scale-degree offsets (not semitones). Stacked in the current
105
+ #: scale, so quality follows the key: "triad" on degree 0 of a major
106
+ #: scale is major, on degree 1 it is minor, and so on.
107
+ DIATONIC_SHAPES = {
108
+ "root": (0,),
109
+ "oct": (0, 7),
110
+ "5th": (0, 4),
111
+ "triad": (0, 2, 4),
112
+ "7th": (0, 2, 4, 6),
113
+ "9th": (0, 2, 4, 6, 8),
114
+ "sus": (0, 3, 4),
115
+ "spread": (0, 4, 9),
116
+ }
117
+
118
+ DIATONIC_SHAPE_NAMES = ("root", "oct", "5th", "triad", "7th", "9th", "sus", "spread")
119
+
120
+ NOTE_NAMES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
121
+
122
+
123
+ def note_name(midi, with_octave=True):
124
+ """``"C4"``, ``"F#3"`` ... MIDI 60 is C4 here. Software that calls
125
+ middle C "C3" should subtract 1 from the octave."""
126
+ n = NOTE_NAMES[int(midi) % 12]
127
+ return "%s%d" % (n, int(midi) // 12 - 1) if with_octave else n
128
+
129
+
130
+ def chord(root, name):
131
+ """MIDI notes of chromatic chord ``name`` (see ``CHORDS``) on ``root``."""
132
+ return [root + i for i in CHORDS[name]]
133
+
134
+
135
+ class Scale:
136
+ """A scale: a root MIDI note and a set of semitone offsets.
137
+
138
+ :param root: MIDI note of scale degree 0
139
+ :param name: a key of ``SCALES``, or an explicit sequence of ascending
140
+ semitone offsets starting at 0
141
+ """
142
+
143
+ def __init__(self, root=60, name="major"):
144
+ self.root = root
145
+ self.set_scale(name)
146
+
147
+ def set_scale(self, name):
148
+ """Switch scale in place, keeping the root. ``name`` is a ``SCALES``
149
+ key or an explicit offset sequence; an unknown name falls back to
150
+ major rather than raising, so a knob mapped past the list is safe."""
151
+ if isinstance(name, str):
152
+ self.name = name if name in SCALES else "major"
153
+ self.steps = SCALES[self.name]
154
+ else:
155
+ self.name = "custom"
156
+ self.steps = tuple(name)
157
+
158
+ @property
159
+ def size(self):
160
+ """Notes per octave in this scale."""
161
+ return len(self.steps)
162
+
163
+ def degree(self, n):
164
+ """MIDI note for 0-based scale degree ``n``. ``n`` may be any
165
+ integer: it wraps with octave shifts, so ``degree(size)`` is the
166
+ octave above the root and ``degree(-1)`` the step below it."""
167
+ octaves, i = divmod(int(n), len(self.steps))
168
+ return self.root + 12 * octaves + self.steps[i]
169
+
170
+ def __getitem__(self, n):
171
+ return self.degree(n)
172
+
173
+ def chord(self, degree, shape="triad"):
174
+ """Diatonic chord as a list of MIDI notes: the degree offsets of
175
+ ``shape`` (a ``DIATONIC_SHAPES`` key or an explicit offset
176
+ sequence) added to ``degree``, each taken through the scale so the
177
+ chord stays in key."""
178
+ offsets = DIATONIC_SHAPES[shape] if isinstance(shape, str) else shape
179
+ return [self.degree(degree + o) for o in offsets]
180
+
181
+ def contains(self, midi):
182
+ """Whether ``midi`` is a note of this scale, in any octave."""
183
+ return (int(midi) - self.root) % 12 in self.steps
184
+
185
+ def snap(self, midi):
186
+ """The scale note nearest ``midi`` (ties round down). Useful for
187
+ quantizing a chromatic source -- MIDI input, an LFO -- onto the
188
+ scale."""
189
+ rel = int(round(midi)) - self.root
190
+ octaves, semis = divmod(rel, 12)
191
+ best = self.steps[0]
192
+ for step in self.steps:
193
+ if abs(step - semis) < abs(best - semis):
194
+ best = step
195
+ return self.root + 12 * octaves + best
196
+
197
+ def __repr__(self):
198
+ return "Scale(root=%d, name=%r)" % (self.root, self.name)