jerryzhou196
Initial chord service
e319a7b unverified
Raw History Blame Contribute Delete
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,
}
@app.command()
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)
@app.command()
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)
@app.command()
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()