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.
Files changed (78) hide show
  1. subsequence/__init__.py +231 -0
  2. subsequence/__main__.py +24 -0
  3. subsequence/assets/web/index.html +345 -0
  4. subsequence/cadences.py +113 -0
  5. subsequence/chord_graphs/__init__.py +100 -0
  6. subsequence/chord_graphs/aeolian_minor.py +158 -0
  7. subsequence/chord_graphs/chromatic_mediant.py +113 -0
  8. subsequence/chord_graphs/diminished.py +97 -0
  9. subsequence/chord_graphs/dorian_minor.py +127 -0
  10. subsequence/chord_graphs/functional_major.py +102 -0
  11. subsequence/chord_graphs/hooktheory_major.py +88 -0
  12. subsequence/chord_graphs/lydian_major.py +130 -0
  13. subsequence/chord_graphs/mixolydian.py +98 -0
  14. subsequence/chord_graphs/phrygian_minor.py +76 -0
  15. subsequence/chord_graphs/suspended.py +109 -0
  16. subsequence/chord_graphs/turnaround_global.py +157 -0
  17. subsequence/chord_graphs/whole_tone.py +77 -0
  18. subsequence/chords.py +419 -0
  19. subsequence/composition.py +6099 -0
  20. subsequence/conductor.py +238 -0
  21. subsequence/constants/__init__.py +24 -0
  22. subsequence/constants/durations.py +37 -0
  23. subsequence/constants/instruments/__init__.py +13 -0
  24. subsequence/constants/instruments/gm_cc.py +46 -0
  25. subsequence/constants/instruments/gm_drums.py +53 -0
  26. subsequence/constants/instruments/gm_instruments.py +32 -0
  27. subsequence/constants/instruments/roland_tr8s.py +320 -0
  28. subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
  29. subsequence/constants/midi_notes.py +29 -0
  30. subsequence/constants/pulses.py +17 -0
  31. subsequence/constants/velocity.py +22 -0
  32. subsequence/definitions.py +232 -0
  33. subsequence/display.py +617 -0
  34. subsequence/easing.py +347 -0
  35. subsequence/event_emitter.py +109 -0
  36. subsequence/form_state.py +665 -0
  37. subsequence/forms.py +257 -0
  38. subsequence/groove.py +323 -0
  39. subsequence/harmonic_rhythm.py +83 -0
  40. subsequence/harmonic_state.py +352 -0
  41. subsequence/harmony.py +197 -0
  42. subsequence/held_notes.py +91 -0
  43. subsequence/helpers/__init__.py +0 -0
  44. subsequence/helpers/network.py +58 -0
  45. subsequence/helpers/wing.py +430 -0
  46. subsequence/intervals.py +436 -0
  47. subsequence/keystroke.py +249 -0
  48. subsequence/link_clock.py +128 -0
  49. subsequence/live_client.py +187 -0
  50. subsequence/live_reloader.py +298 -0
  51. subsequence/live_server.py +161 -0
  52. subsequence/melodic_state.py +483 -0
  53. subsequence/midi.py +97 -0
  54. subsequence/midi_utils.py +329 -0
  55. subsequence/mini_notation.py +164 -0
  56. subsequence/motifs.py +2356 -0
  57. subsequence/osc.py +194 -0
  58. subsequence/pattern.py +363 -0
  59. subsequence/pattern_algorithmic.py +2010 -0
  60. subsequence/pattern_builder.py +2589 -0
  61. subsequence/pattern_midi.py +1208 -0
  62. subsequence/progressions.py +1913 -0
  63. subsequence/py.typed +0 -0
  64. subsequence/roles.py +63 -0
  65. subsequence/sequence_utils.py +3123 -0
  66. subsequence/sequencer.py +2086 -0
  67. subsequence/tuning.py +453 -0
  68. subsequence/voicings.py +151 -0
  69. subsequence/web_ui.py +337 -0
  70. subsequence/weighted_graph.py +156 -0
  71. subsequence-0.6.4.dist-info/METADATA +208 -0
  72. subsequence-0.6.4.dist-info/RECORD +78 -0
  73. subsequence-0.6.4.dist-info/WHEEL +5 -0
  74. subsequence-0.6.4.dist-info/entry_points.txt +2 -0
  75. subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
  76. subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
  77. subsequence-0.6.4.dist-info/scm_version.json +8 -0
  78. 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