System_A_AMT / src /streamlit_app.py
dgeni2's picture
AMT Streamlit: HF upload fix, LilyPond PDF, float32+seed parity, deps
b1296f5
Raw
History Blame Contribute Delete
13.5 kB
"""Streamlit UI for Anticipatory Music Transformer accompaniment."""
from __future__ import annotations
import os
import sys
import warnings
from pathlib import Path
# Show tqdm-style download bars in the terminal where Streamlit runs
os.environ.setdefault("HF_HUB_ENABLE_PROGRESS_BARS", "1")
import streamlit as st
# transformers 4.29.x triggers huggingface_hub's deprecated resume_download warning
warnings.filterwarnings(
"ignore",
message=".*resume_download.*",
category=FutureWarning,
module="huggingface_hub.file_download",
)
_SRC = Path(__file__).resolve().parent
if str(_SRC) not in sys.path:
sys.path.insert(0, str(_SRC))
from generation_service import ( # noqa: E402
Mode,
default_melody_programs,
events_to_midi_bytes,
events_to_musicxml_bytes,
events_to_pdf_bytes,
instrument_choices,
parse_upload_to_events,
prepare_events_window,
run_accompaniment,
validate_melody_vs_events,
)
# ~128M params — faster download & load (default). Medium is ~360M.
MODEL_FAST = "stanford-crfm/music-small-800k"
MODEL_QUALITY = "stanford-crfm/music-medium-800k"
def _safe_upload_stem(uploaded_file) -> str:
if uploaded_file is None or not getattr(uploaded_file, "name", None):
return "anticipation_output"
stem = Path(str(uploaded_file.name)).name
stem = Path(stem).stem
if not stem or stem in (".", ".."):
return "anticipation_output"
return stem
@st.cache_resource
def load_model(model_id: str):
import torch
from transformers import AutoModelForCausalLM
from transformers import logging as tr_logging
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
if torch.cuda.is_available():
device = "cuda"
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
device = "mps"
else:
device = "cpu"
extra: dict = {"torch_dtype": torch.float32}
try:
import accelerate # noqa: F401
extra["low_cpu_mem_usage"] = True
except ImportError:
pass
# float32 everywhere matches Hugging Face CPU Spaces and avoids fp16 vs fp32 sampling drift.
# CRFM checkpoints include unused keys (e.g. token_out_embeddings); harmless noise otherwise.
_v = tr_logging.get_verbosity()
tr_logging.set_verbosity_error()
try:
model = AutoModelForCausalLM.from_pretrained(model_id, **extra)
finally:
tr_logging.set_verbosity(_v)
model = model.to(device)
model.eval()
return model, device
def _reset_outputs():
for key in (
"out_midi",
"out_midi_err",
"out_musicxml",
"out_musicxml_err",
"out_pdf",
"out_pdf_err",
):
st.session_state.pop(key, None)
st.set_page_config(page_title="Harmony from your melody", layout="wide")
st.title("Add harmony to your melody")
st.caption(
"Upload a tune you wrote (sheet music or MIDI). Lock in your **melody**; the model **adds other parts**. "
"Then download MIDI, sheet-music XML, or PDF."
)
st.subheader("1. Upload your file")
uploaded = st.file_uploader(
"Choose one score file",
type=["xml", "musicxml", "mxl", "mid", "midi"],
help=(
"From MuseScore, Finale, Dorico, etc.: export as MusicXML or MIDI. "
"Select a **file**, not a project folder. Types: .xml, .musicxml, .mxl, .mid, .midi."
),
)
st.subheader("2. How much of the piece to use")
st.markdown(
"The AI works on a **short slice** from the start of your file (long scores are trimmed). "
"A smaller slice runs faster."
)
col_len_a, col_len_b = st.columns(2)
with col_len_a:
clip_sec = st.slider(
"Length of the excerpt (seconds)",
min_value=5,
max_value=120,
value=20,
step=1,
help="Only this many seconds from the beginning of your file are loaded and harmonized.",
)
with col_len_b:
prompt_sec = st.slider(
"How many seconds stay “exactly yours” first",
min_value=0,
max_value=int(clip_sec),
value=min(5, int(clip_sec)),
step=1,
help=(
"At the start of the excerpt, this many seconds are treated as fixed context; "
"the model continues and adds harmony after that point within the same window."
),
)
events = None
choices = []
parse_error = None
if uploaded is not None:
try:
raw = uploaded.getvalue()
events = parse_upload_to_events(raw, uploaded.name)
events = prepare_events_window(events, float(clip_sec))
choices = instrument_choices(events)
except Exception as e:
parse_error = str(e)
events = None
if parse_error:
st.error(f"We could not read that file: {parse_error}")
if uploaded is None:
st.info("Upload a file in step 1 to continue.")
st.stop()
if not events:
st.warning("Once the file loads correctly, you will choose your melody part below.")
st.stop()
if not choices:
st.error("This file has no notes we could read. Try exporting again as MusicXML or MIDI.")
st.stop()
st.subheader("3. Which part is your melody?")
st.markdown(
"Each line is an **instrument sound** (MIDI program) and how many notes it has. "
"**Select the part(s) you want to keep exactly as written** (usually your melody). "
"The AI adds **other instruments** you did not write. "
"If the file is only one instrument, select that one—the model still creates backing parts."
)
options = [f"Instrument {prog}{count} notes" for prog, count in choices]
prog_by_label = {options[i]: choices[i][0] for i in range(len(options))}
count_map = {p: c for p, c in choices}
defaults = default_melody_programs(choices)
default_labels = [f"Instrument {p}{count_map[p]} notes" for p in defaults if p in count_map]
selected_labels = st.multiselect(
"Melody (locked parts)",
options=options,
default=[lbl for lbl in default_labels if lbl in options],
help="Locked parts stay note-for-note. New harmony uses other MIDI instruments (fine for a single-line melody).",
)
st.subheader("4. Model size")
st.caption(
"Inference uses **float32** everywhere (same as CPU Spaces) so results line up across machines. "
"First time only: the checkpoint downloads from Hugging Face (watch the **terminal** for a progress bar). "
"After that, the same model stays cached until you restart the app or pick a different model."
)
model_tier = st.radio(
"Choose one",
options=["fast", "quality"],
index=0,
horizontal=True,
format_func=lambda x: (
"Faster — smaller download (~128M), good for trying the app"
if x == "fast"
else "Higher quality — larger download (~360M), slower on CPU"
),
)
preset_model_id = MODEL_FAST if model_tier == "fast" else MODEL_QUALITY
with st.expander("Advanced options (optional)"):
st.markdown("You can usually leave these as they are.")
custom_model_id = st.text_input(
"Custom Hugging Face model id (optional)",
value="",
key="hf_model_override",
placeholder="Leave empty to use the model size you chose above",
help="Only if you use another compatible stanford-crfm checkpoint.",
)
mode: Mode = st.radio(
"Generation style",
options=["anticipatory", "autoregressive"],
index=0,
format_func=lambda x: "Standard — anticipatory (recommended)" if x == "anticipatory" else "Research baseline — plain autoregressive",
help="Anticipatory mode matches the paper’s infilling setup. Autoregressive is a simpler baseline.",
)
top_p = st.slider(
"How surprising / varied the harmony is",
min_value=0.5,
max_value=1.0,
value=0.95,
step=0.01,
help="Higher values (e.g. 0.95–1.0) allow more diverse notes; lower values are more conservative.",
)
generation_seed = st.number_input(
"Random seed",
min_value=0,
max_value=2**31 - 1,
value=42,
step=1,
help=(
"The model samples notes at random; this fixes the RNG so **the same file, settings, model, "
"and seed** match between your laptop and Hugging Face. Change the seed for a new variation."
),
)
model_id = custom_model_id.strip() if custom_model_id.strip() else preset_model_id
st.subheader("5. Generate")
gen = st.button("Generate harmony", type="primary")
if gen:
_reset_outputs()
melody_programs = [prog_by_label[lbl] for lbl in selected_labels]
if not melody_programs:
st.error("Choose at least one instrument as your melody.")
else:
try:
validate_melody_vs_events(events, melody_programs)
except ValueError as e:
st.error(str(e))
else:
with st.status("Loading the AI model…", expanded=True) as load_status:
load_status.write(
"**First run:** downloading can take **several minutes** on a slow connection "
"(hundreds of MB). Check the terminal for a download bar. "
"**Next runs** with the same model reuse the cache and are much quicker."
)
load_status.write(f"Using: `{model_id}`")
try:
model, _device = load_model(model_id.strip())
except Exception as e:
load_status.update(label="Model load failed", state="error")
st.error(f"Could not load the model: {e}")
model = None
else:
load_status.update(label="Model ready", state="complete")
if model is not None:
with st.spinner("Writing harmony… Often **1–5+ minutes** on CPU; faster on GPU."):
try:
out = run_accompaniment(
model,
events,
melody_programs,
float(prompt_sec),
float(clip_sec),
top_p,
mode,
seed=int(generation_seed),
)
except Exception as e:
st.error(f"Generation stopped: {e}")
else:
st.success("Here is your new score. Download below.")
try:
st.session_state["out_midi"] = events_to_midi_bytes(out)
st.session_state.pop("out_midi_err", None)
except Exception as e:
st.session_state.pop("out_midi", None)
st.session_state["out_midi_err"] = str(e)
try:
st.session_state["out_musicxml"] = events_to_musicxml_bytes(out)
st.session_state.pop("out_musicxml_err", None)
except Exception as e:
st.session_state.pop("out_musicxml", None)
st.session_state["out_musicxml_err"] = str(e)
try:
st.session_state["out_pdf"] = events_to_pdf_bytes(out)
st.session_state.pop("out_pdf_err", None)
except Exception as e:
st.session_state["out_pdf"] = None
st.session_state["out_pdf_err"] = str(e)
st.divider()
st.subheader("6. Download")
base = _safe_upload_stem(uploaded)
if st.session_state.get("out_midi"):
st.download_button(
label="Download as MIDI (for DAWs, players)",
data=st.session_state["out_midi"],
file_name=f"{base}_with_harmony.mid",
mime="audio/midi",
)
if st.session_state.get("out_musicxml"):
st.download_button(
label="Download as MusicXML (open in notation software)",
data=st.session_state["out_musicxml"],
file_name=f"{base}_with_harmony.musicxml",
mime="application/vnd.recordare.musicxml+xml",
)
if st.session_state.get("out_pdf"):
st.download_button(
label="Download as PDF (printable score)",
data=st.session_state["out_pdf"],
file_name=f"{base}_with_harmony.pdf",
mime="application/pdf",
)
elif st.session_state.get("out_pdf_err"):
st.caption(
f"**PDF export:** {st.session_state['out_pdf_err']} "
"On macOS, install LilyPond with: `brew install lilypond`, then restart Streamlit."
)
if st.session_state.get("out_midi_err"):
st.caption(f"MIDI export issue: {st.session_state['out_midi_err']}")
if st.session_state.get("out_musicxml_err"):
st.caption(f"MusicXML export issue: {st.session_state['out_musicxml_err']}")
if not any(
st.session_state.get(k)
for k in ("out_midi", "out_musicxml", "out_pdf")
):
st.caption("Run step 4 to create files you can download.")
st.divider()
with st.expander("About limits & credits"):
st.markdown(
"""
- Very long or very dense scores may time out; use a shorter excerpt in step 2.
- Sheet music is converted through MIDI internally, so fine engraving may change slightly.
- **Anticipatory Music Transformer** (Apache-2.0): Thickstun et al.; inference code from
[anticipation](https://github.com/jthickstun/anticipation). Weights from
[Stanford CRFM on Hugging Face](https://huggingface.co/stanford-crfm).
"""
)