fffiloni commited on
Commit
05c5e98
·
verified ·
1 Parent(s): b7f3d13

Upload 6 files

Browse files
Files changed (6) hide show
  1. app.py +617 -0
  2. midi_component.py +290 -0
  3. notation.py +255 -0
  4. pyproject.toml +3 -0
  5. requirements.txt +13 -0
  6. studio_component.py +63 -0
app.py ADDED
@@ -0,0 +1,617 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ # ZeroGPU and third-party caches must be configured before importing libraries.
6
+ os.environ.setdefault("HF_HOME", "/tmp/.cache/huggingface")
7
+ os.environ.setdefault("HF_MODULES_CACHE", "/tmp/hf_modules")
8
+ os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib")
9
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
10
+
11
+ try: # ZeroGPU requires spaces to be imported before torch.
12
+ import spaces
13
+ except ImportError: # Local UI and tests work without the ZeroGPU package.
14
+ class _SpacesFallback:
15
+ @staticmethod
16
+ def GPU(duration=None):
17
+ def decorate(function):
18
+ return function
19
+
20
+ return decorate
21
+
22
+ spaces = _SpacesFallback() # type: ignore[assignment]
23
+
24
+ import base64
25
+ import hashlib
26
+ import math
27
+ import re
28
+ import sys
29
+ import tempfile
30
+ import time
31
+ from collections import defaultdict
32
+ from pathlib import Path
33
+ from typing import Any, Iterator
34
+
35
+ import gradio as gr
36
+
37
+ from notation import file_data_uri, generate_notation
38
+ from studio_component import StudioViewer, initial_studio_value, normalize_viewer_value
39
+
40
+
41
+ ROOT = Path(__file__).resolve().parent
42
+ MODEL_ID = "MuScriptor/muscriptor-medium"
43
+ MODEL_VARIANT = "medium"
44
+ MAX_AUDIO_SECONDS = 60.0
45
+ GPU_BASE_SECONDS = 24
46
+ GPU_SECONDS_PER_CHUNK = 7
47
+ GPU_DURATION_CAP = 120
48
+
49
+ COLORS = (
50
+ "#35c8ff",
51
+ "#f6a623",
52
+ "#fb7185",
53
+ "#9b87f5",
54
+ "#67e8a5",
55
+ "#60a5fa",
56
+ "#f472b6",
57
+ "#a3e635",
58
+ "#2dd4bf",
59
+ "#f6c667",
60
+ )
61
+
62
+ FALLBACK_INSTRUMENTS = (
63
+ "acoustic_piano",
64
+ "electric_piano",
65
+ "clean_electric_guitar",
66
+ "distorted_electric_guitar",
67
+ "acoustic_guitar",
68
+ "electric_bass",
69
+ "acoustic_bass",
70
+ "violin",
71
+ "viola",
72
+ "cello",
73
+ "strings",
74
+ "trumpet",
75
+ "trombone",
76
+ "flutes",
77
+ "clarinet",
78
+ "soprano_and_alto_sax",
79
+ "tenor_sax",
80
+ "voice",
81
+ "drums",
82
+ )
83
+
84
+
85
+ MODEL: Any | None = None
86
+ MODEL_ERROR = ""
87
+ NoteStartEvent: Any = None
88
+ NoteEndEvent: Any = None
89
+ ProgressEvent: Any = None
90
+ INSTRUMENT_NAMES = list(FALLBACK_INSTRUMENTS)
91
+
92
+
93
+ def _load_model() -> Any | None:
94
+ global MODEL_ERROR, NoteStartEvent, NoteEndEvent, ProgressEvent, INSTRUMENT_NAMES
95
+ try:
96
+ import torch
97
+ from huggingface_hub import hf_hub_download
98
+ from muscriptor.events import NoteEndEvent as _NoteEndEvent
99
+ from muscriptor.events import NoteStartEvent as _NoteStartEvent
100
+ from muscriptor.events import ProgressEvent as _ProgressEvent
101
+ from muscriptor.tokenizer.mt3 import MT3Tokenizer, MT3_FULL_PLUS_GROUP_NAMES
102
+ from muscriptor.transcription_model import (
103
+ TranscriptionModel,
104
+ _build_model,
105
+ _remap_single_codebook_keys,
106
+ _resolve_config,
107
+ _resolve_source,
108
+ )
109
+ from safetensors.torch import load_file
110
+ except Exception as exc:
111
+ MODEL_ERROR = f"Dépendances MuScriptor indisponibles : {type(exc).__name__}: {exc}"
112
+ return None
113
+
114
+ NoteStartEvent = _NoteStartEvent
115
+ NoteEndEvent = _NoteEndEvent
116
+ ProgressEvent = _ProgressEvent
117
+ INSTRUMENT_NAMES = list(MT3_FULL_PLUS_GROUP_NAMES)
118
+
119
+ token = os.environ.get("HF_TOKEN")
120
+ if not token:
121
+ MODEL_ERROR = (
122
+ "HF_TOKEN absent. Acceptez la licence du modèle MuScriptor medium, "
123
+ "puis ajoutez un secret HF_TOKEN en lecture dans les réglages du Space."
124
+ )
125
+ return None
126
+
127
+ try:
128
+ source = _resolve_source(MODEL_VARIANT)
129
+ weights_path = Path(
130
+ hf_hub_download(
131
+ repo_id=MODEL_ID,
132
+ filename="model.safetensors",
133
+ token=token,
134
+ )
135
+ )
136
+ config = _resolve_config(source, weights_path)
137
+ device = torch.device("cuda")
138
+ model = _build_model(device, config)
139
+ model.eval()
140
+ state_dict = _remap_single_codebook_keys(load_file(str(weights_path), device="cpu"))
141
+ model.load_state_dict(state_dict)
142
+ model.to("cuda")
143
+ tokenizer = MT3Tokenizer(instrument_vocabulary="MT3_FULL_PLUS", max_shift_steps=1001)
144
+ return TranscriptionModel(model=model, tokenizer=tokenizer, device=device)
145
+ except Exception as exc:
146
+ MODEL_ERROR = f"Chargement du modèle impossible : {type(exc).__name__}: {exc}"
147
+ return None
148
+
149
+
150
+ if os.environ.get("MUSCRIPTOR_SKIP_MODEL_LOAD") != "1":
151
+ started = time.perf_counter()
152
+ print(f"[MuScriptor Studio] Chargement du modèle {MODEL_VARIANT}…", flush=True)
153
+ MODEL = _load_model()
154
+ if MODEL is None:
155
+ print(f"[MuScriptor Studio] Mode interface uniquement : {MODEL_ERROR}", file=sys.stderr, flush=True)
156
+ else:
157
+ print(f"[MuScriptor Studio] Modèle prêt en {time.perf_counter() - started:.2f}s.", flush=True)
158
+
159
+
160
+ def _audio_path(value: Any) -> str | None:
161
+ if value is None:
162
+ return None
163
+ if isinstance(value, (str, Path)):
164
+ return str(value)
165
+ if isinstance(value, dict):
166
+ path = value.get("path") or value.get("name")
167
+ return str(path) if path else None
168
+ path = getattr(value, "path", None) or getattr(value, "name", None)
169
+ return str(path) if path else None
170
+
171
+
172
+ def _audio_duration(path: str | None) -> float:
173
+ if not path:
174
+ return 0.0
175
+ try:
176
+ import soundfile as sf
177
+
178
+ return float(sf.info(path).duration)
179
+ except Exception:
180
+ return 0.0
181
+
182
+
183
+ def _estimate_gpu_duration(
184
+ audio: Any,
185
+ instruments: list[str] | None = None,
186
+ use_sampling: bool = False,
187
+ temperature: float = 1.0,
188
+ beam_size: int = 1,
189
+ *args: Any,
190
+ **kwargs: Any,
191
+ ) -> int:
192
+ duration = _audio_duration(_audio_path(audio)) or 15.0
193
+ chunks = max(1, math.ceil(duration / 5.0))
194
+ beam = max(1, min(4, int(beam_size or 1)))
195
+ return min(GPU_DURATION_CAP, GPU_BASE_SECONDS + chunks * GPU_SECONDS_PER_CHUNK * beam)
196
+
197
+
198
+ def _display_name(name: str) -> str:
199
+ special = {
200
+ "drums": "Batterie",
201
+ "voice": "Voix",
202
+ "flutes": "Flûtes",
203
+ "electric_bass": "Basse électrique",
204
+ "acoustic_bass": "Contrebasse",
205
+ "acoustic_piano": "Piano acoustique",
206
+ "electric_piano": "Piano électrique",
207
+ "soprano_and_alto_sax": "Saxophone soprano / alto",
208
+ "tenor_sax": "Saxophone ténor",
209
+ }
210
+ return special.get(name, name.replace("_", " ").capitalize())
211
+
212
+
213
+ def _color_for(name: str) -> str:
214
+ digest = hashlib.sha1(name.encode("utf-8")).digest()
215
+ return COLORS[int.from_bytes(digest[:2], "big") % len(COLORS)]
216
+
217
+
218
+ def _slug(name: str) -> str:
219
+ return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "piste"
220
+
221
+
222
+ def _data_uri(data: bytes, mime: str = "audio/midi") -> str:
223
+ return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"
224
+
225
+
226
+ def _program_for(name: str) -> int:
227
+ if name == "drums" or MODEL is None:
228
+ return 0
229
+ try:
230
+ return int(MODEL._program_for_instrument(name))
231
+ except Exception:
232
+ return 0
233
+
234
+
235
+ def _notes_from_events(events: list[Any]) -> dict[str, list[dict[str, float | int]]]:
236
+ notes: dict[str, list[dict[str, float | int]]] = defaultdict(list)
237
+ if NoteEndEvent is None:
238
+ return {}
239
+ for event in events:
240
+ if not isinstance(event, NoteEndEvent):
241
+ continue
242
+ start = event.start_event
243
+ notes[start.instrument].append(
244
+ {
245
+ "pitch": int(start.pitch),
246
+ "start": round(float(start.start_time), 4),
247
+ "end": round(max(float(event.end_time), float(start.start_time) + 0.03), 4),
248
+ "velocity": 100,
249
+ }
250
+ )
251
+ for values in notes.values():
252
+ values.sort(key=lambda item: (item["start"], item["pitch"]))
253
+ return dict(notes)
254
+
255
+
256
+ def _track_payloads(
257
+ notes: dict[str, list[dict[str, float | int]]],
258
+ requested: list[str] | None = None,
259
+ midi_by_instrument: dict[str, bytes] | None = None,
260
+ ) -> list[dict[str, Any]]:
261
+ names = set(notes)
262
+ names.update(requested or [])
263
+ ordered = sorted(
264
+ names,
265
+ key=lambda name: (
266
+ notes.get(name, [{}])[0].get("start", float("inf")) if notes.get(name) else float("inf"),
267
+ name,
268
+ ),
269
+ )
270
+ tracks: list[dict[str, Any]] = []
271
+ for index, name in enumerate(ordered):
272
+ track_notes = notes.get(name, [])
273
+ midi = (midi_by_instrument or {}).get(name)
274
+ tracks.append(
275
+ {
276
+ "id": index,
277
+ "key": name,
278
+ "name": _display_name(name),
279
+ "color": _color_for(name),
280
+ "note_count": len(track_notes),
281
+ "program": _program_for(name),
282
+ "is_drum": name == "drums",
283
+ "midi": _data_uri(midi) if midi else "",
284
+ "notes": track_notes,
285
+ }
286
+ )
287
+ return tracks
288
+
289
+
290
+ def _viewer_payload(
291
+ *,
292
+ state: str,
293
+ status: str,
294
+ progress: float,
295
+ audio_name: str,
296
+ elapsed: float,
297
+ duration: float,
298
+ tracks: list[dict[str, Any]],
299
+ full_midi: bytes | None = None,
300
+ ) -> dict[str, Any]:
301
+ value = initial_studio_value()
302
+ value.update(
303
+ {
304
+ "state": state,
305
+ "status": status,
306
+ "progress": round(max(0.0, min(1.0, progress)), 4),
307
+ "audio_name": audio_name,
308
+ "elapsed": round(elapsed, 2),
309
+ "duration": round(duration, 3),
310
+ "note_count": sum(track["note_count"] for track in tracks),
311
+ "tracks": tracks,
312
+ "full_midi": _data_uri(full_midi) if full_midi else "",
313
+ }
314
+ )
315
+ return value
316
+
317
+
318
+ def _event_for_instrument(event: Any, instrument: str) -> bool:
319
+ if NoteStartEvent is not None and isinstance(event, NoteStartEvent):
320
+ return event.instrument == instrument
321
+ if NoteEndEvent is not None and isinstance(event, NoteEndEvent):
322
+ return event.start_event.instrument == instrument
323
+ return False
324
+
325
+
326
+ def _write_midi_outputs(events: list[Any], track_names: list[str]) -> tuple[Path, dict[str, Path], bytes, dict[str, bytes]]:
327
+ if MODEL is None:
328
+ raise RuntimeError(MODEL_ERROR or "Modèle non chargé.")
329
+ output_dir = Path(tempfile.mkdtemp(prefix="muscriptor-midi-"))
330
+ full_bytes = MODEL.events_to_midi_bytes(iter(events))
331
+ full_path = output_dir / "transcription-complete.mid"
332
+ full_path.write_bytes(full_bytes)
333
+ paths: dict[str, Path] = {}
334
+ payloads: dict[str, bytes] = {}
335
+ for name in track_names:
336
+ track_events = [event for event in events if _event_for_instrument(event, name)]
337
+ data = MODEL.events_to_midi_bytes(iter(track_events))
338
+ path = output_dir / f"{_slug(name)}.mid"
339
+ path.write_bytes(data)
340
+ paths[name] = path
341
+ payloads[name] = data
342
+ return full_path, paths, full_bytes, payloads
343
+
344
+
345
+ def _error_result(message: str, audio_name: str = "", duration: float = 0.0):
346
+ viewer = _viewer_payload(
347
+ state="error",
348
+ status=message,
349
+ progress=0,
350
+ audio_name=audio_name,
351
+ elapsed=0,
352
+ duration=duration,
353
+ tracks=[],
354
+ )
355
+ return None, None, viewer, {"state": "error", "message": message, "viewer": viewer}
356
+
357
+
358
+ @spaces.GPU(duration=_estimate_gpu_duration)
359
+ def transcribe_audio(
360
+ audio: Any,
361
+ instruments: list[str] | None,
362
+ use_sampling: bool,
363
+ temperature: float,
364
+ beam_size: int,
365
+ ) -> Iterator[tuple[str | None, list[str] | None, dict[str, Any], dict[str, Any]]]:
366
+ path = _audio_path(audio)
367
+ if not path:
368
+ yield _error_result("Importez un fichier audio avant de lancer la transcription.")
369
+ return
370
+
371
+ audio_name = Path(path).name
372
+ duration = _audio_duration(path)
373
+ if duration <= 0:
374
+ yield _error_result("La durée du fichier audio n’a pas pu être déterminée.", audio_name)
375
+ return
376
+ if duration > MAX_AUDIO_SECONDS:
377
+ yield _error_result(
378
+ f"Cette première version accepte des enregistrements de {MAX_AUDIO_SECONDS:.0f} secondes maximum.",
379
+ audio_name,
380
+ duration,
381
+ )
382
+ return
383
+ if MODEL is None:
384
+ yield _error_result(MODEL_ERROR or "Le modèle MuScriptor n’est pas disponible.", audio_name, duration)
385
+ return
386
+
387
+ requested = list(dict.fromkeys(instruments or []))
388
+ beam = max(1, min(4, int(beam_size or 1)))
389
+ events: list[Any] = []
390
+ started = time.perf_counter()
391
+ last_completed = -1
392
+
393
+ try:
394
+ stream = MODEL.transcribe(
395
+ path,
396
+ instruments=requested or None,
397
+ use_sampling=bool(use_sampling),
398
+ temperature=float(temperature),
399
+ beam_size=beam,
400
+ batch_size=1,
401
+ )
402
+ for event in stream:
403
+ events.append(event)
404
+ if ProgressEvent is None or not isinstance(event, ProgressEvent):
405
+ continue
406
+ if event.completed == last_completed:
407
+ continue
408
+ last_completed = event.completed
409
+ notes = _notes_from_events(events)
410
+ tracks = _track_payloads(notes, requested)
411
+ fraction = event.completed / max(1, event.total)
412
+ status = (
413
+ "ZeroGPU prêt · préparation de la première fenêtre"
414
+ if event.completed == 0
415
+ else f"Fenêtre {event.completed}/{event.total} transcrite"
416
+ )
417
+ viewer = _viewer_payload(
418
+ state="transcribing",
419
+ status=status,
420
+ progress=fraction,
421
+ audio_name=audio_name,
422
+ elapsed=time.perf_counter() - started,
423
+ duration=duration,
424
+ tracks=tracks,
425
+ )
426
+ yield None, None, viewer, {"state": "running", "viewer": viewer}
427
+
428
+ elapsed = time.perf_counter() - started
429
+ notes = _notes_from_events(events)
430
+ preliminary = _track_payloads(notes)
431
+ track_names = [track["key"] for track in preliminary if track["note_count"]]
432
+ full_path, track_paths, full_bytes, midi_by_instrument = _write_midi_outputs(events, track_names)
433
+ tracks = _track_payloads(notes, midi_by_instrument=midi_by_instrument)
434
+ viewer = _viewer_payload(
435
+ state="complete",
436
+ status=f"Transcription terminée en {elapsed:.1f} s · génération de la partition…",
437
+ progress=1,
438
+ audio_name=audio_name,
439
+ elapsed=elapsed,
440
+ duration=duration,
441
+ tracks=tracks,
442
+ full_midi=full_bytes,
443
+ )
444
+ track_file_values = [str(track_paths[name]) for name in track_names]
445
+ session = {
446
+ "state": "complete",
447
+ "title": Path(audio_name).stem,
448
+ "audio_name": audio_name,
449
+ "duration": duration,
450
+ "elapsed": elapsed,
451
+ "tracks": tracks,
452
+ "viewer": viewer,
453
+ "midi_files": [str(full_path), *track_file_values],
454
+ }
455
+ yield str(full_path), track_file_values, viewer, session
456
+ except Exception as exc:
457
+ elapsed = time.perf_counter() - started
458
+ message = f"Transcription interrompue : {type(exc).__name__}: {exc}"
459
+ print(f"[MuScriptor Studio] {message}", file=sys.stderr, flush=True)
460
+ tracks = _track_payloads(_notes_from_events(events), requested)
461
+ viewer = _viewer_payload(
462
+ state="error",
463
+ status=message,
464
+ progress=0,
465
+ audio_name=audio_name,
466
+ elapsed=elapsed,
467
+ duration=duration,
468
+ tracks=tracks,
469
+ )
470
+ yield None, None, viewer, {"state": "error", "message": message, "viewer": viewer}
471
+
472
+
473
+ def finalize_notation(
474
+ session: dict[str, Any] | None,
475
+ tempo_bpm: float,
476
+ time_signature: str,
477
+ quantization: str,
478
+ show_solfege: bool,
479
+ ) -> tuple[str | None, str | None, str | None, dict[str, Any]]:
480
+ session = session or {}
481
+ viewer = normalize_viewer_value(session.get("viewer"))
482
+ if session.get("state") != "complete":
483
+ return None, None, None, viewer
484
+ try:
485
+ result = generate_notation(
486
+ session["tracks"],
487
+ title=session.get("title") or "Transcription MuScriptor",
488
+ tempo_bpm=tempo_bpm,
489
+ time_signature=time_signature,
490
+ quantization=quantization,
491
+ show_solfege=show_solfege,
492
+ midi_files=session.get("midi_files"),
493
+ )
494
+ updated = dict(viewer)
495
+ updated.update(
496
+ {
497
+ "state": "ready",
498
+ "status": "Transcription et partition prêtes",
499
+ "score_svg": result.preview_svg,
500
+ "score_pages": len(result.svg_pages),
501
+ "notation": {
502
+ "tempo": round(float(tempo_bpm), 1),
503
+ "time_signature": time_signature,
504
+ "quantization": quantization,
505
+ "show_solfege": bool(show_solfege),
506
+ "musicxml": file_data_uri(result.musicxml, "application/vnd.recordare.musicxml+xml"),
507
+ "svg": file_data_uri(result.svg_pages[0], "image/svg+xml") if result.svg_pages else "",
508
+ },
509
+ }
510
+ )
511
+ return (
512
+ str(result.musicxml),
513
+ str(result.pdf) if result.pdf else None,
514
+ str(result.bundle),
515
+ updated,
516
+ )
517
+ except Exception as exc:
518
+ updated = dict(viewer)
519
+ updated["state"] = "notation_error"
520
+ updated["status"] = f"MIDI prêt, mais partition non générée : {type(exc).__name__}: {exc}"
521
+ return None, None, None, updated
522
+
523
+
524
+ APP_CSS = (ROOT / "frontend" / "app.css").read_text(encoding="utf-8")
525
+
526
+ INSTRUMENT_CHOICES = [(_display_name(name), name) for name in sorted(INSTRUMENT_NAMES)]
527
+
528
+ HEADER = """
529
+ <header class="studio-masthead">
530
+ <div class="studio-brand"><span class="studio-logo">♫</span><div><strong>MuScriptor Studio</strong><small>Audio multipiste vers MIDI et partition</small></div></div>
531
+ <div class="studio-badges"><span class="model-badge">Medium · 307M</span><span class="gpu-badge">ZeroGPU</span></div>
532
+ </header>
533
+ """
534
+
535
+ with gr.Blocks(title="MuScriptor Studio") as demo:
536
+ gr.HTML(HEADER)
537
+ with gr.Row(elem_id="input-grid", equal_height=True):
538
+ with gr.Column(scale=7, min_width=360, elem_classes="input-card"):
539
+ gr.Markdown("### 1. Importer un enregistrement\nDéposez un morceau de 60 secondes maximum.")
540
+ audio_input = gr.Audio(
541
+ label="Audio source",
542
+ sources=["upload"],
543
+ type="filepath",
544
+ format="wav",
545
+ elem_id="audio-input",
546
+ )
547
+ transcribe_button = gr.Button(
548
+ "Transcrire le morceau",
549
+ variant="primary",
550
+ size="lg",
551
+ elem_id="transcribe-button",
552
+ )
553
+ with gr.Column(scale=5, min_width=320, elem_classes="settings-card"):
554
+ gr.Markdown("### 2. Paramètres")
555
+ instrument_input = gr.CheckboxGroup(
556
+ choices=INSTRUMENT_CHOICES,
557
+ value=[],
558
+ label="Instruments connus",
559
+ info="Facultatif — laissez vide pour la détection automatique.",
560
+ )
561
+ with gr.Accordion("Décodage avancé", open=False):
562
+ use_sampling_input = gr.Checkbox(label="Décodage créatif", value=False)
563
+ temperature_input = gr.Slider(0.2, 1.4, value=1.0, step=0.1, label="Température")
564
+ beam_size_input = gr.Slider(1, 4, value=1, step=1, label="Largeur du beam")
565
+ with gr.Accordion("Partition", open=True):
566
+ with gr.Row():
567
+ tempo_input = gr.Number(value=120, minimum=20, maximum=300, label="Tempo (BPM)")
568
+ time_signature_input = gr.Dropdown(
569
+ ["4/4", "3/4", "6/8", "2/4", "12/8"], value="4/4", label="Mesure"
570
+ )
571
+ quantization_input = gr.Radio(
572
+ ["1/8", "1/16", "1/32"], value="1/16", label="Quantification"
573
+ )
574
+ solfege_input = gr.Checkbox(label="Afficher Do Ré Mi", value=False)
575
+
576
+ session_state = gr.State(value={})
577
+ viewer = StudioViewer()
578
+
579
+ with gr.Row(elem_id="downloads-row"):
580
+ full_midi_output = gr.File(label="MIDI complet")
581
+ track_midi_output = gr.File(label="MIDI par instrument", file_count="multiple")
582
+ musicxml_output = gr.File(label="MusicXML")
583
+ pdf_output = gr.File(label="Partition PDF")
584
+ bundle_output = gr.File(label="Tous les exports (.zip)")
585
+
586
+ transcription = transcribe_button.click(
587
+ transcribe_audio,
588
+ inputs=[
589
+ audio_input,
590
+ instrument_input,
591
+ use_sampling_input,
592
+ temperature_input,
593
+ beam_size_input,
594
+ ],
595
+ outputs=[full_midi_output, track_midi_output, viewer, session_state],
596
+ api_name="transcribe",
597
+ concurrency_limit=1,
598
+ concurrency_id="muscriptor-medium",
599
+ show_progress="full",
600
+ )
601
+ transcription.then(
602
+ finalize_notation,
603
+ inputs=[session_state, tempo_input, time_signature_input, quantization_input, solfege_input],
604
+ outputs=[musicxml_output, pdf_output, bundle_output, viewer],
605
+ api_name="generate_score",
606
+ show_progress="minimal",
607
+ )
608
+
609
+ demo.queue(default_concurrency_limit=1, max_size=8)
610
+
611
+
612
+ if __name__ == "__main__":
613
+ demo.launch(
614
+ css=APP_CSS,
615
+ theme=gr.themes.Base(primary_hue="violet", neutral_hue="slate"),
616
+ footer_links=["gradio"],
617
+ )
midi_component.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from bisect import bisect_right
4
+ from collections import defaultdict, deque
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import gradio as gr
9
+ import mido
10
+
11
+
12
+ ROOT = Path(__file__).resolve().parent
13
+ FRONTEND = ROOT / "frontend"
14
+ MAX_FILE_BYTES = 5 * 1024 * 1024
15
+ MAX_NOTES = 50_000
16
+ TRACK_COLORS = [
17
+ "#8b5cf6",
18
+ "#22d3ee",
19
+ "#fb7185",
20
+ "#fbbf24",
21
+ "#34d399",
22
+ "#60a5fa",
23
+ "#f472b6",
24
+ "#a3e635",
25
+ "#fb923c",
26
+ "#c084fc",
27
+ ]
28
+ PROGRAM_FAMILIES = [
29
+ "Piano",
30
+ "Percussions chromatiques",
31
+ "Orgue",
32
+ "Guitare",
33
+ "Basse",
34
+ "Cordes",
35
+ "Ensemble",
36
+ "Cuivres",
37
+ "Anches",
38
+ "Bois",
39
+ "Synthé lead",
40
+ "Synthé pad",
41
+ "Effets synthétiques",
42
+ "Instruments ethniques",
43
+ "Percussions",
44
+ "Effets sonores",
45
+ ]
46
+
47
+
48
+ def _read_frontend(name: str) -> str:
49
+ return (FRONTEND / name).read_text(encoding="utf-8")
50
+
51
+
52
+ class MidiPlayer(gr.HTML):
53
+ """A browser-synthesized MIDI player built with Gradio's custom HTML API."""
54
+
55
+ def __init__(self, value: Any | None = None, **kwargs: Any) -> None:
56
+ initial_value = value or {
57
+ "status": "empty",
58
+ "message": "Déposez un fichier MIDI pour commencer.",
59
+ }
60
+ super().__init__(
61
+ value=initial_value,
62
+ html_template=_read_frontend("player.html"),
63
+ css_template=_read_frontend("player.css"),
64
+ js_on_load=_read_frontend("player.js"),
65
+ apply_default_css=False,
66
+ min_height=680,
67
+ **kwargs,
68
+ )
69
+
70
+ def api_info(self) -> dict[str, Any]:
71
+ return {"type": "object"}
72
+
73
+
74
+ class TempoMap:
75
+ def __init__(self, events: list[tuple[int, int]], ticks_per_beat: int) -> None:
76
+ if ticks_per_beat <= 0:
77
+ raise ValueError("Les fichiers MIDI avec division temporelle SMPTE ne sont pas pris en charge.")
78
+
79
+ collapsed: dict[int, int] = {0: 500_000}
80
+ for tick, tempo in sorted(events):
81
+ collapsed[tick] = tempo
82
+
83
+ self.ticks_per_beat = ticks_per_beat
84
+ self.segments: list[tuple[int, float, int]] = []
85
+ elapsed = 0.0
86
+ previous_tick = 0
87
+ previous_tempo = collapsed[0]
88
+
89
+ for tick, tempo in sorted(collapsed.items()):
90
+ if tick > 0:
91
+ elapsed += mido.tick2second(
92
+ tick - previous_tick,
93
+ ticks_per_beat,
94
+ previous_tempo,
95
+ )
96
+ self.segments.append((tick, elapsed, tempo))
97
+ previous_tick = tick
98
+ previous_tempo = tempo
99
+
100
+ self._ticks = [segment[0] for segment in self.segments]
101
+
102
+ def seconds(self, tick: int) -> float:
103
+ index = max(0, bisect_right(self._ticks, tick) - 1)
104
+ start_tick, start_seconds, tempo = self.segments[index]
105
+ return start_seconds + mido.tick2second(
106
+ tick - start_tick,
107
+ self.ticks_per_beat,
108
+ tempo,
109
+ )
110
+
111
+
112
+ def _display_name(track: mido.MidiTrack, index: int) -> str:
113
+ for message in track:
114
+ if message.type == "track_name" and message.name.strip():
115
+ return message.name.strip()[:80]
116
+ return f"Piste {index + 1}"
117
+
118
+
119
+ def _instrument_name(programs: set[int], channels: set[int]) -> str:
120
+ if 9 in channels:
121
+ return "Percussions"
122
+ if not programs:
123
+ return "Instrument MIDI"
124
+ names = list(dict.fromkeys(PROGRAM_FAMILIES[program // 8] for program in sorted(programs)))
125
+ return ", ".join(names[:2]) + ("…" if len(names) > 2 else "")
126
+
127
+
128
+ def parse_midi(path: str | Path, original_name: str | None = None) -> dict[str, Any]:
129
+ file_path = Path(path)
130
+ midi = mido.MidiFile(file_path, clip=True)
131
+ if midi.type == 2:
132
+ raise ValueError("Les fichiers MIDI type 2 (séquences asynchrones) ne sont pas pris en charge.")
133
+
134
+ tempo_events: list[tuple[int, int]] = []
135
+ time_signatures: list[dict[str, int]] = []
136
+ global_end_tick = 0
137
+ title = ""
138
+
139
+ for track in midi.tracks:
140
+ tick = 0
141
+ for message in track:
142
+ tick += message.time
143
+ if message.type == "set_tempo":
144
+ tempo_events.append((tick, message.tempo))
145
+ elif message.type == "time_signature":
146
+ time_signatures.append(
147
+ {
148
+ "tick": tick,
149
+ "numerator": message.numerator,
150
+ "denominator": message.denominator,
151
+ }
152
+ )
153
+ elif message.type == "track_name" and not title and message.name.strip():
154
+ title = message.name.strip()[:120]
155
+ global_end_tick = max(global_end_tick, tick)
156
+
157
+ tempo_map = TempoMap(tempo_events, midi.ticks_per_beat)
158
+ tracks: list[dict[str, Any]] = []
159
+ total_notes = 0
160
+ min_pitch = 127
161
+ max_pitch = 0
162
+
163
+ for source_index, track in enumerate(midi.tracks):
164
+ tick = 0
165
+ programs_by_channel: defaultdict[int, int] = defaultdict(int)
166
+ active: defaultdict[tuple[int, int], deque[tuple[int, int, int]]] = defaultdict(deque)
167
+ raw_notes: list[tuple[int, int, int, int, int, int]] = []
168
+ channels: set[int] = set()
169
+ programs: set[int] = set()
170
+
171
+ for message in track:
172
+ tick += message.time
173
+ if message.type == "program_change":
174
+ programs_by_channel[message.channel] = message.program
175
+ programs.add(message.program)
176
+ elif message.type == "note_on" and message.velocity > 0:
177
+ channel = message.channel
178
+ program = programs_by_channel[channel]
179
+ active[(channel, message.note)].append((tick, message.velocity, program))
180
+ channels.add(channel)
181
+ programs.add(program)
182
+ elif message.type in {"note_off", "note_on"}:
183
+ key = (message.channel, message.note)
184
+ if active[key]:
185
+ start_tick, velocity, program = active[key].popleft()
186
+ raw_notes.append(
187
+ (start_tick, max(tick, start_tick + 1), message.note, velocity, message.channel, program)
188
+ )
189
+
190
+ for (channel, pitch), pending in active.items():
191
+ while pending:
192
+ start_tick, velocity, program = pending.popleft()
193
+ raw_notes.append(
194
+ (start_tick, max(global_end_tick, start_tick + 1), pitch, velocity, channel, program)
195
+ )
196
+
197
+ if not raw_notes:
198
+ continue
199
+
200
+ raw_notes.sort(key=lambda note: (note[0], note[2]))
201
+ track_index = len(tracks)
202
+ notes = []
203
+ for start_tick, end_tick, pitch, velocity, channel, program in raw_notes:
204
+ start = tempo_map.seconds(start_tick)
205
+ end = tempo_map.seconds(end_tick)
206
+ notes.append(
207
+ {
208
+ "s": round(start, 6),
209
+ "e": round(max(end, start + 0.01), 6),
210
+ "p": pitch,
211
+ "v": velocity,
212
+ "c": channel,
213
+ "g": program,
214
+ "t": track_index,
215
+ }
216
+ )
217
+ min_pitch = min(min_pitch, pitch)
218
+ max_pitch = max(max_pitch, pitch)
219
+
220
+ total_notes += len(notes)
221
+ if total_notes > MAX_NOTES:
222
+ raise ValueError(f"Le fichier dépasse la limite de {MAX_NOTES:,} notes.")
223
+
224
+ tracks.append(
225
+ {
226
+ "index": track_index,
227
+ "source_index": source_index,
228
+ "name": _display_name(track, source_index),
229
+ "color": TRACK_COLORS[track_index % len(TRACK_COLORS)],
230
+ "instrument": _instrument_name(programs, channels),
231
+ "channels": sorted(channel + 1 for channel in channels),
232
+ "notes": notes,
233
+ }
234
+ )
235
+
236
+ if not tracks:
237
+ raise ValueError("Ce fichier MIDI ne contient aucune note lisible.")
238
+
239
+ duration = max(
240
+ tempo_map.seconds(global_end_tick),
241
+ max(note["e"] for track in tracks for note in track["notes"]),
242
+ )
243
+ initial_tempo = tempo_map.segments[0][2]
244
+ safe_name = Path(original_name or file_path.name).name[:160]
245
+
246
+ return {
247
+ "status": "ready",
248
+ "file_name": safe_name,
249
+ "title": title or Path(safe_name).stem,
250
+ "format": midi.type,
251
+ "ticks_per_beat": midi.ticks_per_beat,
252
+ "duration": round(duration, 6),
253
+ "bpm": round(mido.tempo2bpm(initial_tempo), 1),
254
+ "tempo_changes": len(tempo_map.segments),
255
+ "time_signature": (
256
+ f"{time_signatures[0]['numerator']}/{time_signatures[0]['denominator']}"
257
+ if time_signatures
258
+ else "—"
259
+ ),
260
+ "track_count": len(tracks),
261
+ "note_count": total_notes,
262
+ "pitch_min": min_pitch,
263
+ "pitch_max": max_pitch,
264
+ "tracks": tracks,
265
+ }
266
+
267
+
268
+ def load_midi(value: Any) -> dict[str, Any]:
269
+ try:
270
+ if not isinstance(value, dict) or value.get("status") != "uploaded":
271
+ raise ValueError("Aucun fichier MIDI valide n’a été transmis.")
272
+
273
+ path = Path(str(value.get("path", "")))
274
+ original_name = Path(str(value.get("name", path.name))).name
275
+ if path.suffix.lower() not in {".mid", ".midi"} and Path(original_name).suffix.lower() not in {
276
+ ".mid",
277
+ ".midi",
278
+ }:
279
+ raise ValueError("Formats acceptés : .mid et .midi.")
280
+ if not path.is_file():
281
+ raise ValueError("Le fichier uploadé est introuvable.")
282
+ if path.stat().st_size > MAX_FILE_BYTES:
283
+ raise ValueError("Le fichier dépasse la limite de 5 Mo.")
284
+
285
+ return parse_midi(path, original_name)
286
+ except (EOFError, OSError, ValueError, mido.KeySignatureError) as exc:
287
+ return {
288
+ "status": "error",
289
+ "message": str(exc) or "Impossible de lire ce fichier MIDI.",
290
+ }
notation.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import copy
5
+ import io
6
+ import json
7
+ import re
8
+ import shutil
9
+ import tempfile
10
+ import zipfile
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from statistics import median
14
+ from typing import Any
15
+
16
+
17
+ QUANTIZATION_DIVISORS = {
18
+ "1/8": 2,
19
+ "1/16": 4,
20
+ "1/32": 8,
21
+ }
22
+
23
+ SOLFEGE_NAMES = ("Do", "Do♯", "Ré", "Mi♭", "Mi", "Fa", "Fa♯", "Sol", "La♭", "La", "Si♭", "Si")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class NotationResult:
28
+ musicxml: Path
29
+ pdf: Path | None
30
+ bundle: Path
31
+ svg_pages: tuple[Path, ...]
32
+ preview_svg: str
33
+
34
+
35
+ def _safe_slug(value: str) -> str:
36
+ return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "piste"
37
+
38
+
39
+ def _parse_time_signature(value: str) -> tuple[int, int]:
40
+ try:
41
+ numerator, denominator = value.split("/", 1)
42
+ parsed = int(numerator), int(denominator)
43
+ except (AttributeError, TypeError, ValueError):
44
+ return 4, 4
45
+ if parsed[0] <= 0 or parsed[1] not in {1, 2, 4, 8, 16, 32}:
46
+ return 4, 4
47
+ return parsed
48
+
49
+
50
+ def _music21_score(
51
+ tracks: list[dict[str, Any]],
52
+ *,
53
+ title: str,
54
+ tempo_bpm: float,
55
+ time_signature: str,
56
+ quantization: str,
57
+ show_solfege: bool,
58
+ ):
59
+ try:
60
+ from music21 import chord, clef, instrument, metadata, meter, note, stream, tempo
61
+ except ImportError as exc: # pragma: no cover - deployment configuration
62
+ raise RuntimeError("La dépendance music21 est nécessaire pour générer la partition.") from exc
63
+
64
+ bpm = max(20.0, min(300.0, float(tempo_bpm)))
65
+ numerator, denominator = _parse_time_signature(time_signature)
66
+ divisor = QUANTIZATION_DIVISORS.get(quantization, 4)
67
+
68
+ score = stream.Score(id="muscriptor-score")
69
+ score.metadata = metadata.Metadata()
70
+ score.metadata.title = title or "Transcription MuScriptor"
71
+ score.metadata.composer = "Transcription automatique MuScriptor"
72
+
73
+ for track_index, track in enumerate(tracks):
74
+ notes = list(track.get("notes") or [])
75
+ if not notes:
76
+ continue
77
+
78
+ part = stream.Part(id=f"part-{track_index + 1}")
79
+ part.partName = str(track.get("name") or f"Piste {track_index + 1}")
80
+ midi_instrument = instrument.Instrument()
81
+ midi_instrument.partName = part.partName
82
+ midi_instrument.instrumentName = part.partName
83
+ if track.get("is_drum"):
84
+ midi_instrument.midiChannel = 9
85
+ else:
86
+ midi_instrument.midiProgram = max(0, min(127, int(track.get("program") or 0)))
87
+ part.insert(0, midi_instrument)
88
+ part.insert(0, tempo.MetronomeMark(number=bpm))
89
+ part.insert(0, meter.TimeSignature(f"{numerator}/{denominator}"))
90
+
91
+ pitches = [int(item.get("pitch", 60)) for item in notes]
92
+ if track.get("is_drum"):
93
+ part.insert(0, clef.PercussionClef())
94
+ elif median(pitches) < 55:
95
+ part.insert(0, clef.BassClef())
96
+ else:
97
+ part.insert(0, clef.TrebleClef())
98
+
99
+ grouped: dict[tuple[float, float], list[int]] = {}
100
+ velocities: dict[tuple[float, float], list[int]] = {}
101
+ for item in notes:
102
+ start_seconds = max(0.0, float(item.get("start", 0.0)))
103
+ end_seconds = max(start_seconds + 0.03, float(item.get("end", start_seconds + 0.1)))
104
+ offset = start_seconds * bpm / 60.0
105
+ duration = max(1 / divisor, (end_seconds - start_seconds) * bpm / 60.0)
106
+ key = (round(offset, 5), round(duration, 5))
107
+ grouped.setdefault(key, []).append(max(0, min(127, int(item.get("pitch", 60)))))
108
+ velocities.setdefault(key, []).append(max(1, min(127, int(item.get("velocity", 100)))))
109
+
110
+ for (offset, duration), group_pitches in sorted(grouped.items()):
111
+ if len(group_pitches) == 1:
112
+ musical_item = note.Note(group_pitches[0], quarterLength=duration)
113
+ if show_solfege:
114
+ musical_item.addLyric(SOLFEGE_NAMES[group_pitches[0] % 12])
115
+ else:
116
+ musical_item = chord.Chord(group_pitches, quarterLength=duration)
117
+ if show_solfege:
118
+ musical_item.addLyric(" ".join(SOLFEGE_NAMES[pitch % 12] for pitch in group_pitches))
119
+ musical_item.volume.velocity = round(sum(velocities[(offset, duration)]) / len(velocities[(offset, duration)]))
120
+ part.insert(offset, musical_item)
121
+
122
+ part.quantize(
123
+ quarterLengthDivisors=(divisor,),
124
+ processOffsets=True,
125
+ processDurations=True,
126
+ inPlace=True,
127
+ )
128
+ measured = part.makeMeasures(inPlace=False)
129
+ measured.makeNotation(inPlace=True)
130
+ score.insert(0, measured)
131
+
132
+ if not score.parts:
133
+ raise ValueError("Aucune note n’est disponible pour générer une partition.")
134
+ return score
135
+
136
+
137
+ def _render_svg_pages(musicxml: Path, output_dir: Path) -> tuple[tuple[Path, ...], str]:
138
+ try:
139
+ import verovio
140
+ except ImportError as exc: # pragma: no cover - deployment configuration
141
+ raise RuntimeError("La dépendance verovio est nécessaire pour afficher la partition.") from exc
142
+
143
+ toolkit = verovio.toolkit()
144
+ toolkit.setOptions(
145
+ {
146
+ "adjustPageHeight": True,
147
+ "breaks": "auto",
148
+ "footer": "none",
149
+ "header": "none",
150
+ "pageHeight": 2100,
151
+ "pageWidth": 2970,
152
+ "scale": 38,
153
+ "spacingStaff": 8,
154
+ "spacingSystem": 12,
155
+ }
156
+ )
157
+ if not toolkit.loadFile(str(musicxml)):
158
+ raise RuntimeError("Verovio n’a pas pu lire le MusicXML généré.")
159
+
160
+ pages: list[Path] = []
161
+ preview = ""
162
+ for page_number in range(1, toolkit.getPageCount() + 1):
163
+ svg = toolkit.renderToSVG(page_number)
164
+ if page_number == 1:
165
+ preview = svg
166
+ path = output_dir / f"partition-page-{page_number}.svg"
167
+ path.write_text(svg, encoding="utf-8")
168
+ pages.append(path)
169
+ return tuple(pages), preview
170
+
171
+
172
+ def _render_pdf(svg_pages: tuple[Path, ...], output_path: Path) -> Path | None:
173
+ try:
174
+ import cairosvg
175
+ from pypdf import PdfReader, PdfWriter
176
+ except ImportError:
177
+ return None
178
+
179
+ writer = PdfWriter()
180
+ for svg in svg_pages:
181
+ pdf_bytes = cairosvg.svg2pdf(bytestring=svg.read_bytes())
182
+ reader = PdfReader(io.BytesIO(pdf_bytes))
183
+ for page in reader.pages:
184
+ writer.add_page(page)
185
+ with output_path.open("wb") as handle:
186
+ writer.write(handle)
187
+ return output_path
188
+
189
+
190
+ def generate_notation(
191
+ tracks: list[dict[str, Any]],
192
+ *,
193
+ title: str,
194
+ tempo_bpm: float = 120,
195
+ time_signature: str = "4/4",
196
+ quantization: str = "1/16",
197
+ show_solfege: bool = False,
198
+ midi_files: list[str] | None = None,
199
+ ) -> NotationResult:
200
+ output_dir = Path(tempfile.mkdtemp(prefix="muscriptor-notation-"))
201
+ score = _music21_score(
202
+ tracks,
203
+ title=title,
204
+ tempo_bpm=tempo_bpm,
205
+ time_signature=time_signature,
206
+ quantization=quantization,
207
+ show_solfege=show_solfege,
208
+ )
209
+
210
+ musicxml = output_dir / "partition-complete.musicxml"
211
+ score.write("musicxml", fp=str(musicxml))
212
+
213
+ parts_dir = output_dir / "parties"
214
+ parts_dir.mkdir()
215
+ from music21 import stream as music21_stream
216
+
217
+ for part in score.parts:
218
+ part_score = music21_stream.Score(id=f"score-{part.id}")
219
+ part_score.metadata = copy.deepcopy(score.metadata)
220
+ part_score.insert(0, copy.deepcopy(part))
221
+ part_score.write("musicxml", fp=str(parts_dir / f"{_safe_slug(part.partName)}.musicxml"))
222
+
223
+ svg_pages, preview_svg = _render_svg_pages(musicxml, output_dir)
224
+ pdf = _render_pdf(svg_pages, output_dir / "partition-complete.pdf")
225
+
226
+ bundle = output_dir / "muscriptor-exports.zip"
227
+ with zipfile.ZipFile(bundle, "w", compression=zipfile.ZIP_DEFLATED) as archive:
228
+ archive.write(musicxml, musicxml.name)
229
+ for path in parts_dir.glob("*.musicxml"):
230
+ archive.write(path, f"parties/{path.name}")
231
+ for path in svg_pages:
232
+ archive.write(path, f"svg/{path.name}")
233
+ if pdf:
234
+ archive.write(pdf, pdf.name)
235
+ for value in midi_files or []:
236
+ midi_path = Path(value)
237
+ if midi_path.is_file():
238
+ archive.write(midi_path, f"midi/{midi_path.name}")
239
+
240
+ return NotationResult(
241
+ musicxml=musicxml,
242
+ pdf=pdf,
243
+ bundle=bundle,
244
+ svg_pages=svg_pages,
245
+ preview_svg=preview_svg,
246
+ )
247
+
248
+
249
+ def file_data_uri(path: Path, mime_type: str) -> str:
250
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
251
+ return f"data:{mime_type};base64,{encoded}"
252
+
253
+
254
+ def session_json(value: dict[str, Any]) -> str:
255
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
pyproject.toml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [tool.pytest.ini_options]
2
+ pythonpath = ["."]
3
+ testpaths = ["tests"]
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==6.20.0
2
+ spaces==0.51.0
3
+ muscriptor==0.2.1
4
+ numpy==1.26.4
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
10
+ music21==9.9.1
11
+ verovio==5.7.0
12
+ CairoSVG==2.8.2
13
+ pypdf==6.6.0
studio_component.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import gradio as gr
8
+
9
+
10
+ ROOT = Path(__file__).resolve().parent
11
+ FRONTEND = ROOT / "frontend"
12
+
13
+
14
+ def _read_frontend(name: str) -> str:
15
+ return (FRONTEND / name).read_text(encoding="utf-8")
16
+
17
+
18
+ def initial_studio_value() -> dict[str, Any]:
19
+ return {
20
+ "state": "idle",
21
+ "status": "Importez un enregistrement pour commencer.",
22
+ "progress": 0,
23
+ "audio_name": "",
24
+ "elapsed": 0,
25
+ "duration": 0,
26
+ "note_count": 0,
27
+ "tracks": [],
28
+ "full_midi": "",
29
+ "score_svg": "",
30
+ "score_pages": 0,
31
+ "notation": {},
32
+ }
33
+
34
+
35
+ class StudioViewer(gr.HTML):
36
+ """MuScriptor result viewer implemented with Gradio's custom HTML API."""
37
+
38
+ def __init__(self, value: Any | None = None, **kwargs: Any) -> None:
39
+ super().__init__(
40
+ value=value or initial_studio_value(),
41
+ html_template=_read_frontend("studio.html"),
42
+ css_template=_read_frontend("studio.css"),
43
+ js_on_load=_read_frontend("studio.js"),
44
+ apply_default_css=False,
45
+ min_height=760,
46
+ **kwargs,
47
+ )
48
+
49
+ def api_info(self) -> dict[str, Any]:
50
+ return {"type": "object"}
51
+
52
+
53
+ def normalize_viewer_value(value: Any) -> dict[str, Any]:
54
+ if isinstance(value, dict):
55
+ return value
56
+ if isinstance(value, str):
57
+ try:
58
+ parsed = json.loads(value)
59
+ if isinstance(parsed, dict):
60
+ return parsed
61
+ except json.JSONDecodeError:
62
+ pass
63
+ return initial_studio_value()