""" SymphonyGen demo Space. Paper: https://arxiv.org/abs/2604.25498 (ISMIR 2026) Code: https://github.com/symphonygen/symphonygen Models: https://huggingface.co/SymphonyGen/SymphonyGen The Space clones the code repository, downloads the released packed checkpoints, and exposes a few inference options: skeleton source (sampled / uploaded / analyzed), model variant, dissonance-averse sampling weights, and an instrument mask. Audio previews are rendered with headless MuseScore 3 (as in the paper), falling back to FluidSynth + MuseScore_General.sf3. """ import os import shutil import subprocess import sys import tempfile import uuid from pathlib import Path # ZeroGPU support (no-op elsewhere). Must be imported before torch so that # the CUDA emulation layer can intercept module-level model placements. try: import spaces gpu_decorator = spaces.GPU(duration=120) except ImportError: def gpu_decorator(fn): return fn APP_DIR = Path(__file__).resolve().parent REPO_URL = "https://github.com/symphonygen/symphonygen" REPO_DIR = APP_DIR / "symphonygen" ASSET_DIR = APP_DIR / "asset" OUT_ROOT = Path(tempfile.gettempdir()) / "symphonygen_demo" # --- Code + checkpoints ---------------------------------------------------- if not REPO_DIR.exists(): subprocess.run(["git", "clone", "--depth", "1", REPO_URL, str(REPO_DIR)], check=True) os.environ.setdefault("ASSET_DIR", str(ASSET_DIR)) os.environ.setdefault("WORK_DIR", str(OUT_ROOT / "work")) os.makedirs(os.environ["WORK_DIR"], exist_ok=True) sys.path.insert(0, str(REPO_DIR)) from huggingface_hub import snapshot_download snapshot_download("SymphonyGen/SymphonyGen", local_dir=str(ASSET_DIR), allow_patterns=["*.pt"]) # --- Headless MuseScore 3.6.2 (the paper's renderer) ----------------------- # Same setup as utils/headless_musescore.sh in the code repo: extract the # AppImage (no FUSE in containers) and run squashfs-root/AppRun under Xvfb. MUSESCORE_APPIMAGE = "MuseScore-3.6.2.548021370-x86_64.AppImage" MUSESCORE_URL = f"https://github.com/musescore/MuseScore/releases/download/v3.6.2/{MUSESCORE_APPIMAGE}" MUSESCORE_APPRUN = ASSET_DIR / "squashfs-root" / "AppRun" def setup_headless_musescore(): if MUSESCORE_APPRUN.exists(): return appimage = ASSET_DIR / MUSESCORE_APPIMAGE try: if not appimage.exists(): from urllib.request import urlretrieve urlretrieve(MUSESCORE_URL, appimage) appimage.chmod(0o755) subprocess.run( [f"./{MUSESCORE_APPIMAGE}", "--appimage-extract"], cwd=ASSET_DIR, check=True, capture_output=True, timeout=600, ) subprocess.run(["chmod", "-R", "+x", str(ASSET_DIR / "squashfs-root")], check=True) except Exception as e: print(f"Headless MuseScore setup failed ({e}); falling back to FluidSynth.") setup_headless_musescore() import gradio as gr import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import mido import torch from miditoolkit import MidiFile from arch.config import BAR_NUM from arch.harmo.data_tensor import HarmonyTensorConverter from arch.harmo.generator import HarmonyGenerator from arch.harmo.model import HarmonyGPT from arch.harmo.sampling import HarmPosConstrainer from arch.symph import generate from arch.symph.cond_gen import SymphonyGenerator3D from arch.symph.data_tensor import MusicTensorConverter3D from arch.symph.model import MusicModel3D from arch.symph.sampling import DissonanceConstrainer from data_prep.harmo_analysis import HarmonySkeletonAnalyzer from data_prep.main import export_midi, preprocess_midi from rl.reward.harmo_filter import filter_harmony_by_rule from utils.remove_harmo_outline import POSSIBLE_HARMO_OUTLINE_TRACK_NAMES, midi_remove_harmo_outline MODEL_VARIANTS = { # label: (checkpoint, default register decay) "reinforced+track (GRPO, CLaMP 3 + track density reward)": ("grpo_clamp+track_epoch_6.pt", True), "reinforced (GRPO, pure CLaMP 3 reward)": ("grpo_clamp_epoch_10.pt", False), "pretrained (no RL)": ("stage_two_pretrained.pt", False), } SKELETON_SOURCES = [ "Sample a new skeleton (harmony model)", "Upload a harmony-skeleton MIDI", "Upload any MIDI and analyze its harmony (re-orchestration)", ] # Audio rendering: headless MuseScore 3 first (the paper's renderer), then # FluidSynth with the MuseScore General soundfont, then FluidR3. SOUNDFONTS = [ "/usr/share/sounds/sf3/MuseScore_General.sf3", "/usr/share/sounds/sf3/MuseScore_General_Small.sf3", "/usr/share/sounds/sf2/FluidR3_GM.sf2", ] # Set PROVIDE_MIDI=0 in the Space variables to expose audio previews only. PROVIDE_MIDI = os.environ.get("PROVIDE_MIDI", "1") == "1" EXAMPLE_DIR = APP_DIR / "examples" # Harmony beats must stay on quarters for the symphony model (see Results/batch_filter_gen.py) HarmPosConstrainer.limit_at_quarter = True _model_cache = {} def load_symph_model(ckpt_name: str) -> MusicModel3D: if ckpt_name not in _model_cache: model = MusicModel3D.from_pretrained(str(ASSET_DIR / ckpt_name)) _model_cache[ckpt_name] = model.eval() return _model_cache[ckpt_name] def load_harmo_model() -> HarmonyGPT: if "harmo" not in _model_cache: model = HarmonyGPT.from_pretrained(str(ASSET_DIR / "stage_one_pretrained.pt")) _model_cache["harmo"] = model.eval() return _model_cache["harmo"] # Preload every model at module level: ZeroGPU records the CUDA placements # during startup (emulated CUDA), so @spaces.GPU calls skip transfer costs. load_harmo_model() for _ckpt_name, _ in MODEL_VARIANTS.values(): load_symph_model(_ckpt_name) def sample_skeleton(apply_filters: bool): """ Sample one harmony skeleton, optionally curated by the paper's filters. """ generator = HarmonyGenerator(HarmonyTensorConverter()) harmo_dict = generator.run(load_harmo_model(), num_batches=1, batch_size=8) candidates = list(harmo_dict.values()) note = "" if apply_filters: good = filter_harmony_by_rule(list(candidates), num_requested=1) good = generator.filter_by_log_prob(load_harmo_model(), good, num_requested=1) if good: candidates = good else: note = "No sampled skeleton passed the quality filters; using an unfiltered one. " bars = candidates[0] # Sampled skeletons are pruned into pure template chords (paper Sec. 4.2) HarmonySkeletonAnalyzer(triad_match_strict=True).purify_harmony_(bars) return bars, note MAX_PREVIEW_BARS = BAR_NUM # the model's supported piece length (32 bars) def _bar_starts(midi: MidiFile, max_bars: int) -> list[int]: """ Bar boundary ticks honoring time-signature changes. Same walk as BeatToTime.parse (music/lib/beat_to_time.py): each bar spans whole_ticks * numerator / denominator; a signature change landing mid-bar truncates that bar at the change. """ whole_ticks = 4 * midi.ticks_per_beat changes = sorted(midi.time_signature_changes, key=lambda c: c.time) starts = [0] tick, i = 0, 0 ticks_per_bar = whole_ticks # 4/4 default while len(starts) <= max_bars: while i < len(changes) and changes[i].time <= tick: ticks_per_bar = max(1, whole_ticks * changes[i].numerator // changes[i].denominator) i += 1 next_tick = tick + ticks_per_bar if i < len(changes) and tick < changes[i].time < next_tick: next_tick = changes[i].time starts.append(next_tick) tick = next_tick return starts def piano_roll_image(midi_path: Path, title: str) -> str | None: """ Toy single-color piano roll of the first MAX_PREVIEW_BARS bars. """ try: midi = MidiFile(str(midi_path)) tpb = midi.ticks_per_beat bar_starts = _bar_starts(midi, MAX_PREVIEW_BARS) end_tick = bar_starts[-1] spans_by_pitch: dict[int, list[tuple[float, float]]] = {} for inst in midi.instruments: if inst.is_drum: continue for n in inst.notes: if n.start >= end_tick: continue start = n.start / tpb dur = (min(n.end, end_tick) - n.start) / tpb spans_by_pitch.setdefault(n.pitch, []).append((start, max(dur, 0.1))) if not spans_by_pitch: return None fig, ax = plt.subplots(figsize=(10, 3.5), dpi=110) for t in bar_starts: ax.axvline(t / tpb, color="0.88", linewidth=0.6, zorder=0) for pitch, spans in spans_by_pitch.items(): ax.broken_barh(spans, (pitch - 0.4, 0.8), color="#46608a", linewidth=0) pitches = list(spans_by_pitch) ax.set_xlim(0, end_tick / tpb) ax.set_ylim(min(pitches) - 2, max(pitches) + 2) label_bars = range(0, MAX_PREVIEW_BARS + 1, 4) ax.set_xticks([bar_starts[b] / tpb for b in label_bars]) ax.set_xticklabels([str(b) for b in label_bars]) ax.set_xlabel("bar") ax.set_ylabel("pitch") ax.set_title(title, fontsize=9) fig.tight_layout() img_path = midi_path.with_suffix(".roll.png") fig.savefig(img_path) plt.close(fig) return str(img_path) except Exception: return None def _has_harmo_outline_track(midi_path: Path) -> bool: """ Whether the MIDI is a harmony-skeleton file, identified by track name as in utils/remove_harmo_outline.py. """ try: midi_obj = mido.MidiFile(str(midi_path)) except Exception: return False return any(t.name in POSSIBLE_HARMO_OUTLINE_TRACK_NAMES for t in midi_obj.tracks) def truncate_midi_to_bars(midi_path: Path, max_bars: int = BAR_NUM) -> Path: """ Cap a user-uploaded MIDI at max_bars upfront (the model does not support longer pieces). Returns the original path when already short. """ try: midi = MidiFile(str(midi_path)) end_tick = _bar_starts(midi, max_bars)[-1] if midi.max_tick <= end_tick: return midi_path for inst in midi.instruments: inst.notes = [n for n in inst.notes if n.start < end_tick] for n in inst.notes: n.end = min(n.end, end_tick) for attr in ("control_changes", "pitch_bends", "pedals"): events = getattr(inst, attr, None) if events is not None: setattr(inst, attr, [e for e in events if getattr(e, "time", getattr(e, "start", 0)) < end_tick]) for attr in ("tempo_changes", "time_signature_changes", "key_signature_changes", "markers", "lyrics"): events = getattr(midi, attr, None) if events is not None: setattr(midi, attr, [e for e in events if e.time < end_tick]) capped_path = midi_path.with_suffix(".cap.mid") midi.dump(str(capped_path)) return capped_path except Exception: return midi_path def export_skeleton_midi(bars, out_path: Path): """ Export only the harmony skeleton of the given bars. """ harmo_bars = [bar.__class__(duration_q=bar.duration_q) for bar in bars] for harmo_bar, bar in zip(harmo_bars, bars): harmo_bar.harmony_track = bar.harmony_track export_midi(harmo_bars, out_path) def preview_uploaded_midi(skeleton_source: str, midi_file: str | None): """ Preview the harmony skeleton of an upload (not the raw MIDI): the existing outline track when present, otherwise the analyzed harmony. """ if not midi_file: return gr.update(value=None) path = truncate_midi_to_bars(Path(midi_file)) try: bars = preprocess_midi( path, analyze_harmo=skeleton_source == SKELETON_SOURCES[2], analyze_harmo_if_not_exist=True, ) skeleton_path = path.with_suffix(".skeleton.mid") export_skeleton_midi(bars, skeleton_path) except Exception: return gr.update(value=None) return piano_roll_image(skeleton_path, f"Harmony skeleton (first {MAX_PREVIEW_BARS} bars)") def _mscore_cmd() -> list[str] | None: """ MuseScore invocation, preferring the paper's 3.6.2 AppImage (see utils/midi2audio.py in the code repo). """ if not shutil.which("xvfb-run"): return None if MUSESCORE_APPRUN.exists(): return ["xvfb-run", "--auto-servernum", str(MUSESCORE_APPRUN)] for name in ("mscore3", "musescore3", "mscore", "musescore"): if shutil.which(name): return ["xvfb-run", "--auto-servernum", name] return None def _render_midi_to_wav(render_src: Path, wav_path: Path) -> str | None: """ Tries headless MuseScore 3 under Xvfb, then FluidSynth with the first available soundfont. """ mscore = _mscore_cmd() if mscore: try: subprocess.run( mscore + ["-o", str(wav_path), str(render_src)], check=True, capture_output=True, timeout=300, ) if wav_path.exists(): return str(wav_path) except Exception: pass soundfont = next((sf for sf in SOUNDFONTS if os.path.exists(sf)), None) if shutil.which("fluidsynth") and soundfont: try: subprocess.run( ["fluidsynth", "-ni", soundfont, str(render_src), "-F", str(wav_path), "-r", "44100"], check=True, capture_output=True, timeout=300, ) except Exception: return None return str(wav_path) if wav_path.exists() else None def render_audio(midi_path: Path) -> str | None: """ Render the song preview WAV, muting the harmony outline track. """ no_harmo_path = midi_path.with_suffix(".no_harmo.mid") try: render_src = midi_remove_harmo_outline(midi_path, no_harmo_path) except Exception: render_src = midi_path return _render_midi_to_wav(Path(render_src), midi_path.with_suffix(".wav")) def render_skeleton_audio(skeleton_path: Path) -> str | None: """ Render the harmony skeleton itself, with every track forced to piano (no outline muting here — the skeleton is what we want to hear). """ try: midi = MidiFile(str(skeleton_path)) for inst in midi.instruments: inst.program = 0 # acoustic grand piano inst.is_drum = False piano_path = skeleton_path.with_suffix(".piano.mid") midi.dump(str(piano_path)) except Exception: return None return _render_midi_to_wav(piano_path, piano_path.with_suffix(".wav")) @gpu_decorator def generate_song( skeleton_source: str, midi_file: str | None, apply_filters: bool, variant_label: str, dissonance_averse: bool, hn_weight: float, nn_weight: float, register_decay: bool, forbid_piano: bool, ): out_dir = OUT_ROOT / uuid.uuid4().hex[:8] out_dir.mkdir(parents=True, exist_ok=True) note = "" # 1. The harmony skeleton condition analyze_harmo = False if skeleton_source == SKELETON_SOURCES[0]: bars, note = sample_skeleton(apply_filters) cond = bars else: if not midi_file: raise gr.Error("Please upload a MIDI file for this skeleton source.") cond = truncate_midi_to_bars(Path(midi_file)) if cond != Path(midi_file): note += f"Input capped at the first {BAR_NUM} bars (model limit). " analyze_harmo = skeleton_source == SKELETON_SOURCES[2] if not analyze_harmo and not _has_harmo_outline_track(cond): # Not a skeleton MIDI (no harmony outline track): silently fall # back to analyzing its harmony instead. analyze_harmo = True # 2. Sampling configuration (module-level, as in arch/symph/generator.py) ckpt_name, _ = MODEL_VARIANTS[variant_label] generate.FORBID_PIANO = forbid_piano DissonanceConstrainer.enabled = dissonance_averse DissonanceConstrainer.HN_weight = hn_weight DissonanceConstrainer.NN_weight = nn_weight DissonanceConstrainer.register_decay = register_decay # 3. Generate generator = SymphonyGenerator3D(MusicTensorConverter3D()) model = load_symph_model(ckpt_name) with torch.no_grad(): song_dict = generator.run(model, [cond], group_size=1, analyze_harmo=analyze_harmo) if not song_dict: raise gr.Error("Generation produced no decodable song; please try again.") bars = next(iter(song_dict.values())) song_path = out_dir / "symphonygen_song.mid" export_midi(bars, song_path, harmo_is_cond=True) skeleton_path = out_dir / "harmony_skeleton.mid" export_skeleton_midi(bars, skeleton_path) return song_path, skeleton_path, note def run_generation(*args): """ CPU wrapper: generation on GPU, then audio/piano-roll rendering outside the @spaces.GPU window (saves ZeroGPU quota). """ song_path, skeleton_path, note = generate_song(*args) audio_path = render_audio(song_path) if audio_path is None: note += "Audio preview unavailable (no MuseScore/FluidSynth renderer found)." skeleton_audio = render_skeleton_audio(skeleton_path) skeleton_img = piano_roll_image(skeleton_path, "Harmony skeleton (the condition)") song_img = piano_roll_image(song_path, "Generated orchestral piece") song_file = str(song_path) if PROVIDE_MIDI else None skeleton_file = str(skeleton_path) if PROVIDE_MIDI else None return (audio_path, song_file, skeleton_file, note or "Done.", skeleton_img, song_img, skeleton_audio) def variant_changed(variant_label: str): """ Follow the paper's recommended register decay per variant. """ return gr.update(value=MODEL_VARIANTS[variant_label][1]) with gr.Blocks(title="SymphonyGen Demo") as demo: gr.Markdown( "# 🎻 SymphonyGen\n" "**3D Hierarchical Orchestral Generation with Controllable Harmony Skeleton** (ISMIR 2026)\n\n" "[📄 Paper](https://arxiv.org/abs/2604.25498) · " "[💻 Code](https://github.com/symphonygen/symphonygen) · " "[🤗 Checkpoints](https://huggingface.co/SymphonyGen/SymphonyGen) · " "[🌐 Audio demo page](https://symphonygen.github.io)\n\n" "Generates a 32-bar orchestral piece conditioned on a beat-quantized multi-voice " "harmony skeleton. The skeleton can be sampled from the released harmony model, " "uploaded directly, or analyzed from any MIDI (re-orchestration)." ) with gr.Row(): with gr.Column(): skeleton_source = gr.Radio(SKELETON_SOURCES, value=SKELETON_SOURCES[0], label="Harmony skeleton source") midi_file = gr.File(label=f"Conditioning MIDI (for the upload options; capped at the first {BAR_NUM} bars)", file_types=[".mid", ".midi"], type="filepath") input_roll = gr.Image(label="Input piano roll (harmony skeleton)", interactive=False) input_audio = gr.Audio(label="Skeleton audio preview (piano)") apply_filters = gr.Checkbox(True, label="Curate sampled skeletons with the paper's quality filters (density / repetition / log-probability)") variant = gr.Dropdown(list(MODEL_VARIANTS), value=list(MODEL_VARIANTS)[0], label="Model variant") with gr.Accordion("Dissonance-averse sampling", open=False): dissonance_averse = gr.Checkbox(True, label="Enable") hn_weight = gr.Slider(0.0, 5.0, value=1.0, step=0.5, label="λ_hn (harmonic-vs-non-harmonic clash weight)") nn_weight = gr.Slider(0.0, 20.0, value=10.0, step=1.0, label="λ_nn (non-harmonic-vs-non-harmonic clash weight)") register_decay = gr.Checkbox(True, label="Register-dependent decay (Low Interval Limit)") forbid_piano = gr.Checkbox(True, label="Forbid the piano instrument family") run_btn = gr.Button("Generate", variant="primary") with gr.Column(): audio_out = gr.Audio(label="Audio preview (headless MuseScore 3, as in the paper)") output_roll = gr.Image(label="Output piano roll", interactive=False) song_out = gr.File(label="Generated orchestral MIDI", visible=PROVIDE_MIDI) skeleton_out = gr.File(label="Harmony skeleton MIDI (the condition)", visible=PROVIDE_MIDI) status = gr.Textbox(label="Status", interactive=False) example_midis = sorted(EXAMPLE_DIR.glob("*.mid")) if EXAMPLE_DIR.exists() else [] if example_midis: gr.Examples( examples=[[SKELETON_SOURCES[1], str(p)] for p in example_midis], inputs=[skeleton_source, midi_file], label="Example harmony skeletons (SymphonyNet validation set)", ) # api_visibility="private": endpoints unavailable to gradio_client (UI-only) variant.change(variant_changed, inputs=variant, outputs=register_decay, api_visibility="private") midi_file.change(preview_uploaded_midi, inputs=[skeleton_source, midi_file], outputs=input_roll, api_visibility="private") skeleton_source.change(preview_uploaded_midi, inputs=[skeleton_source, midi_file], outputs=input_roll, api_visibility="private") run_btn.click( run_generation, inputs=[skeleton_source, midi_file, apply_filters, variant, dissonance_averse, hn_weight, nn_weight, register_decay, forbid_piano], outputs=[audio_out, song_out, skeleton_out, status, input_roll, output_roll, input_audio], api_visibility="private", ) if __name__ == "__main__": # No API footer link; one generation at a time (endpoints are private above). demo.queue(max_size=20, default_concurrency_limit=1).launch( footer_links=["gradio", "settings"] )