Add transcribe.py (app pack entry point)
Browse files- transcribe.py +108 -0
transcribe.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Egystormy Tools — portable local MuScriptor transcription pack.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
runtime/python transcribe.py <input_audio> <output.mid>
|
| 6 |
+
|
| 7 |
+
Fully offline and self-contained: the interpreter is ./runtime, all
|
| 8 |
+
third-party libraries live in ./libs, and the model weights in ./weights.
|
| 9 |
+
No Python installation, no setup, no network, no account.
|
| 10 |
+
|
| 11 |
+
Design note — no beat detection, on purpose: this entry point uses
|
| 12 |
+
``TranscriptionModel.transcribe`` + ``events_to_midi_bytes`` directly and
|
| 13 |
+
never calls the beat-grid path (``transcribe_and_postprocess`` /
|
| 14 |
+
``detect_tempo``). MuScriptor's beat detection hard-imports
|
| 15 |
+
beat_this -> torchaudio at call time, and a mismatched torchaudio build
|
| 16 |
+
kills the whole transcription. Skipping it costs nothing that matters
|
| 17 |
+
here: note timings stay true (seconds-accurate); the MIDI simply carries
|
| 18 |
+
no tempo map. (In this pack torch is 2.15.0+cpu while the newest
|
| 19 |
+
torchaudio on the CPU index is 2.11.1+cpu — no matched 2.15 pair exists,
|
| 20 |
+
which is exactly the hazard avoided.)
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import os
|
| 24 |
+
import sys
|
| 25 |
+
import time
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
PACK = Path(__file__).resolve().parent
|
| 29 |
+
sys.path.insert(0, str(PACK / "libs"))
|
| 30 |
+
# Hard-offline: never let a library try to phone home.
|
| 31 |
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
| 32 |
+
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
| 33 |
+
|
| 34 |
+
T0 = time.perf_counter()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def stamp(label: str) -> None:
|
| 38 |
+
print(f"[pack] {label}: {time.perf_counter() - T0:.2f}s elapsed", flush=True)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def main() -> int:
|
| 42 |
+
if len(sys.argv) != 3:
|
| 43 |
+
print("usage: runtime/python transcribe.py <input_audio> <output.mid>",
|
| 44 |
+
file=sys.stderr)
|
| 45 |
+
return 2
|
| 46 |
+
src = Path(sys.argv[1])
|
| 47 |
+
dst = Path(sys.argv[2])
|
| 48 |
+
weights = PACK / "weights" / "model.safetensors"
|
| 49 |
+
if not src.exists():
|
| 50 |
+
print(f"input audio not found: {src}", file=sys.stderr)
|
| 51 |
+
return 2
|
| 52 |
+
if not weights.exists():
|
| 53 |
+
print(f"weights not found: {weights}", file=sys.stderr)
|
| 54 |
+
return 2
|
| 55 |
+
|
| 56 |
+
from muscriptor import TranscriptionModel
|
| 57 |
+
from muscriptor.events import NoteStartEvent, ProgressEvent
|
| 58 |
+
|
| 59 |
+
stamp("imports done")
|
| 60 |
+
|
| 61 |
+
# libsndfile cannot decode m4a/AAC and some other containers; if an
|
| 62 |
+
# ffmpeg happens to be available, transcode those to a temp WAV first.
|
| 63 |
+
# (mp3/wav/flac/ogg decode natively and never reach this branch.)
|
| 64 |
+
audio_arg = src
|
| 65 |
+
tmp_wav = None
|
| 66 |
+
if src.suffix.lower() in {".m4a", ".aac", ".opus", ".wma"}:
|
| 67 |
+
import shutil
|
| 68 |
+
import subprocess
|
| 69 |
+
import tempfile
|
| 70 |
+
|
| 71 |
+
ffmpeg = shutil.which("ffmpeg")
|
| 72 |
+
if ffmpeg:
|
| 73 |
+
fd, tmp_wav = tempfile.mkstemp(suffix=".wav")
|
| 74 |
+
os.close(fd)
|
| 75 |
+
subprocess.run(
|
| 76 |
+
[ffmpeg, "-y", "-v", "error", "-i", str(src), tmp_wav],
|
| 77 |
+
check=True,
|
| 78 |
+
)
|
| 79 |
+
audio_arg = Path(tmp_wav)
|
| 80 |
+
|
| 81 |
+
model = TranscriptionModel.load_model(weights_path=weights, device="cpu")
|
| 82 |
+
stamp("model loaded (cold start)")
|
| 83 |
+
|
| 84 |
+
t_transcribe = time.perf_counter()
|
| 85 |
+
events = []
|
| 86 |
+
notes = 0
|
| 87 |
+
for ev in model.transcribe(str(audio_arg)):
|
| 88 |
+
if isinstance(ev, ProgressEvent):
|
| 89 |
+
print(f"[pack] progress: chunk {ev.completed}/{ev.total}", flush=True)
|
| 90 |
+
else:
|
| 91 |
+
events.append(ev)
|
| 92 |
+
if isinstance(ev, NoteStartEvent):
|
| 93 |
+
notes += 1
|
| 94 |
+
|
| 95 |
+
midi_bytes = model.events_to_midi_bytes(iter(events), beat_grid=None)
|
| 96 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 97 |
+
dst.write_bytes(midi_bytes)
|
| 98 |
+
if tmp_wav:
|
| 99 |
+
os.unlink(tmp_wav)
|
| 100 |
+
|
| 101 |
+
print(f"[pack] transcribe wall time: {time.perf_counter() - t_transcribe:.2f}s",
|
| 102 |
+
flush=True)
|
| 103 |
+
print(f"DONE notes={notes} output={dst} bytes={len(midi_bytes)}", flush=True)
|
| 104 |
+
return 0
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
raise SystemExit(main())
|