subsequence 0.6.4__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.
- subsequence/__init__.py +231 -0
- subsequence/__main__.py +24 -0
- subsequence/assets/web/index.html +345 -0
- subsequence/cadences.py +113 -0
- subsequence/chord_graphs/__init__.py +100 -0
- subsequence/chord_graphs/aeolian_minor.py +158 -0
- subsequence/chord_graphs/chromatic_mediant.py +113 -0
- subsequence/chord_graphs/diminished.py +97 -0
- subsequence/chord_graphs/dorian_minor.py +127 -0
- subsequence/chord_graphs/functional_major.py +102 -0
- subsequence/chord_graphs/hooktheory_major.py +88 -0
- subsequence/chord_graphs/lydian_major.py +130 -0
- subsequence/chord_graphs/mixolydian.py +98 -0
- subsequence/chord_graphs/phrygian_minor.py +76 -0
- subsequence/chord_graphs/suspended.py +109 -0
- subsequence/chord_graphs/turnaround_global.py +157 -0
- subsequence/chord_graphs/whole_tone.py +77 -0
- subsequence/chords.py +419 -0
- subsequence/composition.py +6099 -0
- subsequence/conductor.py +238 -0
- subsequence/constants/__init__.py +24 -0
- subsequence/constants/durations.py +37 -0
- subsequence/constants/instruments/__init__.py +13 -0
- subsequence/constants/instruments/gm_cc.py +46 -0
- subsequence/constants/instruments/gm_drums.py +53 -0
- subsequence/constants/instruments/gm_instruments.py +32 -0
- subsequence/constants/instruments/roland_tr8s.py +320 -0
- subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
- subsequence/constants/midi_notes.py +29 -0
- subsequence/constants/pulses.py +17 -0
- subsequence/constants/velocity.py +22 -0
- subsequence/definitions.py +232 -0
- subsequence/display.py +617 -0
- subsequence/easing.py +347 -0
- subsequence/event_emitter.py +109 -0
- subsequence/form_state.py +665 -0
- subsequence/forms.py +257 -0
- subsequence/groove.py +323 -0
- subsequence/harmonic_rhythm.py +83 -0
- subsequence/harmonic_state.py +352 -0
- subsequence/harmony.py +197 -0
- subsequence/held_notes.py +91 -0
- subsequence/helpers/__init__.py +0 -0
- subsequence/helpers/network.py +58 -0
- subsequence/helpers/wing.py +430 -0
- subsequence/intervals.py +436 -0
- subsequence/keystroke.py +249 -0
- subsequence/link_clock.py +128 -0
- subsequence/live_client.py +187 -0
- subsequence/live_reloader.py +298 -0
- subsequence/live_server.py +161 -0
- subsequence/melodic_state.py +483 -0
- subsequence/midi.py +97 -0
- subsequence/midi_utils.py +329 -0
- subsequence/mini_notation.py +164 -0
- subsequence/motifs.py +2356 -0
- subsequence/osc.py +194 -0
- subsequence/pattern.py +363 -0
- subsequence/pattern_algorithmic.py +2010 -0
- subsequence/pattern_builder.py +2589 -0
- subsequence/pattern_midi.py +1208 -0
- subsequence/progressions.py +1913 -0
- subsequence/py.typed +0 -0
- subsequence/roles.py +63 -0
- subsequence/sequence_utils.py +3123 -0
- subsequence/sequencer.py +2086 -0
- subsequence/tuning.py +453 -0
- subsequence/voicings.py +151 -0
- subsequence/web_ui.py +337 -0
- subsequence/weighted_graph.py +156 -0
- subsequence-0.6.4.dist-info/METADATA +208 -0
- subsequence-0.6.4.dist-info/RECORD +78 -0
- subsequence-0.6.4.dist-info/WHEEL +5 -0
- subsequence-0.6.4.dist-info/entry_points.txt +2 -0
- subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
- subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
- subsequence-0.6.4.dist-info/scm_version.json +8 -0
- subsequence-0.6.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import subsequence.chord_graphs.aeolian_minor
|
|
5
|
+
import subsequence.chord_graphs.chromatic_mediant
|
|
6
|
+
import subsequence.chord_graphs.diminished
|
|
7
|
+
import subsequence.chord_graphs.dorian_minor
|
|
8
|
+
import subsequence.chord_graphs.functional_major
|
|
9
|
+
import subsequence.chord_graphs.hooktheory_major
|
|
10
|
+
import subsequence.chord_graphs.lydian_major
|
|
11
|
+
import subsequence.chord_graphs.mixolydian
|
|
12
|
+
import subsequence.chord_graphs.phrygian_minor
|
|
13
|
+
import subsequence.chord_graphs.suspended
|
|
14
|
+
import subsequence.chord_graphs.turnaround_global
|
|
15
|
+
import subsequence.chord_graphs.whole_tone
|
|
16
|
+
import subsequence.chords
|
|
17
|
+
import subsequence.weighted_graph
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DEFAULT_ROOT_DIVERSITY: float = 0.4
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Graph style registry — see _resolve_graph_style() below.
|
|
26
|
+
# To register a new chord graph, add it to _D7_STYLES or _SIMPLE_STYLES there.
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_graph_style (
|
|
31
|
+
style: str,
|
|
32
|
+
include_dominant_7th: bool,
|
|
33
|
+
minor_turnaround_weight: float
|
|
34
|
+
) -> subsequence.chord_graphs.ChordGraph:
|
|
35
|
+
|
|
36
|
+
"""Create a ChordGraph instance from a string style name and legacy parameters."""
|
|
37
|
+
|
|
38
|
+
if style in ("diatonic_major", "functional_major"):
|
|
39
|
+
return subsequence.chord_graphs.functional_major.DiatonicMajor(
|
|
40
|
+
include_dominant_7th = include_dominant_7th
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if style in ("turnaround", "turnaround_global"):
|
|
44
|
+
return subsequence.chord_graphs.turnaround_global.TurnaroundModulation(
|
|
45
|
+
include_dominant_7th = include_dominant_7th,
|
|
46
|
+
minor_turnaround_weight = minor_turnaround_weight
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Styles with only an include_dominant_7th parameter.
|
|
50
|
+
_D7_STYLES: typing.Dict[
|
|
51
|
+
str,
|
|
52
|
+
typing.Callable[[bool], subsequence.chord_graphs.ChordGraph]
|
|
53
|
+
] = {
|
|
54
|
+
"aeolian_minor": subsequence.chord_graphs.aeolian_minor.AeolianMinor,
|
|
55
|
+
"lydian_major": subsequence.chord_graphs.lydian_major.LydianMajor,
|
|
56
|
+
"dorian_minor": subsequence.chord_graphs.dorian_minor.DorianMinor,
|
|
57
|
+
"hooktheory_major": subsequence.chord_graphs.hooktheory_major.HooktheoryMajor,
|
|
58
|
+
"pop_major": subsequence.chord_graphs.hooktheory_major.HooktheoryMajor,
|
|
59
|
+
}
|
|
60
|
+
if style in _D7_STYLES:
|
|
61
|
+
return _D7_STYLES[style](include_dominant_7th)
|
|
62
|
+
|
|
63
|
+
# Styles that take no extra parameters.
|
|
64
|
+
_SIMPLE_STYLES: typing.Dict[
|
|
65
|
+
str,
|
|
66
|
+
typing.Callable[[], subsequence.chord_graphs.ChordGraph]
|
|
67
|
+
] = {
|
|
68
|
+
"phrygian_minor": subsequence.chord_graphs.phrygian_minor.PhrygianMinor,
|
|
69
|
+
"chromatic_mediant": subsequence.chord_graphs.chromatic_mediant.ChromaticMediant,
|
|
70
|
+
"suspended": subsequence.chord_graphs.suspended.Suspended,
|
|
71
|
+
"mixolydian": subsequence.chord_graphs.mixolydian.Mixolydian,
|
|
72
|
+
"whole_tone": subsequence.chord_graphs.whole_tone.WholeTone,
|
|
73
|
+
"diminished": subsequence.chord_graphs.diminished.Diminished,
|
|
74
|
+
}
|
|
75
|
+
if style in _SIMPLE_STYLES:
|
|
76
|
+
return _SIMPLE_STYLES[style]()
|
|
77
|
+
|
|
78
|
+
raise ValueError(f"Unknown graph style: {style!r}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class HarmonicState:
|
|
83
|
+
|
|
84
|
+
"""Holds the current chord and key context for the composition."""
|
|
85
|
+
|
|
86
|
+
def __init__ (
|
|
87
|
+
self,
|
|
88
|
+
key_name: str,
|
|
89
|
+
graph_style: typing.Union[str, subsequence.chord_graphs.ChordGraph] = "functional_major",
|
|
90
|
+
include_dominant_7th: bool = True,
|
|
91
|
+
key_gravity_blend: float = 1.0,
|
|
92
|
+
nir_strength: float = 0.5,
|
|
93
|
+
minor_turnaround_weight: float = 0.0,
|
|
94
|
+
root_diversity: float = DEFAULT_ROOT_DIVERSITY,
|
|
95
|
+
rng: typing.Optional[random.Random] = None
|
|
96
|
+
) -> None:
|
|
97
|
+
|
|
98
|
+
"""
|
|
99
|
+
Initialize the harmonic state using a chord transition graph.
|
|
100
|
+
|
|
101
|
+
Parameters:
|
|
102
|
+
key_name: Note name for the key (e.g., ``"C"``, ``"F#"``).
|
|
103
|
+
graph_style: Built-in style name or a custom ``ChordGraph`` instance.
|
|
104
|
+
include_dominant_7th: Include V7 chords in the graph (default True).
|
|
105
|
+
key_gravity_blend: Balance between functional and diatonic gravity
|
|
106
|
+
(0.0 = functional only, 1.0 = full diatonic). Default 1.0.
|
|
107
|
+
nir_strength: Melodic inertia from Narmour's Implication-Realization
|
|
108
|
+
model (0.0 = off, 1.0 = full). Default 0.5.
|
|
109
|
+
minor_turnaround_weight: For turnaround style, weight toward minor
|
|
110
|
+
turnarounds (0.0 to 1.0). Default 0.0.
|
|
111
|
+
root_diversity: Root-repetition damping factor (0.0 to 1.0). Each
|
|
112
|
+
recent chord sharing a candidate's root pitch class multiplies
|
|
113
|
+
the transition weight by this factor. At the default (0.4), one
|
|
114
|
+
recent same-root chord reduces the weight to 40%; two reduce it
|
|
115
|
+
to 16%. Set to 1.0 to disable the penalty entirely. Default 0.4.
|
|
116
|
+
rng: Optional seeded ``random.Random`` for deterministic playback.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
if key_gravity_blend < 0 or key_gravity_blend > 1:
|
|
120
|
+
raise ValueError("Key gravity blend must be between 0 and 1")
|
|
121
|
+
|
|
122
|
+
if nir_strength < 0 or nir_strength > 1:
|
|
123
|
+
raise ValueError("NIR strength must be between 0 and 1")
|
|
124
|
+
|
|
125
|
+
if minor_turnaround_weight < 0 or minor_turnaround_weight > 1:
|
|
126
|
+
raise ValueError("Minor turnaround weight must be between 0 and 1")
|
|
127
|
+
|
|
128
|
+
if root_diversity < 0 or root_diversity > 1:
|
|
129
|
+
raise ValueError("Root diversity must be between 0 and 1")
|
|
130
|
+
|
|
131
|
+
self.key_name = key_name
|
|
132
|
+
self.key_root_pc = subsequence.chords.key_name_to_pc(key_name)
|
|
133
|
+
self.key_gravity_blend = key_gravity_blend
|
|
134
|
+
self.nir_strength = nir_strength
|
|
135
|
+
self.root_diversity = root_diversity
|
|
136
|
+
self.minor_turnaround_weight = minor_turnaround_weight
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if isinstance(graph_style, str):
|
|
140
|
+
chord_graph = _resolve_graph_style(graph_style, include_dominant_7th, minor_turnaround_weight)
|
|
141
|
+
|
|
142
|
+
else:
|
|
143
|
+
chord_graph = graph_style
|
|
144
|
+
|
|
145
|
+
self.graph, tonic = chord_graph.build(key_name)
|
|
146
|
+
self._diatonic_chords, self._function_chords = chord_graph.gravity_sets(key_name)
|
|
147
|
+
|
|
148
|
+
self.rng = rng or random.Random()
|
|
149
|
+
self.current_chord = tonic
|
|
150
|
+
self.history: typing.List[subsequence.chords.Chord] = []
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _calculate_nir_score (self, source: subsequence.chords.Chord, target: subsequence.chords.Chord) -> float:
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
Calculate a Narmour Implication-Realization (NIR) score for a transition.
|
|
157
|
+
Returns a multiplier (default 1.0, >1.0 for boost).
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
# step() appends the current chord to history BEFORE choosing, so
|
|
161
|
+
# history[-1] is always the source itself; the implication interval
|
|
162
|
+
# needs the chord we arrived FROM, which is history[-2]. (Using
|
|
163
|
+
# history[-1] here was the pre-2026-06 bug that left the reversal
|
|
164
|
+
# and continuation rules permanently inert.)
|
|
165
|
+
if len(self.history) < 2:
|
|
166
|
+
return 1.0
|
|
167
|
+
|
|
168
|
+
prev = self.history[-2]
|
|
169
|
+
|
|
170
|
+
# Calculate interval from Prev -> Source (The "Implication" generator)
|
|
171
|
+
# Using shortest-path distance in Pitch Class space (-6 to +6)
|
|
172
|
+
prev_diff = (source.root_pc - prev.root_pc) % 12
|
|
173
|
+
if prev_diff > 6:
|
|
174
|
+
prev_diff -= 12
|
|
175
|
+
|
|
176
|
+
prev_interval = abs(prev_diff)
|
|
177
|
+
prev_direction = 1 if prev_diff > 0 else -1 if prev_diff < 0 else 0
|
|
178
|
+
|
|
179
|
+
# Calculate interval from Source -> Target (The "Realization")
|
|
180
|
+
target_diff = (target.root_pc - source.root_pc) % 12
|
|
181
|
+
if target_diff > 6:
|
|
182
|
+
target_diff -= 12
|
|
183
|
+
|
|
184
|
+
target_interval = abs(target_diff)
|
|
185
|
+
target_direction = 1 if target_diff > 0 else -1 if target_diff < 0 else 0
|
|
186
|
+
|
|
187
|
+
score = 1.0
|
|
188
|
+
|
|
189
|
+
# --- Rule A: Reversal (Gap Fill) ---
|
|
190
|
+
# If the previous step was a large leap (> 4 on the 0–6 pitch-class
|
|
191
|
+
# shortest-path scale, where a tritone is 6), expect a direction change.
|
|
192
|
+
if prev_interval > 4:
|
|
193
|
+
# Expect change in direction
|
|
194
|
+
if target_direction != prev_direction and target_direction != 0:
|
|
195
|
+
score += 0.5
|
|
196
|
+
|
|
197
|
+
# Expect smaller interval (Gap Fill)
|
|
198
|
+
if target_interval < 4:
|
|
199
|
+
score += 0.3
|
|
200
|
+
|
|
201
|
+
# --- Rule B: Process (Continuation/Inertia) ---
|
|
202
|
+
# If previous was Small Step (< 3 semitones), expect similarity.
|
|
203
|
+
elif prev_interval > 0 and prev_interval < 3:
|
|
204
|
+
# Expect same direction
|
|
205
|
+
if target_direction == prev_direction:
|
|
206
|
+
score += 0.4
|
|
207
|
+
|
|
208
|
+
# Expect similar size
|
|
209
|
+
if abs(target_interval - prev_interval) <= 1:
|
|
210
|
+
score += 0.2
|
|
211
|
+
|
|
212
|
+
# --- Rule C: Closure ---
|
|
213
|
+
# Return to Tonic (Closure) is often implied after tension
|
|
214
|
+
if target.root_pc == self.key_root_pc:
|
|
215
|
+
score += 0.2
|
|
216
|
+
|
|
217
|
+
# --- Rule D: Proximity ---
|
|
218
|
+
# General preference for small intervals (≤ 3 semitones).
|
|
219
|
+
if target_interval > 0 and target_interval <= 3:
|
|
220
|
+
score += 0.3
|
|
221
|
+
|
|
222
|
+
# Scale the boost portion by nir_strength (score starts at 1.0, boost is the excess)
|
|
223
|
+
return 1.0 + (score - 1.0) * self.nir_strength
|
|
224
|
+
|
|
225
|
+
def _transition_weight (
|
|
226
|
+
self,
|
|
227
|
+
source: subsequence.chords.Chord,
|
|
228
|
+
target: subsequence.chords.Chord,
|
|
229
|
+
weight: int
|
|
230
|
+
) -> float:
|
|
231
|
+
|
|
232
|
+
"""
|
|
233
|
+
Combine three forces that shape chord transition probabilities:
|
|
234
|
+
|
|
235
|
+
1. **Key gravity** — blends functional pull (tonic, dominant) with
|
|
236
|
+
full diatonic pull, controlled by ``key_gravity_blend``.
|
|
237
|
+
2. **Melodic inertia (NIR)** — Narmour's cognitive expectation
|
|
238
|
+
model favoring continuation after small steps and reversal
|
|
239
|
+
after large leaps, controlled by ``nir_strength``.
|
|
240
|
+
3. **Root diversity** — exponential damping that discourages
|
|
241
|
+
revisiting a root pitch class heard recently, controlled by
|
|
242
|
+
``root_diversity``. Each recent chord sharing the target's
|
|
243
|
+
root multiplies the weight by ``root_diversity`` (default
|
|
244
|
+
0.4), so the penalty grows stronger with each consecutive
|
|
245
|
+
same-root step.
|
|
246
|
+
|
|
247
|
+
The final modifier is:
|
|
248
|
+
|
|
249
|
+
``(1 + gravity_boost) × nir_score × diversity``
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
is_function = 1.0 if target in self._function_chords else 0.0
|
|
253
|
+
is_diatonic = 1.0 if target in self._diatonic_chords else 0.0
|
|
254
|
+
|
|
255
|
+
# Decision path: blend controls whether key gravity favors functional or full diatonic chords.
|
|
256
|
+
boost = (1.0 - self.key_gravity_blend) * is_function + self.key_gravity_blend * is_diatonic
|
|
257
|
+
|
|
258
|
+
# Apply NIR gravity
|
|
259
|
+
nir_score = self._calculate_nir_score(source, target)
|
|
260
|
+
|
|
261
|
+
# Root diversity: penalise transitions to a root heard recently.
|
|
262
|
+
recent_same_root = sum(
|
|
263
|
+
1 for h in self.history
|
|
264
|
+
if h.root_pc == target.root_pc
|
|
265
|
+
)
|
|
266
|
+
diversity = self.root_diversity ** recent_same_root
|
|
267
|
+
|
|
268
|
+
return (1.0 + boost) * nir_score * diversity
|
|
269
|
+
|
|
270
|
+
def _record_transition_source (self, chord: subsequence.chords.Chord) -> None:
|
|
271
|
+
|
|
272
|
+
"""History bookkeeping for one transition: the outgoing chord enters history.
|
|
273
|
+
|
|
274
|
+
The first half of :meth:`step` — exposed so a constrained walk can
|
|
275
|
+
interleave it with its own draws (``before_choice``) and the NIR
|
|
276
|
+
weighting sees exactly the context it would live.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
self.history.append(chord)
|
|
280
|
+
if len(self.history) > 4:
|
|
281
|
+
self.history.pop(0)
|
|
282
|
+
|
|
283
|
+
def step (self) -> subsequence.chords.Chord:
|
|
284
|
+
|
|
285
|
+
"""Advance to the next chord based on the transition graph."""
|
|
286
|
+
|
|
287
|
+
# Update history before choosing next (so structure tracks the path)
|
|
288
|
+
self._record_transition_source(self.current_chord)
|
|
289
|
+
|
|
290
|
+
# Decision path: chord changes occur here; key changes are not automatic.
|
|
291
|
+
self.current_chord = self.graph.choose_next(self.current_chord, self.rng, weight_modifier=self._transition_weight)
|
|
292
|
+
|
|
293
|
+
return self.current_chord
|
|
294
|
+
|
|
295
|
+
def plan_next (self) -> subsequence.chords.Chord:
|
|
296
|
+
|
|
297
|
+
"""Choose the next chord without committing it — the horizon's pre-step.
|
|
298
|
+
|
|
299
|
+
Draws from the RNG exactly as :meth:`step` would (the draw IS the
|
|
300
|
+
pre-commitment), but leaves ``current_chord`` and ``history``
|
|
301
|
+
untouched. Pair with :meth:`commit_chord` when the planned chord
|
|
302
|
+
becomes the sounding one; ``commit_chord(plan_next())`` is draw-for-
|
|
303
|
+
draw equivalent to ``step()``.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
saved_history = list(self.history)
|
|
307
|
+
|
|
308
|
+
self._record_transition_source(self.current_chord)
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
return self.graph.choose_next(self.current_chord, self.rng, weight_modifier=self._transition_weight)
|
|
312
|
+
finally:
|
|
313
|
+
self.history = saved_history
|
|
314
|
+
|
|
315
|
+
def commit_chord (self, chord: subsequence.chords.Chord) -> subsequence.chords.Chord:
|
|
316
|
+
|
|
317
|
+
"""Make *chord* current with step()'s history bookkeeping, no RNG draw.
|
|
318
|
+
|
|
319
|
+
Used by the harmonic clock to commit a planned chord or replay a
|
|
320
|
+
frozen progression span while keeping NIR context coherent (the
|
|
321
|
+
outgoing chord enters history as the transition source, exactly as
|
|
322
|
+
``step()`` records it).
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
self._record_transition_source(self.current_chord)
|
|
326
|
+
|
|
327
|
+
self.current_chord = chord
|
|
328
|
+
|
|
329
|
+
return self.current_chord
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def get_current_chord (self) -> subsequence.chords.Chord:
|
|
333
|
+
|
|
334
|
+
"""Return the current chord."""
|
|
335
|
+
|
|
336
|
+
return self.current_chord
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def get_key_name (self) -> str:
|
|
340
|
+
|
|
341
|
+
"""Return the current key name."""
|
|
342
|
+
|
|
343
|
+
return self.key_name
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def get_chord_root_midi (self, base_midi: int, chord: subsequence.chords.Chord) -> int:
|
|
347
|
+
|
|
348
|
+
"""Calculate the MIDI root for a chord relative to the key root."""
|
|
349
|
+
|
|
350
|
+
offset = (chord.root_pc - self.key_root_pc) % 12
|
|
351
|
+
|
|
352
|
+
return base_midi + offset
|
subsequence/harmony.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Harmony helpers — diatonic chords without the chord-graph engine.
|
|
2
|
+
|
|
3
|
+
Standalone convenience functions (``diatonic_chords``, ``diatonic_chord``,
|
|
4
|
+
``diatonic_chord_sequence``) for building chords from a key and mode. For
|
|
5
|
+
generative progressions use ``progressions``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import typing
|
|
9
|
+
|
|
10
|
+
import subsequence.chords
|
|
11
|
+
import subsequence.intervals
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def diatonic_chords (key: str, mode: str = "ionian") -> typing.List[subsequence.chords.Chord]:
|
|
15
|
+
|
|
16
|
+
"""Return the diatonic triads for a key and mode.
|
|
17
|
+
|
|
18
|
+
This is a convenience function for generating chord sequences without
|
|
19
|
+
using the chord graph engine. The returned ``Chord`` objects can be
|
|
20
|
+
passed directly to ``p.chord()`` or ``chord.tones()`` inside a pattern.
|
|
21
|
+
|
|
22
|
+
Parameters:
|
|
23
|
+
key: Note name for the key (e.g., ``"C"``, ``"Eb"``, ``"F#"``).
|
|
24
|
+
mode: A mode with chord qualities defined (e.g. ``"ionian"``,
|
|
25
|
+
``"dorian"``, ``"minor"``). Scales without chord qualities
|
|
26
|
+
(e.g. ``"hirajoshi"``) will raise ``ValueError`` — use
|
|
27
|
+
``p.snap_to_scale()`` for pitch snapping instead.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of ``Chord`` objects, one per scale degree.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
```python
|
|
34
|
+
# All 7 chords in Eb Major
|
|
35
|
+
chords = subsequence.harmony.diatonic_chords("Eb")
|
|
36
|
+
|
|
37
|
+
# Natural minor chords in A
|
|
38
|
+
chords = subsequence.harmony.diatonic_chords("A", mode="minor")
|
|
39
|
+
|
|
40
|
+
# Dorian chords in D
|
|
41
|
+
chords = subsequence.harmony.diatonic_chords("D", mode="dorian")
|
|
42
|
+
```
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
if mode not in subsequence.intervals.SCALE_MODE_MAP:
|
|
46
|
+
available = ", ".join(sorted(subsequence.intervals.SCALE_MODE_MAP.keys()))
|
|
47
|
+
raise ValueError(f"Unknown mode: {mode!r}. Available: {available}")
|
|
48
|
+
|
|
49
|
+
_, qualities = subsequence.intervals.SCALE_MODE_MAP[mode]
|
|
50
|
+
|
|
51
|
+
if qualities is None:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Mode {mode!r} has no chord qualities defined. "
|
|
54
|
+
"Use register_scale(..., qualities=[...]) to add them, "
|
|
55
|
+
"or use p.snap_to_scale() for pitch snapping without harmony."
|
|
56
|
+
)
|
|
57
|
+
key_pc = subsequence.chords.key_name_to_pc(key)
|
|
58
|
+
scale_pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode)
|
|
59
|
+
|
|
60
|
+
return [
|
|
61
|
+
subsequence.chords.Chord(root_pc=root_pc, quality=quality)
|
|
62
|
+
for root_pc, quality in zip(scale_pcs, qualities)
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def diatonic_chord (
|
|
67
|
+
key: str,
|
|
68
|
+
mode: str = "ionian",
|
|
69
|
+
degree: int = 0,
|
|
70
|
+
) -> subsequence.chords.Chord:
|
|
71
|
+
|
|
72
|
+
"""Return a single diatonic chord by scale degree.
|
|
73
|
+
|
|
74
|
+
Convenience wrapper around :func:`diatonic_chords` for the common
|
|
75
|
+
case where only one chord is needed.
|
|
76
|
+
|
|
77
|
+
Parameters:
|
|
78
|
+
key: Root note name (e.g. ``"E"``, ``"Bb"``).
|
|
79
|
+
mode: Scale mode (default ``"ionian"``).
|
|
80
|
+
degree: Zero-indexed scale degree (0 = I/tonic, 4 = V/dominant, etc.).
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ValueError: If *degree* is out of range for the mode.
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
```python
|
|
87
|
+
tonic = diatonic_chord("E", "phrygian") # I
|
|
88
|
+
dominant = diatonic_chord("E", "phrygian", degree=4) # V
|
|
89
|
+
```
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
chords = diatonic_chords(key, mode)
|
|
93
|
+
|
|
94
|
+
if degree < 0 or degree >= len(chords):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"degree {degree} out of range for {mode} (0\u2013{len(chords) - 1})"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return chords[degree]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def diatonic_chord_sequence (
|
|
103
|
+
key: str,
|
|
104
|
+
root_midi: int,
|
|
105
|
+
count: int,
|
|
106
|
+
mode: str = "ionian"
|
|
107
|
+
) -> typing.List[typing.Tuple[subsequence.chords.Chord, int]]:
|
|
108
|
+
|
|
109
|
+
"""Return a list of ``(Chord, midi_root)`` tuples stepping diatonically upward.
|
|
110
|
+
|
|
111
|
+
Useful for mapping a continuous value (like altitude or brightness) to a
|
|
112
|
+
chord, or for building explicit rising/falling progressions without using
|
|
113
|
+
the chord graph engine.
|
|
114
|
+
|
|
115
|
+
The returned list has ``count`` entries. Each entry contains the ``Chord``
|
|
116
|
+
object (quality and pitch class) and the exact MIDI note number to use as
|
|
117
|
+
that chord's root. Pass both directly to ``p.chord(chord, root=midi_root)``.
|
|
118
|
+
|
|
119
|
+
Counts larger than the number of scale degrees wrap into higher octaves
|
|
120
|
+
automatically. The sequence always steps upward — reverse the list for
|
|
121
|
+
a falling sequence.
|
|
122
|
+
|
|
123
|
+
Parameters:
|
|
124
|
+
key: Note name for the key (e.g., ``"D"``, ``"Eb"``, ``"F#"``).
|
|
125
|
+
root_midi: MIDI note number for the first chord's root. Must fall on a
|
|
126
|
+
scale degree of the chosen key and mode.
|
|
127
|
+
count: Number of ``(Chord, midi_root)`` pairs to generate.
|
|
128
|
+
mode: One of ``"ionian"`` (or ``"major"``), ``"dorian"``,
|
|
129
|
+
``"phrygian"``, ``"lydian"``, ``"mixolydian"``,
|
|
130
|
+
``"aeolian"`` (or ``"minor"``), ``"locrian"``,
|
|
131
|
+
``"harmonic_minor"``, ``"melodic_minor"``.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of ``(Chord, int)`` tuples, one per step.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ValueError: If ``key`` or ``mode`` is not recognised, or if
|
|
138
|
+
``root_midi`` does not fall on a scale degree of the key.
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
```python
|
|
142
|
+
# 7-step D Major ladder starting at D3 (MIDI 50)
|
|
143
|
+
sequence = subsequence.harmony.diatonic_chord_sequence("D", root_midi=50, count=7)
|
|
144
|
+
|
|
145
|
+
# Map a 0-1 value to a chord (e.g. from ISS altitude)
|
|
146
|
+
chord, root = sequence[int(ratio * (len(sequence) - 1))]
|
|
147
|
+
p.chord(chord, root=root, sustain=True)
|
|
148
|
+
|
|
149
|
+
# Falling sequence
|
|
150
|
+
for chord, root in reversed(subsequence.harmony.diatonic_chord_sequence("A", 57, 7, "minor")):
|
|
151
|
+
...
|
|
152
|
+
```
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
# Validate mode before looking up the scale key. diatonic_chords() also
|
|
156
|
+
# validates internally, but diatonic_chord_sequence() is called directly
|
|
157
|
+
# from user code so we give a clear error here without going deeper.
|
|
158
|
+
if mode not in subsequence.intervals.SCALE_MODE_MAP:
|
|
159
|
+
available = ", ".join(sorted(subsequence.intervals.SCALE_MODE_MAP.keys()))
|
|
160
|
+
raise ValueError(f"Unknown mode: {mode!r}. Available: {available}")
|
|
161
|
+
|
|
162
|
+
scale_key, _ = subsequence.intervals.SCALE_MODE_MAP[mode]
|
|
163
|
+
scale_ivs = subsequence.intervals.get_intervals(scale_key)
|
|
164
|
+
|
|
165
|
+
key_pc = subsequence.chords.key_name_to_pc(key)
|
|
166
|
+
start_pc = root_midi % 12
|
|
167
|
+
|
|
168
|
+
# Locate the scale degree that matches the starting MIDI note.
|
|
169
|
+
start_degree: typing.Optional[int] = None
|
|
170
|
+
|
|
171
|
+
for i, iv in enumerate(scale_ivs):
|
|
172
|
+
if (key_pc + iv) % 12 == start_pc:
|
|
173
|
+
start_degree = i
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
if start_degree is None:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"MIDI note {root_midi} (pitch class {start_pc}) is not a scale "
|
|
179
|
+
f"degree of {key!r} {mode!r}."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
all_chords = diatonic_chords(key, mode=mode)
|
|
183
|
+
result: typing.List[typing.Tuple[subsequence.chords.Chord, int]] = []
|
|
184
|
+
|
|
185
|
+
num_degrees = len(scale_ivs)
|
|
186
|
+
|
|
187
|
+
for i in range(count):
|
|
188
|
+
degree = (start_degree + i) % num_degrees
|
|
189
|
+
octave_bump = (start_degree + i) // num_degrees
|
|
190
|
+
midi_root = (
|
|
191
|
+
root_midi
|
|
192
|
+
+ (scale_ivs[degree] - scale_ivs[start_degree])
|
|
193
|
+
+ 12 * octave_bump
|
|
194
|
+
)
|
|
195
|
+
result.append((all_chords[degree], midi_root))
|
|
196
|
+
|
|
197
|
+
return result
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Held-note tracking for live MIDI note input.
|
|
2
|
+
|
|
3
|
+
``HeldNotes`` maintains the set of MIDI notes a player is currently holding
|
|
4
|
+
on a ``note_input`` keyboard, so an arpeggiator (or any pattern) can read the
|
|
5
|
+
live pitch set each cycle via ``p.held_notes()``.
|
|
6
|
+
|
|
7
|
+
It is a tiny, dependency-free state machine. All of its state lives on the
|
|
8
|
+
sequencer loop thread: the mido callback thread only appends raw note events
|
|
9
|
+
to a deque, which the loop drains and feeds here. Because it is never touched
|
|
10
|
+
from two threads, it needs no locking — and because it takes the current time
|
|
11
|
+
as an argument (rather than reading the clock itself), it is fully
|
|
12
|
+
deterministic and trivial to unit-test.
|
|
13
|
+
|
|
14
|
+
Two smoothing behaviours guard against the arp dropping to silence:
|
|
15
|
+
|
|
16
|
+
* **``release_ms`` debounce** — a just-released note lingers in the held set
|
|
17
|
+
for a short window, so the momentary all-keys-up gap during a hand-position
|
|
18
|
+
changeover does not register as "nothing held".
|
|
19
|
+
* **``latch``** — the held set persists after release until a *new* chord is
|
|
20
|
+
started (the first key pressed after every key is up replaces it), like a
|
|
21
|
+
hardware arp's latch / a sustain pedal. Under ``latch`` the ``release_ms``
|
|
22
|
+
window is unused — latch dominates.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import typing
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HeldNotes:
|
|
29
|
+
|
|
30
|
+
"""The live set of notes held on a ``note_input`` keyboard."""
|
|
31
|
+
|
|
32
|
+
def __init__ (self, release_ms: float = 0.0, latch: bool = False) -> None:
|
|
33
|
+
|
|
34
|
+
"""Create a held-note tracker.
|
|
35
|
+
|
|
36
|
+
Parameters:
|
|
37
|
+
release_ms: How long (milliseconds) a released note keeps counting
|
|
38
|
+
as held, smoothing the gap during hand-position changes. 0
|
|
39
|
+
removes a note the instant its note-off arrives. Ignored when
|
|
40
|
+
``latch`` is True.
|
|
41
|
+
latch: When True, the held set persists after release until the
|
|
42
|
+
next chord is started.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
self._release_s: float = max(0.0, release_ms) / 1000.0
|
|
46
|
+
self._latch: bool = latch
|
|
47
|
+
# pitch -> velocity for notes physically down right now.
|
|
48
|
+
self._on: typing.Dict[int, int] = {}
|
|
49
|
+
# pitch -> perf_counter deadline for just-released notes (debounce window).
|
|
50
|
+
self._releasing: typing.Dict[int, float] = {}
|
|
51
|
+
# pitch -> velocity for the latched chord (latch mode only).
|
|
52
|
+
self._latched: typing.Dict[int, int] = {}
|
|
53
|
+
|
|
54
|
+
def note_on (self, pitch: int, velocity: int, now: float) -> None:
|
|
55
|
+
|
|
56
|
+
"""Register a note-on (``now`` = a perf_counter timestamp)."""
|
|
57
|
+
|
|
58
|
+
self._releasing.pop(pitch, None)
|
|
59
|
+
if self._latch and not self._on:
|
|
60
|
+
# First key after every key was up — start a fresh latched chord.
|
|
61
|
+
self._latched.clear()
|
|
62
|
+
self._on[pitch] = velocity
|
|
63
|
+
|
|
64
|
+
def note_off (self, pitch: int, now: float) -> None:
|
|
65
|
+
|
|
66
|
+
"""Register a note-off (``now`` = a perf_counter timestamp)."""
|
|
67
|
+
|
|
68
|
+
velocity = self._on.pop(pitch, None)
|
|
69
|
+
if velocity is None:
|
|
70
|
+
return
|
|
71
|
+
if self._latch:
|
|
72
|
+
self._latched[pitch] = velocity
|
|
73
|
+
elif self._release_s > 0.0:
|
|
74
|
+
self._releasing[pitch] = now + self._release_s
|
|
75
|
+
|
|
76
|
+
def snapshot (self, now: float) -> typing.List[int]:
|
|
77
|
+
|
|
78
|
+
"""Return the currently held MIDI notes, sorted ascending.
|
|
79
|
+
|
|
80
|
+
Combines notes physically down, any still within the ``release_ms``
|
|
81
|
+
debounce window at ``now``, and the latched chord. Expired
|
|
82
|
+
release-window notes are pruned as a side effect.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
held: typing.Set[int] = set(self._on)
|
|
86
|
+
held.update(self._latched)
|
|
87
|
+
if self._releasing:
|
|
88
|
+
for pitch in [p for p, deadline in self._releasing.items() if deadline <= now]:
|
|
89
|
+
del self._releasing[pitch]
|
|
90
|
+
held.update(self._releasing)
|
|
91
|
+
return sorted(held)
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Network utility functions.
|
|
2
|
+
|
|
3
|
+
Provides robust local IP and broadcast address discovery without requiring
|
|
4
|
+
external dependencies like ``psutil``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import socket
|
|
8
|
+
import typing
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_local_ip () -> str:
|
|
12
|
+
|
|
13
|
+
"""Discover the primary local IP address of the machine.
|
|
14
|
+
|
|
15
|
+
Uses a non-connecting UDP socket trick to find the IP of the default
|
|
16
|
+
outbound interface currently used for external routing.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
The local IP address as a string, or "127.0.0.1" if discovery fails.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
local_ip = "127.0.0.1"
|
|
23
|
+
try:
|
|
24
|
+
# Connecting a UDP socket to an external address (no data is actually
|
|
25
|
+
# sent) reveals which local IP the OS would use for that route.
|
|
26
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
27
|
+
try:
|
|
28
|
+
probe.connect(("8.8.8.8", 80))
|
|
29
|
+
local_ip = probe.getsockname()[0]
|
|
30
|
+
finally:
|
|
31
|
+
probe.close()
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
return local_ip
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_local_broadcasts () -> typing.List[str]:
|
|
39
|
+
|
|
40
|
+
"""Return subnet broadcast addresses inferred from local interface IPs.
|
|
41
|
+
|
|
42
|
+
Computes the /24 broadcast address based on the primary local IP route.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
A list containing the computed broadcast address, or an empty list
|
|
46
|
+
if the primary interface is a loopback or link-local address.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
broadcasts: typing.List[str] = []
|
|
50
|
+
local_ip = get_local_ip()
|
|
51
|
+
|
|
52
|
+
if not local_ip.startswith("127.") and not local_ip.startswith("169.254."):
|
|
53
|
+
parts = local_ip.split(".")
|
|
54
|
+
if len(parts) == 4:
|
|
55
|
+
parts[3] = "255"
|
|
56
|
+
broadcasts.append(".".join(parts))
|
|
57
|
+
|
|
58
|
+
return broadcasts
|