| """MuScriptor Gradio Space — multi-instrument AMT -> MIDI + MusicXML + piano roll.""" |
| from __future__ import annotations |
|
|
| import json |
| import os |
| import tempfile |
| import time |
| import warnings |
| import uuid |
| from datetime import datetime, timezone |
| from pathlib import Path |
|
|
| import gradio as gr |
| import matplotlib |
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
| import numpy as np |
| import partitura as pt |
| import pretty_midi |
| import soundfile as sf |
| from muscriptor import TranscriptionModel |
|
|
| |
| |
| |
| |
| INSTRUMENT_MAP: dict[str, list[str]] = { |
| "piano": ["acoustic_piano", "electric_piano", "grand_piano", "harpsichord", "upright_piano"], |
| "drums": ["drum_kit"], |
| "bass": ["bass", "acoustic_bass", "electric_bass", "synth_bass", "double_bass", "upright_bass", "fretless_bass", "slap_bass"], |
| "guitar": ["acoustic_guitar", "clean_electric_guitar", "distorted_electric_guitar", "electric_guitar", "synth_guitar", "classical_guitar", "acoustic_nylon_guitar", "acoustic_steel_guitar"], |
| "vocals": ["lead_vocal", "vocal_harmonies", "background_vocal", "synth_choir", "choir"], |
| "other": [ |
| "accordion", "banjo", "bassoon", "brass", "celesta", "cello", "clarinet", |
| "cymbals", "fiddle", "flute", "french_horn", "glockensharp", "glockenspiel", |
| "harp", "marimba", "melodic_percussion", "oboe", "organ", "pad_synth", |
| "percussion", "pizzicato_strings", "saxophone", "sitar", "steel_drum", |
| "string_section", "synth", "synth_brass", "synth_drum", "synth_lead", |
| "synth_pad", "synth_strings", "timpani", "trombone", "trumpet", |
| "tubular_bells", "ukulele", "viola", "violin", "woodwind", "xylophone", |
| ], |
| } |
| FRIENDLY_LABELS = list(INSTRUMENT_MAP.keys()) + ["all"] |
|
|
|
|
| def _resolve_instruments(selection: list[str] | None) -> list[str] | None: |
| """Map the UI's friendly buckets to the model's group names. |
| |
| - None or ["all"] -> None (model decides) |
| - empty list -> None (treat as "all") |
| - any "all" in the list -> None |
| - otherwise, flatten the mapped model names, dedup, preserve order |
| """ |
| if not selection: |
| return None |
| sel = [s.lower() for s in selection] |
| if "all" in sel: |
| return None |
| out: list[str] = [] |
| for label in sel: |
| for name in INSTRUMENT_MAP.get(label, []): |
| if name not in out: |
| out.append(name) |
| return out or None |
|
|
|
|
| |
| _MODELS: dict[str, TranscriptionModel] = {} |
|
|
|
|
| def get_model(size: str) -> TranscriptionModel: |
| size = (size or "medium").lower() |
| if size not in _MODELS: |
| _MODELS[size] = TranscriptionModel.load_model(size) |
| return _MODELS[size] |
|
|
|
|
| |
| def render_piano_roll(midi_path: str) -> str: |
| pm = pretty_midi.PrettyMIDI(midi_path) |
| instruments = [inst for inst in pm.instruments if inst.notes] |
| if not instruments: |
| |
| fig, ax = plt.subplots(figsize=(10, 2.5)) |
| ax.text(0.5, 0.5, "No notes detected", ha="center", va="center", |
| transform=ax.transAxes, fontsize=14, color="#888") |
| ax.set_axis_off() |
| out = tempfile.NamedTemporaryFile(suffix=".png", delete=False) |
| plt.savefig(out.name, dpi=110, bbox_inches="tight") |
| plt.close(fig) |
| return out.name |
|
|
| cmap = plt.get_cmap("tab10") |
| fig, ax = plt.subplots(figsize=(12, max(2.5, 0.45 * len(instruments) + 1.2))) |
| end = pm.get_end_time() or 1.0 |
| for i, inst in enumerate(instruments): |
| y = i |
| for note in inst.notes: |
| ax.add_patch(plt.Rectangle( |
| (note.start, y - 0.4), |
| max(note.end - note.start, 0.01), |
| 0.8, |
| facecolor=cmap(i % 10), |
| edgecolor="none", |
| alpha=0.85, |
| )) |
| ax.set_xlim(0, max(end, 1.0)) |
| ax.set_ylim(-0.5, len(instruments) - 0.5) |
| ax.set_yticks(range(len(instruments))) |
| ax.set_yticklabels([f"{inst.name or inst.program}" for inst in instruments], fontsize=9) |
| ax.set_xlabel("time (s)") |
| ax.set_title(f"Piano roll \u2014 {len(instruments)} instruments, {pm.get_end_time():.1f}s") |
| ax.grid(True, axis="x", alpha=0.2) |
| plt.tight_layout() |
| out = tempfile.NamedTemporaryFile(suffix=".png", delete=False) |
| plt.savefig(out.name, dpi=110) |
| plt.close(fig) |
| return out.name |
|
|
|
|
| |
| def render_musicxml(midi_path: str) -> str: |
| """Convert MIDI to MusicXML using partitura. |
| |
| Returns the path to the .musicxml file (or empty string on failure). |
| Multi-instrument MIDI -> a part per instrument, concatenated. |
| """ |
| try: |
| score = pt.load_score_midi(midi_path) |
| |
| |
| |
| out = tempfile.NamedTemporaryFile(suffix=".musicxml", delete=False) |
| out.close() |
| pt.save_musicxml(out.name, score) |
| return out.name |
| except Exception as e: |
| warnings.warn(f"MusicXML export failed: {e}") |
| return "" |
|
|
|
|
| |
| def audio_duration_sec(path: str) -> float | None: |
| try: |
| info = sf.info(path) |
| return float(info.frames) / float(info.samplerate) |
| except Exception: |
| return None |
|
|
|
|
| |
| def transcribe(audio_path, model_size, instruments, _progress=gr.Progress()): |
| if audio_path is None: |
| raise gr.Error("Upload an audio file or record from the mic first.") |
|
|
| instr = _resolve_instruments(instruments) |
| duration = audio_duration_sec(audio_path) |
| if duration is not None and duration > 180: |
| |
| warnings.warn( |
| f"Input is {duration:.0f}s; the free CPU tier can take 10\u201330 min " |
| "per transcribe on the medium model. Consider using the 'small' " |
| "model or upgrading to a GPU Space for longer files.", |
| stacklevel=2, |
| ) |
|
|
| t0 = time.time() |
| _progress(0.05, desc=f"Loading {model_size} model\u2026") |
| model = get_model(model_size) |
|
|
| _progress(0.20, desc="Transcribing\u2026") |
| midi_bytes = model.transcribe_to_midi(audio_path, instruments=instr) |
|
|
| out_midi = tempfile.NamedTemporaryFile(suffix=".mid", delete=False) |
| out_midi.write(midi_bytes) |
| out_midi.close() |
|
|
| _progress(0.85, desc="Rendering piano roll + MusicXML\u2026") |
| png = render_piano_roll(out_midi.name) |
| musicxml = render_musicxml(out_midi.name) |
|
|
| elapsed = time.time() - t0 |
| summary = { |
| "model": model_size, |
| "instruments": instruments or "all", |
| "instruments_resolved": instr or "all", |
| "elapsed_sec": round(elapsed, 2), |
| "input_duration_sec": round(duration, 2) if duration else None, |
| "midi_path": out_midi.name, |
| "musicxml_path": musicxml or None, |
| "piano_roll_path": png, |
| } |
| |
| |
| summary_json: str = json.dumps(summary, indent=2) |
|
|
| |
| |
| |
| try: |
| with open("/tmp/muscriptor-events.jsonl", "a") as f: |
| f.write(json.dumps({ |
| "event": "transcribe", |
| "ts": datetime.now(timezone.utc).isoformat(), |
| "session_id": str(uuid.uuid4())[:8], |
| "model_size": model_size, |
| "instruments": instruments or ["all"], |
| "input_duration_sec": duration, |
| "elapsed_sec": round(elapsed, 2), |
| "success": True, |
| }) + "\n") |
| except OSError: |
| |
| pass |
|
|
| |
| musicxml_files = [musicxml] if musicxml else [] |
| return png, out_midi.name, musicxml_files, summary_json |
|
|
|
|
| |
| with gr.Blocks(title="MuScriptor \u2014 Multi-Instrument AMT", theme=gr.themes.Soft()) as demo: |
| gr.Markdown( |
| """# MuScriptor \u2014 Multi-Instrument Music Transcription |
| |
| Drag in an audio file (WAV/MP3/FLAC) or record from the mic, pick a model size, |
| and get back a piano roll, downloadable MIDI, and (when possible) MusicXML. |
| |
| Model: [muscriptor/muscriptor-medium](https://huggingface.co/MuScriptor/muscriptor-medium) \u00b7 |
| code: [github.com/muscriptor/muscriptor](https://github.com/muscriptor/muscriptor) \u00b7 |
| licence: weights CC BY-NC 4.0, code MIT.""" |
| ) |
| with gr.Row(): |
| with gr.Column(): |
| audio = gr.Audio(type="filepath", label="Audio input") |
| model_size = gr.Radio( |
| ["small", "medium", "large"], |
| value="small", |
| label="Model size (small is fast on free CPU; medium/large want a GPU)", |
| ) |
| instruments = gr.CheckboxGroup( |
| FRIENDLY_LABELS, value=["all"], label="Instruments (uncheck to filter)" |
| ) |
| run = gr.Button("Transcribe", variant="primary") |
| with gr.Column(): |
| roll = gr.Image(label="Piano roll", type="filepath") |
| midi_dl = gr.File(label="Download MIDI") |
| musicxml_dl = gr.File(label="Download MusicXML (engraving)", file_count="single") |
| summary = gr.Code(label="Run summary", language="json") |
| run.click( |
| transcribe, |
| inputs=[audio, model_size, instruments], |
| outputs=[roll, midi_dl, musicxml_dl, summary], |
| ) |
| gr.Markdown( |
| """### Notes |
| |
| - First run downloads model weights (~600 MB for `medium`). Subsequent runs are cached. |
| - Free CPU tier is fine for files up to 3 minutes. For longer files, pick `small` or upgrade to a GPU Space. |
| - The first user must accept the [CC BY-NC 4.0 licence](https://huggingface.co/MuScriptor/muscriptor-medium) on the model page once per HF account, and the Space must have an `HF_TOKEN` secret set in Settings \u2192 Variables. |
| - Weights are CC BY-NC 4.0 (gated); inference code is MIT. Non-commercial use only.""" |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| |
| |
| port = int(os.environ.get("PORT", "7860")) |
| demo.queue(max_size=8).launch(server_name="0.0.0.0", server_port=port) |
|
|