Mike0021 commited on
Commit
4bada97
·
verified ·
1 Parent(s): d64e1d5

Initial MuScriptor ZeroGPU studio

Browse files
Files changed (7) hide show
  1. .gitignore +8 -0
  2. README.md +17 -7
  3. app.py +692 -0
  4. assets/app.css +1235 -0
  5. assets/visualizer.html +94 -0
  6. assets/visualizer.js +898 -0
  7. requirements.txt +9 -0
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .codex/
5
+ rollout.jsonl
6
+ *.rollout.jsonl
7
+ *.session.jsonl
8
+ verify*.png
README.md CHANGED
@@ -1,13 +1,23 @@
1
  ---
2
- title: Muscriptor Studio
3
- emoji: 🏆
4
  colorFrom: gray
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MuScriptor Studio
3
+ emoji: 🎼
4
  colorFrom: gray
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 6.10.0
 
8
  app_file: app.py
9
+ short_description: Multi-instrument audio transcription to editable MIDI
10
+ startup_duration_timeout: 1h
11
  ---
12
 
13
+ # MuScriptor Studio
14
+
15
+ A custom ZeroGPU interface for [MuScriptor](https://github.com/muscriptor/muscriptor), the multi-instrument audio-to-MIDI model from Kyutai and Mirelo.
16
+
17
+ The app runs the 307M-parameter medium checkpoint, streams transcription progress after each 5-second audio window, renders an interactive color-coded piano roll, and exports both the full arrangement and isolated instrument MIDI files.
18
+
19
+ The checkpoint is gated under CC BY-NC 4.0. This Space requires an `HF_TOKEN` secret whose owner has accepted access to [`MuScriptor/muscriptor-medium`](https://huggingface.co/MuScriptor/muscriptor-medium).
20
+
21
+ ## API
22
+
23
+ The Gradio endpoint is `/transcribe`. It accepts an audio file, optional exact instrument group names, a sampling toggle, and temperature. It returns the combined MIDI, all isolated track files, the browser visualization payload, and a machine-readable manifest.
app.py ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ # ZeroGPU and library caches must be configured before any third-party import.
4
+ os.environ.setdefault("HF_HOME", "/tmp/.cache/huggingface")
5
+ os.environ.setdefault("HF_MODULES_CACHE", "/tmp/hf_modules")
6
+ os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib")
7
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
8
+
9
+ import spaces # noqa: E402 # Must precede torch on ZeroGPU.
10
+
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import math
15
+ import re
16
+ import sys
17
+ import tempfile
18
+ import time
19
+ from collections import defaultdict
20
+ from pathlib import Path
21
+ from typing import Any, Iterator
22
+
23
+ import gradio as gr
24
+ import soundfile as sf
25
+ import torch
26
+ from huggingface_hub import hf_hub_download
27
+ from muscriptor.events import NoteEndEvent, NoteStartEvent, ProgressEvent
28
+ from muscriptor.tokenizer.mt3 import MT3Tokenizer, MT3_FULL_PLUS_GROUP_NAMES
29
+ from muscriptor.transcription_model import (
30
+ TranscriptionModel,
31
+ _build_model,
32
+ _remap_single_codebook_keys,
33
+ _resolve_config,
34
+ _resolve_source,
35
+ )
36
+ from safetensors.torch import load_file
37
+
38
+
39
+ APP_ROOT = Path(__file__).resolve().parent
40
+ ASSET_ROOT = APP_ROOT / "assets"
41
+ MODEL_ID = "MuScriptor/muscriptor-medium"
42
+ MODEL_VARIANT = "medium"
43
+ MAX_AUDIO_SECONDS = 60.0
44
+
45
+ # Initial deployment values. These are recalibrated from cold/warm live calls.
46
+ GPU_BASE_SECONDS = 45
47
+ GPU_SECONDS_PER_CHUNK = 18
48
+ GPU_DURATION_CAP = 240
49
+
50
+ COLORS = (
51
+ "#8b7cff",
52
+ "#35c8ff",
53
+ "#67e8a5",
54
+ "#f6c667",
55
+ "#fb7185",
56
+ "#c084fc",
57
+ "#2dd4bf",
58
+ "#60a5fa",
59
+ "#f472b6",
60
+ "#a3e635",
61
+ )
62
+
63
+
64
+ @spaces.GPU(duration=1)
65
+ def _zerogpu_probe() -> str:
66
+ """Required lightweight ZeroGPU allocation probe."""
67
+ return "ready"
68
+
69
+
70
+ def _load_model_zerogpu() -> TranscriptionModel:
71
+ """Build MuScriptor with CUDA attributes while loading weights on CPU.
72
+
73
+ ZeroGPU intercepts ``.to('cuda')`` at module scope and packs the singleton
74
+ for restoration inside decorated calls. Safetensors cannot target the fake
75
+ CUDA device at startup, so its state dictionary must first be read on CPU.
76
+ """
77
+
78
+ token = os.environ.get("HF_TOKEN")
79
+ if not token:
80
+ raise RuntimeError(
81
+ "HF_TOKEN is required. Add a read token whose owner has accepted "
82
+ f"the gated license at https://huggingface.co/{MODEL_ID}."
83
+ )
84
+
85
+ device = torch.device("cuda")
86
+ source = _resolve_source(MODEL_VARIANT)
87
+ weights_path = Path(
88
+ hf_hub_download(
89
+ repo_id=MODEL_ID,
90
+ filename="model.safetensors",
91
+ token=token,
92
+ )
93
+ )
94
+ config = _resolve_config(source, weights_path)
95
+
96
+ model = _build_model(device, config)
97
+ model.eval()
98
+ state_dict = load_file(str(weights_path), device="cpu")
99
+ state_dict = _remap_single_codebook_keys(state_dict)
100
+ model.load_state_dict(state_dict)
101
+ model.to("cuda")
102
+
103
+ tokenizer = MT3Tokenizer(
104
+ instrument_vocabulary="MT3_FULL_PLUS",
105
+ max_shift_steps=1001,
106
+ )
107
+ return TranscriptionModel(model=model, tokenizer=tokenizer, device=device)
108
+
109
+
110
+ print(f"[MuScriptor Studio] Loading {MODEL_VARIANT} model once at startup…", flush=True)
111
+ _model_load_started = time.perf_counter()
112
+ MODEL = _load_model_zerogpu()
113
+ print(
114
+ "[MuScriptor Studio] Model ready in "
115
+ f"{time.perf_counter() - _model_load_started:.2f}s.",
116
+ flush=True,
117
+ )
118
+
119
+
120
+ def _audio_path(value: Any) -> str | None:
121
+ if value is None:
122
+ return None
123
+ if isinstance(value, (str, Path)):
124
+ return str(value)
125
+ if isinstance(value, dict):
126
+ path = value.get("path") or value.get("name")
127
+ return str(path) if path else None
128
+ path = getattr(value, "path", None) or getattr(value, "name", None)
129
+ return str(path) if path else None
130
+
131
+
132
+ def _audio_duration(path: str | None) -> float | None:
133
+ if not path:
134
+ return None
135
+ try:
136
+ return float(sf.info(path).duration)
137
+ except Exception:
138
+ return None
139
+
140
+
141
+ def _estimate_gpu_duration(audio: Any, *args: Any, **kwargs: Any) -> int:
142
+ """Estimate allocation from 5-second chunks; accepts Gradio extras."""
143
+
144
+ duration = _audio_duration(_audio_path(audio))
145
+ chunks = max(1, math.ceil((duration or 15.0) / 5.0))
146
+ return min(GPU_DURATION_CAP, GPU_BASE_SECONDS + chunks * GPU_SECONDS_PER_CHUNK)
147
+
148
+
149
+ def _display_name(name: str) -> str:
150
+ if name.startswith("program_"):
151
+ return f"MIDI program {name.removeprefix('program_')}"
152
+ special = {
153
+ "drums": "Drums",
154
+ "voice": "Voice",
155
+ "flutes": "Flutes",
156
+ "soprano_and_alto_sax": "Soprano & alto sax",
157
+ }
158
+ return special.get(name, name.replace("_", " ").title())
159
+
160
+
161
+ def _color_for(name: str) -> str:
162
+ digest = hashlib.sha1(name.encode("utf-8")).digest()
163
+ return COLORS[int.from_bytes(digest[:2], "big") % len(COLORS)]
164
+
165
+
166
+ def _slug(name: str) -> str:
167
+ return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "track"
168
+
169
+
170
+ def _data_uri(data: bytes) -> str:
171
+ encoded = base64.b64encode(data).decode("ascii")
172
+ return f"data:audio/midi;base64,{encoded}"
173
+
174
+
175
+ def _program_for(name: str) -> int:
176
+ if name == "drums":
177
+ return 0
178
+ try:
179
+ return int(MODEL._program_for_instrument(name))
180
+ except Exception:
181
+ if name.startswith("program_"):
182
+ try:
183
+ return int(name.removeprefix("program_"))
184
+ except ValueError:
185
+ pass
186
+ return 0
187
+
188
+
189
+ def _notes_from_events(
190
+ events: list[NoteStartEvent | NoteEndEvent | ProgressEvent],
191
+ ) -> dict[str, list[dict[str, float | int]]]:
192
+ notes: dict[str, list[dict[str, float | int]]] = defaultdict(list)
193
+ for event in events:
194
+ if not isinstance(event, NoteEndEvent):
195
+ continue
196
+ start = event.start_event
197
+ notes[start.instrument].append(
198
+ {
199
+ "pitch": int(start.pitch),
200
+ "start": round(float(start.start_time), 4),
201
+ "end": round(max(float(event.end_time), float(start.start_time) + 0.01), 4),
202
+ "velocity": 100,
203
+ }
204
+ )
205
+ for instrument_notes in notes.values():
206
+ instrument_notes.sort(key=lambda note: (note["start"], note["pitch"]))
207
+ return dict(notes)
208
+
209
+
210
+ def _track_payloads(
211
+ notes: dict[str, list[dict[str, float | int]]],
212
+ requested: list[str] | None = None,
213
+ midi_by_instrument: dict[str, bytes] | None = None,
214
+ ) -> list[dict[str, Any]]:
215
+ names = set(notes)
216
+ names.update(requested or [])
217
+ ordered = sorted(
218
+ names,
219
+ key=lambda name: (
220
+ notes.get(name, [{}])[0].get("start", float("inf")) if notes.get(name) else float("inf"),
221
+ name,
222
+ ),
223
+ )
224
+ tracks: list[dict[str, Any]] = []
225
+ for index, name in enumerate(ordered):
226
+ track_notes = notes.get(name, [])
227
+ midi = (midi_by_instrument or {}).get(name)
228
+ tracks.append(
229
+ {
230
+ "id": index,
231
+ "key": name,
232
+ "name": _display_name(name),
233
+ "color": _color_for(name),
234
+ "note_count": len(track_notes),
235
+ "program": _program_for(name),
236
+ "is_drum": name == "drums",
237
+ "midi": _data_uri(midi) if midi else "",
238
+ "notes": track_notes,
239
+ }
240
+ )
241
+ return tracks
242
+
243
+
244
+ def _payload(
245
+ *,
246
+ state: str,
247
+ status: str,
248
+ progress: float,
249
+ audio_name: str,
250
+ elapsed: float,
251
+ duration: float,
252
+ tracks: list[dict[str, Any]],
253
+ full_midi: bytes | None = None,
254
+ ) -> str:
255
+ return json.dumps(
256
+ {
257
+ "state": state,
258
+ "status": status,
259
+ "progress": round(max(0.0, min(1.0, progress)), 4),
260
+ "audio_name": audio_name,
261
+ "elapsed": round(elapsed, 2),
262
+ "duration": round(duration, 2),
263
+ "note_count": sum(track["note_count"] for track in tracks),
264
+ "full_midi": _data_uri(full_midi) if full_midi else "",
265
+ "tracks": tracks,
266
+ },
267
+ separators=(",", ":"),
268
+ )
269
+
270
+
271
+ def _event_is_for_instrument(
272
+ event: NoteStartEvent | NoteEndEvent | ProgressEvent,
273
+ instrument: str,
274
+ ) -> bool:
275
+ if isinstance(event, NoteStartEvent):
276
+ return event.instrument == instrument
277
+ if isinstance(event, NoteEndEvent):
278
+ return event.start_event.instrument == instrument
279
+ return False
280
+
281
+
282
+ def _write_outputs(
283
+ events: list[NoteStartEvent | NoteEndEvent | ProgressEvent],
284
+ track_names: list[str],
285
+ ) -> tuple[Path, dict[str, Path], bytes, dict[str, bytes]]:
286
+ request_dir = Path(tempfile.mkdtemp(prefix="muscriptor-output-"))
287
+ full_bytes = MODEL.events_to_midi_bytes(iter(events))
288
+ full_path = request_dir / "muscriptor-transcription.mid"
289
+ full_path.write_bytes(full_bytes)
290
+
291
+ track_paths: dict[str, Path] = {}
292
+ midi_by_instrument: dict[str, bytes] = {}
293
+ for name in track_names:
294
+ filtered = [event for event in events if _event_is_for_instrument(event, name)]
295
+ track_bytes = MODEL.events_to_midi_bytes(iter(filtered))
296
+ track_path = request_dir / f"{_slug(name)}.mid"
297
+ track_path.write_bytes(track_bytes)
298
+ track_paths[name] = track_path
299
+ midi_by_instrument[name] = track_bytes
300
+ return full_path, track_paths, full_bytes, midi_by_instrument
301
+
302
+
303
+ def _manifest(
304
+ *,
305
+ state: str,
306
+ audio_name: str,
307
+ elapsed: float,
308
+ duration: float,
309
+ tracks: list[dict[str, Any]],
310
+ full_path: Path | None = None,
311
+ track_paths: dict[str, Path] | None = None,
312
+ message: str | None = None,
313
+ ) -> dict[str, Any]:
314
+ result: dict[str, Any] = {
315
+ "state": state,
316
+ "model": MODEL_VARIANT,
317
+ "model_id": MODEL_ID,
318
+ "audio_name": audio_name,
319
+ "audio_duration_seconds": round(duration, 3),
320
+ "inference_seconds": round(elapsed, 3),
321
+ "note_count": sum(track["note_count"] for track in tracks),
322
+ "track_count": len(tracks),
323
+ "tracks": [
324
+ {
325
+ "instrument": track["key"],
326
+ "display_name": track["name"],
327
+ "note_count": track["note_count"],
328
+ "program": track["program"],
329
+ "is_drum": track["is_drum"],
330
+ "file": str(track_paths[track["key"]])
331
+ if track_paths and track["key"] in track_paths
332
+ else None,
333
+ }
334
+ for track in tracks
335
+ ],
336
+ "full_midi": str(full_path) if full_path else None,
337
+ }
338
+ if message:
339
+ result["message"] = message
340
+ return result
341
+
342
+
343
+ @spaces.GPU(duration=_estimate_gpu_duration)
344
+ def transcribe_audio(
345
+ audio: Any,
346
+ instruments: list[str] | None,
347
+ use_sampling: bool,
348
+ temperature: float,
349
+ ) -> Iterator[tuple[str | None, list[str] | None, str, dict[str, Any]]]:
350
+ """Stream chunk progress, then return combined and isolated MIDI files."""
351
+
352
+ path = _audio_path(audio)
353
+ if not path:
354
+ empty_tracks: list[dict[str, Any]] = []
355
+ message = "Drop an audio file before starting transcription."
356
+ yield (
357
+ None,
358
+ None,
359
+ _payload(
360
+ state="error",
361
+ status=message,
362
+ progress=0,
363
+ audio_name="",
364
+ elapsed=0,
365
+ duration=0,
366
+ tracks=empty_tracks,
367
+ ),
368
+ _manifest(
369
+ state="error",
370
+ audio_name="",
371
+ elapsed=0,
372
+ duration=0,
373
+ tracks=empty_tracks,
374
+ message=message,
375
+ ),
376
+ )
377
+ return
378
+
379
+ audio_name = Path(path).name
380
+ duration = _audio_duration(path) or 0.0
381
+ if duration > MAX_AUDIO_SECONDS:
382
+ tracks = _track_payloads({}, instruments)
383
+ message = f"This demo accepts audio up to {MAX_AUDIO_SECONDS:.0f} seconds."
384
+ yield (
385
+ None,
386
+ None,
387
+ _payload(
388
+ state="error",
389
+ status=message,
390
+ progress=0,
391
+ audio_name=audio_name,
392
+ elapsed=0,
393
+ duration=duration,
394
+ tracks=tracks,
395
+ ),
396
+ _manifest(
397
+ state="error",
398
+ audio_name=audio_name,
399
+ elapsed=0,
400
+ duration=duration,
401
+ tracks=tracks,
402
+ message=message,
403
+ ),
404
+ )
405
+ return
406
+
407
+ requested = list(dict.fromkeys(instruments or []))
408
+ started = time.perf_counter()
409
+ events: list[NoteStartEvent | NoteEndEvent | ProgressEvent] = []
410
+ last_completed = -1
411
+
412
+ try:
413
+ stream = MODEL.transcribe(
414
+ path,
415
+ instruments=requested or None,
416
+ use_sampling=bool(use_sampling),
417
+ temperature=float(temperature),
418
+ batch_size=1,
419
+ )
420
+ for event in stream:
421
+ events.append(event)
422
+ if not isinstance(event, ProgressEvent):
423
+ continue
424
+ if event.completed == last_completed:
425
+ continue
426
+ last_completed = event.completed
427
+ elapsed = time.perf_counter() - started
428
+ fraction = event.completed / max(1, event.total)
429
+ notes = _notes_from_events(events)
430
+ tracks = _track_payloads(notes, requested)
431
+ status = (
432
+ "GPU ready · preparing the first 5-second window"
433
+ if event.completed == 0
434
+ else f"Transcribed {event.completed} of {event.total} audio windows"
435
+ )
436
+ yield (
437
+ None,
438
+ None,
439
+ _payload(
440
+ state="transcribing",
441
+ status=status,
442
+ progress=fraction,
443
+ audio_name=audio_name,
444
+ elapsed=elapsed,
445
+ duration=duration,
446
+ tracks=tracks,
447
+ ),
448
+ _manifest(
449
+ state="running",
450
+ audio_name=audio_name,
451
+ elapsed=elapsed,
452
+ duration=duration,
453
+ tracks=tracks,
454
+ ),
455
+ )
456
+
457
+ elapsed = time.perf_counter() - started
458
+ notes = _notes_from_events(events)
459
+ preview_tracks = _track_payloads(notes, requested)
460
+ track_names = [track["key"] for track in preview_tracks]
461
+ full_path, track_paths, full_bytes, midi_by_instrument = _write_outputs(
462
+ events, track_names
463
+ )
464
+ tracks = _track_payloads(notes, requested, midi_by_instrument)
465
+ print(
466
+ "[MuScriptor Studio] "
467
+ f"{audio_name}: {duration:.2f}s audio, {len(tracks)} tracks, "
468
+ f"{sum(track['note_count'] for track in tracks)} notes, "
469
+ f"{elapsed:.2f}s inference.",
470
+ flush=True,
471
+ )
472
+ yield (
473
+ str(full_path),
474
+ [str(track_paths[track["key"]]) for track in tracks],
475
+ _payload(
476
+ state="complete",
477
+ status=f"Transcription complete in {elapsed:.1f} seconds",
478
+ progress=1,
479
+ audio_name=audio_name,
480
+ elapsed=elapsed,
481
+ duration=duration,
482
+ tracks=tracks,
483
+ full_midi=full_bytes,
484
+ ),
485
+ _manifest(
486
+ state="complete",
487
+ audio_name=audio_name,
488
+ elapsed=elapsed,
489
+ duration=duration,
490
+ tracks=tracks,
491
+ full_path=full_path,
492
+ track_paths=track_paths,
493
+ ),
494
+ )
495
+ except Exception as exc:
496
+ elapsed = time.perf_counter() - started
497
+ notes = _notes_from_events(events)
498
+ tracks = _track_payloads(notes, requested)
499
+ message = f"Transcription stopped: {type(exc).__name__}: {exc}"
500
+ print(f"[MuScriptor Studio] {message}", file=sys.stderr, flush=True)
501
+ yield (
502
+ None,
503
+ None,
504
+ _payload(
505
+ state="error",
506
+ status=message,
507
+ progress=0,
508
+ audio_name=audio_name,
509
+ elapsed=elapsed,
510
+ duration=duration,
511
+ tracks=tracks,
512
+ ),
513
+ _manifest(
514
+ state="error",
515
+ audio_name=audio_name,
516
+ elapsed=elapsed,
517
+ duration=duration,
518
+ tracks=tracks,
519
+ message=message,
520
+ ),
521
+ )
522
+
523
+
524
+ def _read_asset(name: str) -> str:
525
+ return (ASSET_ROOT / name).read_text(encoding="utf-8")
526
+
527
+
528
+ APP_CSS = _read_asset("app.css")
529
+ VISUALIZER_TEMPLATE = _read_asset("visualizer.html")
530
+ VISUALIZER_JS = _read_asset("visualizer.js")
531
+
532
+ INSTRUMENT_CHOICES = [
533
+ (_display_name(name), name)
534
+ for name in sorted(MT3_FULL_PLUS_GROUP_NAMES, key=MT3_FULL_PLUS_GROUP_NAMES.get)
535
+ ]
536
+
537
+ INITIAL_VISUALIZER = _payload(
538
+ state="idle",
539
+ status="Ready for audio",
540
+ progress=0,
541
+ audio_name="",
542
+ elapsed=0,
543
+ duration=0,
544
+ tracks=[],
545
+ )
546
+
547
+ HEADER = """
548
+ <header class="masthead-inner">
549
+ <a class="brand" href="https://github.com/muscriptor/muscriptor" target="_blank" rel="noreferrer">
550
+ <span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
551
+ <span>MuScriptor</span>
552
+ </a>
553
+ <div class="header-meta">
554
+ <span id="model-badge"><b></b> Medium · 307M</span>
555
+ <span class="zerogpu-badge">ZeroGPU</span>
556
+ </div>
557
+ </header>
558
+ """
559
+
560
+ INTRO = """
561
+ <div class="intro-copy">
562
+ <span class="eyebrow">Multitrack transcription</span>
563
+ <h1>Turn a recording into<br><em>editable music.</em></h1>
564
+ <p>MuScriptor separates notes by instrument and shapes them into MIDI—one 5-second window at a time.</p>
565
+ </div>
566
+ """
567
+
568
+ UPLOAD_HEADING = """
569
+ <div class="section-heading">
570
+ <span class="step-number">01</span>
571
+ <div><h2>Choose audio</h2><p>WAV, MP3, FLAC or OGG · up to 60 seconds</p></div>
572
+ </div>
573
+ """
574
+
575
+ RESULT_HEADING = """
576
+ <div class="section-heading result-heading">
577
+ <span class="step-number">02</span>
578
+ <div><h2>Transcription</h2><p>Live notes, isolated tracks and MIDI export</p></div>
579
+ </div>
580
+ """
581
+
582
+ FOOTNOTE = """
583
+ <div class="footnote">
584
+ <span>Your audio is processed ephemerally and is not retained.</span>
585
+ <span>Kyutai × Mirelo · CC BY-NC 4.0 weights</span>
586
+ </div>
587
+ """
588
+
589
+ with gr.Blocks(title="MuScriptor — Audio to MIDI") as demo:
590
+ with gr.Column(elem_id="app-shell"):
591
+ gr.HTML(HEADER, elem_id="masthead")
592
+ gr.HTML(INTRO, elem_id="intro")
593
+
594
+ with gr.Row(elem_id="studio-grid", equal_height=False):
595
+ with gr.Column(scale=4, min_width=320, elem_id="upload-card"):
596
+ gr.HTML(UPLOAD_HEADING)
597
+ audio_input = gr.Audio(
598
+ label="Drop audio here",
599
+ sources=["upload"],
600
+ type="filepath",
601
+ elem_id="audio-input",
602
+ )
603
+ with gr.Accordion(
604
+ "Guide the transcription",
605
+ open=False,
606
+ elem_id="advanced-settings",
607
+ ):
608
+ instrument_input = gr.CheckboxGroup(
609
+ choices=INSTRUMENT_CHOICES,
610
+ value=[],
611
+ label="Known instruments",
612
+ info="Optional. Leave blank for automatic discovery.",
613
+ )
614
+ use_sampling_input = gr.Checkbox(
615
+ label="Creative decoding",
616
+ value=False,
617
+ info="Uses stochastic sampling instead of deterministic decoding.",
618
+ )
619
+ temperature_input = gr.Slider(
620
+ minimum=0.2,
621
+ maximum=1.4,
622
+ value=1.0,
623
+ step=0.1,
624
+ label="Temperature",
625
+ )
626
+ transcribe_button = gr.Button(
627
+ "Transcribe audio",
628
+ variant="primary",
629
+ elem_id="transcribe-button",
630
+ )
631
+ gr.HTML(
632
+ '<p class="cold-note"><span></span> Cold starts can take a moment while ZeroGPU allocates the model.</p>'
633
+ )
634
+
635
+ with gr.Column(scale=7, min_width=420, elem_id="result-panel"):
636
+ gr.HTML(RESULT_HEADING)
637
+ visualizer_output = gr.HTML(
638
+ value=INITIAL_VISUALIZER,
639
+ html_template=VISUALIZER_TEMPLATE,
640
+ js_on_load=VISUALIZER_JS,
641
+ elem_id="visualizer-host",
642
+ )
643
+ full_midi_output = gr.File(
644
+ label="Full arrangement MIDI",
645
+ elem_id="download-output",
646
+ )
647
+ track_files_output = gr.File(
648
+ label="Instrument MIDI files",
649
+ file_count="multiple",
650
+ visible=False,
651
+ )
652
+ manifest_output = gr.JSON(label="Transcription manifest", visible=False)
653
+
654
+ gr.HTML(FOOTNOTE)
655
+
656
+ transcribe_button.click(
657
+ fn=transcribe_audio,
658
+ inputs=[
659
+ audio_input,
660
+ instrument_input,
661
+ use_sampling_input,
662
+ temperature_input,
663
+ ],
664
+ outputs=[
665
+ full_midi_output,
666
+ track_files_output,
667
+ visualizer_output,
668
+ manifest_output,
669
+ ],
670
+ api_name="transcribe",
671
+ concurrency_limit=1,
672
+ concurrency_id="muscriptor-medium",
673
+ show_progress="full",
674
+ )
675
+
676
+ demo.queue(default_concurrency_limit=1, max_size=8)
677
+
678
+
679
+ if __name__ == "__main__":
680
+ demo.launch(
681
+ theme=gr.themes.Base(
682
+ primary_hue="indigo",
683
+ neutral_hue="slate",
684
+ font=["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
685
+ ),
686
+ css=APP_CSS,
687
+ js="""() => {
688
+ document.documentElement.classList.add('dark');
689
+ try { localStorage.setItem('theme', 'dark'); } catch (_) {}
690
+ }""",
691
+ show_error=True,
692
+ )
assets/app.css ADDED
@@ -0,0 +1,1235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: dark;
3
+ --ms-bg: #090b0f;
4
+ --ms-bg-elevated: #0d1016;
5
+ --ms-panel: rgba(18, 21, 29, 0.82);
6
+ --ms-panel-solid: #12151d;
7
+ --ms-panel-hover: #171b25;
8
+ --ms-border: rgba(255, 255, 255, 0.085);
9
+ --ms-border-strong: rgba(255, 255, 255, 0.14);
10
+ --ms-text: #f4f5f7;
11
+ --ms-text-soft: #b5bac6;
12
+ --ms-muted: #747b8c;
13
+ --ms-faint: #4f5564;
14
+ --ms-accent: #8b7cff;
15
+ --ms-accent-2: #5f8cff;
16
+ --ms-accent-soft: rgba(139, 124, 255, 0.15);
17
+ --ms-success: #63d8a2;
18
+ --ms-warning: #f4bf75;
19
+ --ms-danger: #ff758e;
20
+ --ms-radius-xl: 22px;
21
+ --ms-radius-lg: 16px;
22
+ --ms-radius-md: 12px;
23
+ --ms-shadow: 0 28px 80px rgba(0, 0, 0, 0.34), 0 1px 0 rgba(255, 255, 255, 0.025) inset;
24
+ --ms-font: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25
+ --ms-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
26
+ }
27
+
28
+ html,
29
+ body {
30
+ min-height: 100%;
31
+ background: var(--ms-bg) !important;
32
+ }
33
+
34
+ body {
35
+ margin: 0;
36
+ color: var(--ms-text);
37
+ font-family: var(--ms-font);
38
+ -webkit-font-smoothing: antialiased;
39
+ text-rendering: optimizeLegibility;
40
+ }
41
+
42
+ body::before {
43
+ position: fixed;
44
+ z-index: -2;
45
+ inset: 0;
46
+ content: "";
47
+ pointer-events: none;
48
+ background:
49
+ radial-gradient(1000px 620px at 10% -10%, rgba(92, 75, 202, 0.18), transparent 62%),
50
+ radial-gradient(780px 500px at 96% 8%, rgba(53, 103, 175, 0.12), transparent 66%),
51
+ linear-gradient(180deg, #0a0c11 0%, #090b0f 50%, #080a0d 100%);
52
+ }
53
+
54
+ body::after {
55
+ position: fixed;
56
+ z-index: -1;
57
+ inset: 0;
58
+ content: "";
59
+ pointer-events: none;
60
+ opacity: 0.28;
61
+ background-image: linear-gradient(rgba(255, 255, 255, 0.012) 1px, transparent 1px),
62
+ linear-gradient(90deg, rgba(255, 255, 255, 0.012) 1px, transparent 1px);
63
+ background-size: 32px 32px;
64
+ mask-image: linear-gradient(to bottom, black, transparent 78%);
65
+ }
66
+
67
+ .gradio-container,
68
+ .gradio-container.dark {
69
+ min-height: 100vh !important;
70
+ background: transparent !important;
71
+ color: var(--ms-text) !important;
72
+ font-family: var(--ms-font) !important;
73
+ }
74
+
75
+ .gradio-container .prose,
76
+ .gradio-container .prose * {
77
+ color: inherit;
78
+ }
79
+
80
+ #app-shell {
81
+ width: min(1180px, calc(100% - 40px));
82
+ margin: 0 auto;
83
+ padding: 38px 0 64px;
84
+ gap: 20px;
85
+ }
86
+
87
+ #masthead {
88
+ position: relative;
89
+ overflow: hidden;
90
+ padding: 18px 4px 28px;
91
+ border: 0 !important;
92
+ background: transparent !important;
93
+ }
94
+
95
+ #masthead .masthead-inner {
96
+ display: flex;
97
+ align-items: center;
98
+ justify-content: space-between;
99
+ gap: 18px;
100
+ }
101
+
102
+ #masthead .brand {
103
+ display: inline-flex;
104
+ align-items: center;
105
+ gap: 11px;
106
+ color: var(--ms-text) !important;
107
+ font-size: 0.92rem;
108
+ font-weight: 680;
109
+ letter-spacing: -0.025em;
110
+ text-decoration: none;
111
+ }
112
+
113
+ .brand-mark {
114
+ display: flex;
115
+ align-items: end;
116
+ justify-content: center;
117
+ width: 30px;
118
+ height: 30px;
119
+ gap: 2px;
120
+ padding: 7px;
121
+ border: 1px solid rgba(139, 124, 255, 0.3);
122
+ border-radius: 9px;
123
+ background: linear-gradient(145deg, rgba(139, 124, 255, 0.2), rgba(95, 140, 255, 0.08));
124
+ box-shadow: 0 8px 28px rgba(75, 60, 180, 0.2), inset 0 1px rgba(255, 255, 255, 0.13);
125
+ }
126
+
127
+ .brand-mark i {
128
+ display: block;
129
+ width: 2px;
130
+ border-radius: 3px;
131
+ background: #d8d4ff;
132
+ }
133
+
134
+ .brand-mark i:nth-child(1) { height: 6px; opacity: 0.62; }
135
+ .brand-mark i:nth-child(2) { height: 12px; }
136
+ .brand-mark i:nth-child(3) { height: 9px; opacity: 0.8; }
137
+ .brand-mark i:nth-child(4) { height: 14px; }
138
+
139
+ .header-meta {
140
+ display: flex;
141
+ align-items: center;
142
+ gap: 8px;
143
+ }
144
+
145
+ #masthead #model-badge {
146
+ margin: 0 !important;
147
+ }
148
+
149
+ .zerogpu-badge {
150
+ display: inline-flex;
151
+ align-items: center;
152
+ min-height: 30px;
153
+ padding: 5px 10px;
154
+ border: 1px solid var(--ms-border);
155
+ border-radius: 999px;
156
+ background: rgba(255, 255, 255, 0.025);
157
+ color: var(--ms-muted);
158
+ font-family: var(--ms-mono);
159
+ font-size: 0.65rem;
160
+ font-weight: 620;
161
+ letter-spacing: 0.07em;
162
+ text-transform: uppercase;
163
+ }
164
+
165
+ #intro {
166
+ padding: 30px 4px 34px;
167
+ border: 0 !important;
168
+ background: transparent !important;
169
+ }
170
+
171
+ .intro-copy {
172
+ max-width: 780px;
173
+ }
174
+
175
+ .eyebrow {
176
+ display: inline-block;
177
+ margin-bottom: 16px;
178
+ color: #aaa1ff;
179
+ font-family: var(--ms-mono);
180
+ font-size: 0.68rem;
181
+ font-weight: 670;
182
+ letter-spacing: 0.13em;
183
+ text-transform: uppercase;
184
+ }
185
+
186
+ .intro-copy h1 {
187
+ margin: 0;
188
+ color: var(--ms-text);
189
+ font-size: clamp(2.55rem, 7.2vw, 5.8rem);
190
+ font-weight: 590;
191
+ letter-spacing: -0.072em;
192
+ line-height: 0.94;
193
+ }
194
+
195
+ .intro-copy h1 em {
196
+ color: transparent;
197
+ background: linear-gradient(112deg, #d9d5ff 4%, #8b7cff 48%, #5f9cff 94%);
198
+ -webkit-background-clip: text;
199
+ background-clip: text;
200
+ font-style: normal;
201
+ }
202
+
203
+ .intro-copy p {
204
+ max-width: 630px;
205
+ margin: 23px 0 0;
206
+ color: var(--ms-text-soft);
207
+ font-size: clamp(0.96rem, 1.8vw, 1.12rem);
208
+ line-height: 1.68;
209
+ letter-spacing: -0.015em;
210
+ }
211
+
212
+ #studio-grid {
213
+ align-items: flex-start;
214
+ gap: 16px;
215
+ }
216
+
217
+ .section-heading {
218
+ display: flex;
219
+ align-items: center;
220
+ gap: 12px;
221
+ padding: 2px 2px 12px;
222
+ }
223
+
224
+ .section-heading .step-number {
225
+ color: var(--ms-faint);
226
+ font-family: var(--ms-mono);
227
+ font-size: 0.62rem;
228
+ letter-spacing: 0.08em;
229
+ }
230
+
231
+ .section-heading h2 {
232
+ margin: 0 0 4px;
233
+ color: var(--ms-text);
234
+ font-size: 0.9rem;
235
+ font-weight: 630;
236
+ letter-spacing: -0.02em;
237
+ }
238
+
239
+ .section-heading p {
240
+ margin: 0;
241
+ color: var(--ms-muted);
242
+ font-size: 0.68rem;
243
+ line-height: 1.4;
244
+ }
245
+
246
+ .cold-note {
247
+ display: flex;
248
+ align-items: flex-start;
249
+ gap: 8px;
250
+ margin: 3px 2px 0 !important;
251
+ color: var(--ms-faint);
252
+ font-size: 0.65rem;
253
+ line-height: 1.5;
254
+ }
255
+
256
+ .cold-note > span {
257
+ width: 6px;
258
+ height: 6px;
259
+ flex: none;
260
+ margin-top: 3px;
261
+ border-radius: 50%;
262
+ background: var(--ms-warning);
263
+ box-shadow: 0 0 8px rgba(244, 191, 117, 0.38);
264
+ }
265
+
266
+ .footnote {
267
+ display: flex;
268
+ justify-content: space-between;
269
+ gap: 16px;
270
+ padding: 14px 5px 0;
271
+ color: var(--ms-faint);
272
+ font-family: var(--ms-mono);
273
+ font-size: 0.58rem;
274
+ line-height: 1.5;
275
+ }
276
+
277
+ #masthead .prose {
278
+ max-width: 760px;
279
+ }
280
+
281
+ #masthead h1 {
282
+ margin: 0 0 13px;
283
+ color: var(--ms-text);
284
+ font-size: clamp(2.35rem, 6vw, 4.9rem);
285
+ font-weight: 620;
286
+ letter-spacing: -0.064em;
287
+ line-height: 0.96;
288
+ }
289
+
290
+ #masthead h1 strong,
291
+ #masthead .accent {
292
+ color: transparent;
293
+ background: linear-gradient(112deg, #ffffff 8%, #beb7ff 52%, #77a6ff 94%);
294
+ -webkit-background-clip: text;
295
+ background-clip: text;
296
+ }
297
+
298
+ #masthead p {
299
+ max-width: 610px;
300
+ margin: 0;
301
+ color: var(--ms-text-soft);
302
+ font-size: clamp(0.98rem, 1.6vw, 1.12rem);
303
+ line-height: 1.68;
304
+ letter-spacing: -0.012em;
305
+ }
306
+
307
+ #masthead a {
308
+ color: #d7d3ff;
309
+ text-decoration-color: rgba(215, 211, 255, 0.35);
310
+ text-underline-offset: 3px;
311
+ }
312
+
313
+ #model-badge {
314
+ display: inline-flex !important;
315
+ align-items: center;
316
+ width: max-content !important;
317
+ min-width: 0 !important;
318
+ min-height: 30px;
319
+ margin-bottom: 22px;
320
+ padding: 5px 11px !important;
321
+ border: 1px solid rgba(139, 124, 255, 0.22) !important;
322
+ border-radius: 999px !important;
323
+ background: rgba(139, 124, 255, 0.09) !important;
324
+ box-shadow: 0 0 32px rgba(111, 92, 255, 0.07) inset;
325
+ color: #cec8ff !important;
326
+ font-family: var(--ms-mono) !important;
327
+ font-size: 0.7rem !important;
328
+ font-weight: 650 !important;
329
+ letter-spacing: 0.08em !important;
330
+ line-height: 1 !important;
331
+ text-transform: uppercase;
332
+ }
333
+
334
+ #model-badge::before {
335
+ width: 6px;
336
+ height: 6px;
337
+ margin-right: 7px;
338
+ border-radius: 50%;
339
+ background: var(--ms-success);
340
+ box-shadow: 0 0 0 4px rgba(99, 216, 162, 0.1), 0 0 12px rgba(99, 216, 162, 0.62);
341
+ content: "";
342
+ }
343
+
344
+ #upload-card,
345
+ #result-panel {
346
+ position: relative;
347
+ border: 1px solid var(--ms-border) !important;
348
+ border-radius: var(--ms-radius-xl) !important;
349
+ background: linear-gradient(145deg, rgba(20, 23, 32, 0.93), rgba(13, 16, 22, 0.9)) !important;
350
+ box-shadow: var(--ms-shadow) !important;
351
+ backdrop-filter: blur(22px);
352
+ }
353
+
354
+ #upload-card {
355
+ padding: clamp(16px, 2.5vw, 26px) !important;
356
+ }
357
+
358
+ #upload-card::before,
359
+ #result-panel::before {
360
+ position: absolute;
361
+ top: 0;
362
+ right: 12%;
363
+ left: 12%;
364
+ height: 1px;
365
+ content: "";
366
+ pointer-events: none;
367
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.16), transparent);
368
+ }
369
+
370
+ #audio-input {
371
+ overflow: hidden;
372
+ min-height: 214px;
373
+ border: 1px dashed rgba(255, 255, 255, 0.16) !important;
374
+ border-radius: var(--ms-radius-lg) !important;
375
+ background: rgba(7, 9, 13, 0.46) !important;
376
+ transition: border-color 180ms ease, background 180ms ease, transform 180ms ease;
377
+ }
378
+
379
+ #audio-input:hover,
380
+ #audio-input:focus-within {
381
+ border-color: rgba(139, 124, 255, 0.54) !important;
382
+ background: rgba(139, 124, 255, 0.045) !important;
383
+ }
384
+
385
+ #audio-input .wrap,
386
+ #audio-input > div,
387
+ #audio-input .upload-container {
388
+ border: 0 !important;
389
+ background: transparent !important;
390
+ }
391
+
392
+ #audio-input .upload-container {
393
+ min-height: 190px;
394
+ }
395
+
396
+ #audio-input button,
397
+ #audio-input .upload-button {
398
+ border-color: var(--ms-border-strong) !important;
399
+ border-radius: 10px !important;
400
+ background: rgba(255, 255, 255, 0.055) !important;
401
+ color: var(--ms-text) !important;
402
+ box-shadow: none !important;
403
+ }
404
+
405
+ #audio-input button:hover,
406
+ #audio-input .upload-button:hover {
407
+ border-color: rgba(139, 124, 255, 0.42) !important;
408
+ background: rgba(139, 124, 255, 0.12) !important;
409
+ }
410
+
411
+ #audio-input audio {
412
+ width: 100%;
413
+ filter: saturate(0.75) contrast(0.96);
414
+ }
415
+
416
+ #audio-input label,
417
+ #audio-input .label-wrap,
418
+ #advanced-settings label,
419
+ #advanced-settings .label-wrap {
420
+ color: var(--ms-text-soft) !important;
421
+ font-size: 0.78rem !important;
422
+ font-weight: 570 !important;
423
+ letter-spacing: -0.005em;
424
+ }
425
+
426
+ #transcribe-button {
427
+ position: relative;
428
+ overflow: hidden;
429
+ width: 100%;
430
+ min-height: 50px;
431
+ margin-top: 4px;
432
+ border: 1px solid rgba(255, 255, 255, 0.15) !important;
433
+ border-radius: 13px !important;
434
+ background: linear-gradient(128deg, #7a69eb 0%, #675ad2 48%, #4f75d5 100%) !important;
435
+ box-shadow: 0 10px 30px rgba(76, 66, 181, 0.25), 0 1px 0 rgba(255, 255, 255, 0.2) inset !important;
436
+ color: #fff !important;
437
+ font-size: 0.91rem !important;
438
+ font-weight: 650 !important;
439
+ letter-spacing: -0.012em;
440
+ transition: transform 150ms ease, box-shadow 150ms ease, filter 150ms ease !important;
441
+ }
442
+
443
+ #transcribe-button::after {
444
+ position: absolute;
445
+ inset: 0;
446
+ content: "";
447
+ pointer-events: none;
448
+ opacity: 0;
449
+ background: linear-gradient(105deg, transparent 20%, rgba(255, 255, 255, 0.2) 48%, transparent 74%);
450
+ transform: translateX(-100%);
451
+ }
452
+
453
+ #transcribe-button:hover:not(:disabled) {
454
+ filter: brightness(1.08);
455
+ transform: translateY(-1px);
456
+ box-shadow: 0 14px 38px rgba(76, 66, 181, 0.32), 0 1px 0 rgba(255, 255, 255, 0.22) inset !important;
457
+ }
458
+
459
+ #transcribe-button:hover:not(:disabled)::after {
460
+ opacity: 1;
461
+ transform: translateX(100%);
462
+ transition: transform 650ms ease;
463
+ }
464
+
465
+ #transcribe-button:active:not(:disabled) {
466
+ transform: translateY(0) scale(0.995);
467
+ }
468
+
469
+ #transcribe-button:disabled,
470
+ #transcribe-button[aria-busy="true"] {
471
+ cursor: wait;
472
+ filter: saturate(0.55) brightness(0.8);
473
+ }
474
+
475
+ #advanced-settings {
476
+ overflow: hidden;
477
+ border: 1px solid var(--ms-border) !important;
478
+ border-radius: var(--ms-radius-md) !important;
479
+ background: rgba(8, 10, 14, 0.42) !important;
480
+ }
481
+
482
+ #advanced-settings > button,
483
+ #advanced-settings > .label-wrap,
484
+ #advanced-settings summary {
485
+ min-height: 44px;
486
+ padding-inline: 14px !important;
487
+ background: transparent !important;
488
+ color: var(--ms-text-soft) !important;
489
+ font-size: 0.8rem !important;
490
+ font-weight: 570 !important;
491
+ }
492
+
493
+ #advanced-settings > div:not(.label-wrap) {
494
+ border-color: var(--ms-border) !important;
495
+ background: transparent !important;
496
+ }
497
+
498
+ #advanced-settings input,
499
+ #advanced-settings textarea,
500
+ #advanced-settings select {
501
+ border-color: var(--ms-border) !important;
502
+ border-radius: 9px !important;
503
+ background: rgba(255, 255, 255, 0.035) !important;
504
+ color: var(--ms-text) !important;
505
+ }
506
+
507
+ #advanced-settings input[type="range"] {
508
+ accent-color: var(--ms-accent);
509
+ }
510
+
511
+ #advanced-settings input[type="checkbox"] {
512
+ accent-color: var(--ms-accent);
513
+ }
514
+
515
+ #result-panel {
516
+ min-width: 0;
517
+ padding: clamp(12px, 2vw, 20px) !important;
518
+ }
519
+
520
+ #visualizer-host {
521
+ min-width: 0;
522
+ border: 0 !important;
523
+ background: transparent !important;
524
+ }
525
+
526
+ #visualizer-host > .wrap,
527
+ #visualizer-host > div,
528
+ #visualizer-host .html-container {
529
+ min-width: 0;
530
+ padding: 0 !important;
531
+ border: 0 !important;
532
+ background: transparent !important;
533
+ }
534
+
535
+ #download-output {
536
+ overflow: hidden;
537
+ border: 1px solid var(--ms-border) !important;
538
+ border-radius: var(--ms-radius-md) !important;
539
+ background: rgba(255, 255, 255, 0.025) !important;
540
+ }
541
+
542
+ #download-output a,
543
+ #download-output button {
544
+ color: #d4d0ff !important;
545
+ }
546
+
547
+ /* Custom transcription visualizer */
548
+ .msv-root {
549
+ --roll-height: 334px;
550
+ display: grid;
551
+ min-width: 0;
552
+ gap: 16px;
553
+ color: var(--ms-text);
554
+ font-family: var(--ms-font);
555
+ }
556
+
557
+ .msv-root *,
558
+ .msv-root *::before,
559
+ .msv-root *::after {
560
+ box-sizing: border-box;
561
+ }
562
+
563
+ .msv-topbar {
564
+ display: flex;
565
+ align-items: flex-start;
566
+ justify-content: space-between;
567
+ gap: 20px;
568
+ padding: 3px 3px 0;
569
+ }
570
+
571
+ .msv-heading {
572
+ min-width: 0;
573
+ }
574
+
575
+ .msv-eyebrow {
576
+ display: flex;
577
+ align-items: center;
578
+ gap: 8px;
579
+ margin: 0 0 7px;
580
+ color: var(--ms-muted);
581
+ font-family: var(--ms-mono);
582
+ font-size: 0.65rem;
583
+ font-weight: 650;
584
+ letter-spacing: 0.09em;
585
+ line-height: 1;
586
+ text-transform: uppercase;
587
+ }
588
+
589
+ .msv-status-dot {
590
+ width: 7px;
591
+ height: 7px;
592
+ flex: none;
593
+ border-radius: 50%;
594
+ background: var(--ms-faint);
595
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.035);
596
+ }
597
+
598
+ .msv-root[data-state="queued"] .msv-status-dot,
599
+ .msv-root[data-state="loading"] .msv-status-dot,
600
+ .msv-root[data-state="cold_start"] .msv-status-dot,
601
+ .msv-root[data-state="transcribing"] .msv-status-dot,
602
+ .msv-root[data-state="processing"] .msv-status-dot {
603
+ background: var(--ms-warning);
604
+ box-shadow: 0 0 0 4px rgba(244, 191, 117, 0.1), 0 0 14px rgba(244, 191, 117, 0.38);
605
+ animation: msv-pulse 1.7s ease-in-out infinite;
606
+ }
607
+
608
+ .msv-root[data-state="complete"] .msv-status-dot,
609
+ .msv-root[data-state="completed"] .msv-status-dot,
610
+ .msv-root[data-state="ready"] .msv-status-dot {
611
+ background: var(--ms-success);
612
+ box-shadow: 0 0 0 4px rgba(99, 216, 162, 0.09), 0 0 13px rgba(99, 216, 162, 0.32);
613
+ }
614
+
615
+ .msv-root[data-state="error"] .msv-status-dot,
616
+ .msv-root[data-state="failed"] .msv-status-dot {
617
+ background: var(--ms-danger);
618
+ box-shadow: 0 0 0 4px rgba(255, 117, 142, 0.1);
619
+ }
620
+
621
+ .msv-title {
622
+ overflow: hidden;
623
+ max-width: 640px;
624
+ margin: 0;
625
+ color: var(--ms-text);
626
+ font-size: clamp(1.05rem, 2vw, 1.35rem);
627
+ font-weight: 610;
628
+ letter-spacing: -0.032em;
629
+ line-height: 1.22;
630
+ text-overflow: ellipsis;
631
+ white-space: nowrap;
632
+ }
633
+
634
+ .msv-subtitle {
635
+ margin: 7px 0 0;
636
+ color: var(--ms-muted);
637
+ font-size: 0.76rem;
638
+ line-height: 1.45;
639
+ }
640
+
641
+ .msv-actions {
642
+ display: flex;
643
+ flex: none;
644
+ align-items: center;
645
+ gap: 7px;
646
+ }
647
+
648
+ .msv-button,
649
+ .msv-download,
650
+ .msv-track-button,
651
+ .msv-track-download {
652
+ display: inline-flex;
653
+ align-items: center;
654
+ justify-content: center;
655
+ border: 1px solid var(--ms-border) !important;
656
+ border-radius: 9px;
657
+ background: rgba(255, 255, 255, 0.035);
658
+ box-shadow: none;
659
+ color: var(--ms-text-soft) !important;
660
+ font: 560 0.73rem/1 var(--ms-font);
661
+ text-decoration: none !important;
662
+ cursor: pointer;
663
+ transition: color 140ms ease, border-color 140ms ease, background 140ms ease, transform 140ms ease;
664
+ }
665
+
666
+ .msv-button {
667
+ width: 34px;
668
+ height: 34px;
669
+ padding: 0;
670
+ }
671
+
672
+ .msv-button svg,
673
+ .msv-download svg,
674
+ .msv-track-download svg {
675
+ width: 15px;
676
+ height: 15px;
677
+ fill: none;
678
+ stroke: currentColor;
679
+ stroke-linecap: round;
680
+ stroke-linejoin: round;
681
+ stroke-width: 1.8;
682
+ }
683
+
684
+ .msv-button:hover:not(:disabled),
685
+ .msv-download:hover:not([aria-disabled="true"]),
686
+ .msv-track-button:hover,
687
+ .msv-track-download:hover:not([aria-disabled="true"]) {
688
+ border-color: rgba(139, 124, 255, 0.32) !important;
689
+ background: var(--ms-accent-soft);
690
+ color: #e4e1ff !important;
691
+ transform: translateY(-1px);
692
+ }
693
+
694
+ .msv-button:focus-visible,
695
+ .msv-download:focus-visible,
696
+ .msv-track-button:focus-visible,
697
+ .msv-track-download:focus-visible {
698
+ outline: 2px solid rgba(139, 124, 255, 0.7);
699
+ outline-offset: 2px;
700
+ }
701
+
702
+ .msv-button:disabled,
703
+ .msv-download[aria-disabled="true"],
704
+ .msv-track-download[aria-disabled="true"] {
705
+ pointer-events: none;
706
+ opacity: 0.34;
707
+ cursor: not-allowed;
708
+ }
709
+
710
+ .msv-button[aria-pressed="true"] {
711
+ border-color: rgba(139, 124, 255, 0.34) !important;
712
+ background: rgba(139, 124, 255, 0.18);
713
+ color: #e7e4ff !important;
714
+ }
715
+
716
+ .msv-download {
717
+ height: 34px;
718
+ gap: 7px;
719
+ padding: 0 11px;
720
+ }
721
+
722
+ .msv-progress-wrap {
723
+ display: grid;
724
+ gap: 9px;
725
+ padding: 12px 14px;
726
+ border: 1px solid var(--ms-border);
727
+ border-radius: 12px;
728
+ background: rgba(8, 10, 14, 0.46);
729
+ }
730
+
731
+ .msv-progress-wrap[hidden] {
732
+ display: none;
733
+ }
734
+
735
+ .msv-progress-meta {
736
+ display: flex;
737
+ align-items: center;
738
+ justify-content: space-between;
739
+ gap: 12px;
740
+ }
741
+
742
+ .msv-progress-copy {
743
+ overflow: hidden;
744
+ color: var(--ms-text-soft);
745
+ font-size: 0.74rem;
746
+ line-height: 1.3;
747
+ text-overflow: ellipsis;
748
+ white-space: nowrap;
749
+ }
750
+
751
+ .msv-progress-value {
752
+ flex: none;
753
+ color: var(--ms-muted);
754
+ font-family: var(--ms-mono);
755
+ font-size: 0.67rem;
756
+ }
757
+
758
+ .msv-progress-track {
759
+ position: relative;
760
+ overflow: hidden;
761
+ height: 4px;
762
+ border-radius: 999px;
763
+ background: rgba(255, 255, 255, 0.055);
764
+ }
765
+
766
+ .msv-progress-bar {
767
+ width: 0;
768
+ height: 100%;
769
+ border-radius: inherit;
770
+ background: linear-gradient(90deg, var(--ms-accent), #6b9bff);
771
+ box-shadow: 0 0 18px rgba(119, 102, 255, 0.45);
772
+ transition: width 360ms cubic-bezier(0.2, 0.8, 0.2, 1);
773
+ }
774
+
775
+ .msv-progress-wrap[data-indeterminate="true"] .msv-progress-bar {
776
+ width: 34% !important;
777
+ animation: msv-indeterminate 1.45s ease-in-out infinite;
778
+ }
779
+
780
+ .msv-stats {
781
+ display: grid;
782
+ grid-template-columns: repeat(4, minmax(0, 1fr));
783
+ overflow: hidden;
784
+ border: 1px solid var(--ms-border);
785
+ border-radius: 12px;
786
+ background: rgba(8, 10, 14, 0.35);
787
+ }
788
+
789
+ .msv-stat {
790
+ min-width: 0;
791
+ padding: 11px 13px;
792
+ }
793
+
794
+ .msv-stat + .msv-stat {
795
+ border-left: 1px solid var(--ms-border);
796
+ }
797
+
798
+ .msv-stat-label {
799
+ display: block;
800
+ margin-bottom: 4px;
801
+ color: var(--ms-faint);
802
+ font-family: var(--ms-mono);
803
+ font-size: 0.58rem;
804
+ font-weight: 650;
805
+ letter-spacing: 0.075em;
806
+ text-transform: uppercase;
807
+ }
808
+
809
+ .msv-stat-value {
810
+ display: block;
811
+ overflow: hidden;
812
+ color: var(--ms-text-soft);
813
+ font-size: 0.78rem;
814
+ font-weight: 570;
815
+ letter-spacing: -0.01em;
816
+ text-overflow: ellipsis;
817
+ white-space: nowrap;
818
+ }
819
+
820
+ .msv-roll-shell {
821
+ position: relative;
822
+ overflow: hidden;
823
+ min-width: 0;
824
+ border: 1px solid var(--ms-border);
825
+ border-radius: 14px;
826
+ background: #0a0d12;
827
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.025) inset;
828
+ }
829
+
830
+ .msv-roll-labelbar {
831
+ display: flex;
832
+ align-items: center;
833
+ justify-content: space-between;
834
+ height: 38px;
835
+ padding: 0 12px 0 14px;
836
+ border-bottom: 1px solid var(--ms-border);
837
+ background: rgba(255, 255, 255, 0.018);
838
+ }
839
+
840
+ .msv-roll-label {
841
+ color: var(--ms-muted);
842
+ font-family: var(--ms-mono);
843
+ font-size: 0.62rem;
844
+ font-weight: 650;
845
+ letter-spacing: 0.075em;
846
+ text-transform: uppercase;
847
+ }
848
+
849
+ .msv-roll-hint {
850
+ color: var(--ms-faint);
851
+ font-size: 0.66rem;
852
+ }
853
+
854
+ .msv-roll-scroll {
855
+ position: relative;
856
+ overflow-x: auto;
857
+ overflow-y: hidden;
858
+ width: 100%;
859
+ height: var(--roll-height);
860
+ scrollbar-color: rgba(255, 255, 255, 0.13) transparent;
861
+ scrollbar-width: thin;
862
+ }
863
+
864
+ .msv-roll-scroll::-webkit-scrollbar {
865
+ height: 8px;
866
+ }
867
+
868
+ .msv-roll-scroll::-webkit-scrollbar-track {
869
+ background: transparent;
870
+ }
871
+
872
+ .msv-roll-scroll::-webkit-scrollbar-thumb {
873
+ border: 2px solid #0a0d12;
874
+ border-radius: 999px;
875
+ background: rgba(255, 255, 255, 0.14);
876
+ }
877
+
878
+ .msv-roll-stage {
879
+ position: relative;
880
+ width: 100%;
881
+ height: 100%;
882
+ min-width: 100%;
883
+ }
884
+
885
+ .msv-roll-canvas,
886
+ .msv-cursor-canvas {
887
+ position: absolute;
888
+ inset: 0;
889
+ display: block;
890
+ width: 100%;
891
+ height: 100%;
892
+ }
893
+
894
+ .msv-cursor-canvas {
895
+ z-index: 2;
896
+ pointer-events: none;
897
+ }
898
+
899
+ .msv-empty {
900
+ position: absolute;
901
+ z-index: 3;
902
+ inset: 0;
903
+ display: flex;
904
+ align-items: center;
905
+ justify-content: center;
906
+ padding: 24px;
907
+ pointer-events: none;
908
+ background: radial-gradient(circle at 50% 40%, rgba(139, 124, 255, 0.055), transparent 42%);
909
+ }
910
+
911
+ .msv-empty[hidden] {
912
+ display: none;
913
+ }
914
+
915
+ .msv-empty-inner {
916
+ max-width: 360px;
917
+ text-align: center;
918
+ }
919
+
920
+ .msv-empty-icon {
921
+ display: grid;
922
+ width: 42px;
923
+ height: 42px;
924
+ margin: 0 auto 13px;
925
+ border: 1px solid var(--ms-border);
926
+ border-radius: 12px;
927
+ background: rgba(255, 255, 255, 0.025);
928
+ color: var(--ms-muted);
929
+ place-items: center;
930
+ }
931
+
932
+ .msv-empty-icon svg {
933
+ width: 20px;
934
+ height: 20px;
935
+ fill: none;
936
+ stroke: currentColor;
937
+ stroke-linecap: round;
938
+ stroke-linejoin: round;
939
+ stroke-width: 1.55;
940
+ }
941
+
942
+ .msv-empty-title {
943
+ margin: 0;
944
+ color: var(--ms-text-soft);
945
+ font-size: 0.83rem;
946
+ font-weight: 580;
947
+ }
948
+
949
+ .msv-empty-copy {
950
+ margin: 6px 0 0;
951
+ color: var(--ms-faint);
952
+ font-size: 0.7rem;
953
+ line-height: 1.55;
954
+ }
955
+
956
+ .msv-root[data-state="queued"] .msv-empty-icon,
957
+ .msv-root[data-state="loading"] .msv-empty-icon,
958
+ .msv-root[data-state="cold_start"] .msv-empty-icon,
959
+ .msv-root[data-state="transcribing"] .msv-empty-icon,
960
+ .msv-root[data-state="processing"] .msv-empty-icon {
961
+ color: #bbb4ff;
962
+ animation: msv-float 2.5s ease-in-out infinite;
963
+ }
964
+
965
+ .msv-section-heading {
966
+ display: flex;
967
+ align-items: center;
968
+ justify-content: space-between;
969
+ padding: 2px 3px 0;
970
+ }
971
+
972
+ .msv-section-heading h3 {
973
+ margin: 0;
974
+ color: var(--ms-text-soft);
975
+ font-size: 0.76rem;
976
+ font-weight: 610;
977
+ letter-spacing: -0.01em;
978
+ }
979
+
980
+ .msv-track-count {
981
+ color: var(--ms-faint);
982
+ font-family: var(--ms-mono);
983
+ font-size: 0.62rem;
984
+ }
985
+
986
+ .msv-track-list {
987
+ display: grid;
988
+ grid-template-columns: repeat(2, minmax(0, 1fr));
989
+ gap: 8px;
990
+ }
991
+
992
+ .msv-track-empty {
993
+ grid-column: 1 / -1;
994
+ padding: 15px;
995
+ border: 1px dashed var(--ms-border);
996
+ border-radius: 11px;
997
+ color: var(--ms-faint);
998
+ font-size: 0.72rem;
999
+ text-align: center;
1000
+ }
1001
+
1002
+ .msv-track-card {
1003
+ display: grid;
1004
+ grid-template-columns: auto minmax(0, 1fr) auto;
1005
+ align-items: center;
1006
+ min-width: 0;
1007
+ min-height: 62px;
1008
+ gap: 11px;
1009
+ padding: 10px 11px;
1010
+ border: 1px solid var(--ms-border);
1011
+ border-radius: 11px;
1012
+ background: rgba(255, 255, 255, 0.022);
1013
+ transition: border-color 150ms ease, background 150ms ease, opacity 150ms ease;
1014
+ }
1015
+
1016
+ .msv-track-card:hover {
1017
+ border-color: var(--ms-border-strong);
1018
+ background: rgba(255, 255, 255, 0.032);
1019
+ }
1020
+
1021
+ .msv-track-card[data-muted="true"] {
1022
+ opacity: 0.48;
1023
+ }
1024
+
1025
+ .msv-track-card[data-inactive="true"] {
1026
+ opacity: 0.56;
1027
+ }
1028
+
1029
+ .msv-track-swatch {
1030
+ width: 9px;
1031
+ height: 34px;
1032
+ border-radius: 999px;
1033
+ background: var(--track-color, var(--ms-accent));
1034
+ box-shadow: 0 0 17px color-mix(in srgb, var(--track-color, var(--ms-accent)) 27%, transparent);
1035
+ }
1036
+
1037
+ .msv-track-copy {
1038
+ min-width: 0;
1039
+ }
1040
+
1041
+ .msv-track-name {
1042
+ overflow: hidden;
1043
+ margin: 0 0 5px;
1044
+ color: var(--ms-text-soft);
1045
+ font-size: 0.76rem;
1046
+ font-weight: 590;
1047
+ line-height: 1.15;
1048
+ text-overflow: ellipsis;
1049
+ white-space: nowrap;
1050
+ }
1051
+
1052
+ .msv-track-meta {
1053
+ overflow: hidden;
1054
+ color: var(--ms-faint);
1055
+ font-family: var(--ms-mono);
1056
+ font-size: 0.58rem;
1057
+ letter-spacing: 0.015em;
1058
+ text-overflow: ellipsis;
1059
+ white-space: nowrap;
1060
+ }
1061
+
1062
+ .msv-track-controls {
1063
+ display: flex;
1064
+ align-items: center;
1065
+ gap: 5px;
1066
+ }
1067
+
1068
+ .msv-track-button,
1069
+ .msv-track-download {
1070
+ width: 27px;
1071
+ height: 27px;
1072
+ padding: 0;
1073
+ font-family: var(--ms-mono);
1074
+ font-size: 0.62rem;
1075
+ }
1076
+
1077
+ .msv-track-button[aria-pressed="true"] {
1078
+ border-color: color-mix(in srgb, var(--track-color, var(--ms-accent)) 48%, transparent) !important;
1079
+ background: color-mix(in srgb, var(--track-color, var(--ms-accent)) 15%, transparent);
1080
+ color: #fff !important;
1081
+ }
1082
+
1083
+ .msv-track-download svg {
1084
+ width: 13px;
1085
+ height: 13px;
1086
+ }
1087
+
1088
+ .msv-sr-only {
1089
+ position: absolute !important;
1090
+ overflow: hidden !important;
1091
+ width: 1px !important;
1092
+ height: 1px !important;
1093
+ padding: 0 !important;
1094
+ border: 0 !important;
1095
+ margin: -1px !important;
1096
+ clip: rect(0, 0, 0, 0) !important;
1097
+ white-space: nowrap !important;
1098
+ }
1099
+
1100
+ @keyframes msv-pulse {
1101
+ 0%, 100% { opacity: 0.62; transform: scale(0.88); }
1102
+ 50% { opacity: 1; transform: scale(1); }
1103
+ }
1104
+
1105
+ @keyframes msv-indeterminate {
1106
+ 0% { transform: translateX(-120%); }
1107
+ 55% { transform: translateX(190%); }
1108
+ 100% { transform: translateX(310%); }
1109
+ }
1110
+
1111
+ @keyframes msv-float {
1112
+ 0%, 100% { transform: translateY(0); }
1113
+ 50% { transform: translateY(-4px); }
1114
+ }
1115
+
1116
+ @media (max-width: 800px) {
1117
+ #app-shell {
1118
+ width: min(100% - 24px, 1180px);
1119
+ padding-top: 22px;
1120
+ }
1121
+
1122
+ #masthead {
1123
+ padding-bottom: 18px;
1124
+ }
1125
+
1126
+ #intro {
1127
+ padding: 22px 3px 27px;
1128
+ }
1129
+
1130
+ #masthead h1 {
1131
+ letter-spacing: -0.052em;
1132
+ }
1133
+
1134
+ .msv-track-list {
1135
+ grid-template-columns: 1fr;
1136
+ }
1137
+
1138
+ .msv-root {
1139
+ --roll-height: 308px;
1140
+ }
1141
+ }
1142
+
1143
+ @media (max-width: 600px) {
1144
+ #app-shell {
1145
+ width: min(100% - 16px, 1180px);
1146
+ padding-bottom: 30px;
1147
+ gap: 12px;
1148
+ }
1149
+
1150
+ #upload-card,
1151
+ #result-panel {
1152
+ border-radius: 17px !important;
1153
+ }
1154
+
1155
+ #upload-card {
1156
+ padding: 12px !important;
1157
+ }
1158
+
1159
+ #masthead .masthead-inner,
1160
+ .footnote {
1161
+ align-items: flex-start;
1162
+ flex-direction: column;
1163
+ }
1164
+
1165
+ .header-meta {
1166
+ width: 100%;
1167
+ }
1168
+
1169
+ .intro-copy h1 {
1170
+ font-size: clamp(2.65rem, 15vw, 4.2rem);
1171
+ letter-spacing: -0.062em;
1172
+ }
1173
+
1174
+ #result-panel {
1175
+ padding: 10px !important;
1176
+ }
1177
+
1178
+ #audio-input {
1179
+ min-height: 180px;
1180
+ }
1181
+
1182
+ .msv-root {
1183
+ --roll-height: 276px;
1184
+ gap: 12px;
1185
+ }
1186
+
1187
+ .msv-topbar {
1188
+ display: grid;
1189
+ gap: 12px;
1190
+ }
1191
+
1192
+ .msv-title {
1193
+ font-size: 1.05rem;
1194
+ }
1195
+
1196
+ .msv-actions {
1197
+ width: 100%;
1198
+ }
1199
+
1200
+ .msv-download {
1201
+ flex: 1;
1202
+ }
1203
+
1204
+ .msv-stats {
1205
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1206
+ }
1207
+
1208
+ .msv-stat:nth-child(3) {
1209
+ border-left: 0;
1210
+ border-top: 1px solid var(--ms-border);
1211
+ }
1212
+
1213
+ .msv-stat:nth-child(4) {
1214
+ border-top: 1px solid var(--ms-border);
1215
+ }
1216
+
1217
+ .msv-roll-hint {
1218
+ display: none;
1219
+ }
1220
+
1221
+ .msv-progress-copy {
1222
+ max-width: calc(100vw - 118px);
1223
+ }
1224
+ }
1225
+
1226
+ @media (prefers-reduced-motion: reduce) {
1227
+ *,
1228
+ *::before,
1229
+ *::after {
1230
+ scroll-behavior: auto !important;
1231
+ animation-duration: 0.01ms !important;
1232
+ animation-iteration-count: 1 !important;
1233
+ transition-duration: 0.01ms !important;
1234
+ }
1235
+ }
assets/visualizer.html ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <section class="msv-root" data-state="idle" aria-label="MuScriptor transcription result">
2
+ <header class="msv-topbar">
3
+ <div class="msv-heading">
4
+ <p class="msv-eyebrow">
5
+ <span class="msv-status-dot" aria-hidden="true"></span>
6
+ <span data-field="state-label">Ready for audio</span>
7
+ </p>
8
+ <h2 class="msv-title" data-field="audio-name">Your transcription</h2>
9
+ <p class="msv-subtitle" data-field="status">Upload a recording to reveal its notes and instruments.</p>
10
+ </div>
11
+
12
+ <div class="msv-actions" aria-label="Playback and download controls">
13
+ <button class="msv-button" type="button" data-action="play" aria-label="Play transcription" title="Play transcription" disabled>
14
+ <svg viewBox="0 0 20 20" aria-hidden="true">
15
+ <path d="M6.8 4.7 15 10l-8.2 5.3V4.7Z"></path>
16
+ </svg>
17
+ </button>
18
+ <button class="msv-button" type="button" data-action="stop" aria-label="Stop playback" title="Stop playback" disabled>
19
+ <svg viewBox="0 0 20 20" aria-hidden="true">
20
+ <rect x="5.5" y="5.5" width="9" height="9" rx="1"></rect>
21
+ </svg>
22
+ </button>
23
+ <a class="msv-download" data-action="download-full" aria-label="Download full MIDI" aria-disabled="true" tabindex="-1">
24
+ <svg viewBox="0 0 20 20" aria-hidden="true">
25
+ <path d="M10 3.5v9m0 0 3.2-3.2M10 12.5 6.8 9.3M4.5 15.7h11"></path>
26
+ </svg>
27
+ <span>Full MIDI</span>
28
+ </a>
29
+ </div>
30
+ </header>
31
+
32
+ <div class="msv-progress-wrap" data-region="progress" hidden>
33
+ <div class="msv-progress-meta">
34
+ <span class="msv-progress-copy" data-field="progress-copy">Preparing inference…</span>
35
+ <span class="msv-progress-value" data-field="progress-value">0%</span>
36
+ </div>
37
+ <div class="msv-progress-track" role="progressbar" aria-label="Transcription progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
38
+ <div class="msv-progress-bar" data-field="progress-bar"></div>
39
+ </div>
40
+ </div>
41
+
42
+ <div class="msv-stats" aria-label="Transcription statistics">
43
+ <div class="msv-stat">
44
+ <span class="msv-stat-label">Duration</span>
45
+ <span class="msv-stat-value" data-field="duration">—</span>
46
+ </div>
47
+ <div class="msv-stat">
48
+ <span class="msv-stat-label">Notes</span>
49
+ <span class="msv-stat-value" data-field="note-count">—</span>
50
+ </div>
51
+ <div class="msv-stat">
52
+ <span class="msv-stat-label">Tracks</span>
53
+ <span class="msv-stat-value" data-field="track-count">—</span>
54
+ </div>
55
+ <div class="msv-stat">
56
+ <span class="msv-stat-label">Elapsed</span>
57
+ <span class="msv-stat-value" data-field="elapsed">—</span>
58
+ </div>
59
+ </div>
60
+
61
+ <div class="msv-roll-shell">
62
+ <div class="msv-roll-labelbar">
63
+ <span class="msv-roll-label">Piano roll</span>
64
+ <span class="msv-roll-hint">Scroll horizontally to explore</span>
65
+ </div>
66
+ <div class="msv-roll-scroll" data-region="roll-scroll" tabindex="0" aria-label="Scrollable piano roll">
67
+ <div class="msv-roll-stage" data-region="roll-stage">
68
+ <canvas class="msv-roll-canvas" data-canvas="notes" role="img" aria-label="Color-coded transcription piano roll"></canvas>
69
+ <canvas class="msv-cursor-canvas" data-canvas="cursor" aria-hidden="true"></canvas>
70
+ </div>
71
+ <div class="msv-empty" data-region="empty">
72
+ <div class="msv-empty-inner">
73
+ <div class="msv-empty-icon" aria-hidden="true">
74
+ <svg viewBox="0 0 24 24">
75
+ <path d="M5 15.5V8.7m3 9.1V5.6m3 11.1V9.2m3 7.8V3.8m3 12.4V7.4m3 7.3v-4.1"></path>
76
+ </svg>
77
+ </div>
78
+ <p class="msv-empty-title" data-field="empty-title">A score will appear here</p>
79
+ <p class="msv-empty-copy" data-field="empty-copy">MuScriptor separates notes into individual, color-coded instrument tracks.</p>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </div>
84
+
85
+ <div class="msv-section-heading">
86
+ <h3>Instrument tracks</h3>
87
+ <span class="msv-track-count" data-field="track-summary">No tracks yet</span>
88
+ </div>
89
+ <div class="msv-track-list" data-region="tracks" aria-label="Instrument tracks">
90
+ <div class="msv-track-empty">Tracks will appear after transcription.</div>
91
+ </div>
92
+
93
+ <p class="msv-sr-only" data-region="announcer" role="status" aria-live="polite"></p>
94
+ </section>
assets/visualizer.js ADDED
@@ -0,0 +1,898 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const root = element.querySelector(".msv-root");
2
+
3
+ if (!root) {
4
+ throw new Error("MuScriptor visualizer template is missing .msv-root");
5
+ }
6
+
7
+ const $ = (selector) => root.querySelector(selector);
8
+ const notesCanvas = $('[data-canvas="notes"]');
9
+ const cursorCanvas = $('[data-canvas="cursor"]');
10
+ const rollScroll = $('[data-region="roll-scroll"]');
11
+ const rollStage = $('[data-region="roll-stage"]');
12
+ const emptyRegion = $('[data-region="empty"]');
13
+ const tracksRegion = $('[data-region="tracks"]');
14
+ const announcer = $('[data-region="announcer"]');
15
+ const progressRegion = $('[data-region="progress"]');
16
+ const progressTrack = progressRegion.querySelector('[role="progressbar"]');
17
+ const playButton = $('[data-action="play"]');
18
+ const stopButton = $('[data-action="stop"]');
19
+ const fullDownload = $('[data-action="download-full"]');
20
+
21
+ const fallbackColors = [
22
+ "#8b7cff",
23
+ "#5f9cff",
24
+ "#54c7a4",
25
+ "#f2b96f",
26
+ "#e878a6",
27
+ "#58c4dc",
28
+ "#b48cf2",
29
+ "#91c96f",
30
+ ];
31
+
32
+ const stateLabels = {
33
+ idle: "Ready for audio",
34
+ queued: "ZeroGPU queued",
35
+ loading: "Loading model",
36
+ cold_start: "Starting ZeroGPU",
37
+ transcribing: "Transcribing",
38
+ processing: "Transcribing",
39
+ complete: "Transcription complete",
40
+ completed: "Transcription complete",
41
+ ready: "Transcription complete",
42
+ error: "Transcription failed",
43
+ failed: "Transcription failed",
44
+ };
45
+
46
+ const activeStates = new Set(["queued", "loading", "cold_start", "transcribing", "processing"]);
47
+ const terminalStates = new Set(["complete", "completed", "ready", "error", "failed", "idle"]);
48
+ const trackState = new Map();
49
+ let snapshot = normalizePayload({});
50
+ let geometry = null;
51
+ let playback = null;
52
+ let resizeFrame = 0;
53
+ let announceTimer = 0;
54
+
55
+ function asFinite(value, fallback = 0) {
56
+ const number = Number(value);
57
+ return Number.isFinite(number) ? number : fallback;
58
+ }
59
+
60
+ function clamp(value, minimum, maximum) {
61
+ return Math.min(maximum, Math.max(minimum, value));
62
+ }
63
+
64
+ function safeColor(value, index) {
65
+ const candidate = typeof value === "string" ? value.trim() : "";
66
+ if (candidate && typeof CSS !== "undefined" && CSS.supports("color", candidate)) {
67
+ return candidate;
68
+ }
69
+ return fallbackColors[index % fallbackColors.length];
70
+ }
71
+
72
+ function titleFromId(value) {
73
+ return String(value || "Instrument")
74
+ .replace(/[_-]+/g, " ")
75
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
76
+ }
77
+
78
+ function normalizeNote(note) {
79
+ const pitch = clamp(Math.round(asFinite(note && note.pitch, 60)), 0, 127);
80
+ const start = Math.max(0, asFinite(note && note.start, 0));
81
+ const end = Math.max(start + 0.015, asFinite(note && note.end, start + 0.12));
82
+ const velocity = clamp(Math.round(asFinite(note && note.velocity, 90)), 1, 127);
83
+ return { pitch, start, end, velocity };
84
+ }
85
+
86
+ function normalizeTrack(track, index) {
87
+ const id = String(track && track.id != null ? track.id : `track-${index + 1}`);
88
+ const notes = Array.isArray(track && track.notes)
89
+ ? track.notes.filter((note) => note && typeof note === "object").map(normalizeNote)
90
+ : [];
91
+ notes.sort((left, right) => left.start - right.start || left.pitch - right.pitch);
92
+ return {
93
+ id,
94
+ name: String((track && track.name) || titleFromId(id)),
95
+ color: safeColor(track && track.color, index),
96
+ note_count: Math.max(0, Math.round(asFinite(track && track.note_count, notes.length))),
97
+ program: track && track.program != null ? Math.round(asFinite(track.program, 0)) : null,
98
+ is_drum: Boolean(track && track.is_drum),
99
+ midi: typeof (track && track.midi) === "string" ? track.midi : "",
100
+ notes,
101
+ };
102
+ }
103
+
104
+ function parseIncoming(value) {
105
+ if (value && typeof value === "object") {
106
+ return value;
107
+ }
108
+ if (typeof value !== "string" || !value.trim()) {
109
+ return {};
110
+ }
111
+ try {
112
+ const parsed = JSON.parse(value);
113
+ return parsed && typeof parsed === "object" ? parsed : {};
114
+ } catch (error) {
115
+ return {
116
+ state: "error",
117
+ status: "The transcription response could not be read.",
118
+ _parse_error: true,
119
+ };
120
+ }
121
+ }
122
+
123
+ function normalizePayload(raw) {
124
+ const tracks = Array.isArray(raw && raw.tracks) ? raw.tracks.map(normalizeTrack) : [];
125
+ const notesFromTracks = tracks.reduce((total, track) => total + track.notes.length, 0);
126
+ const noteEnd = tracks.reduce(
127
+ (latest, track) => track.notes.reduce((trackEnd, note) => Math.max(trackEnd, note.end), latest),
128
+ 0,
129
+ );
130
+ const suppliedDuration = Math.max(0, asFinite(raw && raw.duration, 0));
131
+ const inferredState = tracks.length || (raw && raw.full_midi) ? "complete" : "idle";
132
+ const state = String((raw && raw.state) || inferredState)
133
+ .trim()
134
+ .toLowerCase()
135
+ .replace(/[\s-]+/g, "_");
136
+ const rawProgress = Number(raw && raw.progress);
137
+ let progress = Number.isFinite(rawProgress) ? rawProgress : null;
138
+ if (progress != null && progress > 1) {
139
+ progress /= 100;
140
+ }
141
+ if (progress != null) {
142
+ progress = clamp(progress, 0, 1);
143
+ }
144
+ return {
145
+ state,
146
+ status: typeof (raw && raw.status) === "string" ? raw.status.trim() : "",
147
+ progress,
148
+ audio_name: typeof (raw && raw.audio_name) === "string" ? raw.audio_name.trim() : "",
149
+ elapsed: Math.max(0, asFinite(raw && raw.elapsed, 0)),
150
+ duration: Math.max(suppliedDuration, noteEnd),
151
+ note_count: Math.max(0, Math.round(asFinite(raw && raw.note_count, notesFromTracks))),
152
+ full_midi: typeof (raw && raw.full_midi) === "string" ? raw.full_midi : "",
153
+ tracks,
154
+ _parse_error: Boolean(raw && raw._parse_error),
155
+ };
156
+ }
157
+
158
+ function stateCopy(payload) {
159
+ if (payload.status) {
160
+ return payload.status;
161
+ }
162
+ switch (payload.state) {
163
+ case "queued":
164
+ return "Waiting briefly for a ZeroGPU worker…";
165
+ case "loading":
166
+ case "cold_start":
167
+ return "Waking the model. A cold start can take a little longer.";
168
+ case "transcribing":
169
+ case "processing":
170
+ return "Listening in five-second windows and separating instruments…";
171
+ case "complete":
172
+ case "completed":
173
+ case "ready":
174
+ return "Every color is an independently playable instrument track.";
175
+ case "error":
176
+ case "failed":
177
+ return "Something interrupted the transcription. Please try again.";
178
+ default:
179
+ return "Upload a recording to reveal its notes and instruments.";
180
+ }
181
+ }
182
+
183
+ function formatClock(seconds) {
184
+ if (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0) {
185
+ return "—";
186
+ }
187
+ const total = Math.round(Number(seconds));
188
+ const minutes = Math.floor(total / 60);
189
+ const remainder = total % 60;
190
+ return `${minutes}:${String(remainder).padStart(2, "0")}`;
191
+ }
192
+
193
+ function formatElapsed(seconds) {
194
+ if (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0) {
195
+ return "—";
196
+ }
197
+ if (Number(seconds) < 60) {
198
+ return `${Number(seconds).toFixed(Number(seconds) < 10 ? 1 : 0)}s`;
199
+ }
200
+ return formatClock(seconds);
201
+ }
202
+
203
+ function fileStem(name) {
204
+ const raw = String(name || "muscriptor").replace(/\.[a-z0-9]{1,5}$/i, "");
205
+ const safe = raw
206
+ .normalize("NFKD")
207
+ .replace(/[^a-z0-9]+/gi, "-")
208
+ .replace(/^-+|-+$/g, "")
209
+ .toLowerCase();
210
+ return safe || "muscriptor";
211
+ }
212
+
213
+ function normalizeMidiHref(value) {
214
+ const midi = String(value || "").trim();
215
+ if (!midi) {
216
+ return "";
217
+ }
218
+ if (/^(data:|blob:|https?:\/\/|\/)/i.test(midi)) {
219
+ return midi;
220
+ }
221
+ return `data:audio/midi;base64,${midi.replace(/\s+/g, "")}`;
222
+ }
223
+
224
+ function setText(selector, value) {
225
+ const target = $(selector);
226
+ if (target) {
227
+ target.textContent = value;
228
+ }
229
+ }
230
+
231
+ function announce(message) {
232
+ window.clearTimeout(announceTimer);
233
+ announcer.textContent = "";
234
+ announceTimer = window.setTimeout(() => {
235
+ announcer.textContent = message;
236
+ }, 60);
237
+ }
238
+
239
+ function hasSolo() {
240
+ return snapshot.tracks.some((track) => trackState.get(track.id)?.solo);
241
+ }
242
+
243
+ function isAudible(trackId) {
244
+ const state = trackState.get(trackId) || { solo: false, muted: false };
245
+ return !state.muted && (!hasSolo() || state.solo);
246
+ }
247
+
248
+ function syncTrackState(tracks) {
249
+ const currentIds = new Set(tracks.map((track) => track.id));
250
+ for (const id of trackState.keys()) {
251
+ if (!currentIds.has(id)) {
252
+ trackState.delete(id);
253
+ }
254
+ }
255
+ tracks.forEach((track) => {
256
+ if (!trackState.has(track.id)) {
257
+ trackState.set(track.id, { solo: false, muted: false });
258
+ }
259
+ });
260
+ }
261
+
262
+ function updateDownload(anchor, midi, filename) {
263
+ const href = normalizeMidiHref(midi);
264
+ if (href) {
265
+ anchor.href = href;
266
+ anchor.download = filename;
267
+ anchor.removeAttribute("aria-disabled");
268
+ anchor.removeAttribute("tabindex");
269
+ } else {
270
+ anchor.removeAttribute("href");
271
+ anchor.removeAttribute("download");
272
+ anchor.setAttribute("aria-disabled", "true");
273
+ anchor.setAttribute("tabindex", "-1");
274
+ }
275
+ }
276
+
277
+ function trackMeta(track) {
278
+ const notes = `${track.note_count.toLocaleString()} ${track.note_count === 1 ? "note" : "notes"}`;
279
+ if (track.is_drum) {
280
+ return `${notes} · percussion`;
281
+ }
282
+ if (track.program != null) {
283
+ return `${notes} · program ${track.program}`;
284
+ }
285
+ return notes;
286
+ }
287
+
288
+ function downloadIcon() {
289
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
290
+ svg.setAttribute("viewBox", "0 0 20 20");
291
+ svg.setAttribute("aria-hidden", "true");
292
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
293
+ path.setAttribute("d", "M10 3.5v9m0 0 3.2-3.2M10 12.5 6.8 9.3M4.5 15.7h11");
294
+ svg.append(path);
295
+ return svg;
296
+ }
297
+
298
+ function renderTracks() {
299
+ tracksRegion.replaceChildren();
300
+ const stem = fileStem(snapshot.audio_name);
301
+
302
+ if (!snapshot.tracks.length) {
303
+ const empty = document.createElement("div");
304
+ empty.className = "msv-track-empty";
305
+ empty.textContent = activeStates.has(snapshot.state)
306
+ ? "Instrument tracks will arrive as transcription completes."
307
+ : "Tracks will appear after transcription.";
308
+ tracksRegion.append(empty);
309
+ return;
310
+ }
311
+
312
+ snapshot.tracks.forEach((track) => {
313
+ const state = trackState.get(track.id);
314
+ const card = document.createElement("article");
315
+ card.className = "msv-track-card";
316
+ card.dataset.trackId = track.id;
317
+ card.dataset.muted = String(Boolean(state.muted));
318
+ card.dataset.inactive = String(!isAudible(track.id));
319
+ card.style.setProperty("--track-color", track.color);
320
+ card.setAttribute("aria-label", `${track.name}, ${trackMeta(track)}`);
321
+
322
+ const swatch = document.createElement("span");
323
+ swatch.className = "msv-track-swatch";
324
+ swatch.setAttribute("aria-hidden", "true");
325
+
326
+ const copy = document.createElement("div");
327
+ copy.className = "msv-track-copy";
328
+ const name = document.createElement("h4");
329
+ name.className = "msv-track-name";
330
+ name.textContent = track.name;
331
+ const meta = document.createElement("div");
332
+ meta.className = "msv-track-meta";
333
+ meta.textContent = trackMeta(track);
334
+ copy.append(name, meta);
335
+
336
+ const controls = document.createElement("div");
337
+ controls.className = "msv-track-controls";
338
+ controls.setAttribute("aria-label", `${track.name} controls`);
339
+
340
+ const solo = document.createElement("button");
341
+ solo.className = "msv-track-button";
342
+ solo.type = "button";
343
+ solo.textContent = "S";
344
+ solo.title = `Solo ${track.name}`;
345
+ solo.setAttribute("aria-label", `Solo ${track.name}`);
346
+ solo.setAttribute("aria-pressed", String(Boolean(state.solo)));
347
+ solo.addEventListener("click", () => {
348
+ state.solo = !state.solo;
349
+ renderTracks();
350
+ refreshTrackMix();
351
+ drawPianoRoll();
352
+ announce(`${track.name} solo ${state.solo ? "on" : "off"}.`);
353
+ });
354
+
355
+ const mute = document.createElement("button");
356
+ mute.className = "msv-track-button";
357
+ mute.type = "button";
358
+ mute.textContent = "M";
359
+ mute.title = `Mute ${track.name}`;
360
+ mute.setAttribute("aria-label", `Mute ${track.name}`);
361
+ mute.setAttribute("aria-pressed", String(Boolean(state.muted)));
362
+ mute.addEventListener("click", () => {
363
+ state.muted = !state.muted;
364
+ renderTracks();
365
+ refreshTrackMix();
366
+ drawPianoRoll();
367
+ announce(`${track.name} mute ${state.muted ? "on" : "off"}.`);
368
+ });
369
+
370
+ const download = document.createElement("a");
371
+ download.className = "msv-track-download";
372
+ download.title = `Download ${track.name} MIDI`;
373
+ download.setAttribute("aria-label", `Download ${track.name} MIDI`);
374
+ download.append(downloadIcon());
375
+ updateDownload(download, track.midi, `${stem}-${fileStem(track.name)}.mid`);
376
+
377
+ controls.append(solo, mute, download);
378
+ card.append(swatch, copy, controls);
379
+ tracksRegion.append(card);
380
+ });
381
+ }
382
+
383
+ function refreshTrackMix() {
384
+ if (!playback) {
385
+ return;
386
+ }
387
+ const now = playback.context.currentTime;
388
+ playback.trackGains.forEach((gain, id) => {
389
+ gain.gain.cancelScheduledValues(now);
390
+ gain.gain.setTargetAtTime(isAudible(id) ? 1 : 0.0001, now, 0.018);
391
+ });
392
+ }
393
+
394
+ function renderProgress() {
395
+ const active = activeStates.has(snapshot.state);
396
+ progressRegion.hidden = !active;
397
+ if (!active) {
398
+ return;
399
+ }
400
+
401
+ const determinate = snapshot.progress != null;
402
+ const percent = Math.round((snapshot.progress || 0) * 100);
403
+ progressRegion.dataset.indeterminate = String(!determinate);
404
+ setText('[data-field="progress-copy"]', stateCopy(snapshot));
405
+ setText('[data-field="progress-value"]', determinate ? `${percent}%` : "Working");
406
+ $('[data-field="progress-bar"]').style.width = `${percent}%`;
407
+ progressTrack.setAttribute("aria-valuenow", String(percent));
408
+ progressTrack.setAttribute("aria-valuetext", determinate ? `${percent}%` : "In progress");
409
+ }
410
+
411
+ function renderEmptyState(visibleTracks) {
412
+ const noteTotal = snapshot.tracks.reduce((total, track) => total + track.notes.length, 0);
413
+ const hasAudibleNotes = visibleTracks.some((track) => track.notes.length);
414
+ let title = "A score will appear here";
415
+ let copy = "MuScriptor separates notes into individual, color-coded instrument tracks.";
416
+
417
+ if (snapshot.state === "queued" || snapshot.state === "loading" || snapshot.state === "cold_start") {
418
+ title = "Waking ZeroGPU";
419
+ copy = "The first run can take a little longer while the model is allocated and loaded.";
420
+ } else if (snapshot.state === "transcribing" || snapshot.state === "processing") {
421
+ title = "Listening in five-second windows";
422
+ copy = "Notes and instrument tracks will appear as soon as transcription finishes.";
423
+ } else if (snapshot.state === "error" || snapshot.state === "failed") {
424
+ title = "Transcription could not finish";
425
+ copy = stateCopy(snapshot);
426
+ } else if (noteTotal && !hasAudibleNotes) {
427
+ title = "All tracks are muted";
428
+ copy = "Unmute a track or change the solo selection to bring its notes back.";
429
+ } else if (snapshot.tracks.length && !noteTotal) {
430
+ title = "No pitched notes detected";
431
+ copy = "The MIDI tracks are available below even though the piano roll is empty.";
432
+ }
433
+
434
+ setText('[data-field="empty-title"]', title);
435
+ setText('[data-field="empty-copy"]', copy);
436
+ emptyRegion.hidden = hasAudibleNotes;
437
+ }
438
+
439
+ function niceTimeStep(duration) {
440
+ if (duration <= 12) return 1;
441
+ if (duration <= 45) return 2;
442
+ if (duration <= 120) return 5;
443
+ if (duration <= 360) return 10;
444
+ return 30;
445
+ }
446
+
447
+ function setupCanvas(canvas, width, height, ratio) {
448
+ const pixelWidth = Math.max(1, Math.round(width * ratio));
449
+ const pixelHeight = Math.max(1, Math.round(height * ratio));
450
+ if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
451
+ canvas.width = pixelWidth;
452
+ canvas.height = pixelHeight;
453
+ }
454
+ canvas.style.width = `${width}px`;
455
+ canvas.style.height = `${height}px`;
456
+ const context = canvas.getContext("2d");
457
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
458
+ return context;
459
+ }
460
+
461
+ function roundedRect(context, x, y, width, height, radius) {
462
+ const r = Math.min(radius, width / 2, height / 2);
463
+ context.beginPath();
464
+ context.moveTo(x + r, y);
465
+ context.arcTo(x + width, y, x + width, y + height, r);
466
+ context.arcTo(x + width, y + height, x, y + height, r);
467
+ context.arcTo(x, y + height, x, y, r);
468
+ context.arcTo(x, y, x + width, y, r);
469
+ context.closePath();
470
+ }
471
+
472
+ function computePitchRange(tracks) {
473
+ let minimumPitch = 128;
474
+ let maximumPitch = -1;
475
+ tracks.forEach((track) => {
476
+ track.notes.forEach((note) => {
477
+ minimumPitch = Math.min(minimumPitch, note.pitch);
478
+ maximumPitch = Math.max(maximumPitch, note.pitch);
479
+ });
480
+ });
481
+ if (maximumPitch < 0) {
482
+ return { minimum: 36, maximum: 84 };
483
+ }
484
+ let minimum = clamp(minimumPitch - 2, 0, 127);
485
+ let maximum = clamp(maximumPitch + 2, 0, 127);
486
+ if (maximum - minimum < 24) {
487
+ const missing = 24 - (maximum - minimum);
488
+ minimum = clamp(minimum - Math.ceil(missing / 2), 0, 127);
489
+ maximum = clamp(minimum + 24, 0, 127);
490
+ minimum = Math.max(0, maximum - 24);
491
+ }
492
+ return { minimum, maximum };
493
+ }
494
+
495
+ function drawPianoRoll() {
496
+ const visibleTracks = snapshot.tracks.filter((track) => isAudible(track.id));
497
+ renderEmptyState(visibleTracks);
498
+
499
+ const viewportWidth = Math.max(320, rollScroll.clientWidth || root.clientWidth || 720);
500
+ const height = Math.max(220, rollScroll.clientHeight || 334);
501
+ const duration = Math.max(snapshot.duration, 1);
502
+ const timePixels = duration <= 15 ? 72 : duration <= 60 ? 54 : duration <= 180 ? 38 : 25;
503
+ const width = Math.round(Math.max(viewportWidth, Math.min(9000, 68 + duration * timePixels)));
504
+ const ratio = width > 4200 ? 1 : Math.min(2, window.devicePixelRatio || 1);
505
+ const keyWidth = 52;
506
+ const rulerHeight = 24;
507
+ const rightPadding = 14;
508
+ const plotWidth = Math.max(1, width - keyWidth - rightPadding);
509
+ const rangeSource = visibleTracks.some((track) => track.notes.length) ? visibleTracks : snapshot.tracks;
510
+ const pitchRange = computePitchRange(rangeSource);
511
+ const pitchCount = pitchRange.maximum - pitchRange.minimum + 1;
512
+ const rowHeight = (height - rulerHeight) / pitchCount;
513
+
514
+ rollStage.style.width = `${width}px`;
515
+ const context = setupCanvas(notesCanvas, width, height, ratio);
516
+ setupCanvas(cursorCanvas, width, height, ratio).clearRect(0, 0, width, height);
517
+ context.clearRect(0, 0, width, height);
518
+ context.fillStyle = "#0a0d12";
519
+ context.fillRect(0, 0, width, height);
520
+
521
+ context.fillStyle = "#0e1118";
522
+ context.fillRect(0, 0, width, rulerHeight);
523
+
524
+ const blackNotes = new Set([1, 3, 6, 8, 10]);
525
+ for (let pitch = pitchRange.minimum; pitch <= pitchRange.maximum; pitch += 1) {
526
+ const row = pitchRange.maximum - pitch;
527
+ const y = rulerHeight + row * rowHeight;
528
+ if (blackNotes.has(pitch % 12)) {
529
+ context.fillStyle = "rgba(255,255,255,0.013)";
530
+ context.fillRect(keyWidth, y, width - keyWidth, rowHeight);
531
+ }
532
+ context.beginPath();
533
+ context.moveTo(keyWidth, Math.round(y) + 0.5);
534
+ context.lineTo(width, Math.round(y) + 0.5);
535
+ context.strokeStyle = pitch % 12 === 0 ? "rgba(255,255,255,0.068)" : "rgba(255,255,255,0.026)";
536
+ context.lineWidth = 1;
537
+ context.stroke();
538
+ }
539
+
540
+ const step = niceTimeStep(duration);
541
+ context.font = '9px "SFMono-Regular", Consolas, monospace';
542
+ context.textBaseline = "middle";
543
+ for (let second = 0; second <= duration + 0.001; second += step) {
544
+ const x = keyWidth + (second / duration) * plotWidth;
545
+ context.beginPath();
546
+ context.moveTo(Math.round(x) + 0.5, 0);
547
+ context.lineTo(Math.round(x) + 0.5, height);
548
+ context.strokeStyle = second === 0 ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,0.045)";
549
+ context.lineWidth = 1;
550
+ context.stroke();
551
+ context.fillStyle = "rgba(180,185,198,0.55)";
552
+ const label = second < 60 ? `${Math.round(second)}s` : formatClock(second);
553
+ context.fillText(label, x + 5, rulerHeight / 2 + 0.5);
554
+ }
555
+
556
+ context.fillStyle = "#10141b";
557
+ context.fillRect(0, rulerHeight, keyWidth, height - rulerHeight);
558
+ context.beginPath();
559
+ context.moveTo(keyWidth - 0.5, 0);
560
+ context.lineTo(keyWidth - 0.5, height);
561
+ context.strokeStyle = "rgba(255,255,255,0.1)";
562
+ context.stroke();
563
+
564
+ context.font = '8px "SFMono-Regular", Consolas, monospace';
565
+ context.textAlign = "right";
566
+ for (let pitch = pitchRange.minimum; pitch <= pitchRange.maximum; pitch += 1) {
567
+ const row = pitchRange.maximum - pitch;
568
+ const y = rulerHeight + row * rowHeight;
569
+ const isBlack = blackNotes.has(pitch % 12);
570
+ if (isBlack) {
571
+ context.fillStyle = "#090b10";
572
+ context.fillRect(0, y, keyWidth * 0.66, rowHeight);
573
+ }
574
+ if (pitch % 12 === 0 && rowHeight >= 4.5) {
575
+ context.fillStyle = "rgba(191,196,209,0.52)";
576
+ context.textBaseline = "middle";
577
+ context.fillText(`C${Math.floor(pitch / 12) - 1}`, keyWidth - 7, y + rowHeight / 2);
578
+ }
579
+ }
580
+ context.textAlign = "left";
581
+
582
+ visibleTracks.forEach((track) => {
583
+ track.notes.forEach((note) => {
584
+ const x = keyWidth + (clamp(note.start, 0, duration) / duration) * plotWidth;
585
+ const endX = keyWidth + (clamp(note.end, 0, duration) / duration) * plotWidth;
586
+ const widthPx = Math.max(2.2, endX - x);
587
+ const row = pitchRange.maximum - clamp(note.pitch, pitchRange.minimum, pitchRange.maximum);
588
+ const y = rulerHeight + row * rowHeight + Math.max(0.65, rowHeight * 0.12);
589
+ const heightPx = Math.max(2, rowHeight * 0.76);
590
+ context.globalAlpha = 0.56 + (note.velocity / 127) * 0.38;
591
+ context.fillStyle = track.color;
592
+ roundedRect(context, x, y, widthPx, heightPx, Math.min(2.5, heightPx * 0.34));
593
+ context.fill();
594
+ if (heightPx >= 4) {
595
+ context.globalAlpha = 0.24;
596
+ context.fillStyle = "#ffffff";
597
+ roundedRect(context, x + 0.7, y + 0.7, Math.max(1, widthPx - 1.4), 1, 0.5);
598
+ context.fill();
599
+ }
600
+ });
601
+ });
602
+ context.globalAlpha = 1;
603
+
604
+ geometry = { width, height, ratio, duration, keyWidth, plotWidth };
605
+ const audibleNames = visibleTracks.map((track) => track.name).join(", ");
606
+ notesCanvas.setAttribute(
607
+ "aria-label",
608
+ visibleTracks.length
609
+ ? `Piano roll with ${visibleTracks.length} audible tracks: ${audibleNames}`
610
+ : "Piano roll with no audible tracks",
611
+ );
612
+ }
613
+
614
+ function clearCursor() {
615
+ if (!geometry) return;
616
+ const context = setupCanvas(cursorCanvas, geometry.width, geometry.height, geometry.ratio);
617
+ context.clearRect(0, 0, geometry.width, geometry.height);
618
+ }
619
+
620
+ function drawCursor(seconds, follow = false) {
621
+ if (!geometry) return;
622
+ const context = setupCanvas(cursorCanvas, geometry.width, geometry.height, geometry.ratio);
623
+ context.clearRect(0, 0, geometry.width, geometry.height);
624
+ const x = geometry.keyWidth + (clamp(seconds, 0, geometry.duration) / geometry.duration) * geometry.plotWidth;
625
+ const gradient = context.createLinearGradient(x, 0, x + 10, 0);
626
+ gradient.addColorStop(0, "rgba(238,235,255,0.92)");
627
+ gradient.addColorStop(0.18, "rgba(150,133,255,0.22)");
628
+ gradient.addColorStop(1, "rgba(150,133,255,0)");
629
+ context.fillStyle = gradient;
630
+ context.fillRect(x, 0, 12, geometry.height);
631
+ context.fillStyle = "#f3f1ff";
632
+ context.fillRect(Math.round(x), 0, 1.25, geometry.height);
633
+
634
+ if (follow && x > rollScroll.scrollLeft + rollScroll.clientWidth * 0.82) {
635
+ rollScroll.scrollTo({ left: Math.max(0, x - rollScroll.clientWidth * 0.28), behavior: "smooth" });
636
+ }
637
+ }
638
+
639
+ function noteFrequency(pitch) {
640
+ return clamp(440 * 2 ** ((pitch - 69) / 12), 28, 4200);
641
+ }
642
+
643
+ function scheduleNote(track, note, currentElapsed) {
644
+ if (!playback) return;
645
+ const context = playback.context;
646
+ const trackGain = playback.trackGains.get(track.id);
647
+ if (!trackGain) return;
648
+
649
+ const scheduledStart = playback.startedAt + note.start;
650
+ const start = Math.max(context.currentTime + 0.006, scheduledStart);
651
+ const naturalLength = Math.max(0.025, note.end - Math.max(note.start, currentElapsed));
652
+ const length = track.is_drum ? Math.min(0.13, naturalLength) : Math.min(8, naturalLength);
653
+ const end = start + length;
654
+ const oscillator = context.createOscillator();
655
+ const envelope = context.createGain();
656
+ const amplitude = (note.velocity / 127) * (track.is_drum ? 0.05 : 0.075);
657
+
658
+ oscillator.type = track.is_drum ? "square" : ["sine", "triangle", "sine", "triangle"][
659
+ Math.abs(track.id.split("").reduce((sum, letter) => sum + letter.charCodeAt(0), 0)) % 4
660
+ ];
661
+ oscillator.frequency.setValueAtTime(track.is_drum ? 52 + (note.pitch % 24) * 7 : noteFrequency(note.pitch), start);
662
+ if (track.is_drum) {
663
+ oscillator.frequency.exponentialRampToValueAtTime(34, end);
664
+ }
665
+
666
+ envelope.gain.setValueAtTime(0.0001, start);
667
+ envelope.gain.exponentialRampToValueAtTime(Math.max(0.0002, amplitude), start + Math.min(0.012, length * 0.25));
668
+ envelope.gain.exponentialRampToValueAtTime(0.0001, end);
669
+ oscillator.connect(envelope);
670
+ envelope.connect(trackGain);
671
+ oscillator.start(start);
672
+ oscillator.stop(end + 0.01);
673
+ playback.nodes.add(oscillator);
674
+ oscillator.addEventListener("ended", () => playback && playback.nodes.delete(oscillator), { once: true });
675
+ }
676
+
677
+ function runScheduler() {
678
+ if (!playback) return;
679
+ const elapsed = playback.context.currentTime - playback.startedAt;
680
+ const horizon = elapsed + 0.42;
681
+ snapshot.tracks.forEach((track) => {
682
+ let index = playback.nextNote.get(track.id) || 0;
683
+ while (index < track.notes.length && track.notes[index].start <= horizon) {
684
+ const note = track.notes[index];
685
+ if (note.end > elapsed) {
686
+ scheduleNote(track, note, elapsed);
687
+ }
688
+ index += 1;
689
+ }
690
+ playback.nextNote.set(track.id, index);
691
+ });
692
+ }
693
+
694
+ function playbackFrame() {
695
+ if (!playback) return;
696
+ const elapsed = Math.max(0, playback.context.currentTime - playback.startedAt);
697
+ drawCursor(elapsed, true);
698
+ if (elapsed >= playback.duration + 0.08) {
699
+ stopPlayback(true);
700
+ announce("Playback finished.");
701
+ return;
702
+ }
703
+ playback.frame = window.requestAnimationFrame(playbackFrame);
704
+ }
705
+
706
+ async function startPlayback() {
707
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
708
+ const hasNotes = snapshot.tracks.some((track) => track.notes.length);
709
+ if (!AudioContextClass || !hasNotes) {
710
+ announce(AudioContextClass ? "There are no notes to play." : "WebAudio is not available in this browser.");
711
+ return;
712
+ }
713
+ stopPlayback(false);
714
+
715
+ const context = new AudioContextClass({ latencyHint: "interactive" });
716
+ if (context.state === "suspended") {
717
+ await context.resume();
718
+ }
719
+ const master = context.createGain();
720
+ const compressor = context.createDynamicsCompressor();
721
+ master.gain.value = 0.62;
722
+ compressor.threshold.value = -14;
723
+ compressor.knee.value = 14;
724
+ compressor.ratio.value = 8;
725
+ compressor.attack.value = 0.004;
726
+ compressor.release.value = 0.12;
727
+ master.connect(compressor);
728
+ compressor.connect(context.destination);
729
+
730
+ const trackGains = new Map();
731
+ snapshot.tracks.forEach((track) => {
732
+ const gain = context.createGain();
733
+ gain.gain.value = isAudible(track.id) ? 1 : 0.0001;
734
+ gain.connect(master);
735
+ trackGains.set(track.id, gain);
736
+ });
737
+
738
+ playback = {
739
+ context,
740
+ master,
741
+ trackGains,
742
+ nodes: new Set(),
743
+ nextNote: new Map(snapshot.tracks.map((track) => [track.id, 0])),
744
+ startedAt: context.currentTime + 0.07,
745
+ duration: Math.max(snapshot.duration, 0.1),
746
+ timer: 0,
747
+ frame: 0,
748
+ };
749
+ runScheduler();
750
+ playback.timer = window.setInterval(runScheduler, 90);
751
+ playback.frame = window.requestAnimationFrame(playbackFrame);
752
+ playButton.disabled = true;
753
+ playButton.setAttribute("aria-pressed", "true");
754
+ stopButton.disabled = false;
755
+ announce("Playback started.");
756
+ }
757
+
758
+ function stopPlayback(resetCursor = true) {
759
+ if (playback) {
760
+ window.clearInterval(playback.timer);
761
+ window.cancelAnimationFrame(playback.frame);
762
+ playback.nodes.forEach((node) => {
763
+ try {
764
+ node.stop();
765
+ } catch (error) {
766
+ // A node that has already ended cannot be stopped again.
767
+ }
768
+ });
769
+ playback.context.close().catch(() => {});
770
+ playback = null;
771
+ }
772
+ const hasNotes = snapshot.tracks.some((track) => track.notes.length);
773
+ playButton.disabled = !hasNotes;
774
+ playButton.setAttribute("aria-pressed", "false");
775
+ stopButton.disabled = true;
776
+ if (resetCursor) {
777
+ clearCursor();
778
+ }
779
+ }
780
+
781
+ function render(nextSnapshot, options = {}) {
782
+ const previousState = snapshot.state;
783
+ const previousSignature = snapshot.tracks
784
+ .map((track) => `${track.id}:${track.notes.length}:${track.notes.at(-1)?.end || 0}`)
785
+ .join("|");
786
+ const nextSignature = nextSnapshot.tracks
787
+ .map((track) => `${track.id}:${track.notes.length}:${track.notes.at(-1)?.end || 0}`)
788
+ .join("|");
789
+ if (playback && previousSignature !== nextSignature) {
790
+ stopPlayback(true);
791
+ }
792
+
793
+ snapshot = nextSnapshot;
794
+ syncTrackState(snapshot.tracks);
795
+ root.dataset.state = snapshot.state || "idle";
796
+ setText('[data-field="state-label"]', stateLabels[snapshot.state] || titleFromId(snapshot.state));
797
+ setText('[data-field="audio-name"]', snapshot.audio_name || "Your transcription");
798
+ setText('[data-field="status"]', stateCopy(snapshot));
799
+ setText('[data-field="duration"]', formatClock(snapshot.duration));
800
+ setText('[data-field="note-count"]', snapshot.note_count ? snapshot.note_count.toLocaleString() : "—");
801
+ setText('[data-field="track-count"]', snapshot.tracks.length ? snapshot.tracks.length.toLocaleString() : "—");
802
+ setText('[data-field="elapsed"]', formatElapsed(snapshot.elapsed));
803
+ setText(
804
+ '[data-field="track-summary"]',
805
+ snapshot.tracks.length
806
+ ? `${snapshot.tracks.length} ${snapshot.tracks.length === 1 ? "track" : "tracks"}`
807
+ : "No tracks yet",
808
+ );
809
+
810
+ updateDownload(fullDownload, snapshot.full_midi, `${fileStem(snapshot.audio_name)}-full.mid`);
811
+ renderProgress();
812
+ renderTracks();
813
+ drawPianoRoll();
814
+
815
+ const hasNotes = snapshot.tracks.some((track) => track.notes.length);
816
+ if (!playback) {
817
+ playButton.disabled = !hasNotes;
818
+ stopButton.disabled = true;
819
+ }
820
+
821
+ const submitButton = findSubmitButton();
822
+ if (submitButton && terminalStates.has(snapshot.state)) {
823
+ submitButton.removeAttribute("aria-busy");
824
+ }
825
+
826
+ if (!options.silent && (previousState !== snapshot.state || options.local)) {
827
+ announce(`${stateLabels[snapshot.state] || titleFromId(snapshot.state)}. ${stateCopy(snapshot)}`);
828
+ }
829
+ }
830
+
831
+ function updateFromProps() {
832
+ render(normalizePayload(parseIncoming(props.value)));
833
+ }
834
+
835
+ function findSubmitButton() {
836
+ const localRoot = element.getRootNode && element.getRootNode();
837
+ return (localRoot && localRoot.querySelector && localRoot.querySelector("#transcribe-button"))
838
+ || document.querySelector("#transcribe-button");
839
+ }
840
+
841
+ function bindSubmitButton() {
842
+ const button = findSubmitButton();
843
+ if (!button || button.__muscriptorVisualizerBound) {
844
+ return Boolean(button);
845
+ }
846
+ button.__muscriptorVisualizerBound = true;
847
+ button.addEventListener("click", () => {
848
+ if (button.matches(":disabled") || button.getAttribute("aria-disabled") === "true") {
849
+ return;
850
+ }
851
+ button.setAttribute("aria-busy", "true");
852
+ render(
853
+ normalizePayload({
854
+ ...snapshot,
855
+ state: "queued",
856
+ status: "Request sent. Allocating a ZeroGPU worker…",
857
+ progress: 0.01,
858
+ elapsed: 0,
859
+ duration: 0,
860
+ note_count: 0,
861
+ full_midi: "",
862
+ tracks: [],
863
+ }),
864
+ { local: true },
865
+ );
866
+ }, { capture: true });
867
+ return true;
868
+ }
869
+
870
+ playButton.addEventListener("click", () => {
871
+ startPlayback().catch(() => {
872
+ stopPlayback(true);
873
+ announce("Playback could not start. Check this browser's audio permissions.");
874
+ });
875
+ });
876
+ stopButton.addEventListener("click", () => {
877
+ stopPlayback(true);
878
+ announce("Playback stopped.");
879
+ });
880
+
881
+ const resizeObserver = new ResizeObserver(() => {
882
+ window.cancelAnimationFrame(resizeFrame);
883
+ resizeFrame = window.requestAnimationFrame(drawPianoRoll);
884
+ });
885
+ resizeObserver.observe(rollScroll);
886
+
887
+ if (!bindSubmitButton()) {
888
+ const buttonObserver = new MutationObserver(() => {
889
+ if (bindSubmitButton()) {
890
+ buttonObserver.disconnect();
891
+ }
892
+ });
893
+ buttonObserver.observe(document.body, { childList: true, subtree: true });
894
+ window.setTimeout(() => buttonObserver.disconnect(), 15000);
895
+ }
896
+
897
+ updateFromProps();
898
+ watch("value", updateFromProps);
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio==6.10.0
2
+ spaces==0.51.0
3
+ muscriptor==0.2.1
4
+ numpy==2.3.5
5
+ einops==0.8.2
6
+ mido==1.3.3
7
+ soundfile==0.14.0
8
+ safetensors==0.8.0
9
+ huggingface_hub==1.21.0