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,231 @@
1
+
2
+ """
3
+ Subsequence - an algorithmic composition framework for Python.
4
+
5
+ Subsequence gives you a palette of mathematical building blocks -
6
+ Euclidean rhythms, cellular automata, L-systems, Markov chains,
7
+ cognitive melody generation - and a stateful engine that lets them
8
+ interact and evolve over time. Unlike tools that loop a fixed pattern
9
+ forever, Subsequence rebuilds every pattern fresh before each cycle
10
+ with full context, so algorithms feed into each other and compositions
11
+ emerge that no single technique could produce alone. It generates pure
12
+ MIDI (no audio engine) to control hardware synths, modular systems,
13
+ drum machines, or software VSTs/DAWs.
14
+
15
+ What makes it different:
16
+
17
+ - **A rich algorithmic palette.** Euclidean and Bresenham rhythm
18
+ generators, cellular automata (1D and 2D), L-system string rewriting,
19
+ Markov chains, cognitive melody via the Narmour model, probability-
20
+ weighted ghost notes, position-aware thinning, drones and continuous
21
+ notes, Perlin and pink noise, logistic chaos maps - plus groove
22
+ templates, velocity shaping, and pitch-bend automation to shape
23
+ how they sound.
24
+ - **Stateful patterns that evolve.** Each pattern is a Python function
25
+ rebuilt fresh every cycle with full context - current chord, section,
26
+ cycle count, shared data from other patterns. A Euclidean rhythm can
27
+ thin itself as tension builds, a cellular automaton can seed from the
28
+ harmony, and a Markov chain can shift behaviour between sections.
29
+ - **Optional chord graph.** Define weighted chord and key transitions
30
+ via probability graphs, with gravity and automatic voice leading.
31
+ A dozen built-in palettes and frozen progressions to lock some sections
32
+ while others evolve freely. Layer on cognitive harmony for
33
+ Narmour-based melodic inertia.
34
+ - **Sub-microsecond clock.** Hybrid sleep+spin timing achieves typical
35
+ pulse jitter of < 5 us on Linux, with zero long-term drift.
36
+ - **Turn anything into music.** ``composition.schedule()`` runs any
37
+ Python function on a beat cycle - APIs, sensors, files. Anything
38
+ Python can reach becomes a musical parameter.
39
+ - **Pure MIDI, zero sound engine.** No audio synthesis, no heavyweight
40
+ dependencies. Route to hardware synths, drum machines, Eurorack, or
41
+ software instruments.
42
+
43
+ Composition tools:
44
+
45
+ - **Rhythm and feel.** Euclidean and Bresenham generators, multi-voice
46
+ weighted Bresenham distribution (``bresenham_poly()``), ghost note
47
+ layers (``ghost_fill()``), position-aware note removal (``thin()`` -
48
+ the musical inverse of ``ghost_fill``), evolving cellular-automaton
49
+ rhythms (``cellular_1d()``, ``cellular_2d()``), smooth Perlin noise (``perlin_1d()``,
50
+ ``perlin_2d()``, ``perlin_1d_sequence()``, ``perlin_2d_grid()``),
51
+ deterministic chaos sequences (``logistic_map()``), pink 1/f noise
52
+ (``pink_noise()``), L-system string rewriting (``p.lsystem()``),
53
+ Markov-chain generation (``p.markov()``), aperiodic binary rhythms
54
+ (``p.thue_morse()``), golden-ratio beat placement (``p.fibonacci()``),
55
+ Gray-Scott reaction-diffusion patterns (``p.reaction_diffusion()``),
56
+ Lorenz strange-attractor generation (``p.lorenz()``), exhaustive
57
+ pitch-subsequence melodies (``p.de_bruijn()``), step-wise melodies
58
+ with guaranteed pitch diversity (``p.self_avoiding_walk()``), drones
59
+ and explicit note on/off events (``p.drone()``, ``p.drone_off()``,
60
+ ``p.silence()``),
61
+ groove templates (``Groove.swing()``, ``Groove.from_agr()``), swing via
62
+ ``p.swing()`` (a shortcut for ``Groove.swing()``), randomize,
63
+ velocity shaping and ramps (``p.build_velocity_ramp()``), dropout, per-step
64
+ probability, and polyrhythms via independent pattern lengths.
65
+ - **Melody generation.** ``p.melody()`` with ``MelodicState`` applies
66
+ the Narmour Implication-Realization model to single-note lines:
67
+ continuation after small steps, reversal after large leaps, chord-tone
68
+ weighting, range gravity, and pitch-diversity penalty. History persists
69
+ across bar rebuilds for natural phrase continuity.
70
+ - **Chord parts.** ``comp.chords()`` and ``p.progression()`` play a chord
71
+ progression — generated from a chord-graph style or given explicitly — at a
72
+ declared *harmonic rhythm*: a fixed length, a shaped ``[WHOLE, HALF, HALF]``
73
+ sequence, or ``between(WHOLE, 3 * WHOLE, step=WHOLE)`` for chords of varying,
74
+ quantized length. Voicing density, ``detached`` articulation, and a seed for
75
+ a fixed phrase are all declarative.
76
+ - **Expression.** CC messages/ramps, pitch bend, note-correlated
77
+ bend/portamento/slide, program changes, SysEx, and OSC output - all
78
+ from within patterns.
79
+ - **Form and structure.** Musical form as a weighted graph, ordered list,
80
+ or generator. Patterns read ``p.section`` to adapt. Conductor signals
81
+ (LFOs, ramps) shape intensity over time.
82
+ - **Sequences as lists.** ``p.hit_steps("kick", [0, 4, 8, 12])`` and
83
+ ``p.sequence(steps=..., pitches=..., velocities=...)`` place rhythms
84
+ and lines from plain Python lists - the vocabulary the generator and
85
+ density helpers in ``sequence_utils`` all speak.
86
+ - **Scales.** ``p.snap_to_scale()`` snaps notes to any
87
+ scale. ``scale_notes()`` generates a list of MIDI note numbers from
88
+ a key, mode, and range or note count - useful for arpeggios, Markov
89
+ chains, and melodic walks. Built-in western and non-western modes,
90
+ plus ``register_scale()`` for your own.
91
+ - **Microtonal tuning.** ``composition.tuning()`` applies a tuning
92
+ system globally; ``p.apply_tuning()`` overrides per-pattern.
93
+ Supports Scala ``.scl`` files, explicit cent lists, frequency ratios,
94
+ and N-TET equal temperaments. Polyphonic parts use explicit channel
95
+ rotation so simultaneous notes can carry independent pitch bends
96
+ without MPE. Compatible with any standard MIDI synthesiser.
97
+ - **Randomness tools.** Weighted choice, no-repeat shuffle, random
98
+ walk, probability gates. Deterministic seeding makes every decision
99
+ repeatable: set it composition-wide (``seed=42``) or per generator
100
+ (``seed=`` on any generator, with ``rng=`` for an explicit instance —
101
+ precedence ``rng`` > ``seed`` > the pattern's ``p.rng``). See the
102
+ README "Conventions" section for the API's shared vocabulary.
103
+ - **Pattern transforms.** Legato, detached, fixed gate (``p.duration()``),
104
+ reverse, time-stretch, rotate, transpose, invert, randomize, and
105
+ conditional ``p.every()``.
106
+
107
+ Integration:
108
+
109
+ - **MIDI clock.** Master (``clock_output()``) or follower
110
+ (``clock_follow=True``). When multiple inputs are connected, only
111
+ one may be designated as the master clock source; messages from
112
+ other inputs are filtered to prevent sync interference. Sync to a
113
+ DAW or drive hardware.
114
+ - **Latency compensation.** Declare each output device's physical
115
+ latency (``latency_ms=``); Subsequence delays the faster devices so
116
+ a mix of hardware and slower software instruments sound together.
117
+ - **MIDI mirroring with per-device drum maps.** Fan a pattern out to
118
+ extra ``(device, channel)`` destinations; an entry can carry its own
119
+ ``drum_note_map`` so one named drum hit re-resolves to the right voice
120
+ on each device — a DRM1 and a General MIDI sampler alike.
121
+ - **Shared project definitions.** ``load_definitions("project.yaml")``
122
+ reads a small per-project YAML file naming notes, CCs, channels,
123
+ programs, and NRPNs — the same file the Subsample sampler reads — so
124
+ both tools use identical names and a renumber is a single edit.
125
+ - **Hardware control.** CC input mapping from knobs/faders to
126
+ ``composition.data``; patterns read and write the same dict via
127
+ ``p.data`` for both external data access and cross-pattern
128
+ communication. OSC for bidirectional communication with mixers,
129
+ lighting, visuals.
130
+ - **Live held-note arpeggiator.** ``composition.note_input()`` tracks the
131
+ notes you hold on a keyboard; a pattern reads them with ``p.held_notes()``
132
+ and arpeggiates them (``p.arpeggio(p.held_notes())``), with ``release_ms``
133
+ debounce and ``latch``. A performance layer over the deterministic
134
+ composition - empty when rendering headlessly.
135
+ - **Live coding.** Hot-swap patterns, change tempo, mute/unmute, and
136
+ tweak parameters during playback via a built-in TCP eval server.
137
+ - **Hotkeys.** Single keystrokes to jump sections, toggle mutes, or
138
+ fire any action - with optional bar-boundary quantization.
139
+ - **Real-time pattern triggering.** ``composition.trigger()`` generates
140
+ one-shot patterns in response to sensors, OSC, or any event.
141
+ - **Terminal display.** Live status line (BPM, bar, section, chord).
142
+ Add ``grid=True`` for an ASCII pattern grid showing velocity and
143
+ sustain - makes legato, detached, and staccato articulations visually
144
+ distinct at a glance.
145
+ Add ``grid_scale=2`` to zoom in horizontally, revealing swing and
146
+ groove micro-timing.
147
+ - **Web UI Dashboard (Beta).** Enable with ``composition.web_ui()`` to
148
+ broadcast live composition metadata and visualize piano-roll pattern
149
+ grids in a reactive HTTP/WebSocket browser dashboard.
150
+ - **Ableton Link.** Industry-standard wireless tempo/phase sync
151
+ (``comp.link()``; requires ``pip install subsequence[link]``).
152
+ Any Link-enabled app on the same LAN — Ableton Live, iOS synths,
153
+ other Subsequence instances — stays in time automatically.
154
+ - **Recording.** Record to standard MIDI file. Render to file without
155
+ waiting for real-time playback.
156
+
157
+ Minimal example:
158
+
159
+ ```python
160
+ import subsequence
161
+ import subsequence.constants.instruments.gm_drums as gm_drums
162
+
163
+ comp = subsequence.Composition(bpm=120)
164
+
165
+ @comp.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
166
+ def drums (p):
167
+ (p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100)
168
+ .hit_steps("snare_1", [4, 12], velocity=90)
169
+ .hit_steps("hi_hat_closed", range(16), velocity=70))
170
+
171
+ comp.play()
172
+ ```
173
+
174
+ Community and Feedback:
175
+
176
+ - **Discussions:** Chat and ask questions at https://github.com/simonholliday/subsequence/discussions
177
+ - **Issues:** Report bugs and request features at https://github.com/simonholliday/subsequence/issues
178
+
179
+ Package-level exports: ``Composition``, ``Chord``, ``Groove``, ``MelodicState``, ``Tuning``, ``Motif``, ``Phrase``, ``motif``, ``sentence``, ``period``, ``Degree``, ``ChordTone``, ``Approach``, ``MotifEvent``, ``ControlEvent``, ``Progression``, ``ChordSpan``, ``PitchSet``, ``progression``, ``Cadence``, ``Section``, ``Form``, ``roles``, ``sieve``, ``residual_class``, ``between``, ``parse_chord``, ``register_chord_quality``, ``register_scale``, ``scale_notes``, ``bank_select``, ``Definitions``, ``load_definitions``.
180
+ """
181
+
182
+ import subsequence.cadences
183
+ import subsequence.chords
184
+ import subsequence.forms
185
+ import subsequence.roles
186
+ import subsequence.composition
187
+ import subsequence.definitions
188
+ import subsequence.groove
189
+ import subsequence.harmonic_rhythm
190
+ import subsequence.intervals
191
+ import subsequence.melodic_state
192
+ import subsequence.midi_utils
193
+ import subsequence.motifs
194
+ import subsequence.progressions
195
+ import subsequence.sequence_utils
196
+ import subsequence.tuning
197
+
198
+
199
+ Composition = subsequence.composition.Composition
200
+ Motif = subsequence.motifs.Motif
201
+ Phrase = subsequence.motifs.Phrase
202
+ motif = subsequence.motifs.motif
203
+ sentence = subsequence.motifs.sentence
204
+ period = subsequence.motifs.period
205
+ Cadence = subsequence.cadences.Cadence
206
+ Section = subsequence.forms.Section
207
+ Form = subsequence.forms.Form
208
+ Degree = subsequence.motifs.Degree
209
+ ChordTone = subsequence.motifs.ChordTone
210
+ Approach = subsequence.motifs.Approach
211
+ MotifEvent = subsequence.motifs.MotifEvent
212
+ ControlEvent = subsequence.motifs.ControlEvent
213
+ Progression = subsequence.progressions.Progression
214
+ ChordSpan = subsequence.progressions.ChordSpan
215
+ PitchSet = subsequence.progressions.PitchSet
216
+ progression = subsequence.progressions.progression
217
+ Chord = subsequence.chords.Chord
218
+ Groove = subsequence.groove.Groove
219
+ MelodicState = subsequence.melodic_state.MelodicState
220
+ Tuning = subsequence.tuning.Tuning
221
+ between = subsequence.harmonic_rhythm.between
222
+ parse_chord = subsequence.chords.parse_chord
223
+ register_chord_quality = subsequence.chords.register_chord_quality
224
+ register_scale = subsequence.intervals.register_scale
225
+ scale_notes = subsequence.intervals.scale_notes
226
+ bank_select = subsequence.midi_utils.bank_select
227
+ Definitions = subsequence.definitions.Definitions
228
+ load_definitions = subsequence.definitions.load_definitions
229
+ roles = subsequence.roles
230
+ sieve = subsequence.sequence_utils.sieve
231
+ residual_class = subsequence.sequence_utils.residual_class
@@ -0,0 +1,24 @@
1
+ """
2
+ Entry point for ``python -m subsequence`` — confirms the install and points to the docs.
3
+ """
4
+
5
+ import logging
6
+
7
+
8
+ # Configure logging
9
+ logging.basicConfig(level=logging.INFO)
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def main () -> None:
14
+
15
+ """
16
+ Main entry point for the subsequence module.
17
+ """
18
+
19
+ logger.info("Subsequence module loaded.")
20
+ logger.info("To make your first sound, follow the Cookbook: https://subsequence.live/cookbook/00-setup.html")
21
+
22
+
23
+ if __name__ == "__main__":
24
+ main()
@@ -0,0 +1,345 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Subsequence Dashboard</title>
8
+ <style>
9
+ :root {
10
+ --bg-color: #0d1117;
11
+ --text-color: #c9d1d9;
12
+ --accent-color: #58a6ff;
13
+ --panel-bg: #161b22;
14
+ --border-color: #30363d;
15
+ }
16
+
17
+ body {
18
+ background-color: var(--bg-color);
19
+ color: var(--text-color);
20
+ font-family: 'Inter', -apple-system, sans-serif;
21
+ margin: 0;
22
+ padding: 20px;
23
+ }
24
+
25
+ .dashboard {
26
+ max-width: 1200px;
27
+ margin: 0 auto;
28
+ display: grid;
29
+ gap: 20px;
30
+ }
31
+
32
+ .header {
33
+ display: flex;
34
+ justify-content: space-between;
35
+ align-items: center;
36
+ border-bottom: 1px solid var(--border-color);
37
+ padding-bottom: 20px;
38
+ }
39
+
40
+ .header h1 {
41
+ margin: 0;
42
+ font-size: 1.5rem;
43
+ }
44
+
45
+ .status {
46
+ display: flex;
47
+ gap: 20px;
48
+ font-size: 1.2rem;
49
+ font-weight: 600;
50
+ }
51
+
52
+ .status span {
53
+ color: var(--accent-color);
54
+ }
55
+
56
+ .grid {
57
+ display: grid;
58
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
59
+ gap: 20px;
60
+ }
61
+
62
+ .panel {
63
+ background: var(--panel-bg);
64
+ border: 1px solid var(--border-color);
65
+ border-radius: 8px;
66
+ padding: 20px;
67
+ }
68
+
69
+ .panel h2 {
70
+ margin-top: 0;
71
+ font-size: 1.2rem;
72
+ border-bottom: 1px solid var(--border-color);
73
+ padding-bottom: 15px;
74
+ margin-bottom: 20px;
75
+ }
76
+
77
+ .gauge-container {
78
+ margin-bottom: 20px;
79
+ }
80
+
81
+ .gauge-label {
82
+ display: flex;
83
+ justify-content: space-between;
84
+ margin-bottom: 8px;
85
+ font-size: 0.95rem;
86
+ }
87
+
88
+ .gauge-bar {
89
+ width: 100%;
90
+ height: 12px;
91
+ background: var(--border-color);
92
+ border-radius: 6px;
93
+ overflow: hidden;
94
+ position: relative;
95
+ }
96
+
97
+ .gauge-fill {
98
+ height: 100%;
99
+ background: var(--accent-color);
100
+ transition: width 0.1s linear;
101
+ }
102
+
103
+ .pattern-grid-container {
104
+ margin-bottom: 15px;
105
+ background: rgba(0, 0, 0, 0.2);
106
+ border: 1px solid var(--border-color);
107
+ border-radius: 6px;
108
+ padding: 12px;
109
+ }
110
+
111
+ .pattern-grid-container:last-child {
112
+ margin-bottom: 0;
113
+ }
114
+
115
+ .pattern-grid-header {
116
+ display: flex;
117
+ justify-content: space-between;
118
+ margin-bottom: 10px;
119
+ font-family: monospace;
120
+ font-size: 1.1rem;
121
+ }
122
+
123
+ .pattern-grid {
124
+ position: relative;
125
+ width: 100%;
126
+ background: rgba(255, 255, 255, 0.03);
127
+ border-radius: 2px;
128
+ overflow: hidden;
129
+ }
130
+
131
+ .note-rect {
132
+ position: absolute;
133
+ border-radius: 2px;
134
+ }
135
+
136
+ .piano-roll-label {
137
+ position: absolute;
138
+ left: 4px;
139
+ font-size: 0.70rem;
140
+ color: rgba(255, 255, 255, 0.6);
141
+ font-family: monospace;
142
+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.7);
143
+ pointer-events: none;
144
+ z-index: 5;
145
+ white-space: nowrap;
146
+ }
147
+
148
+ .playhead {
149
+ position: absolute;
150
+ top: 0;
151
+ bottom: 0;
152
+ width: 1px;
153
+ background-color: rgba(255, 255, 255, 0.8);
154
+ box-shadow: 0 0 4px rgba(255, 255, 255, 0.5);
155
+ z-index: 10;
156
+ }
157
+ </style>
158
+ </head>
159
+
160
+ <body>
161
+ <div id="app"></div>
162
+
163
+ <script type="module">
164
+ import { h, Component, render } from 'https://esm.sh/preact@10.19.6';
165
+ import { useState, useEffect } from 'https://esm.sh/preact@10.19.6/hooks';
166
+ import htm from 'https://esm.sh/htm@3.1.1';
167
+
168
+ const html = htm.bind(h);
169
+
170
+ function Transport({ state }) {
171
+ let sectionStr = state.section || '---';
172
+ if (state.section && state.section_bars) {
173
+ sectionStr = `[${state.section} ${state.section_bar}/${state.section_bars}`;
174
+ if (state.next_section) {
175
+ sectionStr += ` \u2192 ${state.next_section}`;
176
+ }
177
+ sectionStr += ']';
178
+ }
179
+
180
+ return html`
181
+ <div class="header">
182
+ <h1>Subsequence <span>Dashboard</span></h1>
183
+ <div class="status">
184
+ <div>BPM: <span>${state.bpm ? state.bpm.toFixed(1) : '---'}</span></div>
185
+ ${state.key ? html`<div>Key: <span>${state.key}</span></div>` : ''}
186
+ <div>Bar: <span>${state.global_bar || 0}.${state.global_beat || 0}</span></div>
187
+ <div>Section: <span>${sectionStr}</span></div>
188
+ <div>Chord: <span>${state.chord || '---'}</span></div>
189
+ </div>
190
+ </div>
191
+ `;
192
+ }
193
+
194
+ function SignalGauge({ name, value }) {
195
+ // value is expected to be roughly 0.0 - 1.0. Clamp it to 0-100% just in case
196
+ const pct = Math.max(0, Math.min(100, value * 100));
197
+ return html`
198
+ <div class="gauge-container">
199
+ <div class="gauge-label">
200
+ <span>${name}</span>
201
+ <span>${value.toFixed(2)}</span>
202
+ </div>
203
+ <div class="gauge-bar">
204
+ <div class="gauge-fill" style="width: ${pct}%"></div>
205
+ </div>
206
+ </div>
207
+ `;
208
+ }
209
+
210
+ function PatternGrid({ pattern, state }) {
211
+ const color = pattern.muted ? '#ff7b72' : '#3fb950';
212
+ const notes = pattern.notes || [];
213
+ const length_pulses = pattern.length_pulses || 1;
214
+ const drum_map = pattern.drum_map || {};
215
+
216
+ // Reverse the drum map: { midi_note: "kick" }
217
+ const reverseDrumMap = {};
218
+ for (const [name, noteNum] of Object.entries(drum_map)) {
219
+ if (!reverseDrumMap[noteNum]) reverseDrumMap[noteNum] = name;
220
+ }
221
+
222
+ // Find unique pitches and sort descending (higher pitches at top)
223
+ const pitches = [...new Set(notes.map(n => n.p))].sort((a, b) => b - a);
224
+ const rowHeight = 16;
225
+ const gridHeight = Math.max(48, pitches.length * rowHeight);
226
+
227
+ const pitchToY = (p) => {
228
+ const idx = pitches.indexOf(p);
229
+ if (idx === -1) return 0;
230
+ // Center the note vertically in its "row"
231
+ return idx * rowHeight + 1;
232
+ };
233
+
234
+ const getPitchName = (p) => {
235
+ if (reverseDrumMap[p]) return reverseDrumMap[p];
236
+ const noteNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
237
+ const octave = Math.floor(p / 12) - 1;
238
+ const note = noteNames[p % 12];
239
+ return `${note}${octave}`;
240
+ };
241
+
242
+ const localPlayhead = (state.playhead_pulse || 0) % length_pulses;
243
+ const playheadLeft = (localPlayhead / length_pulses) * 100;
244
+
245
+ const renderLabel = (p) => {
246
+ const top = pitchToY(p);
247
+ return html`<div class="piano-roll-label" style="top: ${top}px; line-height: ${rowHeight - 2}px;">${getPitchName(p)}</div>`;
248
+ };
249
+
250
+ const renderNote = (n, i) => {
251
+ const left = (n.s / length_pulses) * 100;
252
+ // Clamp width to remainder of bar so it doesn't spill over
253
+ const width = Math.min((n.d / length_pulses) * 100, 100 - left);
254
+ const top = pitchToY(n.p);
255
+ const opacity = Math.max(0.3, n.v / 127);
256
+ return html`<div key=${i} class="note-rect" style="left: ${left}%; width: ${Math.max(0.5, width)}%; top: ${top}px; height: ${rowHeight - 2}px; opacity: ${opacity}; background-color: ${color};"></div>`;
257
+ };
258
+
259
+ return html`
260
+ <div class="pattern-grid-container">
261
+ <div class="pattern-grid-header">
262
+ <span>${pattern.name}</span>
263
+ <span style="color: ${color}; font-weight: bold; font-size: 0.85rem;">${pattern.muted ? 'MUTED' : 'PLAYING'}</span>
264
+ </div>
265
+ <div class="pattern-grid" style="height: ${gridHeight}px;">
266
+ ${pitches.map(renderLabel)}
267
+ ${notes.map(renderNote)}
268
+ <div class="playhead" style="left: ${playheadLeft}%;"></div>
269
+ </div>
270
+ </div>
271
+ `;
272
+ }
273
+
274
+ function App() {
275
+ const [state, setState] = useState({
276
+ bpm: null,
277
+ chord: null,
278
+ section: null,
279
+ patterns: [],
280
+ signals: {}
281
+ });
282
+ const [connected, setConnected] = useState(false);
283
+
284
+ useEffect(() => {
285
+ let ws;
286
+
287
+ function connect() {
288
+ // __WS_PORT__ is substituted by web_ui.py at serve time so
289
+ // WebUI(ws_port=...) actually reaches the dashboard; the
290
+ // regex guard keeps the raw file working (default 8765)
291
+ // when opened without the server.
292
+ const wsPortToken = '__WS_PORT__';
293
+ const wsPort = /^\d+$/.test(wsPortToken) ? wsPortToken : '8765';
294
+ ws = new WebSocket('ws://' + window.location.hostname + ':' + wsPort);
295
+
296
+ ws.onopen = () => setConnected(true);
297
+
298
+ ws.onmessage = (event) => {
299
+ const data = JSON.parse(event.data);
300
+ setState(data);
301
+ };
302
+
303
+ ws.onclose = () => {
304
+ setConnected(false);
305
+ // Try to reconnect in 2s
306
+ setTimeout(connect, 2000);
307
+ };
308
+ }
309
+
310
+ connect();
311
+
312
+ return () => {
313
+ if (ws) ws.close();
314
+ };
315
+ }, []);
316
+
317
+ return html`
318
+ <div class="dashboard">
319
+ <${Transport} state=${state} />
320
+
321
+ ${!connected ? html`<div style="color: #ff7b72; padding: 20px; text-align: center; border: 1px solid #ff7b72; border-radius: 8px;">Not connected to Subsequence websocket...</div>` : null}
322
+
323
+ <div class="grid">
324
+ <div class="panel">
325
+ <h2>Signals & Conductor</h2>
326
+ ${Object.keys(state.signals).length === 0 ? html`<p style="color: grey; font-style: italic;">No active signals</p>` : null}
327
+ ${Object.entries(state.signals).map(([name, value]) =>
328
+ html`<${SignalGauge} key=${name} name=${name} value=${value} />`
329
+ )}
330
+ </div>
331
+ <div class="panel">
332
+ <h2>Active Patterns</h2>
333
+ ${state.patterns.length === 0 ? html`<p style="color: grey; font-style: italic;">No active patterns</p>` : null}
334
+ ${state.patterns.map(p => html`<${PatternGrid} key=${p.name} pattern=${p} state=${state} />`)}
335
+ </div>
336
+ </div>
337
+ </div>
338
+ `;
339
+ }
340
+
341
+ render(html`<${App} />`, document.getElementById('app'));
342
+ </script>
343
+ </body>
344
+
345
+ </html>