cgcardona / muse public
render_midi_demo.py python
1616 lines 72.4 KB
47f42f27 feat: overhaul MIDI demo — funky groove, multi-synth audio, DAW track v… Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 #!/usr/bin/env python3
2 """MIDI Demo Page — Groove in Em × Muse VCS.
3
4 Outputs: artifacts/midi-demo.html
5
6 Demonstrates Muse's 21-dimensional MIDI version control using an original
7 funky-soul groove composition built across a 5-act VCS narrative:
8
9 Instruments:
10 - Drums (kick/snare/hi-hat/ghost snares/crash)
11 - Bass guitar (E minor pentatonic walking line)
12 - Electric Piano (Em7→Am7→Bm7→Cmaj7 chord voicings)
13 - Lead Synth (E pentatonic melody with pitch bends)
14 - Brass/Ensemble (stabs and pads — conflict & resolution)
15
16 VCS Narrative:
17 Act 1 — Foundation (3 commits on main)
18 Act 2 — Divergence (feat/groove + feat/harmony branches)
19 Act 3 — Clean Merge (feat/groove + feat/harmony → main)
20 Act 4 — Conflict (conflict/brass-a vs conflict/ensemble)
21 Act 5 — Resolution (resolved mix, v1.0 tag, 21 dimensions)
22 """
23
24 import json
25 import logging
26 import math
27 import pathlib
28
29 logger = logging.getLogger(__name__)
30
31 # ─────────────────────────────────────────────────────────────────────────────
32 # MUSICAL CONSTANTS (96 BPM, E minor)
33 # ─────────────────────────────────────────────────────────────────────────────
34 BPM: int = 96
35 BEAT: float = 60.0 / BPM # 0.625 s / beat
36 BAR: float = 4 * BEAT # 2.5 s / bar
37 S16: float = BEAT / 4 # 16th note = 0.15625 s
38 E8: float = BEAT / 2 # 8th note = 0.3125 s
39 Q4: float = BEAT # quarter = 0.625 s
40 H2: float = 2 * BEAT # half = 1.25 s
41 W1: float = 4 * BEAT # whole = 2.5 s
42 BARS: int = 8 # every commit plays 8 bars ≈ 20 s
43
44 # GM Drum pitches
45 KICK = 36; SNARE = 38; HAT_C = 42; HAT_O = 46; CRASH = 49; RIDE = 51
46
47 # E-minor chord voicings (mid register)
48 _EM7 = [52, 55, 59, 62] # E3 G3 B3 D4
49 _AM9 = [57, 60, 64, 67] # A3 C4 E4 G4
50 _BM7 = [59, 62, 66, 69] # B3 D4 F#4 A4
51 _CMAJ = [60, 64, 67, 71] # C4 E4 G4 B4
52 CHORDS: list[list[int]] = [_EM7, _AM9, _BM7, _CMAJ]
53
54 # E pentatonic (lead)
55 PENTA: list[int] = [64, 67, 69, 71, 74, 76] # E4 G4 A4 B4 D5 E5
56
57
58 def _n(pitch: int, vel: int, t: float, dur: float, instr: str) -> list[object]:
59 """Pack a single MIDI note: [pitch, vel, start_sec, dur_sec, instr]."""
60 return [pitch, vel, round(t, 5), round(dur, 5), instr]
61
62
63 def _bs(bar: int) -> float:
64 """Bar start time in seconds (0-indexed)."""
65 return bar * BAR
66
67
68 # ─────────────────────────────────────────────────────────────────────────────
69 # DRUMS
70 # ─────────────────────────────────────────────────────────────────────────────
71
72 def gen_drums_basic(bars: range) -> list[list[object]]:
73 """Kick + snare only — the skeleton groove."""
74 notes: list[list[object]] = []
75 for b in bars:
76 t = _bs(b)
77 if b == bars.start:
78 notes.append(_n(CRASH, 100, t, 1.0, "crash"))
79 # Kick beats 1 & 3
80 notes.append(_n(KICK, 110, t, 0.08, "kick"))
81 notes.append(_n(KICK, 100, t + 2 * Q4, 0.08, "kick"))
82 # Snare beats 2 & 4
83 notes.append(_n(SNARE, 95, t + Q4, 0.10, "snare"))
84 notes.append(_n(SNARE, 90, t + 3 * Q4, 0.10, "snare"))
85 return notes
86
87
88 def gen_drums_full(bars: range) -> list[list[object]]:
89 """Full funk pattern: kick/snare + hi-hat 16ths + ghost snares."""
90 notes = gen_drums_basic(bars)
91 for b in bars:
92 t = _bs(b)
93 # Closed hi-hat every 16th (open on 14th 16th)
94 for i in range(16):
95 if i == 14:
96 notes.append(_n(HAT_O, 72, t + i * S16, 0.20, "hat_o"))
97 else:
98 vel = 75 if i % 4 == 0 else (60 if i % 2 == 0 else 45)
99 notes.append(_n(HAT_C, vel, t + i * S16, 0.06, "hat_c"))
100 # Ghost snares (very soft, add texture)
101 for ghost_16th in [2, 6, 10, 14]:
102 notes.append(_n(SNARE, 22, t + ghost_16th * S16, 0.04, "ghost"))
103 # Syncopated kick pickup on odd bars
104 if b % 2 == 1:
105 notes.append(_n(KICK, 78, t + 3 * Q4 + S16, 0.07, "kick"))
106 return notes
107
108
109 # ─────────────────────────────────────────────────────────────────────────────
110 # BASS GUITAR (E minor pentatonic walking line)
111 # ─────────────────────────────────────────────────────────────────────────────
112 # E2=40 G2=43 A2=45 B2=47 C3=48 D3=50 E3=52
113 _BASS_CELLS: list[list[tuple[float, int, float, int]]] = [
114 # (beat_offset, pitch, dur_beats, vel) — bar 0 mod 4 (Em root)
115 [(0.0, 40, 1.00, 95), (1.00, 43, 0.50, 85), (1.50, 45, 0.25, 80), (1.75, 47, 2.25, 90)],
116 # bar 1 mod 4 (Am flavor)
117 [(0.0, 40, 1.25, 95), (1.25, 43, 0.50, 85), (1.75, 45, 0.75, 85),
118 (2.50, 47, 0.50, 80), (3.00, 50, 1.00, 80)],
119 # bar 2 mod 4 (Am → Bm)
120 [(0.0, 45, 1.00, 90), (1.00, 48, 0.50, 80), (1.50, 47, 1.75, 85), (3.25, 45, 0.75, 75)],
121 # bar 3 mod 4 (Bm → Em)
122 [(0.0, 47, 1.00, 90), (1.00, 50, 0.50, 85), (1.50, 45, 1.00, 80), (2.50, 40, 1.50, 95)],
123 ]
124
125
126 def gen_bass(bars: range) -> list[list[object]]:
127 """E minor pentatonic walking bass line — 4-bar repeating cell."""
128 notes: list[list[object]] = []
129 for b in bars:
130 t = _bs(b)
131 for beat_off, pitch, dur_beats, vel in _BASS_CELLS[b % 4]:
132 notes.append(_n(pitch, vel, t + beat_off * Q4, dur_beats * Q4, "bass"))
133 return notes
134
135
136 # ─────────────────────────────────────────────────────────────────────────────
137 # ELECTRIC PIANO (Em7 → Am9 → Bm7 → Cmaj7 comping)
138 # ─────────────────────────────────────────────────────────────────────────────
139 # Syncopated comping hits within each bar
140 _COMP_HITS: list[tuple[float, float, int]] = [
141 # (beat_offset, dur_beats, base_vel)
142 (0.00, 0.35, 85), # beat 1 stab
143 (1.50, 0.50, 70), # beat 2+ upbeat
144 (2.00, 0.35, 80), # beat 3 stab
145 (3.50, 1.00, 72), # beat 4+ sustain into next bar
146 ]
147
148
149 def gen_epiano(bars: range) -> list[list[object]]:
150 """Funky electric piano comping — syncopated voicings."""
151 notes: list[list[object]] = []
152 for b in bars:
153 t = _bs(b)
154 chord = CHORDS[b % 4]
155 for beat_off, dur_beats, base_vel in _COMP_HITS:
156 for i, pitch in enumerate(chord):
157 vel = min(127, base_vel + (3 - i) * 3) # root loudest
158 notes.append(_n(pitch, vel, t + beat_off * Q4, dur_beats * Q4, "epiano"))
159 return notes
160
161
162 # ─────────────────────────────────────────────────────────────────────────────
163 # LEAD SYNTH (E pentatonic melody, 4-bar cell)
164 # ─────────────────────────────────────────────────────────────────────────────
165 # (abs_beat_within_4_bars, pitch_idx, dur_beats, vel)
166 _LEAD_CELL: list[tuple[float, int, float, int]] = [
167 # bar 0 — call phrase (ascending)
168 (0.00, 2, 0.50, 85), (0.50, 3, 0.25, 80), (0.75, 4, 0.25, 82),
169 (1.00, 4, 0.50, 88), (1.50, 3, 0.50, 78), (2.00, 3, 0.40, 80),
170 (2.50, 2, 0.50, 75), (3.00, 1, 1.00, 82),
171 # bar 1 — response (peak)
172 (4.00, 0, 0.50, 75), (4.50, 1, 0.50, 78), (5.00, 2, 1.00, 88),
173 (6.00, 3, 0.50, 82), (6.50, 4, 0.25, 80), (6.75, 5, 0.25, 85),
174 (7.00, 5, 1.00, 92),
175 # bar 2 — descent
176 (8.00, 4, 0.50, 85), (8.50, 3, 0.50, 80), (9.00, 2, 0.50, 78),
177 (9.50, 1, 0.50, 75), (10.00, 0, 1.00, 80), (11.00, 1, 1.00, 82),
178 # bar 3 — resolution
179 (12.00, 2, 0.50, 80), (12.50, 3, 0.50, 82), (13.00, 4, 1.00, 88),
180 (14.00, 3, 0.50, 80), (14.50, 2, 0.50, 78), (15.00, 0, 1.50, 92),
181 ]
182
183
184 def gen_lead(bars: range) -> list[list[object]]:
185 """E pentatonic melody — 4-bar repeating call-and-response phrase."""
186 notes: list[list[object]] = []
187 first = bars.start
188 for b in bars:
189 t = _bs(b)
190 cell_bar = (b - first) % 4
191 for abs_beat, pidx, dur_beats, vel in _LEAD_CELL:
192 if int(abs_beat) // 4 == cell_bar:
193 local_beat = abs_beat - cell_bar * 4
194 notes.append(_n(PENTA[pidx], vel, t + local_beat * Q4, dur_beats * Q4, "lead"))
195 return notes
196
197
198 # ─────────────────────────────────────────────────────────────────────────────
199 # BRASS / ENSEMBLE
200 # ─────────────────────────────────────────────────────────────────────────────
201
202 def gen_brass_a(bars: range) -> list[list[object]]:
203 """Brass A: punchy staccato off-beat stabs. G major triad."""
204 STAB = [55, 59, 62] # G3 B3 D4 (Em → G power)
205 notes: list[list[object]] = []
206 for b in bars:
207 t = _bs(b)
208 for beat_off in [0.5, 1.5, 2.5, 3.5]:
209 for pitch in STAB:
210 notes.append(_n(pitch, 95, t + beat_off * Q4, E8 * 0.55, "brass"))
211 return notes
212
213
214 def gen_brass_b(bars: range) -> list[list[object]]:
215 """Brass B / Ensemble: legato swell pads. Em9 voicing."""
216 PAD = [52, 55, 59, 64, 67] # E3 G3 B3 E4 G4
217 notes: list[list[object]] = []
218 for b in bars:
219 t = _bs(b)
220 for pitch in PAD:
221 notes.append(_n(pitch, 70, t, H2 * 1.8, "brassb"))
222 notes.append(_n(pitch + 12, 55, t + H2, H2, "brassb")) # octave upper bloom
223 return notes
224
225
226 # ─────────────────────────────────────────────────────────────────────────────
227 # COMMIT DATA (13 commits, 4 branches, 5 acts)
228 # ─────────────────────────────────────────────────────────────────────────────
229
230 def _all_notes(instrs: list[str], bars: range) -> list[list[object]]:
231 """Gather notes for the given instruments over the bar range."""
232 generators: dict[str, list[list[object]]] = {}
233 if any(i in instrs for i in ["kick", "snare", "hat_c", "hat_o", "ghost", "crash"]):
234 full = set(instrs) & {"hat_c", "hat_o", "ghost"}
235 if full:
236 generators.update({k: [] for k in ["kick","snare","hat_c","hat_o","ghost","crash"]})
237 for nt in gen_drums_full(bars):
238 if nt[4] in instrs:
239 generators[str(nt[4])].append(nt)
240 else:
241 for nt in gen_drums_basic(bars):
242 if nt[4] in instrs:
243 generators.setdefault(str(nt[4]), []).append(nt)
244 if "bass" in instrs:
245 generators["bass"] = gen_bass(bars)
246 if "epiano" in instrs:
247 generators["epiano"] = gen_epiano(bars)
248 if "lead" in instrs:
249 generators["lead"] = gen_lead(bars)
250 if "brass" in instrs:
251 generators["brass"] = gen_brass_a(bars)
252 if "brassb" in instrs:
253 generators["brassb"] = gen_brass_b(bars)
254
255 all_notes: list[list[object]] = []
256 for lst in generators.values():
257 all_notes.extend(lst)
258 return all_notes
259
260
261 _R = range(0, BARS) # all 8 bars
262 _DK = ["kick", "snare"]
263 _DF = ["kick", "snare", "hat_c", "hat_o", "ghost", "crash"]
264
265
266 def _build_commits() -> list[dict[str, object]]:
267 """Return the full ordered commit list with note payloads."""
268
269 def mk(
270 sha: str,
271 branch: str,
272 label: str,
273 cmd: str,
274 output: str,
275 act: int,
276 instrs: list[str],
277 dim_act: dict[str, int],
278 parents: list[str] | None = None,
279 conflict: bool = False,
280 resolved: bool = False,
281 ) -> dict[str, object]:
282 notes = _all_notes(instrs, _R)
283 return {
284 "sha": sha,
285 "branch": branch,
286 "label": label,
287 "cmd": cmd,
288 "output": output,
289 "act": act,
290 "notes": notes,
291 "dimAct": dim_act,
292 "parents": parents or [],
293 "conflict": conflict,
294 "resolved": resolved,
295 }
296
297 # Dimension shorthand
298 _META = {"time_signatures": 2, "key_signatures": 2, "tempo_map": 2, "markers": 2, "track_structure": 1}
299 _VOL = {"cc_volume": 2, "cc_pan": 1}
300 _BASS_D = {"cc_portamento": 2, "cc_reverb": 1, "cc_expression": 1, "cc_other": 1}
301 _PIANO = {"cc_sustain": 2, "cc_chorus": 1, "cc_soft_pedal": 1, "cc_sostenuto": 1}
302 _LEAD_D = {"pitch_bend": 3, "cc_modulation": 2, "channel_pressure": 2, "poly_pressure": 1}
303 _BRASS_D = {"cc_expression": 3}
304 _ENS_D = {"cc_reverb": 3, "cc_chorus": 2} # CONFLICT source
305
306 c: list[dict[str, object]] = []
307
308 c.append(mk("a0f4d2e1", "main",
309 "muse init\\n--domain midi",
310 "muse init --domain midi",
311 "✓ Initialized Muse repository\n domain: midi | .muse/ created",
312 0, [],
313 {**_META},
314 ))
315
316 c.append(mk("1b3c8f02", "main",
317 "Foundation\\n4/4 · 96 BPM · Em",
318 "muse commit -m 'Foundation: 4/4, 96 BPM, Em key'",
319 "✓ [main 1b3c8f02] Foundation: 4/4, 96 BPM, Em key\n"
320 " 1 file changed — .museattributes, time_sig, key_sig, markers",
321 1, [],
322 {**_META, "program_change": 1},
323 ["a0f4d2e1"],
324 ))
325
326 c.append(mk("2d9e1a47", "main",
327 "Foundation\\nkick + snare groove",
328 "muse commit -m 'Foundation: kick+snare groove pattern'",
329 "✓ [main 2d9e1a47] Foundation: kick+snare groove pattern\n"
330 " notes dim active | cc_volume",
331 1, _DK,
332 {**_META, "notes": 2, **_VOL},
333 ["1b3c8f02"],
334 ))
335
336 # ── Act 2: Divergence ─────────────────────────────────────────────────────
337
338 c.append(mk("3f0b5c8d", "feat/groove",
339 "Groove\\nfull drum kit + bass",
340 "muse commit -m 'Groove: hi-hat 16ths, ghost snares, bass root motion'",
341 "✓ [feat/groove 3f0b5c8d] Groove: hi-hat 16ths, ghost snares, bass root motion\n"
342 " notes, program_change, cc_portamento, cc_pan",
343 2, [*_DF, "bass"],
344 {**_META, "notes": 3, **_VOL, "program_change": 2, **_BASS_D},
345 ["2d9e1a47"],
346 ))
347
348 c.append(mk("4a2c7e91", "feat/groove",
349 "Groove\\nbass expression + reverb",
350 "muse commit -m 'Groove: bass portamento slides, CC reverb tail'",
351 "✓ [feat/groove 4a2c7e91] Groove: bass portamento slides, CC reverb tail\n"
352 " cc_portamento, cc_reverb, cc_expression active",
353 2, [*_DF, "bass"],
354 {**_META, "notes": 3, **_VOL, "program_change": 2, **_BASS_D},
355 ["3f0b5c8d"],
356 ))
357
358 c.append(mk("5e8d3b14", "feat/harmony",
359 "Harmony\\nEm7→Am9→Bm7→Cmaj7",
360 "muse commit -m 'Harmony: Em7 chord voicings, CC sustain + chorus'",
361 "✓ [feat/harmony 5e8d3b14] Harmony: Em7 chord voicings, CC sustain + chorus\n"
362 " notes, cc_sustain, cc_chorus, cc_soft_pedal",
363 2, [*_DF, "epiano"],
364 {**_META, "notes": 3, **_VOL, "program_change": 2, **_PIANO},
365 ["2d9e1a47"],
366 ))
367
368 c.append(mk("6c1f9a52", "feat/harmony",
369 "Melody\\nE pentatonic + pitch bends",
370 "muse commit -m 'Melody: E pentatonic lead, pitch_bend, channel_pressure'",
371 "✓ [feat/harmony 6c1f9a52] Melody: E pentatonic lead, pitch_bend, channel_pressure\n"
372 " pitch_bend, cc_modulation, channel_pressure, poly_pressure",
373 2, [*_DF, "epiano", "lead"],
374 {**_META, "notes": 3, **_VOL, "program_change": 2, **_PIANO, **_LEAD_D},
375 ["5e8d3b14"],
376 ))
377
378 # ── Act 3: Clean Merge ────────────────────────────────────────────────────
379
380 c.append(mk("7b4e2d85", "main",
381 "MERGE\\nfeat/groove + feat/harmony",
382 "muse merge feat/groove feat/harmony",
383 "✓ Merged 'feat/groove' into 'main' — 0 conflicts\n"
384 "✓ Merged 'feat/harmony' into 'main' — 0 conflicts\n"
385 " Full rhythm + harmony stack active",
386 3, [*_DF, "bass", "epiano", "lead"],
387 {**_META, "notes": 4, **_VOL, "program_change": 3,
388 **_BASS_D, **_PIANO, **_LEAD_D},
389 ["4a2c7e91", "6c1f9a52"],
390 ))
391
392 # ── Act 4: Conflict ───────────────────────────────────────────────────────
393
394 c.append(mk("8d7f1c36", "conflict/brass-a",
395 "Brass A\\nstaccato stabs",
396 "muse commit -m 'Brass A: punchy stabs, CC expression bus'",
397 "✓ [conflict/brass-a 8d7f1c36] Brass A: punchy stabs, CC expression bus\n"
398 " brass track | cc_expression elevated",
399 4, [*_DF, "bass", "epiano", "lead", "brass"],
400 {**_META, "notes": 4, **_VOL, "program_change": 3,
401 **_BASS_D, **_PIANO, **_LEAD_D, **_BRASS_D},
402 ["7b4e2d85"],
403 ))
404
405 c.append(mk("9e0a4b27", "conflict/ensemble",
406 "Ensemble\\nlegato pads",
407 "muse commit -m 'Ensemble: legato pads, CC reverb swell'",
408 "✓ [conflict/ensemble 9e0a4b27] Ensemble: legato pads, CC reverb swell\n"
409 " brassb track | cc_reverb elevated (CONFLICT INCOMING)",
410 4, [*_DF, "bass", "epiano", "lead", "brassb"],
411 {**_META, "notes": 4, **_VOL, "program_change": 3,
412 **_BASS_D, **_PIANO, **_LEAD_D, **_ENS_D},
413 ["7b4e2d85"],
414 ))
415
416 c.append(mk("a1b5c8d9", "main",
417 "MERGE\\nconflict/brass-a → main",
418 "muse merge conflict/brass-a",
419 "✓ Merged 'conflict/brass-a' into 'main' — 0 conflicts\n"
420 " stab brass layer integrated",
421 4, [*_DF, "bass", "epiano", "lead", "brass"],
422 {**_META, "notes": 4, **_VOL, "program_change": 3,
423 **_BASS_D, **_PIANO, **_LEAD_D, **_BRASS_D},
424 ["7b4e2d85", "8d7f1c36"],
425 ))
426
427 c.append(mk("b2c6d9e0", "main",
428 "⚠ CONFLICT\\ncc_reverb dimension",
429 "muse merge conflict/ensemble",
430 "⚠ CONFLICT detected in dimension: cc_reverb\n"
431 " conflict/brass-a: cc_reverb = 45\n"
432 " conflict/ensemble: cc_reverb = 82\n"
433 " → muse resolve --strategy=auto cc_reverb",
434 4, [*_DF, "bass", "epiano", "lead", "brass", "brassb"],
435 {**_META, "notes": 5, **_VOL, "program_change": 4,
436 **_BASS_D, **_PIANO, **_LEAD_D, **_BRASS_D, **_ENS_D},
437 ["a1b5c8d9", "9e0a4b27"],
438 conflict=True,
439 ))
440
441 # ── Act 5: Resolution ─────────────────────────────────────────────────────
442
443 c.append(mk("c3d7e0f1", "main",
444 "RESOLVED · v1.0\\n21 dimensions active",
445 "muse resolve --strategy=auto cc_reverb && muse tag add v1.0",
446 "✓ Resolved cc_reverb — took max(45, 82) = 82\n"
447 "✓ All 21 MIDI dimensions active\n"
448 "✓ Tag 'v1.0' created → [main c3d7e0f1]",
449 5, [*_DF, "bass", "epiano", "lead", "brass", "brassb"],
450 {**_META, "notes": 5, **_VOL, "program_change": 4,
451 **_BASS_D, **_PIANO, **_LEAD_D, **_BRASS_D, **_ENS_D},
452 ["b2c6d9e0"],
453 resolved=True,
454 ))
455
456 return c
457
458
459 # ─────────────────────────────────────────────────────────────────────────────
460 # HTML TEMPLATE
461 # ─────────────────────────────────────────────────────────────────────────────
462
463 _HTML = """\
464 <!DOCTYPE html>
465 <html lang="en">
466 <head>
467 <meta charset="utf-8">
468 <meta name="viewport" content="width=device-width,initial-scale=1">
469 <title>Muse · MIDI Demo — Groove in Em</title>
470 <link rel="preconnect" href="https://fonts.googleapis.com">
471 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
472 <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
473 <script src="https://cdn.jsdelivr.net/npm/tone@14.7.77/build/Tone.js"></script>
474 <style>
475 *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
476 :root{
477 --bg:#07090f;--surface:#0d1118;--panel:#111724;--border:rgba(255,255,255,0.07);
478 --text:#e8eaf0;--muted:rgba(255,255,255,0.38);--accent:#33ddff;
479 --pink:#ff6b9d;--purple:#a855f7;--gold:#f59e0b;--green:#34d399;
480 --kick:#ef4444;--snare:#fb923c;--hat:#facc15;--crash:#fef9c3;
481 --bass:#a855f7;--epiano:#22d3ee;--lead:#f472b6;--brass:#34d399;--brassb:#86efac;
482 --main:#4f8ef7;--groove:#a855f7;--harmony:#22d3ee;--bra:#ef4444;--ens:#f59e0b;
483 }
484 html{font-size:14px;scroll-behavior:smooth}
485 body{background:var(--bg);color:var(--text);font-family:'Inter',sans-serif;min-height:100vh;overflow-x:hidden}
486
487 /* ── NAV ── */
488 nav{display:flex;align-items:center;justify-content:space-between;padding:0 20px;height:48px;
489 background:rgba(13,17,24,0.92);border-bottom:1px solid var(--border);
490 position:sticky;top:0;z-index:50;backdrop-filter:blur(8px)}
491 .nav-logo{font-size:13px;font-family:'JetBrains Mono',monospace;color:var(--accent);letter-spacing:.05em}
492 .nav-links{display:flex;gap:18px}
493 .nav-links a{font-size:12px;color:var(--muted);text-decoration:none;transition:color .2s}
494 .nav-links a:hover,.nav-links a.active{color:var(--text)}
495 .nav-badge{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--accent);
496 background:rgba(51,221,255,.1);border:1px solid rgba(51,221,255,.2);
497 padding:2px 8px;border-radius:20px}
498
499 /* ── HERO ── */
500 .hero{padding:28px 24px 18px;text-align:center}
501 .hero h1{font-size:clamp(22px,3.5vw,36px);font-weight:700;letter-spacing:-.02em;
502 background:linear-gradient(135deg,#fff 30%,var(--accent) 100%);
503 -webkit-background-clip:text;-webkit-text-fill-color:transparent}
504 .hero-sub{font-size:13px;color:var(--muted);margin-top:6px}
505 .hero-tags{display:flex;justify-content:center;flex-wrap:wrap;gap:8px;margin-top:12px}
506 .hero-tag{font-size:10px;font-family:'JetBrains Mono',monospace;padding:2px 8px;
507 border-radius:20px;background:rgba(255,255,255,0.06);border:1px solid var(--border);color:var(--muted)}
508 .hero-tag.on{color:var(--accent);background:rgba(51,221,255,.08);border-color:rgba(51,221,255,.2)}
509
510 /* ── MAIN GRID ── */
511 .main-grid{display:grid;grid-template-columns:320px 1fr;gap:12px;padding:0 14px 14px;
512 max-width:1400px;margin:0 auto}
513 @media(max-width:900px){.main-grid{grid-template-columns:1fr}}
514
515 /* ── PANELS ── */
516 .panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;overflow:hidden}
517 .panel-hd{display:flex;align-items:center;justify-content:space-between;
518 padding:9px 14px;border-bottom:1px solid var(--border);
519 font-size:11px;font-family:'JetBrains Mono',monospace;color:var(--muted);letter-spacing:.05em}
520 .panel-hd span{color:var(--text);font-size:12px}
521
522 /* ── DAG ── */
523 #dag-wrap{padding:10px 0 6px}
524 #dag-svg{display:block;width:100%;overflow:visible}
525 #dag-branch-badge{font-size:10px;font-family:'JetBrains Mono',monospace;color:var(--accent);
526 background:rgba(51,221,255,.1);border:1px solid rgba(51,221,255,.15);
527 padding:1px 6px;border-radius:12px}
528
529 /* ── ACT BADGE ── */
530 .act-badge{display:inline-flex;align-items:center;gap:5px;font-size:10px;
531 font-family:'JetBrains Mono',monospace;color:var(--muted)}
532 .act-dot{width:6px;height:6px;border-radius:50%;background:currentColor}
533
534 /* ── COMMAND LOG ── */
535 #cmd-terminal{margin:10px;background:#060a12;border:1px solid var(--border);
536 border-radius:6px;padding:10px;min-height:100px;max-height:140px;overflow:hidden}
537 .term-dots{display:flex;gap:4px;margin-bottom:8px}
538 .term-dot{width:9px;height:9px;border-radius:50%}
539 .t-red{background:#ff5f57}.t-yel{background:#febc2e}.t-grn{background:#28c840}
540 #cmd-prompt{font-family:'JetBrains Mono',monospace;font-size:11px;line-height:1.6;color:#c4c9d4}
541 .cmd-line{color:var(--accent)}
542 .cmd-ok{color:var(--green)}
543 .cmd-warn{color:var(--gold)}
544 .cmd-err{color:var(--kick)}
545 .cmd-cursor{display:inline-block;width:6px;height:13px;background:var(--accent);
546 animation:blink .9s step-end infinite;vertical-align:middle}
547 @keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
548
549 /* ── DAW TRACK VIEW ── */
550 .daw-wrap{position:relative;overflow-x:auto;overflow-y:hidden}
551 #daw-svg{display:block}
552 .daw-time-label{font-family:'JetBrains Mono',monospace;font-size:9px;fill:var(--muted)}
553 .daw-track-label{font-family:'JetBrains Mono',monospace;font-size:9px;fill:var(--muted);text-anchor:end}
554 .playhead-line{stroke:rgba(255,255,255,0.8);stroke-width:1.5;pointer-events:none}
555
556 /* ── CONTROLS ── */
557 .ctrl-bar{display:flex;align-items:center;gap:10px;padding:10px 14px;
558 background:var(--surface);border-top:1px solid var(--border);
559 border-bottom:1px solid var(--border);flex-wrap:wrap}
560 .ctrl-group{display:flex;align-items:center;gap:6px}
561 .ctrl-btn{width:36px;height:36px;border-radius:50%;border:1px solid var(--border);
562 background:rgba(255,255,255,.05);color:var(--text);cursor:pointer;
563 display:flex;align-items:center;justify-content:center;font-size:13px;
564 transition:all .15s}
565 .ctrl-btn:hover{background:rgba(255,255,255,.1);border-color:var(--accent)}
566 .ctrl-btn:disabled{opacity:.3;cursor:not-allowed}
567 .ctrl-play{width:44px;height:44px;border-radius:50%;border:none;
568 background:var(--accent);color:#000;cursor:pointer;font-size:15px;
569 display:flex;align-items:center;justify-content:center;
570 transition:all .15s;box-shadow:0 0 16px rgba(51,221,255,.3)}
571 .ctrl-play:hover{transform:scale(1.08)}
572 .ctrl-play.playing{background:var(--pink);box-shadow:0 0 20px rgba(255,107,157,.4)}
573 .ctrl-info{font-family:'JetBrains Mono',monospace;font-size:11px}
574 .ctrl-time{color:var(--accent);min-width:40px}
575 .ctrl-sha{color:var(--muted);font-size:10px}
576 .ctrl-msg{font-size:11px;color:var(--text);flex:1;min-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
577 .audio-status{font-size:10px;color:var(--muted);font-family:'JetBrains Mono',monospace}
578 .audio-status.ready{color:var(--green)}
579 .audio-status.loading{color:var(--gold)}
580
581 /* ── 21-DIM PANEL ── */
582 .dim-grid{display:grid;grid-template-columns:1fr 1fr;gap:3px;padding:10px 12px;max-height:280px;overflow-y:auto}
583 .dim-row{display:flex;align-items:center;gap:5px;padding:3px 5px;border-radius:4px;
584 transition:background .2s;cursor:default;min-width:0}
585 .dim-row:hover{background:rgba(255,255,255,.04)}
586 .dim-row.active{background:rgba(255,255,255,.02)}
587 .dim-dot{width:7px;height:7px;border-radius:50%;background:rgba(255,255,255,.15);flex-shrink:0;transition:all .3s}
588 .dim-name{font-size:9.5px;font-family:'JetBrains Mono',monospace;color:var(--muted);
589 white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;transition:color .3s}
590 .dim-row.active .dim-name{color:var(--text)}
591 .dim-bar-wrap{width:32px;height:4px;background:rgba(255,255,255,.07);border-radius:2px;flex-shrink:0}
592 .dim-bar{height:100%;width:0;border-radius:2px;transition:width .4s,background .3s}
593 .dim-group-label{grid-column:1/-1;font-size:9px;font-family:'JetBrains Mono',monospace;
594 color:rgba(255,255,255,.2);text-transform:uppercase;letter-spacing:.08em;
595 padding:5px 5px 2px;border-top:1px solid var(--border);margin-top:4px}
596 .dim-group-label:first-child{border-top:none;margin-top:0}
597
598 /* ── HEATMAP ── */
599 #heatmap-wrap{padding:12px 14px;overflow-x:auto}
600 #heatmap-svg{display:block}
601
602 /* ── CLI REFERENCE ── */
603 .cli-section{max-width:1400px;margin:0 auto;padding:0 14px 40px}
604 .cli-section h2{font-size:16px;font-weight:600;margin-bottom:14px;color:var(--accent)}
605 .cli-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:10px}
606 .cli-card{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:12px 14px}
607 .cli-cmd{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--accent);margin-bottom:4px}
608 .cli-desc{font-size:12px;color:var(--muted);margin-bottom:6px}
609 .cli-flags{display:flex;flex-direction:column;gap:2px}
610 .cli-flag{font-family:'JetBrains Mono',monospace;font-size:10px;color:rgba(255,255,255,.4)}
611 .cli-flag span{color:var(--text)}
612
613 /* ── INIT OVERLAY ── */
614 #init-overlay{position:fixed;inset:0;background:rgba(7,9,15,.88);
615 display:flex;flex-direction:column;align-items:center;justify-content:center;
616 z-index:100;backdrop-filter:blur(6px);gap:16px;text-align:center}
617 #init-overlay h2{font-size:24px;font-weight:700;color:var(--text)}
618 #init-overlay p{font-size:14px;color:var(--muted);max-width:400px}
619 .btn-init{padding:12px 28px;border-radius:8px;border:none;background:var(--accent);
620 color:#000;font-size:15px;font-weight:600;cursor:pointer;transition:all .2s}
621 .btn-init:hover{transform:scale(1.05);box-shadow:0 0 20px rgba(51,221,255,.4)}
622
623 /* ── BRANCH LEGEND ── */
624 .branch-legend{display:flex;flex-wrap:wrap;gap:10px;padding:6px 14px 10px}
625 .bl-item{display:flex;align-items:center;gap:5px;font-size:10px;
626 font-family:'JetBrains Mono',monospace;color:var(--muted)}
627 .bl-dot{width:9px;height:9px;border-radius:50%}
628 .bl-item.active .bl-dot{box-shadow:0 0 6px currentColor}
629 .bl-item.active span{color:var(--text)}
630
631 /* ── SCROLLBAR ── */
632 ::-webkit-scrollbar{width:5px;height:5px}
633 ::-webkit-scrollbar-track{background:transparent}
634 ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15);border-radius:3px}
635 </style>
636 </head>
637 <body>
638
639 <div id="init-overlay">
640 <h2>Muse MIDI Demo</h2>
641 <p>Groove in Em — 5-act VCS narrative · 5 instruments · 21 dimensions<br>Click to initialize audio engine.</p>
642 <button class="btn-init" id="btn-init-audio">Initialize Audio ▶</button>
643 </div>
644
645 <nav>
646 <span class="nav-logo">muse / midi-demo</span>
647 <div class="nav-links">
648 <a href="index.html">Docs</a>
649 <a href="demo.html">Demo</a>
650 <a href="midi-demo.html" class="active">MIDI</a>
651 </div>
652 <span class="nav-badge">v0.1.2</span>
653 </nav>
654
655 <div class="hero">
656 <h1>Groove in Em · Muse VCS</h1>
657 <div class="hero-sub">5-act VCS narrative · 5 instruments · 13 commits · 4 branches · 21 MIDI dimensions</div>
658 <div class="hero-tags">
659 <span class="hero-tag on">drums</span>
660 <span class="hero-tag on">bass</span>
661 <span class="hero-tag on">electric piano</span>
662 <span class="hero-tag on">lead synth</span>
663 <span class="hero-tag on">brass</span>
664 <span class="hero-tag">96 BPM</span>
665 <span class="hero-tag">E minor</span>
666 </div>
667 </div>
668
669 <!-- CONTROLS -->
670 <div class="ctrl-bar">
671 <div class="ctrl-group">
672 <button class="ctrl-btn" id="btn-first" title="First commit">⏮</button>
673 <button class="ctrl-btn" id="btn-prev" title="Previous commit">◀</button>
674 <button class="ctrl-play" id="btn-play" disabled title="Play / Pause">▶</button>
675 <button class="ctrl-btn" id="btn-next" title="Next commit">▶</button>
676 <button class="ctrl-btn" id="btn-last" title="Last commit">⏭</button>
677 </div>
678 <div class="ctrl-group ctrl-info">
679 <span class="ctrl-time" id="time-display">0:00</span>
680 <span class="ctrl-sha" id="sha-display">a0f4d2e1</span>
681 </div>
682 <div class="ctrl-msg" id="msg-display">muse init --domain midi</div>
683 <span class="audio-status" id="audio-status">○ click ▶ to load audio</span>
684 </div>
685
686 <!-- MAIN GRID -->
687 <div class="main-grid">
688
689 <!-- LEFT COLUMN -->
690 <div style="display:flex;flex-direction:column;gap:12px">
691
692 <!-- DAG -->
693 <div class="panel">
694 <div class="panel-hd">
695 <span>COMMIT DAG</span>
696 <span id="dag-branch-badge" class="nav-badge" style="border-color:rgba(79,142,247,.3);color:#4f8ef7">main</span>
697 </div>
698 <div class="branch-legend" id="branch-legend"></div>
699 <div id="dag-wrap"><svg id="dag-svg"></svg></div>
700 </div>
701
702 <!-- CMD LOG -->
703 <div class="panel">
704 <div class="panel-hd"><span>COMMAND LOG</span><span id="act-label">Act 0</span></div>
705 <div id="cmd-terminal">
706 <div class="term-dots">
707 <div class="term-dot t-red"></div>
708 <div class="term-dot t-yel"></div>
709 <div class="term-dot t-grn"></div>
710 </div>
711 <div id="cmd-prompt"><span class="cmd-cursor"></span></div>
712 </div>
713 </div>
714
715 <!-- 21-DIM PANEL -->
716 <div class="panel">
717 <div class="panel-hd"><span>21 MIDI DIMENSIONS</span><span id="dim-active-count">0 active</span></div>
718 <div class="dim-grid" id="dim-list"></div>
719 </div>
720
721 </div><!-- /left -->
722
723 <!-- RIGHT COLUMN -->
724 <div style="display:flex;flex-direction:column;gap:12px">
725
726 <!-- DAW TRACK VIEW -->
727 <div class="panel">
728 <div class="panel-hd"><span>DAW TRACK VIEW</span><span id="daw-commit-label">commit 0/12</span></div>
729 <div class="daw-wrap">
730 <svg id="daw-svg"></svg>
731 </div>
732 </div>
733
734 <!-- HEATMAP -->
735 <div class="panel">
736 <div class="panel-hd"><span>DIMENSION ACTIVITY HEATMAP</span><span style="color:var(--muted);font-size:10px">commits × 21 dimensions</span></div>
737 <div id="heatmap-wrap"><svg id="heatmap-svg"></svg></div>
738 </div>
739
740 </div><!-- /right -->
741 </div><!-- /main-grid -->
742
743 <!-- CLI REFERENCE -->
744 <div class="cli-section">
745 <h2>MIDI Plugin — Command Reference</h2>
746 <div class="cli-grid" id="cli-grid"></div>
747 </div>
748
749 <script>
750 // ═══════════════════════════════════════════════════════════════
751 // DATA
752 // ═══════════════════════════════════════════════════════════════
753 const BPM = __BPM__;
754 const BEAT = 60 / BPM;
755 const BAR = 4 * BEAT;
756 const TOTAL_SECS = 8 * BAR;
757
758 const COMMITS = __COMMITS__;
759
760 const DIMS_21 = [
761 {id:'notes', label:'notes', group:'core', color:'#33ddff', desc:'Note-on/off events'},
762 {id:'pitch_bend', label:'pitch_bend', group:'expr', color:'#f472b6', desc:'Pitch wheel automation'},
763 {id:'channel_pressure',label:'channel_pressure',group:'expr',color:'#fb923c',desc:'Channel aftertouch'},
764 {id:'poly_pressure', label:'poly_pressure', group:'expr', color:'#f97316', desc:'Per-note aftertouch'},
765 {id:'cc_modulation', label:'cc_modulation', group:'cc', color:'#a78bfa', desc:'CC 1 — vibrato/LFO'},
766 {id:'cc_volume', label:'cc_volume', group:'cc', color:'#60a5fa', desc:'CC 7 — channel volume'},
767 {id:'cc_pan', label:'cc_pan', group:'cc', color:'#34d399', desc:'CC 10 — stereo pan'},
768 {id:'cc_expression', label:'cc_expression', group:'cc', color:'#f59e0b', desc:'CC 11 — expression'},
769 {id:'cc_sustain', label:'cc_sustain', group:'cc', color:'#22d3ee', desc:'CC 64 — sustain pedal'},
770 {id:'cc_portamento', label:'cc_portamento', group:'cc', color:'#a855f7', desc:'CC 65 — portamento on/off'},
771 {id:'cc_sostenuto', label:'cc_sostenuto', group:'cc', color:'#818cf8', desc:'CC 66 — sostenuto pedal'},
772 {id:'cc_soft_pedal', label:'cc_soft_pedal', group:'cc', color:'#6ee7b7', desc:'CC 67 — soft pedal'},
773 {id:'cc_reverb', label:'cc_reverb', group:'fx', color:'#c4b5fd', desc:'CC 91 — reverb send'},
774 {id:'cc_chorus', label:'cc_chorus', group:'fx', color:'#93c5fd', desc:'CC 93 — chorus send'},
775 {id:'cc_other', label:'cc_other', group:'fx', color:'#6b7280', desc:'Other CC controllers'},
776 {id:'program_change',label:'program_change', group:'meta', color:'#f9a825', desc:'Instrument program selection'},
777 {id:'tempo_map', label:'tempo_map', group:'meta', color:'#ef4444', desc:'BPM automation'},
778 {id:'time_signatures',label:'time_signatures',group:'meta',color:'#ec4899', desc:'Meter changes'},
779 {id:'key_signatures',label:'key_signatures', group:'meta', color:'#d946ef', desc:'Key / mode changes'},
780 {id:'markers', label:'markers', group:'meta', color:'#8b5cf6', desc:'Named timeline markers'},
781 {id:'track_structure',label:'track_structure',group:'meta',color:'#64748b', desc:'Track count & arrangement'},
782 ];
783
784 const BRANCH_COLOR = {
785 'main':'#4f8ef7', 'feat/groove':'#a855f7',
786 'feat/harmony':'#22d3ee', 'conflict/brass-a':'#ef4444', 'conflict/ensemble':'#f59e0b'
787 };
788
789 const INSTR_COLOR = {
790 kick:'#ef4444', snare:'#fb923c', hat_c:'#facc15', hat_o:'#86efac',
791 ghost:'rgba(251,146,60,0.35)', crash:'#fef3c7',
792 bass:'#a855f7', epiano:'#22d3ee', lead:'#f472b6', brass:'#34d399', brassb:'#86efac'
793 };
794
795 const INSTR_LABEL = {
796 kick:'KICK', snare:'SNARE', hat_c:'HAT', hat_o:'HAT',
797 ghost:'GHOST', crash:'CRASH', bass:'BASS', epiano:'E.PIANO', lead:'LEAD',
798 brass:'BRASS A', brassb:'BRASS B'
799 };
800
801 const ACT_LABELS = ['Init', 'Foundation', 'Divergence', 'Clean Merge', 'Conflict', 'Resolution'];
802
803 // ═══════════════════════════════════════════════════════════════
804 // STATE
805 // ═══════════════════════════════════════════════════════════════
806 const state = {
807 cur: 0,
808 isPlaying: false,
809 audioReady: false,
810 pausedAt: null, // null = not paused, number = paused at this second
811 playStartWallClock: 0,
812 playStartAudioSec: 0,
813 rafId: null,
814 };
815
816 let instruments = {};
817 let masterBus = null;
818
819 // ═══════════════════════════════════════════════════════════════
820 // AUDIO ENGINE (Tone.js, multi-instrument)
821 // ═══════════════════════════════════════════════════════════════
822 async function initAudio() {
823 const overlay = document.getElementById('init-overlay');
824 const statusEl = document.getElementById('audio-status');
825 const btn = document.getElementById('btn-play');
826
827 if (overlay) overlay.style.display = 'none';
828 statusEl.textContent = '◌ loading…';
829 statusEl.className = 'audio-status loading';
830
831 await Tone.start();
832
833 // Master chain: Compressor → Limiter → Destination
834 const limiter = new Tone.Limiter(-1).toDestination();
835 const masterComp = new Tone.Compressor({threshold:-18, ratio:4, attack:0.003, release:0.25}).connect(limiter);
836 masterBus = masterComp;
837
838 // Per-instrument reverb sends
839 const roomRev = new Tone.Reverb({decay:1.8, wet:0.18}).connect(masterBus);
840 const hallRev = new Tone.Reverb({decay:3.5, wet:0.28}).connect(masterBus);
841
842 // 808-style kick
843 const kick = new Tone.MembraneSynth({
844 pitchDecay:0.08, octaves:8,
845 envelope:{attack:0.001, decay:0.28, sustain:0, release:0.12},
846 volume:2
847 }).connect(masterBus);
848
849 // Snare
850 const snare = new Tone.NoiseSynth({
851 noise:{type:'white'},
852 envelope:{attack:0.001, decay:0.14, sustain:0, release:0.06},
853 volume:-4
854 }).connect(masterBus);
855
856 // Closed hi-hat
857 const hat_c = new Tone.MetalSynth({
858 frequency:600, harmonicity:5.1, modulationIndex:32,
859 resonance:4000, octaves:1.5,
860 envelope:{attack:0.001, decay:0.028, release:0.01},
861 volume:-16
862 }).connect(masterBus);
863
864 // Open hi-hat
865 const hat_o = new Tone.MetalSynth({
866 frequency:600, harmonicity:5.1, modulationIndex:32,
867 resonance:4000, octaves:1.5,
868 envelope:{attack:0.001, decay:0.22, release:0.08},
869 volume:-13
870 }).connect(masterBus);
871
872 // Ghost snare (quieter)
873 const ghost = new Tone.NoiseSynth({
874 noise:{type:'white'},
875 envelope:{attack:0.001, decay:0.04, sustain:0, release:0.01},
876 volume:-20
877 }).connect(masterBus);
878
879 // Crash cymbal
880 const crash = new Tone.MetalSynth({
881 frequency:300, harmonicity:5.1, modulationIndex:64,
882 resonance:4000, octaves:2.5,
883 envelope:{attack:0.001, decay:1.6, release:0.8},
884 volume:-10
885 }).connect(masterBus);
886
887 // Bass guitar (fat mono saw + resonant filter)
888 const bass = new Tone.MonoSynth({
889 oscillator:{type:'sawtooth'},
890 filter:{Q:3, type:'lowpass', rolloff:-24},
891 filterEnvelope:{attack:0.002, decay:0.15, sustain:0.5, release:0.4, baseFrequency:260, octaves:3},
892 envelope:{attack:0.004, decay:0.12, sustain:0.85, release:0.35},
893 volume:-2
894 }).connect(masterBus);
895 bass.connect(roomRev);
896
897 // Electric piano (FM — warm Rhodes-ish)
898 const epiano = new Tone.PolySynth(Tone.FMSynth, {
899 harmonicity:3.01, modulationIndex:14,
900 oscillator:{type:'triangle'},
901 envelope:{attack:0.01, decay:1.1, sustain:0.5, release:0.6},
902 modulation:{type:'square'},
903 modulationEnvelope:{attack:0.002, decay:0.12, sustain:0.2, release:0.01},
904 volume:-10
905 }).connect(masterBus);
906 epiano.connect(roomRev);
907
908 // Lead synth (fat detune sawtooth)
909 const lead = new Tone.PolySynth(Tone.Synth, {
910 oscillator:{type:'fatsawtooth', spread:28, count:3},
911 envelope:{attack:0.025, decay:0.18, sustain:0.65, release:0.45},
912 volume:-9
913 }).connect(masterBus);
914 lead.connect(hallRev);
915
916 // Brass A (punchy staccato)
917 const brass = new Tone.PolySynth(Tone.Synth, {
918 oscillator:{type:'sawtooth'},
919 envelope:{attack:0.008, decay:0.25, sustain:0.75, release:0.18},
920 volume:-8
921 }).connect(masterBus);
922 brass.connect(roomRev);
923
924 // Brass B / Ensemble (legato lush pads)
925 const brassb = new Tone.PolySynth(Tone.Synth, {
926 oscillator:{type:'triangle'},
927 envelope:{attack:0.32, decay:0.6, sustain:0.82, release:0.9},
928 volume:-12
929 }).connect(masterBus);
930 brassb.connect(hallRev);
931
932 instruments = { kick, snare, hat_c, hat_o, ghost, crash, bass, epiano, lead, brass, brassb };
933
934 state.audioReady = true;
935 btn.disabled = false;
936 statusEl.textContent = '● audio ready';
937 statusEl.className = 'audio-status ready';
938 }
939
940 // ── Play helpers ────────────────────────────────────────────────
941
942 function fmtTime(sec) {
943 const m = Math.floor(sec / 60);
944 const s = Math.floor(sec % 60);
945 return `${m}:${s.toString().padStart(2,'0')}`;
946 }
947
948 function _scheduleNotes(notes, offsetSec) {
949 // Group by (instr, start_sec, dur_sec) for polyphonic batching
950 const groups = {};
951 for (const [pitch, vel, startSec, durSec, instr] of notes) {
952 if (startSec < offsetSec - 0.01) continue; // skip already-played
953 const key = `${instr}__${startSec.toFixed(4)}__${durSec.toFixed(4)}`;
954 if (!groups[key]) groups[key] = {instr, startSec, durSec, velMax:0, pitches:[]};
955 groups[key].pitches.push(pitch);
956 groups[key].velMax = Math.max(groups[key].velMax, vel);
957 }
958
959 const origin = Tone.now() + 0.15 - offsetSec;
960
961 for (const grp of Object.values(groups)) {
962 const syn = instruments[grp.instr];
963 if (!syn) continue;
964 const when = origin + grp.startSec;
965 if (when < Tone.now()) continue;
966 const velN = grp.velMax / 127;
967 const dur = Math.max(0.02, grp.durSec);
968
969 try {
970 if (grp.instr === 'kick') syn.triggerAttackRelease('C2', dur, when, velN);
971 else if (['snare','ghost'].includes(grp.instr)) syn.triggerAttackRelease(dur, when, velN);
972 else if (['hat_c','hat_o','crash'].includes(grp.instr)) syn.triggerAttackRelease(dur, when, velN);
973 else {
974 const freqs = grp.pitches.map(p => Tone.Frequency(p,'midi').toNote());
975 syn.triggerAttackRelease(freqs.length === 1 ? freqs[0] : freqs, dur, when, velN);
976 }
977 } catch(e) { /* ignore scheduling errors */ }
978 }
979 }
980
981 function stopPlayback() {
982 state.isPlaying = false;
983 state.pausedAt = null;
984 if (state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; }
985 try { Tone.getTransport().stop(); Tone.getTransport().cancel(); } catch(e) {}
986 document.getElementById('time-display').textContent = '0:00';
987 document.getElementById('btn-play').className = 'ctrl-play';
988 document.getElementById('btn-play').textContent = '▶';
989 DAW.setPlayhead(0);
990 }
991
992 function pausePlayback() {
993 const elapsed = (performance.now() - state.playStartWallClock) / 1000;
994 state.pausedAt = state.playStartAudioSec + elapsed;
995 state.isPlaying = false;
996 if (state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; }
997 try { Tone.getTransport().stop(); Tone.getTransport().cancel(); } catch(e) {}
998 document.getElementById('btn-play').className = 'ctrl-play';
999 document.getElementById('btn-play').textContent = '▶';
1000 }
1001
1002 function playNotes(notes, fromSec) {
1003 const startAt = fromSec ?? 0;
1004 state.isPlaying = true;
1005 state.pausedAt = null;
1006 state.playStartWallClock = performance.now() - startAt * 1000;
1007 state.playStartAudioSec = startAt;
1008
1009 _scheduleNotes(notes, startAt);
1010
1011 document.getElementById('btn-play').className = 'ctrl-play playing';
1012 document.getElementById('btn-play').textContent = '⏸';
1013
1014 // Animation loop
1015 const animate = () => {
1016 const elapsed = (performance.now() - state.playStartWallClock) / 1000;
1017 const sec = state.playStartAudioSec + elapsed;
1018 document.getElementById('time-display').textContent = fmtTime(elapsed);
1019 DAW.setPlayhead(sec);
1020
1021 if (elapsed >= TOTAL_SECS + 0.5) {
1022 stopPlayback();
1023 return;
1024 }
1025 state.rafId = requestAnimationFrame(animate);
1026 };
1027 state.rafId = requestAnimationFrame(animate);
1028 }
1029
1030 // ═══════════════════════════════════════════════════════════════
1031 // COMMIT NAVIGATION
1032 // ═══════════════════════════════════════════════════════════════
1033 function selectCommit(idx) {
1034 const wasPlaying = state.isPlaying;
1035 if (state.isPlaying) stopPlayback();
1036
1037 state.cur = Math.max(0, Math.min(COMMITS.length - 1, idx));
1038 const commit = COMMITS[state.cur];
1039
1040 // Update UI elements
1041 document.getElementById('sha-display').textContent = commit.sha.slice(0,8);
1042 document.getElementById('msg-display').textContent = commit.cmd;
1043 document.getElementById('act-label').textContent = `Act ${commit.act} · ${ACT_LABELS[commit.act] || ''}`;
1044 document.getElementById('daw-commit-label').textContent = `commit ${state.cur + 1}/${COMMITS.length}`;
1045
1046 const bColor = BRANCH_COLOR[commit.branch] || '#fff';
1047 const badge = document.getElementById('dag-branch-badge');
1048 badge.textContent = commit.branch;
1049 badge.style.color = bColor;
1050 badge.style.borderColor = bColor + '40';
1051 badge.style.background = bColor + '14';
1052
1053 DAG.select(state.cur);
1054 DAW.render(commit);
1055 DimPanel.update(commit);
1056 CmdLog.show(commit);
1057
1058 if (wasPlaying && commit.notes.length) {
1059 playNotes(commit.notes, 0);
1060 }
1061 }
1062
1063 // ═══════════════════════════════════════════════════════════════
1064 // DAG RENDERER
1065 // ═══════════════════════════════════════════════════════════════
1066 const DAG = (() => {
1067 const W = 300, PADX = 30, PADY = 22, NODE_R = 11;
1068
1069 // Assign column per branch
1070 const BRANCH_COL = {
1071 'main':0, 'feat/groove':1, 'feat/harmony':2,
1072 'conflict/brass-a':1, 'conflict/ensemble':2
1073 };
1074
1075 const positions = COMMITS.map((c, i) => {
1076 const col = BRANCH_COL[c.branch] ?? 0;
1077 const ncols = 3;
1078 const xStep = (W - 2*PADX) / (ncols - 0.5);
1079 return { x: PADX + col * xStep, y: PADY + i * 34, c };
1080 });
1081
1082 const H = PADY + (COMMITS.length - 1) * 34 + PADY + 10;
1083 const svg = d3.select('#dag-svg').attr('width', W).attr('height', H);
1084
1085 // Gradient defs
1086 const defs = svg.append('defs');
1087 Object.entries(BRANCH_COLOR).forEach(([branch, color]) => {
1088 const g = defs.append('radialGradient').attr('id', `glow-${branch.replace(/\\W/g,'_')}`);
1089 g.append('stop').attr('offset','0%').attr('stop-color', color).attr('stop-opacity', 0.4);
1090 g.append('stop').attr('offset','100%').attr('stop-color', color).attr('stop-opacity', 0);
1091 });
1092
1093 // Edges
1094 COMMITS.forEach((c, i) => {
1095 const p2 = positions[i];
1096 (c.parents || []).forEach(psha => {
1097 const pi = COMMITS.findIndex(x => x.sha === psha);
1098 if (pi < 0) return;
1099 const p1 = positions[pi];
1100 if (p1.x === p2.x) {
1101 svg.append('line')
1102 .attr('x1', p1.x).attr('y1', p1.y)
1103 .attr('x2', p2.x).attr('y2', p2.y - NODE_R - 1)
1104 .attr('stroke', BRANCH_COLOR[c.branch] || '#666')
1105 .attr('stroke-width', 1.5).attr('stroke-opacity', 0.4);
1106 } else {
1107 const my = (p1.y + p2.y) / 2;
1108 const path = `M${p1.x},${p1.y} C${p1.x},${my} ${p2.x},${my} ${p2.x},${p2.y - NODE_R - 1}`;
1109 svg.append('path').attr('d', path).attr('fill','none')
1110 .attr('stroke', BRANCH_COLOR[c.branch] || '#666')
1111 .attr('stroke-width', 1.5).attr('stroke-opacity', 0.3)
1112 .attr('stroke-dasharray', '4,2');
1113 }
1114 });
1115 });
1116
1117 // Nodes
1118 const nodeGs = svg.selectAll('.dag-node').data(COMMITS).join('g')
1119 .attr('class','dag-node')
1120 .attr('transform',(_,i) => `translate(${positions[i].x},${positions[i].y})`)
1121 .attr('cursor','pointer')
1122 .on('click',(_,d) => selectCommit(COMMITS.indexOf(d)));
1123
1124 // Glow
1125 nodeGs.append('circle').attr('r', NODE_R+7).attr('class','node-glow')
1126 .attr('fill', d => `url(#glow-${d.branch.replace(/\\W/g,'_')})`)
1127 .attr('opacity', 0);
1128
1129 // Ring
1130 nodeGs.append('circle').attr('r', NODE_R+3).attr('class','node-ring')
1131 .attr('fill','none').attr('stroke', d => BRANCH_COLOR[d.branch]||'#fff')
1132 .attr('stroke-width', 1.5).attr('opacity', 0);
1133
1134 // Main circle
1135 nodeGs.append('circle').attr('r', NODE_R)
1136 .attr('fill', d => d.conflict ? '#1a0505' : d.resolved ? '#011a0d' : '#0d1118')
1137 .attr('stroke', d => BRANCH_COLOR[d.branch]||'#fff').attr('stroke-width', 1.8);
1138
1139 // Icon
1140 nodeGs.append('text').attr('text-anchor','middle').attr('dy','0.38em')
1141 .attr('font-size', 9).attr('fill', d => BRANCH_COLOR[d.branch]||'#fff')
1142 .attr('font-family','JetBrains Mono, monospace')
1143 .text(d => d.conflict ? '⚠' : d.resolved ? '✓' : d.sha.slice(0,4));
1144
1145 // Label
1146 nodeGs.each(function(d, i) {
1147 const g = d3.select(this);
1148 const lines = d.label.split('\\n');
1149 lines.forEach((line, li) => {
1150 g.append('text').attr('text-anchor','start')
1151 .attr('x', NODE_R + 5).attr('y', (li - (lines.length-1)/2) * 11 + 1)
1152 .attr('font-size', 8.5).attr('fill','rgba(255,255,255,0.45)')
1153 .attr('font-family','JetBrains Mono, monospace').text(line);
1154 });
1155 });
1156
1157 function select(idx) {
1158 svg.selectAll('.node-ring').attr('opacity', 0);
1159 svg.selectAll('.node-glow').attr('opacity', 0);
1160 const c = COMMITS[idx];
1161 svg.selectAll('.dag-node').filter(d => d.sha === c.sha)
1162 .select('.node-ring').attr('opacity', 1);
1163 svg.selectAll('.dag-node').filter(d => d.sha === c.sha)
1164 .select('.node-glow').attr('opacity', 1);
1165 }
1166
1167 return { select };
1168 })();
1169
1170 // ═══════════════════════════════════════════════════════════════
1171 // DAW TRACK VIEW
1172 // ═══════════════════════════════════════════════════════════════
1173 const DAW = (() => {
1174 const LABEL_W = 64;
1175 const DRUM_TYPES = { crash:0, hat_o:1, hat_c:2, ghost:3, snare:4, kick:5 };
1176
1177 const TRACKS = [
1178 { key:'drums', label:'DRUMS', instrs:['kick','snare','hat_c','hat_o','ghost','crash'], color:'#ef4444', h:62 },
1179 { key:'bass', label:'BASS', instrs:['bass'], color:'#a855f7', h:44, pMin:36, pMax:60 },
1180 { key:'epiano', label:'E.PIANO', instrs:['epiano'], color:'#22d3ee', h:52, pMin:50, pMax:74 },
1181 { key:'lead', label:'LEAD', instrs:['lead'], color:'#f472b6', h:44, pMin:62, pMax:78 },
1182 { key:'brass', label:'BRASS', instrs:['brass','brassb'], color:'#34d399', h:44, pMin:50, pMax:78 },
1183 ];
1184
1185 const GAP = 5;
1186 const totalH = TRACKS.reduce((a,t) => a + t.h + GAP, 0) + 30; // +30 for time axis
1187 const svgW = 720;
1188
1189 const svg = d3.select('#daw-svg').attr('width', svgW).attr('height', totalH);
1190 d3.select('#daw-svg').style('min-width', `${svgW}px`);
1191
1192 const contentW = svgW - LABEL_W;
1193 const xScale = d3.scaleLinear().domain([0, TOTAL_SECS]).range([LABEL_W, svgW - 8]);
1194
1195 // Time axis
1196 const timeG = svg.append('g').attr('transform', `translate(0,${totalH - 24})`);
1197 timeG.append('line').attr('x1', LABEL_W).attr('x2', svgW-8).attr('y1',0).attr('y2',0)
1198 .attr('stroke','rgba(255,255,255,0.1)');
1199 d3.range(0, TOTAL_SECS+1, BAR).forEach(sec => {
1200 const x = xScale(sec);
1201 timeG.append('line').attr('x1',x).attr('x2',x).attr('y1',0).attr('y2',5)
1202 .attr('stroke','rgba(255,255,255,0.2)');
1203 timeG.append('text').attr('x',x).attr('y',15)
1204 .attr('class','daw-time-label').attr('text-anchor','middle')
1205 .text(`${Math.round(sec)}s`);
1206 });
1207
1208 // Bar lines (every beat)
1209 d3.range(0, TOTAL_SECS, BEAT).forEach(sec => {
1210 svg.append('line')
1211 .attr('x1', xScale(sec)).attr('x2', xScale(sec))
1212 .attr('y1', 0).attr('y2', totalH-24)
1213 .attr('stroke', sec % BAR < 0.01 ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.025)')
1214 .attr('stroke-width', sec % BAR < 0.01 ? 1 : 0.5);
1215 });
1216
1217 // Track backgrounds
1218 let yOff = 0;
1219 TRACKS.forEach(track => {
1220 svg.append('rect').attr('x', LABEL_W).attr('y', yOff).attr('width', contentW)
1221 .attr('height', track.h).attr('fill','rgba(255,255,255,0.015)').attr('rx', 3);
1222 svg.append('text').attr('x', LABEL_W - 6).attr('y', yOff + track.h/2 + 1)
1223 .attr('class','daw-track-label').attr('dy','0.35em').text(track.label)
1224 .attr('fill', track.color + '88');
1225 // Separator
1226 svg.append('line').attr('x1', 0).attr('x2', svgW)
1227 .attr('y1', yOff + track.h + GAP/2).attr('y2', yOff + track.h + GAP/2)
1228 .attr('stroke','rgba(255,255,255,0.04)');
1229 yOff += track.h + GAP;
1230 });
1231
1232 // Note groups (cleared on each render)
1233 const notesG = svg.append('g').attr('class','notes-g');
1234
1235 // Playhead
1236 const playheadG = svg.append('g');
1237 const playheadLine = playheadG.append('line').attr('class','playhead-line')
1238 .attr('x1', xScale(0)).attr('x2', xScale(0))
1239 .attr('y1', 0).attr('y2', totalH - 26).attr('opacity', 0);
1240
1241 function setPlayhead(sec) {
1242 const x = xScale(Math.min(sec, TOTAL_SECS));
1243 playheadLine.attr('x1', x).attr('x2', x).attr('opacity', sec > 0 ? 0.8 : 0);
1244 }
1245
1246 function render(commit) {
1247 notesG.selectAll('*').remove();
1248 const notes = commit.notes || [];
1249 if (!notes.length) return;
1250
1251 const byInstr = {};
1252 for (const [pitch, vel, startSec, durSec, instr] of notes) {
1253 (byInstr[instr] = byInstr[instr] || []).push([pitch, vel, startSec, durSec]);
1254 }
1255
1256 let yOff = 0;
1257 TRACKS.forEach(track => {
1258 const trackNotes = track.instrs.flatMap(k => (byInstr[k] || []).map(n => ({...n, instr:k})));
1259 if (!trackNotes.length) { yOff += track.h + GAP; return; }
1260
1261 if (track.key === 'drums') {
1262 const nRows = 6;
1263 const rowH = (track.h - 4) / nRows;
1264 for (const nt of trackNotes) {
1265 const row = DRUM_TYPES[nt.instr] ?? 2;
1266 const y = yOff + 2 + row * rowH;
1267 const x = xScale(nt[2]);
1268 const w = Math.max(2, (xScale(nt[2] + nt[3]) - x) * 0.9);
1269 notesG.append('rect').attr('x', x).attr('y', y).attr('width', w)
1270 .attr('height', rowH - 1).attr('rx', 1)
1271 .attr('fill', INSTR_COLOR[nt.instr] || '#fff')
1272 .attr('opacity', nt.instr === 'ghost' ? 0.4 : 0.85);
1273 }
1274 } else {
1275 const pMin = track.pMin || 36;
1276 const pMax = track.pMax || 80;
1277 for (const nt of trackNotes) {
1278 const pitch = nt[0]; const vel = nt[1];
1279 const frac = (pitch - pMin) / (pMax - pMin);
1280 const y = yOff + track.h - 4 - frac * (track.h - 8);
1281 const x = xScale(nt[2]);
1282 const w = Math.max(3, (xScale(nt[2] + nt[3]) - x) * 0.9);
1283 const alpha = 0.5 + (vel / 127) * 0.5;
1284 notesG.append('rect').attr('x', x).attr('y', y - 3)
1285 .attr('width', w).attr('height', 6).attr('rx', 2)
1286 .attr('fill', INSTR_COLOR[nt.instr] || track.color)
1287 .attr('opacity', alpha);
1288 }
1289 }
1290
1291 yOff += track.h + GAP;
1292 });
1293 }
1294
1295 return { render, setPlayhead };
1296 })();
1297
1298 // ═══════════════════════════════════════════════════════════════
1299 // 21-DIMENSION PANEL
1300 // ═══════════════════════════════════════════════════════════════
1301 const DimPanel = (() => {
1302 const container = document.getElementById('dim-list');
1303 const groups = ['core','expr','cc','fx','meta'];
1304 const GL = {core:'Core',expr:'Expression',cc:'Controllers (CC)',fx:'Effects',meta:'Meta / Structure'};
1305
1306 groups.forEach(grp => {
1307 const dims = DIMS_21.filter(d => d.group === grp);
1308 if (!dims.length) return;
1309 const lbl = document.createElement('div');
1310 lbl.className = 'dim-group-label'; lbl.textContent = GL[grp];
1311 container.appendChild(lbl);
1312 dims.forEach(dim => {
1313 const row = document.createElement('div');
1314 row.className = 'dim-row'; row.id = `dr-${dim.id}`; row.title = dim.desc;
1315 row.innerHTML = `<div class="dim-dot" id="dd-${dim.id}"></div>
1316 <div class="dim-name">${dim.label}</div>
1317 <div class="dim-bar-wrap"><div class="dim-bar" id="db-${dim.id}"></div></div>`;
1318 container.appendChild(row);
1319 });
1320 });
1321
1322 function update(commit) {
1323 const act = commit.dimAct || {};
1324 let cnt = 0;
1325 DIMS_21.forEach(dim => {
1326 const level = act[dim.id] || 0;
1327 const row = document.getElementById(`dr-${dim.id}`);
1328 const dot = document.getElementById(`dd-${dim.id}`);
1329 const bar = document.getElementById(`db-${dim.id}`);
1330 if (!row) return;
1331 if (level > 0) {
1332 cnt++;
1333 row.classList.add('active');
1334 dot.style.background = dim.color; dot.style.boxShadow = `0 0 5px ${dim.color}`;
1335 bar.style.background = dim.color; bar.style.width = `${Math.min(level*25,100)}%`;
1336 } else {
1337 row.classList.remove('active');
1338 dot.style.background = 'rgba(255,255,255,0.12)'; dot.style.boxShadow = '';
1339 bar.style.width = '0'; bar.style.background = '';
1340 }
1341 });
1342 document.getElementById('dim-active-count').textContent = `${cnt} active`;
1343 }
1344
1345 return { update };
1346 })();
1347
1348 // ═══════════════════════════════════════════════════════════════
1349 // COMMAND LOG
1350 // ═══════════════════════════════════════════════════════════════
1351 const CmdLog = (() => {
1352 const prompt = document.getElementById('cmd-prompt');
1353 let timer = null;
1354
1355 function show(commit) {
1356 if (timer) clearTimeout(timer);
1357 const lines = commit.output.split('\\n');
1358 const isWarn = commit.conflict;
1359 const isOk = commit.resolved;
1360
1361 let html = `<div class="cmd-line">$ ${commit.cmd}</div>`;
1362 lines.forEach(line => {
1363 const cls = line.startsWith('⚠') || line.includes('CONFLICT') ? 'cmd-warn'
1364 : line.startsWith('✓') ? 'cmd-ok'
1365 : line.startsWith('✗') ? 'cmd-err'
1366 : '';
1367 html += `<div class="${cls}">${line}</div>`;
1368 });
1369 prompt.innerHTML = html;
1370 }
1371
1372 return { show };
1373 })();
1374
1375 // ═══════════════════════════════════════════════════════════════
1376 // HEATMAP
1377 // ═══════════════════════════════════════════════════════════════
1378 (function buildHeatmap() {
1379 const cellW = 32, cellH = 10, padL = 88, padT = 10;
1380 const nCols = COMMITS.length;
1381 const nRows = DIMS_21.length;
1382 const W = padL + nCols * cellW + 10;
1383 const H = padT + nRows * cellH + 22;
1384
1385 const svg = d3.select('#heatmap-svg').attr('width', W).attr('height', H);
1386 d3.select('#heatmap-svg').style('min-width', `${W}px`);
1387
1388 // Row labels
1389 DIMS_21.forEach((dim, ri) => {
1390 svg.append('text').attr('x', padL - 4).attr('y', padT + ri * cellH + cellH/2 + 1)
1391 .attr('text-anchor','end').attr('dy','0.35em')
1392 .attr('font-family','JetBrains Mono,monospace').attr('font-size', 7.5)
1393 .attr('fill', dim.color + 'aa').text(dim.label);
1394 });
1395
1396 // Col labels (sha)
1397 COMMITS.forEach((c, ci) => {
1398 svg.append('text').attr('x', padL + ci * cellW + cellW/2)
1399 .attr('y', H - 6).attr('text-anchor','middle')
1400 .attr('font-family','JetBrains Mono,monospace').attr('font-size', 7)
1401 .attr('fill', BRANCH_COLOR[c.branch] + 'aa').text(c.sha.slice(0,4));
1402 });
1403
1404 // Cells
1405 const cells = svg.selectAll('.hm-cell')
1406 .data(COMMITS.flatMap((c,ci) => DIMS_21.map((dim,ri) => ({ci,ri,dim,c,level:c.dimAct[dim.id]||0}))))
1407 .join('rect').attr('class','hm-cell')
1408 .attr('x', d => padL + d.ci * cellW + 1)
1409 .attr('y', d => padT + d.ri * cellH + 1)
1410 .attr('width', cellW - 2).attr('height', cellH - 2).attr('rx', 1)
1411 .attr('fill', d => d.level > 0 ? d.dim.color : 'rgba(255,255,255,0.04)')
1412 .attr('opacity', d => d.level > 0 ? Math.min(0.9, 0.25 + d.level * 0.22) : 1)
1413 .attr('cursor','pointer')
1414 .on('mouseover', function(evt, d) {
1415 d3.select(this).attr('stroke', d.dim.color).attr('stroke-width', 1);
1416 })
1417 .on('mouseout', function() { d3.select(this).attr('stroke','none'); });
1418
1419 // Highlight column on commit select
1420 window._heatmapSelectCol = function(idx) {
1421 cells.attr('opacity', d => {
1422 const base = d.level > 0 ? Math.min(0.9, 0.25 + d.level * 0.22) : 1;
1423 if (d.ci !== idx) return d.level > 0 ? base * 0.4 : 0.3;
1424 return base;
1425 });
1426 svg.selectAll('.hm-col-hl').remove();
1427 svg.append('rect').attr('class','hm-col-hl')
1428 .attr('x', padL + idx * cellW).attr('y', padT - 2)
1429 .attr('width', cellW).attr('height', nRows * cellH + 4)
1430 .attr('fill','none').attr('stroke', BRANCH_COLOR[COMMITS[idx].branch]||'#fff')
1431 .attr('stroke-width', 1).attr('rx', 2).attr('opacity', 0.45);
1432 };
1433 })();
1434
1435 // ═══════════════════════════════════════════════════════════════
1436 // BRANCH LEGEND
1437 // ═══════════════════════════════════════════════════════════════
1438 (function buildLegend() {
1439 const el = document.getElementById('branch-legend');
1440 Object.entries(BRANCH_COLOR).forEach(([branch, color]) => {
1441 const item = document.createElement('div');
1442 item.className = 'bl-item';
1443 item.innerHTML = `<div class="bl-dot" style="background:${color};box-shadow:0 0 5px ${color}"></div>
1444 <span>${branch}</span>`;
1445 el.appendChild(item);
1446 });
1447 })();
1448
1449 // ═══════════════════════════════════════════════════════════════
1450 // CLI REFERENCE
1451 // ═══════════════════════════════════════════════════════════════
1452 (function buildCLI() {
1453 const commands = [
1454 { cmd:'muse init --domain midi',
1455 desc:'Initialize a Muse repository with the MIDI domain plugin.',
1456 flags:['--domain <name> specify domain plugin (midi, code, …)',
1457 '--bare create a bare repository'],
1458 ret:'✓ .muse/ directory created with domain config' },
1459 { cmd:'muse commit -m <msg>',
1460 desc:'Snapshot current MIDI state and create a new commit.',
1461 flags:['-m <message> commit message',
1462 '--domain <name> override domain for this commit',
1463 '--no-verify skip pre-commit hooks'],
1464 ret:'[<branch> <sha8>] <message>' },
1465 { cmd:'muse status',
1466 desc:'Show working directory status vs HEAD snapshot.',
1467 flags:['--short machine-readable one-line output',
1468 '--porcelain stable scripting format'],
1469 ret:'Added/modified/removed files; clean or dirty state' },
1470 { cmd:'muse diff [<sha>]',
1471 desc:'Show 21-dimensional delta between working dir and a commit.',
1472 flags:['--stat summary only (file counts + dim counts)',
1473 '--dim <name> filter to one MIDI dimension',
1474 '--commit <sha> compare two commits'],
1475 ret:'StructuredDelta per file: notes±, CC changes, bend curves' },
1476 { cmd:'muse log [--oneline] [--stat]',
1477 desc:'Show commit history with branch topology.',
1478 flags:['--oneline compact one-line format',
1479 '--stat include files + dimension summary',
1480 '--graph ASCII branch graph'],
1481 ret:'Ordered commit list with SHA, message, branch, timestamp' },
1482 { cmd:'muse branch -b <name>',
1483 desc:'Create a new branch at the current HEAD.',
1484 flags:['-b <name> name of the new branch',
1485 '--list list all branches',
1486 '-d <name> delete a branch'],
1487 ret:'✓ Branch <name> created at <sha8>' },
1488 { cmd:'muse checkout <branch>',
1489 desc:'Switch to a branch or restore a commit.',
1490 flags:['<branch> branch name or commit SHA',
1491 '-b <name> create and switch in one step'],
1492 ret:'Switched to branch <name>; working dir restored' },
1493 { cmd:'muse merge <branch> [<branch2>]',
1494 desc:'Three-way MIDI merge using the 21-dim engine.',
1495 flags:['<branch> branch to merge into current',
1496 '--strategy ours|theirs|auto conflict resolution',
1497 '--no-ff always create a merge commit'],
1498 ret:'✓ 0 conflicts — or — ⚠ CONFLICT in <dim> on <file>' },
1499 { cmd:'muse resolve --strategy <s> <dim>',
1500 desc:'Resolve a dimension conflict after a failed merge.',
1501 flags:['--strategy ours|theirs|auto|manual merge strategy',
1502 '<dim> MIDI dimension to resolve (e.g. cc_reverb)'],
1503 ret:'✓ Resolved <dim> using strategy <s>' },
1504 { cmd:'muse stash / stash pop',
1505 desc:'Park uncommitted changes and restore later.',
1506 flags:['stash save working dir to stash',
1507 'stash pop restore last stash',
1508 'stash list list all stash entries'],
1509 ret:'✓ Stashed <N> changes / ✓ Popped stash@{0}' },
1510 { cmd:'muse cherry-pick <sha>',
1511 desc:'Apply a single commit from any branch.',
1512 flags:['<sha> commit ID to cherry-pick (full or short)',
1513 '--no-commit apply changes without committing'],
1514 ret:'[<branch> <sha8>] cherry-pick of <src-sha>' },
1515 { cmd:'muse tag add <name>',
1516 desc:'Create a lightweight tag at the current HEAD.',
1517 flags:['add <name> create tag',
1518 'list list all tags',
1519 'delete <name> delete a tag'],
1520 ret:'✓ Tag <name> → <sha8>' },
1521 ];
1522
1523 const grid = document.getElementById('cli-grid');
1524 commands.forEach(c => {
1525 const card = document.createElement('div');
1526 card.className = 'cli-card';
1527 card.innerHTML = `<div class="cli-cmd">$ ${c.cmd}</div>
1528 <div class="cli-desc">${c.desc}</div>
1529 <div class="cli-flags">${c.flags.map(f => `<div class="cli-flag">${f.replace(/^(--?\\S+)/,'<span>$1</span>')}</div>`).join('')}</div>
1530 <div style="margin-top:6px;font-size:10px;color:rgba(255,255,255,0.3);font-family:'JetBrains Mono',monospace">→ ${c.ret}</div>`;
1531 grid.appendChild(card);
1532 });
1533 })();
1534
1535 // ═══════════════════════════════════════════════════════════════
1536 // EVENT WIRING
1537 // ═══════════════════════════════════════════════════════════════
1538 document.getElementById('btn-init-audio').addEventListener('click', initAudio);
1539
1540 document.getElementById('btn-play').addEventListener('click', () => {
1541 if (!state.audioReady) { initAudio(); return; }
1542 const commit = COMMITS[state.cur];
1543 if (!commit.notes.length) return;
1544
1545 if (state.isPlaying) {
1546 pausePlayback();
1547 } else if (state.pausedAt !== null) {
1548 playNotes(commit.notes, state.pausedAt);
1549 } else {
1550 playNotes(commit.notes, 0);
1551 }
1552 });
1553
1554 document.getElementById('btn-prev').addEventListener('click', () => selectCommit(state.cur - 1));
1555 document.getElementById('btn-next').addEventListener('click', () => selectCommit(state.cur + 1));
1556 document.getElementById('btn-first').addEventListener('click', () => selectCommit(0));
1557 document.getElementById('btn-last').addEventListener('click', () => selectCommit(COMMITS.length - 1));
1558
1559 document.addEventListener('keydown', e => {
1560 if (e.key === ' ') { e.preventDefault(); document.getElementById('btn-play').click(); }
1561 else if (e.key === 'ArrowRight') selectCommit(state.cur + 1);
1562 else if (e.key === 'ArrowLeft') selectCommit(state.cur - 1);
1563 });
1564
1565 // ═══════════════════════════════════════════════════════════════
1566 // INIT
1567 // ═══════════════════════════════════════════════════════════════
1568 document.getElementById('btn-play').disabled = true;
1569 selectCommit(0);
1570 </script>
1571 </body>
1572 </html>
1573 """
1574
1575
1576 # ─────────────────────────────────────────────────────────────────────────────
1577 # RENDERER
1578 # ─────────────────────────────────────────────────────────────────────────────
1579
1580 def render_midi_demo() -> str:
1581 """Build and return the complete HTML string."""
1582 commits = _build_commits()
1583
1584 # Serialize commits (notes lists contain mixed-type elements)
1585 commits_json = json.dumps(commits, separators=(",", ":"))
1586
1587 html = _HTML
1588 html = html.replace("__BPM__", str(BPM))
1589 html = html.replace("__COMMITS__", commits_json)
1590 return html
1591
1592
1593 # ─────────────────────────────────────────────────────────────────────────────
1594 # MAIN
1595 # ─────────────────────────────────────────────────────────────────────────────
1596
1597 def main() -> None:
1598 import argparse
1599
1600 parser = argparse.ArgumentParser(description="Generate artifacts/midi-demo.html")
1601 parser.add_argument("--output-dir", default="artifacts", help="Output directory")
1602 args = parser.parse_args()
1603
1604 out_dir = pathlib.Path(args.output_dir)
1605 out_dir.mkdir(parents=True, exist_ok=True)
1606
1607 html = render_midi_demo()
1608 out_path = out_dir / "midi-demo.html"
1609 out_path.write_text(html, encoding="utf-8")
1610 logger.info("Written: %s (%d bytes)", out_path, len(html))
1611 print(f"✓ MIDI demo → {out_path}")
1612
1613
1614 if __name__ == "__main__":
1615 logging.basicConfig(level=logging.INFO)
1616 main()