circuitpython-synthtools 0.6.0__tar.gz → 0.7.0__tar.gz

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.
Files changed (117) hide show
  1. {circuitpython_synthtools-0.6.0/circuitpython_synthtools.egg-info → circuitpython_synthtools-0.7.0}/PKG-INFO +3 -1
  2. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/README.rst +2 -0
  3. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0/circuitpython_synthtools.egg-info}/PKG-INFO +3 -1
  4. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/circuitpython_synthtools.egg-info/SOURCES.txt +3 -0
  5. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/docs/api.rst +6 -0
  6. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/docs/examples.rst +10 -0
  7. circuitpython_synthtools-0.7.0/examples/synthtools_harmony_demo.py +71 -0
  8. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/pyproject.toml +1 -1
  9. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/__init__.py +7 -1
  10. circuitpython_synthtools-0.7.0/synthtools/harmony.py +198 -0
  11. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/run_tests.sh +2 -0
  12. circuitpython_synthtools-0.7.0/tests/test_harmony.py +139 -0
  13. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/.gitattributes +0 -0
  14. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md +0 -0
  15. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/.github/workflows/build.yml +0 -0
  16. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/.github/workflows/failure-help-text.yml +0 -0
  17. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/.github/workflows/release_gh.yml +0 -0
  18. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/.github/workflows/release_pypi.yml +0 -0
  19. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/.gitignore +0 -0
  20. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/.pre-commit-config.yaml +0 -0
  21. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/.pylintrc +0 -0
  22. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/.readthedocs.yaml +0 -0
  23. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/CODE_OF_CONDUCT.md +0 -0
  24. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/LICENSE +0 -0
  25. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/LICENSES/CC-BY-4.0.txt +0 -0
  26. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/LICENSES/MIT.txt +0 -0
  27. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/LICENSES/Unlicense.txt +0 -0
  28. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/README.rst.license +0 -0
  29. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/circuitpython_synthtools.egg-info/dependency_links.txt +0 -0
  30. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/circuitpython_synthtools.egg-info/requires.txt +0 -0
  31. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/circuitpython_synthtools.egg-info/top_level.txt +0 -0
  32. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/conftest.py +0 -0
  33. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/docs/_static/custom.css +0 -0
  34. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/docs/_static/favicon.ico +0 -0
  35. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/docs/_static/favicon.ico.license +0 -0
  36. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/docs/api.rst.license +0 -0
  37. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/docs/conf.py +0 -0
  38. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/docs/examples.rst.license +0 -0
  39. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/docs/index.rst +0 -0
  40. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/docs/index.rst.license +0 -0
  41. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/docs/requirements.txt +0 -0
  42. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/REUSE.toml +0 -0
  43. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/arpeggiator_demo.py +0 -0
  44. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/step_sequencer_bassdemo.py +0 -0
  45. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/step_sequencer_demo.py +0 -0
  46. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synth_setup.py +0 -0
  47. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synthinst_code1.py +0 -0
  48. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synthinst_fenv_demo.py +0 -0
  49. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synthinst_pad_demo.py +0 -0
  50. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synthinst_pitch_demo.py +0 -0
  51. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synthinst_wave_demo.py +0 -0
  52. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synthtools_ahr_envelope_bend.py +0 -0
  53. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synthtools_bassline_demo.py +0 -0
  54. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synthtools_fm_demo.py +0 -0
  55. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synthtools_midi_demo.py +0 -0
  56. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synthtools_simpletest.py +0 -0
  57. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synthtools_swarm_demo.py +0 -0
  58. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/synthtools_wavetable_simple.py +0 -0
  59. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/trig_sequencer_demo.py +0 -0
  60. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/ui/synthtools_gaugecluster_test.py +0 -0
  61. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/ui/synthtools_paramscaler_demo.py +0 -0
  62. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/wavetables/BRAIDS02.WAV +0 -0
  63. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/wavetables/PLAITS02.WAV +0 -0
  64. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/wavs/kit0_909/00_909kick4.wav +0 -0
  65. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/wavs/kit0_909/01_909snare2.wav +0 -0
  66. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/wavs/kit0_909/02_909hatclosed2a.wav +0 -0
  67. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/wavs/kit0_909/03_909hatopen5.wav +0 -0
  68. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/wavs/kit0_909/04_909clap1.wav +0 -0
  69. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/wavs/kit0_909/05_909tommed.wav +0 -0
  70. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/wavs/kit0_909/06_909blip.wav +0 -0
  71. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/wavs/kit0_909/07_909cym2.wav +0 -0
  72. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/examples/wavs/kit0_909/readme.txt +0 -0
  73. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/optional_requirements.txt +0 -0
  74. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/requirements.txt +0 -0
  75. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/ruff.toml +0 -0
  76. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/setup.cfg +0 -0
  77. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/ahr_envelope.py +0 -0
  78. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/arpeggiator.py +0 -0
  79. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/audio_fx.py +0 -0
  80. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/bassline_synth.py +0 -0
  81. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/blocks.py +0 -0
  82. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/fm_synth.py +0 -0
  83. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/gauge_cluster.py +0 -0
  84. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/param_scaler.py +0 -0
  85. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/paramset.py +0 -0
  86. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/patch.py +0 -0
  87. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/pitch_glider.py +0 -0
  88. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/step_sequencer.py +0 -0
  89. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/subtractive_synth.py +0 -0
  90. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/swarm_synth.py +0 -0
  91. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/synth.py +0 -0
  92. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/trig_sequencer.py +0 -0
  93. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/utils.py +0 -0
  94. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/waves.py +0 -0
  95. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/wavetable.py +0 -0
  96. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/synthtools/wavetable_synth.py +0 -0
  97. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/README.md +0 -0
  98. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/hw/run_on_device.py +0 -0
  99. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/hw/test_device.py +0 -0
  100. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/hw/test_display_cost.py +0 -0
  101. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/plot_env.py +0 -0
  102. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/stubs/synthio.py +0 -0
  103. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/stubs/ulab/__init__.py +0 -0
  104. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/stubs/ulab/numpy.py +0 -0
  105. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/stubs_fx/audiodelays.py +0 -0
  106. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/stubs_fx/audiofilters.py +0 -0
  107. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/test_arpeggiator.py +0 -0
  108. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/test_audio_fx.py +0 -0
  109. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/test_env_shapes.py +0 -0
  110. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/test_fm_synth.py +0 -0
  111. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/test_mono.py +0 -0
  112. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/test_param_scaler.py +0 -0
  113. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/test_paramset.py +0 -0
  114. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/test_patch.py +0 -0
  115. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/test_pytest_smoke.py +0 -0
  116. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/test_swarm_synth.py +0 -0
  117. {circuitpython_synthtools-0.6.0 → circuitpython_synthtools-0.7.0}/tests/test_wiring.py +0 -0
