Spaces:
Paused
Paused
Download muscriptor/main.py from jerrdeh/muscriptor-chords: direct link, hf CLI and curl.
- Browser
- Download file 16.7 kB
-
https://huggingface.co/spaces/jerrdeh/muscriptor-chords/resolve/main/muscriptor/main.py
- Command line
-
hf download hf://spaces/jerrdeh/muscriptor-chords/muscriptor/main.py
-
curl -L -o main.py https://huggingface.co/spaces/jerrdeh/muscriptor-chords/resolve/main/muscriptor/main.py
16.7 kB
| """CLI for muscriptor: audio → MIDI transcription.""" | |
| import dataclasses | |
| import json | |
| import sys | |
| from enum import Enum | |
| from pathlib import Path | |
| from typing import Annotated, Literal | |
| import typer | |
| from muscriptor.events import NoteEndEvent, NoteStartEvent, ProgressEvent | |
| from muscriptor.tokenizer.mt3 import ( | |
| MT3_FULL_PLUS_GROUP_NAMES, | |
| resolve_instrument_names, | |
| ) | |
| from muscriptor.transcription_model import TranscriptionModel | |
| from muscriptor.utils.beats import BeatDetectionError, TempoDetection | |
| from muscriptor.utils.download import ModelDownloadError | |
| from muscriptor.utils.sheets import ( | |
| MuseScoreError, | |
| MuseScoreNotFoundError, | |
| find_musescore, | |
| prepare_output_dir, | |
| write_sheets, | |
| ) | |
| app = typer.Typer(add_completion=False, help="muscriptor — audio-to-MIDI transcription") | |
| def _load_model( | |
| model_path: str | None, device: str | None, dtype: str | None = None | |
| ) -> TranscriptionModel: | |
| """load_model with CLI-friendly failure: known download problems (missing | |
| HuggingFace authentication, …) print a plain message instead of a traceback.""" | |
| try: | |
| return TranscriptionModel.load_model( | |
| weights_path=model_path, device=device, dtype=dtype | |
| ) | |
| except ModelDownloadError as e: | |
| typer.echo(f"Error: {e}", err=True) | |
| raise typer.Exit(1) | |
| class OutputFormat(str, Enum): | |
| midi = "midi" | |
| json = "json" | |
| jsonl = "jsonl" | |
| sheets = "sheets" | |
| def _transcribe( | |
| model, | |
| kwargs: dict, | |
| detect_tempo: str, | |
| recognize_chords: bool = True, | |
| quantize: bool = False, | |
| ): | |
| """transcribe_and_postprocess, with the CLI's --detect-tempo spelling and errors.""" | |
| try: | |
| mode: TempoDetection = { | |
| "true": True, | |
| "false": False, | |
| "best-effort": "best-effort", | |
| }[detect_tempo] | |
| return model.transcribe_and_postprocess( | |
| **kwargs, | |
| detect_tempo=mode, | |
| recognize_chords=recognize_chords, | |
| quantize=quantize, | |
| ) | |
| except BeatDetectionError as e: | |
| typer.echo(f"Error: {e}", err=True) | |
| typer.echo("Pass --detect-tempo best-effort or false to continue.", err=True) | |
| raise typer.Exit(1) | |
| def _event_to_dict(ev: NoteStartEvent | NoteEndEvent) -> dict: | |
| if isinstance(ev, NoteStartEvent): | |
| return {"type": "start", **dataclasses.asdict(ev)} | |
| return { | |
| "type": "end", | |
| "end_time": ev.end_time, | |
| "start_event_index": ev.start_event_index, | |
| } | |
| def transcribe( | |
| audio_file: Annotated[ | |
| Path, typer.Argument(help="Input audio file (wav, mp3, flac, …)") | |
| ], | |
| output: Annotated[ | |
| Path | None, | |
| typer.Option( | |
| "--output", | |
| "-o", | |
| help=( | |
| "Output file path. Use '-' to write to stdout (all progress / " | |
| "timing info is sent to stderr in that case). " | |
| "Default: <audio_file>.<ext> where ext matches --format. " | |
| "With --format sheets this is a directory instead: it must be " | |
| "empty or not exist yet, and is created if missing." | |
| ), | |
| ), | |
| ] = None, | |
| format: Annotated[ | |
| OutputFormat, | |
| typer.Option( | |
| "--format", | |
| "-f", | |
| help=( | |
| "Output format: midi (default), json (single array of events), " | |
| "jsonl (one event per line, streamed as transcription " | |
| "progresses), or sheets (a directory of engraved PDFs plus " | |
| "MusicXML and MIDI; requires MuseScore to be installed)" | |
| ), | |
| case_sensitive=False, | |
| ), | |
| ] = OutputFormat.midi, | |
| notes: Annotated[ | |
| bool, typer.Option("--notes", help="Print decoded events to stdout") | |
| ] = False, | |
| sampling: Annotated[ | |
| bool, | |
| typer.Option( | |
| "--sampling", help="Use temperature sampling instead of greedy decoding" | |
| ), | |
| ] = False, | |
| temperature: Annotated[ | |
| float, | |
| typer.Option( | |
| "--temperature", "-t", help="Sampling temperature (only with --sampling)" | |
| ), | |
| ] = 1.0, | |
| cfg_coef: Annotated[ | |
| float, typer.Option("--cfg-coef", help="Classifier-free guidance coefficient") | |
| ] = 1.0, # todo: make it dynamic | |
| model_path: Annotated[ | |
| str | None, | |
| typer.Option( | |
| "--model", | |
| "-m", | |
| help=( | |
| "Model size ('small', 'medium', 'large'; default: medium), " | |
| "a local safetensors path, or an hf:// / http(s):// URL" | |
| ), | |
| ), | |
| ] = None, | |
| device: Annotated[ | |
| str, | |
| typer.Option( | |
| "--device", "-d", help="Device: 'auto', 'cpu', 'cuda', 'cuda:0', 'mps', …" | |
| ), | |
| ] = "auto", | |
| dtype: Annotated[ | |
| str | None, | |
| typer.Option( | |
| "--dtype", | |
| help=( | |
| "Transformer dtype: 'float32', 'float16' or 'bfloat16'. " | |
| "Default: float16 on MPS, float32 elsewhere." | |
| ), | |
| ), | |
| ] = None, | |
| batch_size: Annotated[ | |
| int | None, | |
| typer.Option( | |
| "--batch-size", | |
| "-b", | |
| help=( | |
| "Chunks generated per forward pass (default: 1; with " | |
| "--no-prelude-forcing: 4 on GPU, 1 on CPU). Values > 1 lower " | |
| "quality at chunk boundaries and require --no-prelude-forcing." | |
| ), | |
| ), | |
| ] = None, | |
| strict_eos: Annotated[ | |
| bool, | |
| typer.Option( | |
| "--strict-eos", | |
| help="Raise an error if a chunk fails to emit EOS within the generation budget (default: downgrade to a warning)", | |
| ), | |
| ] = False, | |
| beam_size: Annotated[ | |
| int, | |
| typer.Option( | |
| "--beam-size", | |
| help="Beam search width (1 = greedy/sampling, ≥2 enables beam search)", | |
| ), | |
| ] = 1, | |
| prelude_forcing: Annotated[ | |
| bool, | |
| typer.Option( | |
| "--prelude-forcing/--no-prelude-forcing", | |
| help=( | |
| "Teacher-force each chunk's tie prologue from the previous " | |
| "chunk's still-sounding notes, so chunks can't restart with " | |
| "the wrong instruments. Needs chunks generated in order, so " | |
| "it requires --batch-size 1 (the default)." | |
| ), | |
| ), | |
| ] = True, | |
| auralize: Annotated[ | |
| Path | None, | |
| typer.Option( | |
| "--auralize", | |
| help=( | |
| "Write a stereo auralization (L=original audio, R=MIDI synthesis) to " | |
| "this path. Requires fluidsynth on PATH. Extension determines format: " | |
| ".wav (default) or .mp3. Only valid with --format midi." | |
| ), | |
| ), | |
| ] = None, | |
| soundfont: Annotated[ | |
| Path | None, | |
| typer.Option( | |
| "--soundfont", | |
| help=( | |
| "Path to a .sf2 SoundFont for auralization. Defaults to " | |
| "MuseScore_General.sf2, downloaded once and cached locally." | |
| ), | |
| ), | |
| ] = None, | |
| instruments: Annotated[ | |
| str | None, | |
| typer.Option( | |
| "--instruments", | |
| help=( | |
| "Comma-separated list of expected instrument group names. " | |
| "When given, every instrument not in the list is forbidden " | |
| "from being decoded at all. Case-insensitive; unambiguous " | |
| "abbreviations are accepted (e.g. 'timp,cello,dist'). Run " | |
| "'muscriptor list-instruments' to see all available names." | |
| ), | |
| ), | |
| ] = None, | |
| # typer can't take the bool | Literal["best-effort"] union the API uses, so | |
| # the CLI spells all three states as strings and converts below. | |
| detect_tempo: Annotated[ | |
| Literal["true", "false", "best-effort"], | |
| typer.Option( | |
| help=( | |
| "Detect the tempo and time signature from the audio and write " | |
| "them into the MIDI. 'true' fails if no steady tempo is found, " | |
| "'best-effort' warns and uses a placeholder 120 BPM with no " | |
| "time signature, 'false' skips detection altogether." | |
| ), | |
| ), | |
| ] = "best-effort", | |
| chords: Annotated[ | |
| bool, | |
| typer.Option( | |
| "--chords/--no-chords", | |
| help=( | |
| "Recognize the chords in the audio (BTC, ISMIR 2019) and write " | |
| "them as markers in the MIDI, and as chord symbols above the " | |
| "staff with --format sheets. Chord changes are snapped to the " | |
| "detected beats, so --detect-tempo false leaves them unaligned." | |
| ), | |
| ), | |
| ] = True, | |
| ) -> None: | |
| """Transcribe an audio file to MIDI.""" | |
| instrument_names: list[str] | None = None | |
| if instruments is not None: | |
| tokens = [n for n in instruments.split(",") if n.strip()] | |
| try: | |
| instrument_names = resolve_instrument_names(tokens) | |
| except ValueError as e: | |
| typer.echo( | |
| f"Error: {e}. " | |
| "Run 'muscriptor list-instruments' to see available names.", | |
| err=True, | |
| ) | |
| raise typer.Exit(1) | |
| typer.echo(f"Instruments: {', '.join(instrument_names)}", err=True) | |
| if not audio_file.exists(): | |
| typer.echo(f"Error: file not found: {audio_file}", err=True) | |
| raise typer.Exit(1) | |
| if prelude_forcing and batch_size is not None and batch_size != 1: | |
| typer.echo( | |
| f"Error: --batch-size {batch_size} requires --no-prelude-forcing: " | |
| "batching disables prelude forcing, which lowers transcription " | |
| "quality at chunk boundaries.", | |
| err=True, | |
| ) | |
| raise typer.Exit(1) | |
| is_stdout = output is not None and str(output) == "-" | |
| if output is None: | |
| if format == OutputFormat.sheets: | |
| output = audio_file.parent / f"{audio_file.stem}_sheets" | |
| else: | |
| suffix = { | |
| OutputFormat.midi: ".mid", | |
| OutputFormat.json: ".json", | |
| OutputFormat.jsonl: ".jsonl", | |
| }[format] | |
| output = audio_file.with_suffix(suffix) | |
| if format == OutputFormat.sheets: | |
| if is_stdout: | |
| typer.echo( | |
| "Error: --format sheets writes a directory, so it cannot write " | |
| "to stdout. Pass a directory path with -o.", | |
| err=True, | |
| ) | |
| raise typer.Exit(1) | |
| # Both checked before the model loads: transcribing a song takes long | |
| # enough that discovering a missing MuseScore afterwards would sting. | |
| try: | |
| prepare_output_dir(output) | |
| find_musescore() | |
| except (ValueError, MuseScoreNotFoundError) as e: | |
| typer.echo(f"Error: {e}", err=True) | |
| raise typer.Exit(1) | |
| _device = None if device == "auto" else device | |
| # All chatty progress/timing info goes to stderr — stdout is reserved for | |
| # the actual output when `-o -` is used. | |
| typer.echo("Loading model…", err=True) | |
| model = _load_model(model_path, _device, dtype) | |
| typer.echo(f"Transcribing {audio_file} …", err=True) | |
| if auralize is not None and format != OutputFormat.midi: | |
| typer.echo("Error: --auralize requires --format midi", err=True) | |
| raise typer.Exit(1) | |
| kwargs = dict( | |
| audio=audio_file, | |
| use_sampling=sampling, | |
| temperature=temperature, | |
| cfg_coef=cfg_coef, | |
| instruments=instrument_names, | |
| batch_size=batch_size, | |
| no_eos_is_ok=not strict_eos, | |
| beam_size=beam_size, | |
| prelude_forcing=prelude_forcing, | |
| ) | |
| if format == OutputFormat.sheets: | |
| # Quantize to get the "idealized" timing, otherwise we might get very weird | |
| # 1/64th rests etc. | |
| midi_bytes, grid = _transcribe( | |
| model, kwargs, detect_tempo, recognize_chords=chords, quantize=True | |
| ) | |
| typer.echo(f"Engraving sheet music with MuseScore → {output} …", err=True) | |
| try: | |
| written = write_sheets( | |
| midi_bytes, | |
| output, | |
| quantized=grid is not None and grid.beat_subdivision is not None, | |
| ) | |
| except MuseScoreError as e: | |
| typer.echo(f"Error: {e}", err=True) | |
| raise typer.Exit(1) | |
| for path in written: | |
| typer.echo(f" {path.name}", err=True) | |
| typer.echo(f"Saved {len(written)} files to {output}", err=True) | |
| elif format == OutputFormat.midi: | |
| midi_bytes, _ = _transcribe( | |
| model, kwargs, detect_tempo, recognize_chords=chords | |
| ) | |
| if is_stdout: | |
| sys.stdout.buffer.write(midi_bytes) | |
| sys.stdout.buffer.flush() | |
| else: | |
| output.write_bytes(midi_bytes) | |
| typer.echo(f"Saved MIDI to {output}", err=True) | |
| if notes: | |
| typer.echo( | |
| "Re-run with --format json to inspect the event stream.", err=True | |
| ) | |
| if auralize is not None and not is_stdout: | |
| from muscriptor.utils.auralization import auralize as do_auralize | |
| typer.echo(f"Auralizing → {auralize} …", err=True) | |
| do_auralize( | |
| midi_path=output, | |
| original_audio_path=audio_file, | |
| output_path=auralize, | |
| soundfont_path=soundfont, | |
| ) | |
| typer.echo(f"Saved auralization to {auralize}", err=True) | |
| elif format == OutputFormat.jsonl: | |
| # Stream one JSON object per line, flushing after each event so the | |
| # file (or stdout pipe) can be consumed live. | |
| if is_stdout: | |
| sink = sys.stdout | |
| close_after = False | |
| else: | |
| sink = output.open("w") | |
| close_after = True | |
| try: | |
| for e in model.transcribe(**kwargs): | |
| if isinstance(e, ProgressEvent): | |
| continue | |
| sink.write(json.dumps(_event_to_dict(e)) + "\n") | |
| sink.flush() | |
| if notes: | |
| typer.echo(str(e), err=True) | |
| finally: | |
| if close_after: | |
| sink.close() | |
| if not is_stdout: | |
| typer.echo(f"Saved JSONL to {output}", err=True) | |
| else: # json | |
| events = [ | |
| e for e in model.transcribe(**kwargs) if not isinstance(e, ProgressEvent) | |
| ] | |
| payload = json.dumps([_event_to_dict(e) for e in events], indent=2) | |
| if is_stdout: | |
| sys.stdout.write(payload + "\n") | |
| sys.stdout.flush() | |
| else: | |
| output.write_text(payload) | |
| typer.echo(f"Saved JSON to {output}", err=True) | |
| if notes: | |
| for e in events: | |
| typer.echo(str(e), err=True) | |
| def serve( | |
| host: Annotated[str, typer.Option("--host", help="Bind address")] = "127.0.0.1", | |
| port: Annotated[int, typer.Option("--port", help="Port to listen on")] = 8222, | |
| model_path: Annotated[ | |
| str | None, | |
| typer.Option( | |
| "--model", | |
| "-m", | |
| help=( | |
| "Model size ('small', 'medium', 'large'; default: medium), " | |
| "a local safetensors path, or an hf:// / http(s):// URL" | |
| ), | |
| ), | |
| ] = None, | |
| device: Annotated[ | |
| str, | |
| typer.Option( | |
| "--device", "-d", help="Device: 'auto', 'cpu', 'cuda', 'cuda:0', 'mps', …" | |
| ), | |
| ] = "auto", | |
| dtype: Annotated[ | |
| str | None, | |
| typer.Option( | |
| "--dtype", | |
| help=( | |
| "Transformer dtype: 'float32', 'float16' or 'bfloat16'. " | |
| "Default: float16 on MPS, float32 elsewhere." | |
| ), | |
| ), | |
| ] = None, | |
| ): | |
| """Run the HTTP transcription server (POST /transcribe → SSE event stream).""" | |
| import logging | |
| import uvicorn | |
| from muscriptor.server import create_app | |
| logging.basicConfig( | |
| level=logging.INFO, format="%(levelname)s %(name)s: %(message)s" | |
| ) | |
| _device = None if device == "auto" else device | |
| typer.echo("Loading model…") | |
| model = _load_model(model_path, _device, dtype) | |
| web_dir = Path(__file__).resolve().parent / "web_dist" | |
| fastapi_app = create_app(model, web_dir=web_dir if web_dir.is_dir() else None) | |
| uvicorn.run(fastapi_app, host=host, port=port) | |
| def list_instruments(): | |
| """List the instrument group names accepted by --instruments.""" | |
| for name in MT3_FULL_PLUS_GROUP_NAMES: | |
| typer.echo(name) | |
| def main(): | |
| app() | |
| if __name__ == "__main__": | |
| main() | |