render_midi_demo.py
python
| 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() |