System_A_AMT / src /generation_service.py
dgeni2's picture
AMT Streamlit: HF upload fix, LilyPond PDF, float32+seed parity, deps
b1296f5
Raw
History Blame Contribute Delete
9.9 kB
"""MusicXML/MIDI ingestion, AMT accompaniment, and score export."""
from __future__ import annotations
import os
import random
import shutil
import tempfile
import zipfile
from io import BytesIO
from pathlib import Path
from typing import List, Literal, Sequence, Tuple
import mido
import numpy as np
import torch
from music21 import converter
from music21 import environment as m21_environment
from anticipation import ops
from anticipation.convert import events_to_midi, midi_to_events
from anticipation.sample import generate, generate_ar
from anticipation.tokenize import extract_instruments
Mode = Literal["anticipatory", "autoregressive"]
def set_generation_rng(seed: int) -> None:
"""Make torch.multinomial sampling reproducible for the same seed (local vs cloud, same stack)."""
random.seed(seed)
np.random.seed(seed % (2**32))
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
torch.mps.manual_seed(seed)
try:
import torch.backends.cudnn as cudnn
cudnn.deterministic = True
cudnn.benchmark = False
except Exception:
pass
_LILYPOND_CANDIDATES = (
"/opt/homebrew/bin/lilypond",
"/usr/local/bin/lilypond",
"/usr/bin/lilypond",
)
def _find_lilypond() -> str | None:
w = shutil.which("lilypond")
if w:
return w
for p in _LILYPOND_CANDIDATES:
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
return None
def _configure_lilypond() -> str:
"""Point music21 at a LilyPond binary (required for lily.pdf)."""
exe = _find_lilypond()
if not exe:
raise EnvironmentError(
"LilyPond is not installed or not on your PATH, so PDF export cannot run. "
"Install LilyPond: macOS: `brew install lilypond` · Ubuntu/Debian: `sudo apt install lilypond`. "
"The Docker image for Hugging Face already includes LilyPond."
)
try:
us = m21_environment.UserSettings()
us["lilypondPath"] = exe
except Exception:
# UserSettings can fail in restricted environments; `which lilypond` may still suffice.
pass
return exe
class DummyTqdm:
"""Silence tqdm in anticipation.sample when running inside Streamlit."""
def __init__(self, iterable=None, **_kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return None
def update(self, _n=1):
pass
def _patch_tqdm():
import anticipation.sample as sample_mod
sample_mod.tqdm = lambda *args, **kwargs: DummyTqdm()
def _restore_tqdm():
import anticipation.sample as sample_mod
from tqdm import tqdm as real_tqdm
sample_mod.tqdm = real_tqdm
def sniff_score_format(data: bytes) -> Literal["midi", "mxl", "musicxml"]:
"""
Detect file type from bytes (do not rely on the browser filename).
"""
if not data:
raise ValueError("The file is empty. Choose a real score file, not a folder.")
if len(data) < 4:
raise ValueError("The file is too small to be a music score.")
if data[:4] == b"MThd":
return "midi"
if data[:2] == b"PK":
return "mxl"
head = data[:4000].lstrip(b"\xef\xbb\xbf")
if head.startswith(b"<?xml") or b"<score-partwise" in head or b"<score-timewise" in head:
return "musicxml"
raise ValueError(
"This does not look like MusicXML or MIDI. "
"Export from your notation app as **MusicXML** or **Standard MIDI** (not a project folder)."
)
def _parse_musicxml_bytes_to_stream(data: bytes):
"""
music21.converter.parse(BytesIO) sets an empty path string internally; cleanpath('')
can resolve to the process cwd (a directory), so parseFile() opens the folder → EISDIR.
Always parse MusicXML from a real temp file.
"""
fd, path = tempfile.mkstemp(suffix=".musicxml")
os.close(fd)
try:
with open(path, "wb") as f:
f.write(data)
return converter.parse(path, format="musicxml")
finally:
try:
os.unlink(path)
except OSError:
pass
def _m21_stream_to_events_via_temp_midi(score_stream) -> List:
"""
music21's MIDI writer uses open(path); BytesIO breaks or mis-resolves paths.
Write to a real temp file, then read with mido.
"""
fd, path = tempfile.mkstemp(suffix=".mid")
os.close(fd)
try:
score_stream.write("midi", fp=path)
mf = mido.MidiFile(path)
return midi_to_events(mf)
finally:
try:
os.unlink(path)
except OSError:
pass
def _music21_stream_from_midi_bytes(midi_bytes: bytes):
fd, path = tempfile.mkstemp(suffix=".mid")
try:
with os.fdopen(fd, "wb") as tmp:
tmp.write(midi_bytes)
return converter.parse(path, format="midi")
finally:
try:
os.unlink(path)
except OSError:
pass
def parse_upload_to_events(data: bytes, _filename: str | None = None) -> List:
"""Load MusicXML, MXL, or MIDI bytes into anticipation event tokens.
Format is detected from the bytes (magic / XML header), not the filename.
"""
kind = sniff_score_format(data)
if kind == "midi":
mf = mido.MidiFile(file=BytesIO(data))
return midi_to_events(mf)
if kind == "mxl":
with zipfile.ZipFile(BytesIO(data)) as zf:
xml_names = [n for n in zf.namelist() if n.endswith(".xml")]
if not xml_names:
raise ValueError("Compressed MusicXML (.mxl) has no XML inside.")
xml_data = zf.read(xml_names[0])
score_stream = _parse_musicxml_bytes_to_stream(xml_data)
return _m21_stream_to_events_via_temp_midi(score_stream)
# musicxml (text XML)
if kind == "musicxml":
score_stream = _parse_musicxml_bytes_to_stream(data)
return _m21_stream_to_events_via_temp_midi(score_stream)
raise ValueError("Unsupported format.")
def prepare_events_window(events: Sequence, clip_length_sec: float) -> List:
"""Clip to [0, clip_length_sec] and shift so the earliest event starts at t=0."""
clipped = ops.clip(events, 0, clip_length_sec, clip_duration=False)
if not clipped:
return []
t0 = ops.min_time(clipped, seconds=False)
if t0 > 0:
clipped = ops.translate(clipped, -t0, seconds=False)
return clipped
def instrument_choices(events: Sequence) -> List[Tuple[int, int]]:
"""[(program, note_count), ...] descending by count."""
inst = ops.get_instruments(events)
return sorted(inst.items(), key=lambda x: -x[1])
def default_melody_programs(choices: Sequence[Tuple[int, int]]) -> List[int]:
"""Pick the busiest non-drum program as default melody."""
non_drum = [(p, c) for p, c in choices if p != 128]
if non_drum:
return [non_drum[0][0]]
if choices:
return [choices[0][0]]
return []
def validate_melody_vs_events(events: Sequence, melody_programs: Sequence[int]) -> None:
"""Ensure at least one locked melody note exists.
Melody-only scores (one instrument, all notes locked) are valid: the model adds
harmony on *other* instruments that were not in the original file.
"""
_ev, ctr = extract_instruments(events, list(melody_programs))
if not ctr:
raise ValueError(
"None of your selected parts appear in this file. Pick an instrument that has notes in the excerpt."
)
def run_accompaniment(
model,
events: Sequence,
melody_programs: Sequence[int],
prompt_sec: float,
clip_sec: float,
top_p: float,
mode: Mode,
seed: int = 42,
) -> List:
set_generation_rng(seed)
events, controls = extract_instruments(events, list(melody_programs))
prompt = ops.clip(events, 0, prompt_sec, clip_duration=False)
_patch_tqdm()
try:
with torch.inference_mode():
if mode == "anticipatory":
gen = generate(model, prompt_sec, clip_sec, prompt, controls, top_p=top_p)
out = ops.clip(ops.combine(gen, controls), 0, clip_sec)
else:
gen = generate_ar(model, prompt_sec, clip_sec, prompt, controls, top_p=top_p)
out = ops.clip(gen, 0, clip_sec)
finally:
_restore_tqdm()
return out
def events_to_midi_bytes(events: Sequence) -> bytes:
mid = events_to_midi(events)
bio = BytesIO()
mid.save(file=bio)
return bio.getvalue()
def events_to_musicxml_bytes(events: Sequence) -> bytes:
score_stream = _music21_stream_from_midi_bytes(events_to_midi_bytes(events))
fd, path = tempfile.mkstemp(suffix=".musicxml")
os.close(fd)
try:
score_stream.write("musicxml", fp=path)
with open(path, "rb") as f:
return f.read()
finally:
try:
os.unlink(path)
except OSError:
pass
def events_to_pdf_bytes(events: Sequence) -> bytes:
"""
music21 needs the format `lily.pdf` (LilyPond), not plain `pdf`.
"""
_configure_lilypond()
score_stream = _music21_stream_from_midi_bytes(events_to_midi_bytes(events))
tmpdir = tempfile.mkdtemp(prefix="amt_lily_")
try:
out_pdf = os.path.join(tmpdir, "score.pdf")
written = score_stream.write("lily.pdf", fp=out_pdf)
path = Path(written) if written is not None else Path(out_pdf)
if not path.is_file():
path = Path(out_pdf)
if not path.is_file():
raise RuntimeError(
"LilyPond ran but no PDF was found. Check that `lilypond` works in a terminal "
"(e.g. `lilypond --version`)."
)
return path.read_bytes()
finally:
shutil.rmtree(tmpdir, ignore_errors=True)