@@ -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
  =============
@@ -142,6 +142,8 @@ What's Included
142
142
  ``synthio.Note`` graphs (the engines above have their own portamento,
143
143
  via ``mono`` + ``glide_time``)
144
144
  * ``RollingAverage`` -- moving-average smoothing for noisy knob reads
145
+ * ``Scale`` / ``chord`` -- scales and diatonic/chromatic chords for
146
+ mapping pads and MIDI onto pitch
145
147
 
146
148
  Documentation
147
149
  =============
@@ -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
  =============
@@ -49,6 +49,7 @@ examples/synthinst_wave_demo.py
49
49
  examples/synthtools_ahr_envelope_bend.py
50
50
  examples/synthtools_bassline_demo.py
51
51
  examples/synthtools_fm_demo.py
52
+ examples/synthtools_harmony_demo.py
52
53
  examples/synthtools_midi_demo.py
53
54
  examples/synthtools_simpletest.py
54
55
  examples/synthtools_swarm_demo.py
@@ -75,6 +76,7 @@ synthtools/bassline_synth.py
75
76
  synthtools/blocks.py
76
77
  synthtools/fm_synth.py
77
78
  synthtools/gauge_cluster.py
79
+ synthtools/harmony.py
78
80
  synthtools/param_scaler.py
79
81
  synthtools/paramset.py
80
82
  synthtools/patch.py
@@ -95,6 +97,7 @@ tests/test_arpeggiator.py
95
97
  tests/test_audio_fx.py
96
98
  tests/test_env_shapes.py
97
99
  tests/test_fm_synth.py
100
+ tests/test_harmony.py
98
101
  tests/test_mono.py
99
102
  tests/test_param_scaler.py
100
103
  tests/test_paramset.py
@@ -82,6 +82,12 @@ Waves
82
82
  .. automodule:: synthtools.waves
83
83
  :members:
84
84
 
85
+ Harmony
86
+ =======
87
+
88
+ .. automodule:: synthtools.harmony
89
+ :members:
90
+
85
91
  Arpeggiator
86
92
  ===========
87
93
 
@@ -53,6 +53,16 @@ A bright DX-style FM bell using FMSynth, with a live fm_ratio / fm_index sweep
53
53
  :caption: examples/synthtools_fm_demo.py
54
54
  :linenos:
55
55
 
56
+ Harmony demo
57
+ ------------
58
+
59
+ Walk scales and play diatonic chords with harmony.Scale, cycling through a
60
+ short chord progression
61
+
62
+ .. literalinclude:: ../examples/synthtools_harmony_demo.py
63
+ :caption: examples/synthtools_harmony_demo.py
64
+ :linenos:
65
+
56
66
 
57
67
 
58
68
 
@@ -0,0 +1,71 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
2
+ # SPDX-License-Identifier: MIT
3
+ #
4
+ # Play scales and diatonic chords with synthtools.harmony.
5
+ #
6
+ # A Scale is a root note plus a set of semitone offsets; index it by
7
+ # degree and it wraps with octave shifts, so `scale.degree(i)` maps a row
8
+ # of 8 pads straight onto any scale. `scale.chord(degree, shape)` stacks
9
+ # the chord in-key, so the quality follows the mode for free: the triad
10
+ # on degree 0 of a major scale is major, on degree 1 it is minor.
11
+ #
12
+ # It prints every note and chord it plays, so it also reads as a plain
13
+ # console demo with the mixer down.
14
+
15
+ import time
16
+
17
+ from synth_setup import synth as engine
18
+
19
+ from synthtools import Patch, SubtractiveSynth
20
+ from synthtools.harmony import DIATONIC_SHAPE_NAMES, Scale, note_name
21
+
22
+ # fmt: off
23
+ patch = Patch(name="harmony demo", wave="TRI",
24
+ amp_env=[0.01, 0.15, 0.5, 0.25],
25
+ filt_type="LPF", filt_f=2600, filt_q=0.7)
26
+ # fmt: on
27
+ synth = SubtractiveSynth(engine, patch)
28
+
29
+ # (key note, scale name) -- MIDI 48 is C3
30
+ PROGRESSION = (
31
+ (48, "major"),
32
+ (45, "minor"),
33
+ (50, "dorian"),
34
+ (43, "mixolydian"),
35
+ (48, "major_pentatonic"),
36
+ )
37
+ CHORD_SHAPES = ("triad", "7th", "9th", "spread")
38
+ # roman numeral for the degree the chord is built on
39
+ DEGREES = ((0, "I"), (3, "IV"), (4, "V"), (5, "vi"))
40
+
41
+
42
+ def play(notes, secs):
43
+ for n in notes:
44
+ synth.note_on(n, velocity=100)
45
+ time.sleep(secs)
46
+ for n in notes:
47
+ synth.note_off(n)
48
+ time.sleep(0.06)
49
+
50
+
51
+ while True:
52
+ for root, scale_name in PROGRESSION:
53
+ scale = Scale(root=root, name=scale_name)
54
+ print("\n--- %s %s ---" % (note_name(root, with_octave=False), scale.name))
55
+
56
+ # walk the scale, one note per pad
57
+ line = " ".join(note_name(scale.degree(i)) for i in range(scale.size + 1))
58
+ print("scale:", line)
59
+ for i in range(scale.size + 1):
60
+ play([scale.degree(i)], 0.16)
61
+ time.sleep(0.3)
62
+
63
+ # diatonic chords on a few degrees, through a few voicings
64
+ for degree, roman in DEGREES:
65
+ for shape in CHORD_SHAPES:
66
+ notes = scale.chord(degree, shape)
67
+ print(" %-3s %-6s %s" % (roman, shape, [note_name(n) for n in notes]))
68
+ play(notes, 0.4)
69
+ time.sleep(0.15)
70
+
71
+ print("\n(all %d DIATONIC_SHAPES: %s)" % (len(DIATONIC_SHAPE_NAMES), DIATONIC_SHAPE_NAMES))
@@ -13,7 +13,7 @@ requires = [
13
13
  [project]
14
14
  name = "circuitpython-synthtools"
15
15
  description = "CircuitPython helper library to do help doing synthio"
16
- version = "0.6.0"
16
+ version = "0.7.0"
17
17
  readme = "README.rst"
18
18
  authors = [
19
19
  {name = "Tod Kurt", email = "tod@todbot.com"}
@@ -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",
@@ -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)
@@ -41,6 +41,7 @@ if command -v python3 >/dev/null 2>&1; then
41
41
  run python3 tests/test_arpeggiator.py
42
42
  run python3 tests/test_paramset.py
43
43
  run python3 tests/test_param_scaler.py
44
+ run python3 tests/test_harmony.py
44
45
  else
45
46
  echo "python3 not found, skipping the CPython tier"
46
47
  status=1
@@ -57,6 +58,7 @@ if command -v micropython >/dev/null 2>&1; then
57
58
  run micropython tests/test_arpeggiator.py
58
59
  run micropython tests/test_paramset.py
59
60
  run micropython tests/test_param_scaler.py
61
+ run micropython tests/test_harmony.py
60
62
  else
61
63
  printf '\nmicropython not found, skipping the portability tier.\n'
62
64
  printf 'Install it to catch the MicroPython-only bugs: brew install micropython\n'
@@ -0,0 +1,139 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt
2
+ # SPDX-License-Identifier: MIT
3
+ """harmony: scales, chords, and the degree-wrap arithmetic.
4
+
5
+ harmony.py imports nothing, so this runs on a bare interpreter and is a
6
+ portability canary alongside test_patch.py / test_paramset.py: the whole
7
+ reason Scale.degree() uses divmod is that a pad row indexes past the end
8
+ of the scale, and negative divmod has to wrap the same way on MicroPython.
9
+
10
+ python3 tests/test_harmony.py
11
+ micropython tests/test_harmony.py
12
+ """
13
+
14
+ import sys
15
+
16
+ _D = __file__.rsplit("/", 1)[0] if "/" in __file__ else "."
17
+ sys.path.insert(0, _D + "/../synthtools") # direct: skips synthtools/__init__
18
+
19
+ from harmony import ( # noqa: E402
20
+ CHORD_NAMES,
21
+ CHORDS,
22
+ DIATONIC_SHAPE_NAMES,
23
+ DIATONIC_SHAPES,
24
+ SCALE_NAMES,
25
+ SCALES,
26
+ Scale,
27
+ chord,
28
+ note_name,
29
+ )
30
+
31
+ fails = []
32
+
33
+
34
+ def ck(cond, msg):
35
+ if not cond:
36
+ fails.append(msg)
37
+
38
+
39
+ # --- the portability canary -------------------------------------------
40
+ # Python floors towards negative infinity, so divmod(-1, 7) == (-1, 6):
41
+ # degree(-1) is the top note of the octave below. MicroPython must agree.
42
+ ck(divmod(-1, 7) == (-1, 6), "negative divmod does not wrap: %r" % (divmod(-1, 7),))
43
+ ck(divmod(-8, 7) == (-2, 6), "negative divmod does not wrap over an octave")
44
+
45
+
46
+ # --- Scale.degree ----------------------------------------------------
47
+ s = Scale(60, "major")
48
+ ck(
49
+ [s.degree(n) for n in range(8)] == [60, 62, 64, 65, 67, 69, 71, 72],
50
+ "C major degrees 0..7 wrong: %r" % [s.degree(n) for n in range(8)],
51
+ )
52
+ ck(s.degree(7) == s.root + 12, "degree(size) is not the octave: %d" % s.degree(7))
53
+ ck(s.degree(-1) == 59, "degree(-1) is not the leading tone below: %d" % s.degree(-1))
54
+ ck(s.degree(14) == 84, "two octaves up wrong: %d" % s.degree(14))
55
+ ck(s[3] == s.degree(3), "__getitem__ disagrees with degree()")
56
+ ck(s.size == 7, "major scale size should be 7, got %d" % s.size)
57
+
58
+ # a pentatonic keeps climbing past its 5 notes
59
+ p = Scale(57, "minor_pentatonic")
60
+ ck(
61
+ [p.degree(n) for n in range(6)] == [57, 60, 62, 64, 67, 69],
62
+ "A minor pentatonic wrong: %r" % [p.degree(n) for n in range(6)],
63
+ )
64
+
65
+
66
+ # --- chromatic chord() --------------------------------------------------
67
+ ck(chord(60, "maj") == [60, 64, 67], "C maj wrong: %r" % chord(60, "maj"))
68
+ ck(chord(60, "min") == [60, 63, 67], "C min wrong: %r" % chord(60, "min"))
69
+ ck(chord(60, "dom7") == [60, 64, 67, 70], "C7 wrong: %r" % chord(60, "dom7"))
70
+ ck(chord(48, "maj9") == [48, 52, 55, 59, 62], "Cmaj9 wrong: %r" % chord(48, "maj9"))
71
+
72
+
73
+ # --- Scale.chord: diatonic quality falls out of the key --------------
74
+ c = Scale(60, "major")
75
+ ck(c.chord(0, "triad") == [60, 64, 67], "I is not major: %r" % c.chord(0, "triad"))
76
+ ck(c.chord(1, "triad") == [62, 65, 69], "ii is not minor: %r" % c.chord(1, "triad"))
77
+ ck(c.chord(6, "triad") == [71, 74, 77], "vii is not diminished-shaped")
78
+ ck(c.chord(4, "7th") == [67, 71, 74, 77], "V7 is not dominant-shaped: %r" % c.chord(4, "7th"))
79
+ ck(len(c.chord(0, "9th")) == 5, "9th is not 5 notes")
80
+ ck(c.chord(0, (0, 4)) == [60, 67], "explicit degree-offset shape ignored")
81
+ ck(c.chord(0, "spread")[-1] > c.chord(0, "spread")[0] + 12, "spread does not open past an octave")
82
+
83
+
84
+ # --- set_scale ------------------------------------------------------
85
+ q = Scale(60, "major")
86
+ q.set_scale("bogus_scale")
87
+ ck(q.name == "major" and q.steps == SCALES["major"], "unknown scale did not fall back to major")
88
+ q.set_scale((0, 3, 7))
89
+ ck(q.name == "custom" and q.steps == (0, 3, 7), "explicit offsets not accepted")
90
+ ck(q.degree(0) == 60 and q.degree(1) == 63 and q.degree(3) == 72, "custom scale degrees wrong")
91
+
92
+
93
+ # --- contains / snap --------------------------------------------------
94
+ cm = Scale(60, "major")
95
+ ck(cm.contains(64) and cm.contains(64 + 12) and cm.contains(64 - 24), "E should be in C major")
96
+ ck(not cm.contains(61) and not cm.contains(61 + 12), "C# should not be in C major")
97
+ ck(cm.snap(61) == 60, "snap C#4 -> C4 expected, got %d" % cm.snap(61))
98
+ ck(cm.snap(66) == 65, "snap F#4 -> F4 expected, got %d" % cm.snap(66))
99
+ ck(cm.snap(67) == 67, "snap of an in-scale note must be a no-op, got %d" % cm.snap(67))
100
+ ck(cm.snap(72) == 72, "snap of the octave must be a no-op, got %d" % cm.snap(72))
101
+
102
+
103
+ # --- note_name -----------------------------------------------------
104
+ ck(note_name(60) == "C4", "MIDI 60 is C4 here, got %s" % note_name(60))
105
+ ck(note_name(69) == "A4", "MIDI 69 is A4, got %s" % note_name(69))
106
+ ck(note_name(61, False) == "C#", "note_name without octave wrong: %s" % note_name(61, False))
107
+ ck(note_name(48) == "C3" and note_name(72) == "C5", "octave numbering off")
108
+
109
+
110
+ # --- table invariants -----------------------------------------------
111
+ for name, steps in SCALES.items():
112
+ ck(isinstance(steps, tuple) and steps[0] == 0, "scale %s must be a tuple starting at 0" % name)
113
+ ck(list(steps) == sorted(steps), "scale %s not ascending" % name)
114
+ ck(all(0 <= x <= 11 for x in steps), "scale %s has an out-of-octave step" % name)
115
+ for name, ivals in CHORDS.items():
116
+ ck(isinstance(ivals, tuple) and ivals[0] == 0, "chord %s must be a tuple starting at 0" % name)
117
+ for name, offs in DIATONIC_SHAPES.items():
118
+ ck(isinstance(offs, tuple) and offs[0] == 0, "shape %s must be a tuple starting at 0" % name)
119
+
120
+ # The *_NAMES tuples are hand-written (MicroPython dict order is not
121
+ # stable) so a UI selector index means the same thing everywhere: check
122
+ # they cover their dicts exactly, and that the fixed order holds.
123
+ for names, table, what in (
124
+ (SCALE_NAMES, SCALES, "SCALE_NAMES"),
125
+ (CHORD_NAMES, CHORDS, "CHORD_NAMES"),
126
+ (DIATONIC_SHAPE_NAMES, DIATONIC_SHAPES, "DIATONIC_SHAPE_NAMES"),
127
+ ):
128
+ ck(set(names) == set(table), "%s does not cover its dict exactly" % what)
129
+ ck(len(names) == len(table), "%s has a duplicate or a gap" % what)
130
+ ck(SCALE_NAMES[0] == "major", "major must stay first in SCALE_NAMES (the default)")
131
+ ck(DIATONIC_SHAPE_NAMES[3] == "triad", "DIATONIC_SHAPE_NAMES order shifted")
132
+
133
+
134
+ if fails:
135
+ print("FAIL (%d)" % len(fails))
136
+ for f in fails:
137
+ print(" -", f)
138
+ sys.exit(1)
139
+ print("test_harmony: ok")