cpyang's picture
Upload folder using huggingface_hub
7fb1e50 verified
Raw
History Blame Contribute Delete
52.2 kB
"""
backend.py – API layer for the ProGress UI.
Wraps:
• ProGress_Supplement – phrase loading, rejection sampling, scoring, stitching
• SchenkerDiff – (optional) diffusion-model inference for new phrase generation
"""
from __future__ import annotations
import base64
import copy
import json
import os
import random
import sys
import tempfile
from collections import defaultdict
from pathlib import Path
from typing import Any
# ── Path setup ────────────────────────────────────────────────────────────────
# This app is self-contained: its cross-repo dependencies (phrase_stitching/ and
# SchenkerDiff/) are vendored under ./vendor so it can ship as a single package.
# When run in-place inside the original research tree (no ./vendor present), it
# falls back to the sibling ProGress_Supplement/ and SchenkerDiff/ folders.
# Either root can be overridden with the PROGRESS_SUPPLEMENT_DIR /
# PROGRESS_SCHENKER_DIR environment variables.
PKG_DIR = Path(__file__).resolve().parent
if (PKG_DIR / "vendor" / "SchenkerDiff").exists(): # packaged / deployed layout
SUPPLEMENT_DIR = PKG_DIR / "vendor"
SCHENKER_DIR = PKG_DIR / "vendor" / "SchenkerDiff"
else: # original research-tree layout
BASE_DIR = PKG_DIR.parent
SUPPLEMENT_DIR = BASE_DIR / "ProGress_Supplement"
SCHENKER_DIR = BASE_DIR / "SchenkerDiff"
SUPPLEMENT_DIR = Path(os.environ.get("PROGRESS_SUPPLEMENT_DIR", SUPPLEMENT_DIR))
SCHENKER_DIR = Path(os.environ.get("PROGRESS_SCHENKER_DIR", SCHENKER_DIR))
OUTPUT_VIS_DIR = SCHENKER_DIR / "output_vis"
DIFFUSION_OUT = SUPPLEMENT_DIR / "phrase_stitching" / "diffusion_output"
CACHE_FILE = Path(__file__).parent / ".phrase_cache.json"
CHECKPOINT_PATH = SCHENKER_DIR / "last-v1.ckpt"
for _p in [str(SUPPLEMENT_DIR), str(SCHENKER_DIR), str(OUTPUT_VIS_DIR)]:
if _p not in sys.path:
sys.path.insert(0, _p)
# ── ProGress_Supplement imports ───────────────────────────────────────────────
from phrase_stitching.RN_analysis import (
analyze_entire_phrase,
check_bad_counterpoint,
check_bad_mode_mixture,
check_illegal_harmonics_on_integer_beats,
InvalidAnalysisException,
MODES_ROMAN_NUMERALS,
)
from phrase_stitching.stitch import (
combine_two_scores,
extend_last_note_to_fill_measure,
POSSIBLE_STARTS_ENDING_FROM_TONIC,
transpose_score,
)
from phrase_stitching.write_inner_voices import write_inner_voices
from phrase_stitching.config import (
SCORE_INCLUDES_III,
SCORE_INCLUDES_v,
SCORE_MAJOR_AND_MINOR,
)
# ── Structure catalogue ───────────────────────────────────────────────────────
STRUCTURE_NAMES: list[str] = [
"I – V – I (Major)",
"I – IV – V – I (Major)",
"i – III – iv – i (Minor)",
"i – III – V – i (Minor)",
"i – VI – iv – i (Minor)",
]
def structures_for_mode(mode: str) -> list[str]:
"""Harmonic structures whose mode matches the opening phrase.
Names are tagged "(Major)" / "(Minor)"; a major phrase is offered the Major
structures and a minor phrase the Minor ones. A "mixed"/ambiguous phrase
(see `_detect_mode`) gets the full list so it isn't wrongly constrained.
"""
m = str(mode).lower()
if m.startswith("maj"):
tag = "(Major)"
elif m.startswith("min"):
tag = "(Minor)"
else:
return list(STRUCTURE_NAMES)
return [s for s in STRUCTURE_NAMES if tag in s]
# ─────────────────────────────────────────────────────────────────────────────
# Phrase loading & rejection sampling
# ─────────────────────────────────────────────────────────────────────────────
def _quality_score(analysis: list[str]) -> float:
"""Compute a simple quality score from config weights."""
score = 0.0
rn_set = set(analysis)
if "III" in rn_set or "iii" in rn_set:
score += SCORE_INCLUDES_III
if "v" in rn_set:
score += SCORE_INCLUDES_v
major_core = MODES_ROMAN_NUMERALS["major"] - {"V", "viio"}
minor_core = MODES_ROMAN_NUMERALS["minor"] - {"V", "viio"}
if rn_set & major_core and rn_set & minor_core:
score += SCORE_MAJOR_AND_MINOR
return round(score, 3)
def _detect_mode(analysis: list[str]) -> str:
rn_set = set(analysis)
major_core = MODES_ROMAN_NUMERALS["major"] - {"V", "viio"}
minor_core = MODES_ROMAN_NUMERALS["minor"] - {"V", "viio"}
has_major = bool(rn_set & major_core)
has_minor = bool(rn_set & minor_core)
if has_major and not has_minor:
return "major"
if has_minor and not has_major:
return "minor"
return "mixed"
def load_phrases(use_cache: bool = True, progress=None) -> dict[str, Any]:
"""
Load and rejection-sample all phrases from the diffusion_output folder.
Returns a phrases_data dict:
scores – list[music21.Score]
analyses – list[list[str]]
info – list[dict] (metadata per phrase)
starts – dict[str, list[int]] harmony → [phrase_id]
ends – dict[str, list[int]] harmony → [phrase_id]
stats – dict (loaded / rejected / total)
"""
cache_valid_sources: list[dict] | None = None
if use_cache and CACHE_FILE.exists():
try:
with open(CACHE_FILE) as f:
cache = json.load(f)
cache_valid_sources = cache.get("valid", None)
except Exception:
cache_valid_sources = None
scores: list[Any] = []
analyses: list[list[str]] = []
info: list[dict] = []
starts: dict = defaultdict(list)
ends: dict = defaultdict(list)
n_loaded = 0
n_rejected = 0
# ── Cache hit: re-parse XMLs (fast) but skip re-analysis ─────────────────
if cache_valid_sources is not None:
total = len(cache_valid_sources)
for idx, entry in enumerate(cache_valid_sources):
if progress:
progress(idx / max(total, 1), desc=f"Loading from cache ({idx}/{total})…")
xml_path = Path(entry["source"])
if not xml_path.exists():
continue
try:
import music21.converter as _conv
score = _conv.parse(str(xml_path))
except Exception:
continue
analysis = entry["analysis"]
phrase_id = len(scores)
scores.append(score)
analyses.append(analysis)
meta = {
"id": phrase_id,
"start_rn": entry["start_rn"],
"end_rn": entry["end_rn"],
"mode": entry["mode"],
"quality": entry["quality"],
"source": entry["source"],
}
info.append(meta)
starts[entry["start_rn"]].append(phrase_id)
ends[entry["end_rn"]].append(phrase_id)
n_loaded += 1
stats = {"loaded": n_loaded, "rejected": 0, "total": n_loaded, "from_cache": True}
return dict(scores=scores, analyses=analyses, info=info,
starts=starts, ends=ends, stats=stats)
# ── Full load: parse + analyse + reject ───────────────────────────────────
folders = [1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13]
total_files = len(folders) * 100
cache_entries: list[dict] = []
for j in folders:
for i in range(1, 101):
xml_path = DIFFUSION_OUT / f"output_graphs_{j}" / f"output_graph_{i}.xml"
done = (j - 1) * 100 + i
if progress:
progress(done / total_files,
desc=f"Analysing phrase {j}/{i} ({n_loaded} valid, {n_rejected} rejected)…")
if not xml_path.exists():
n_rejected += 1
continue
try:
score, analysis = analyze_entire_phrase(str(xml_path))
check_illegal_harmonics_on_integer_beats(score)
check_bad_mode_mixture(score)
check_bad_counterpoint(score)
except (InvalidAnalysisException, FileNotFoundError, Exception):
n_rejected += 1
continue
start_rn = analysis[0]
end_rn = analysis[-1]
quality = _quality_score(analysis)
mode = _detect_mode(analysis)
phrase_id = len(scores)
scores.append(score)
analyses.append(analysis)
meta = dict(id=phrase_id, start_rn=start_rn, end_rn=end_rn,
mode=mode, quality=quality, source=str(xml_path))
info.append(meta)
starts[start_rn].append(phrase_id)
ends[end_rn].append(phrase_id)
cache_entries.append(dict(
source=str(xml_path), analysis=analysis,
start_rn=start_rn, end_rn=end_rn, mode=mode, quality=quality,
))
n_loaded += 1
# Persist cache
try:
with open(CACHE_FILE, "w") as f:
json.dump({"valid": cache_entries}, f)
except Exception:
pass
stats = {"loaded": n_loaded, "rejected": n_rejected,
"total": n_loaded + n_rejected, "from_cache": False}
return dict(scores=scores, analyses=analyses, info=info,
starts=starts, ends=ends, stats=stats)
# ─────────────────────────────────────────────────────────────────────────────
# Phrase table helpers
# ─────────────────────────────────────────────────────────────────────────────
import pandas as pd
ALL_HARMONIES = sorted({
"I", "i", "ii", "iio", "iii", "III", "IV", "iv",
"V", "v", "vi", "VI", "viio", "VII",
})
def build_phrase_df(info: list[dict],
mode_filter: str = "all",
start_filter: str = "any",
end_filter: str = "any",
selected_ids: set[int] | None = None) -> pd.DataFrame:
rows = []
for p in info:
if mode_filter != "all" and p["mode"] != mode_filter:
continue
if start_filter != "any" and p["start_rn"] != start_filter:
continue
if end_filter != "any" and p["end_rn"] != end_filter:
continue
rows.append({
"ID": p["id"],
"Start": p["start_rn"],
"End": p["end_rn"],
"Mode": p["mode"],
"Quality": p["quality"],
"Fav": "♥" if (selected_ids and p["id"] in selected_ids) else "",
})
return pd.DataFrame(rows, columns=["ID", "Start", "End", "Mode", "Quality", "Fav"])
# ─────────────────────────────────────────────────────────────────────────────
# MIDI / audio helpers
# ─────────────────────────────────────────────────────────────────────────────
# HTML to inject into the document <head> via gr.HTML(head=...).
# Loads html-midi-player + its dependencies once for the whole page.
MIDI_PLAYER_HEAD = (
'<script src="https://cdn.jsdelivr.net/combine/'
'npm/tone@14.7.58,'
'npm/@magenta/music@1.23.1/es6/core.js,'
'npm/focus-visible@5,'
'npm/html-midi-player@1.5.0"></script>'
)
OSMD_HEAD = (
'<script src="https://cdn.jsdelivr.net/npm/'
'opensheetmusicdisplay@1.8.8/build/opensheetmusicdisplay.min.js"></script>'
)
def _rebuild_sites(score):
"""Deep-copy a score to rebuild music21's element-site bookkeeping.
gradio's gr.State pickles the pooled scores on HF Spaces; unpickling leaves
each element's `activeSite` missing from its `siteDict`, so music21 export
raises KeyError(<id>) deep in expandRepeats()/sortTuple(). A fresh deep copy
reconstructs consistent sites. Used as an on-failure retry, so clean scores
(e.g. local, in-memory) pay no extra cost.
"""
import copy
return copy.deepcopy(score)
def score_to_midi_bytes(score) -> bytes:
"""Convert a music21 Score to raw MIDI bytes."""
with tempfile.NamedTemporaryFile(suffix=".mid", delete=False) as f:
tmp = f.name
try:
try:
score.write("midi", fp=tmp)
except Exception:
_rebuild_sites(score).write("midi", fp=tmp)
with open(tmp, "rb") as f:
return f.read()
finally:
try:
os.unlink(tmp)
except OSError:
pass
def midi_player_html(midi_bytes: bytes, height: str = "140px", show_viz: bool = True) -> str:
"""Return an HTML snippet with an embedded MIDI player (html-midi-player).
Layout notes:
* Gradio 6 does NOT execute <script> tags inside gr.HTML values, so the
library must come from gr.HTML(head=…) on the page header; the custom
elements then upgrade automatically when this HTML is inserted.
* The visualizer carries its MIDI in data-vizsrc (not a live src); the
MIDI_VIZ_JS_ON_LOAD handler sets src once, after configuring a fresh
node, so the piano roll renders a single time (Safari loses the
double-render that a connect-time src + later config triggered).
* Each element gets explicit dimensions + display:block so they reserve
their box up-front (avoids the "everything piles on top" pre-upgrade
flash).
"""
b64 = base64.b64encode(midi_bytes).decode()
data_uri = f"data:audio/midi;base64,{b64}"
pid = random.randint(100_000, 999_999)
viz_block = ""
viz_attr = ""
if show_viz:
# height:auto sizes the host to the rendered roll. Keep only a SMALL
# min-height as a pre-upgrade reserve — NOT the caller's full `height`:
# a tall floor made the host taller than a short phrase's roll, and Safari
# centers the SVG in the leftover space (Chrome top-aligns), leaving a big
# gap above the roll on the Browse tab. A small floor lets the host hug
# the SVG, so there's no empty band — and the roll itself is unchanged
# (the SVG keeps its own size; only the reserve padding is removed).
viz_block = (
f'<div style="display:flex;justify-content:center;width:100%;'
f'overflow-x:auto;">'
f'<midi-visualizer type="piano-roll" id="viz{pid}" data-vizsrc="{data_uri}" '
f'style="display:block;height:auto;min-height:40px;'
f'max-width:100%;background:#fff;border-radius:6px;'
f'border:1px solid #e2e8f0;'
f'box-sizing:border-box;overflow:auto;"></midi-visualizer>'
f'</div>'
)
viz_attr = f'visualizer="#viz{pid}"'
player_block = (
f'<midi-player src="{data_uri}" sound-font {viz_attr} '
f'style="display:block;width:100%;min-height:80px;'
f'box-sizing:border-box;'
# The player panel is always light, but its time text inherits the page
# text colour — which is light in dark mode, making the time invisible.
# Pin a fixed dark colour so it stays legible in both themes.
f'color:#1f2937;'
f'--player-background-color:#f8fafc;'
# Play button + seek bar follow the theme accent (claret / terracotta in
# dark) instead of a hardcoded blue. --player-button-color colours the
# play button; the seek <input type=range> lives in the player's shadow
# DOM and ignores it, but accent-color INHERITS through the shadow boundary
# — so set it on the host to recolour the slider too.
f'accent-color:var(--pg-accent);'
f'--player-button-color:var(--pg-accent);"></midi-player>'
)
container = (
'<div style="display:flex;flex-direction:column;gap:8px;width:100%;'
'margin:4px 0;">'
f"{viz_block}{player_block}"
"</div>"
)
return container
def score_to_xml_b64(score) -> str:
"""Convert a music21 Score to a base64-encoded MusicXML string."""
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as f:
tmp = f.name
try:
try:
score.write("musicxml", fp=tmp)
except Exception:
_rebuild_sites(score).write("musicxml", fp=tmp)
with open(tmp, "rb") as f:
return base64.b64encode(f.read()).decode()
finally:
try:
os.unlink(tmp)
except OSError:
pass
def sheet_music_html(score, height: str = "360px") -> str:
"""Return an HTML snippet that renders a music21 Score as sheet music via OSMD.
Gradio 6 does NOT execute <script> tags inside gr.HTML values, so this
emits only a marker <div> carrying the base64 MusicXML in a data attribute.
The actual rendering is done by OSMD_JS_ON_LOAD, which must be passed as
js_on_load= to every gr.HTML component that displays scores.
"""
b64 = score_to_xml_b64(score)
return (
f'<div class="osmd-target" data-osmd="{b64}" '
f'style="width:100%;max-width:900px;margin:0 auto;'
f'min-height:{height};background:#fff;'
f'border-radius:6px;padding:8px;overflow-x:auto;'
f'border:1px solid #e2e8f0;box-sizing:border-box;">'
f'<em style="color:#94a3b8">Rendering score…</em></div>'
)
# js_on_load handler for gr.HTML components that display OSMD scores.
# `element` is provided by Gradio; it is the component's root DOM element.
# Re-renders whenever the component's HTML value changes (MutationObserver).
OSMD_JS_ON_LOAD = """
function ensureOsmd(cb) {
if (window.opensheetmusicdisplay) { cb(); return; }
if (!window.__osmdLoading) {
window.__osmdLoading = true;
var s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/opensheetmusicdisplay@1.8.8/build/opensheetmusicdisplay.min.js';
document.head.appendChild(s);
}
var t = setInterval(function() {
if (window.opensheetmusicdisplay) { clearInterval(t); cb(); }
}, 150);
}
function renderAll() {
element.querySelectorAll('[data-osmd]').forEach(function(el) {
var b64 = el.getAttribute('data-osmd');
if (!b64 || el.getAttribute('data-osmd-done') === b64) return;
el.setAttribute('data-osmd-done', b64);
ensureOsmd(function() {
try {
// Dispose any previous instance on this element so an old piece can't
// redraw over the new one (a source of flicker between pieces).
if (el.__osmd) { try { el.__osmd.clear(); } catch (e) {} el.__osmd = null; }
var bin = atob(b64);
var bytes = new Uint8Array(bin.length);
for (var j = 0; j < bin.length; j++) bytes[j] = bin.charCodeAt(j);
var xml = new TextDecoder('utf-8').decode(bytes);
el.innerHTML = '';
// autoResize:false — each instance otherwise adds a window resize
// listener that is never removed; after several pieces they all fire and
// flicker between scores.
var osmd = new opensheetmusicdisplay.OpenSheetMusicDisplay(el, {
autoResize: false, backend: 'svg', drawTitle: false,
drawComposer: false, drawLyricist: false,
drawPartNames: false, drawPartAbbreviations: false,
});
el.__osmd = osmd;
osmd.load(xml).then(function() {
// Skip if a newer piece replaced this content while we were loading.
if (el.getAttribute('data-osmd-done') === b64 && el.__osmd === osmd) osmd.render();
});
} catch (e) {
el.innerHTML = "<em style='color:#e11d48'>Score render error: " + e + "</em>";
}
});
});
}
renderAll();
// Keep exactly one observer per element even if this handler re-runs on each
// value change (otherwise observers accumulate and re-render N times → flicker).
if (element.__osmdObserver) element.__osmdObserver.disconnect();
element.__osmdObserver = new MutationObserver(renderAll);
element.__osmdObserver.observe(element, { childList: true, subtree: true });
"""
# js_on_load handler for gr.HTML components containing a <midi-visualizer>.
# Two jobs:
# 1. Bump the piano-roll zoom (default rendering is far too small to read).
# 2. Whenever the src changes (user previews a different phrase), swap the
# visualizer for a freshly created element. html-midi-player's
# VisualizerElement calls attachShadow unguarded in connectedCallback, so
# a reused/reattached node breaks and keeps showing the old piano roll;
# a brand-new node always initializes cleanly.
MIDI_VIZ_JS_ON_LOAD = """
function refreshViz() {
element.querySelectorAll('midi-visualizer').forEach(function(v) {
var src = v.getAttribute('src') || v.getAttribute('data-vizsrc') || '';
if (!src || v.__vizSrc === src) return;
// Build the fresh node WITHOUT src so it connects (and attaches its shadow
// root) cleanly, then set config and src LAST so the roll renders exactly
// once, already configured. Copying src up-front made the element start a
// render on connect AND render again when config was set — a double-render
// that Safari/WebKit intermittently loses, leaving a blank piano roll.
// Compose runs this once per piece so it never showed there; Browse
// re-creates the visualizer on every row click, which is where it bit.
var fresh = document.createElement('midi-visualizer');
for (var i = 0; i < v.attributes.length; i++) {
if (v.attributes[i].name === 'src') continue;
fresh.setAttribute(v.attributes[i].name, v.attributes[i].value);
}
fresh.__vizSrc = src;
v.replaceWith(fresh);
customElements.whenDefined('midi-visualizer').then(function() {
fresh.config = { noteHeight: 8, pixelsPerTimeStep: 60 };
fresh.setAttribute('src', src); // render once, already configured
var id = fresh.getAttribute('id');
if (!id) return;
var p = element.querySelector('midi-player[visualizer="#' + id + '"]');
if (p) customElements.whenDefined('midi-player').then(function() {
p.setAttribute('visualizer', '#' + id);
});
});
});
}
refreshViz();
if (element.__vizObserver) element.__vizObserver.disconnect();
element.__vizObserver = new MutationObserver(refreshViz);
element.__vizObserver.observe(element,
{ childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
"""
# ─────────────────────────────────────────────────────────────────────────────
# Phrase stitching
# ─────────────────────────────────────────────────────────────────────────────
def _candidates(starts: dict, ends: dict, via_key: str, end_key: str) -> list[int]:
"""
Collect phrase IDs whose start harmony is compatible with the given
key-change pivot and whose end harmony matches end_key.
"""
possible_starts = POSSIBLE_STARTS_ENDING_FROM_TONIC.get(via_key, [])
end_set = set(ends.get(end_key, []))
return [pid for k in possible_starts for pid in starts.get(k, []) if pid in end_set]
def _sample_three_distinct(
pools: list[list[int]],
preferred: set[int] | None,
exclude: set[int] | None = None,
max_tries: int = 3000,
) -> list[int]:
"""Draw one ID from each of three pools; all distinct and not in `exclude`."""
exclude = exclude or set()
def draw(pool: list[int]) -> int:
if preferred:
pref_pool = [x for x in pool if x in preferred and x not in exclude]
if pref_pool:
return random.choice(pref_pool)
any_pool = [x for x in pool if x not in exclude]
if not any_pool:
return random.choice(pool)
return random.choice(any_pool)
for _ in range(max_tries):
picks = [draw(p) for p in pools]
if len(set(picks)) == 3 and not (set(picks) & exclude):
return picks
raise ValueError(
"Cannot find 3 distinct phrases for middle/middle-2/end (distinct from "
"the locked beginning). Generate more phrases or pick a different starting one."
)
def _sample_four_distinct(
pools: list[list[int]],
preferred: set[int] | None,
max_tries: int = 3000,
) -> list[int]:
"""
Draw one ID from each of the four pools so all four are distinct,
preferring IDs that appear in `preferred` where possible.
"""
def draw(pool: list[int]) -> int:
if preferred:
pref_pool = [x for x in pool if x in preferred]
if pref_pool:
return random.choice(pref_pool)
return random.choice(pool)
for _ in range(max_tries):
picks = [draw(p) for p in pools]
if len(set(picks)) == 4:
return picks
raise ValueError(
"Cannot find 4 distinct phrases for all sections. "
"Try loading more phrases or picking a different structure."
)
def _realize(scores: list, analyses: list, pid: int, semitones: int = 0):
"""Deep-copy phrase `pid`, extend, fill inner voices, optionally transpose."""
s = copy.deepcopy(scores[pid])
a = list(analyses[pid])
s, a = extend_last_note_to_fill_measure(s, a)
write_inner_voices(s, a)
if semitones:
s = transpose_score(s, semitones)
return s
def _require(pool: list[int], label: str) -> None:
if not pool:
raise ValueError(
f"No valid phrases found for section '{label}'. "
"Make sure all phrases have been loaded."
)
def format_satb(score):
"""
Re-lay-out a stitched 4-part score in SATB order with register-appropriate
clefs.
The stitched score's parts arrive as (melody, bass, inner1, inner2), with
the inner voices notated in treble clef regardless of register, and each
section reprinting its own clef/meter/tempo. This sorts the inner voices
into alto/tenor by average pitch, orders parts S-A-T-B, gives each voice a
clef that fits its register, and keeps only the opening meter/tempo marks.
"""
from music21 import clef as m21clef
from music21 import meter as m21meter
from music21 import tempo as m21tempo
from music21.stream import Score as M21Score
parts = list(score.parts)
if len(parts) < 4:
return score
def avg_midi(p):
pitches = [n.pitch.midi for n in p.recurse().notes if hasattr(n, "pitch")]
return sum(pitches) / len(pitches) if pitches else 60.0
soprano, bass = parts[0], parts[1]
inners = sorted(parts[2:4], key=avg_midi, reverse=True) # higher = alto
ordered = [soprano, inners[0], inners[1], bass]
for p in ordered:
# One clef per part, chosen by register; drop all section reprints.
for old in list(p.recurse().getElementsByClass(m21clef.Clef)):
old.activeSite.remove(old)
a = avg_midi(p)
if a >= 60:
new_clef = m21clef.TrebleClef()
elif a >= 50:
new_clef = m21clef.Treble8vbClef()
else:
new_clef = m21clef.BassClef()
target = p.measure(1) if p.hasMeasures() else p
(target if target is not None else p).insert(0, new_clef)
# Keep only the first time signature / tempo mark.
for cls in (m21meter.TimeSignature, m21tempo.MetronomeMark):
seen = False
for el in list(p.recurse().getElementsByClass(cls)):
if seen:
el.activeSite.remove(el)
seen = True
out = M21Score()
for p in ordered:
out.append(p)
return out
# Each stitch function returns (final_score, [beg_id, mid_id, mid2_id, end_id]).
def _stitch_generic(
starts: dict, ends: dict, scores: list, analyses: list,
beg_key: str,
mid_via: str, mid_end: str, mid_semitones: int,
mid2_via: str, mid2_end: str, mid2_semitones: int,
end_via: str, end_end: str,
preferred: set[int] | None,
fixed_beg: int | None = None,
) -> tuple:
mid_pool = _candidates(starts, ends, mid_via, mid_end)
mid2_pool = _candidates(starts, ends, mid2_via, mid2_end)
end_pool = _candidates(starts, ends, end_via, end_end)
_require(mid_pool, f"middle ({mid_via}{mid_end})")
_require(mid2_pool, f"middle 2 ({mid2_via}{mid2_end})")
_require(end_pool, f"end ({end_via}{end_end})")
if fixed_beg is not None:
b = fixed_beg
# Sample the other three so all four IDs are distinct.
m, m2, e = _sample_three_distinct(
[mid_pool, mid2_pool, end_pool], preferred, exclude={b},
)
else:
beg_pool = ends.get(beg_key, [])
_require(beg_pool, f"beginning ({beg_key})")
b, m, m2, e = _sample_four_distinct(
[beg_pool, mid_pool, mid2_pool, end_pool], preferred,
)
parts = [
_realize(scores, analyses, b),
_realize(scores, analyses, m, mid_semitones),
_realize(scores, analyses, m2, mid2_semitones),
_realize(scores, analyses, e),
]
final = parts[0]
for p in parts[1:]:
final = combine_two_scores(final, p)
return final, [b, m, m2, e]
def stitch_I_V_I(starts, ends, scores, analyses, preferred=None, fixed_beg=None):
return _stitch_generic(
starts, ends, scores, analyses,
beg_key="I",
mid_via="V", mid_end="I", mid_semitones=-5,
mid2_via="I", mid2_end="I", mid2_semitones=-5,
end_via="IV", end_end="I",
preferred=preferred, fixed_beg=fixed_beg,
)
def stitch_I_IV_V_I(starts, ends, scores, analyses, preferred=None, fixed_beg=None):
return _stitch_generic(
starts, ends, scores, analyses,
beg_key="I",
mid_via="IV", mid_end="I", mid_semitones=5,
mid2_via="ii", mid2_end="i", mid2_semitones=7,
end_via="IV", end_end="I",
preferred=preferred, fixed_beg=fixed_beg,
)
def stitch_i_III_iv_i(starts, ends, scores, analyses, preferred=None, fixed_beg=None):
return _stitch_generic(
starts, ends, scores, analyses,
beg_key="i",
mid_via="III", mid_end="I", mid_semitones=3,
mid2_via="ii", mid2_end="i", mid2_semitones=5,
end_via="V", end_end="i",
preferred=preferred, fixed_beg=fixed_beg,
)
def stitch_i_III_V_i(starts, ends, scores, analyses, preferred=None, fixed_beg=None):
return _stitch_generic(
starts, ends, scores, analyses,
beg_key="i",
mid_via="III", mid_end="I", mid_semitones=3,
mid2_via="iii", mid2_end="i", mid2_semitones=7,
end_via="IV", end_end="i",
preferred=preferred, fixed_beg=fixed_beg,
)
def stitch_i_VI_iv_i(starts, ends, scores, analyses, preferred=None, fixed_beg=None):
return _stitch_generic(
starts, ends, scores, analyses,
beg_key="i",
mid_via="VI", mid_end="I", mid_semitones=-4,
mid2_via="vi", mid2_end="i", mid2_semitones=-7,
end_via="V", end_end="i",
preferred=preferred, fixed_beg=fixed_beg,
)
_STITCH_FNS = {
"I – V – I (Major)": stitch_I_V_I,
"I – IV – V – I (Major)": stitch_I_IV_V_I,
"i – III – iv – i (Minor)": stitch_i_III_iv_i,
"i – III – V – i (Minor)": stitch_i_III_V_i,
"i – VI – iv – i (Minor)": stitch_i_VI_iv_i,
}
def run_stitch(
structure: str,
phrases_data: dict,
selected_ids: set[int] | None = None,
fixed_beg: int | None = None,
) -> tuple:
"""
Run phrase stitching for the chosen structure.
fixed_beg – if set, lock the beginning section to this phrase ID instead of
sampling. Used when the user has picked a specific I-starting
phrase that should anchor the piece.
Returns (final_score, [beg_id, mid_id, mid2_id, end_id]).
"""
fn = _STITCH_FNS[structure]
# Prefer high-quality phrases (non-zero quality = have III/v/mode mixture).
high_quality = {p["id"] for p in phrases_data["info"] if p["quality"] > 0}
preferred = (set(selected_ids) if selected_ids else set()) | high_quality
return fn(
phrases_data["starts"],
phrases_data["ends"],
phrases_data["scores"],
phrases_data["analyses"],
preferred=preferred or None,
fixed_beg=fixed_beg,
)
# ─────────────────────────────────────────────────────────────────────────────
# SchenkerDiff generation (optional – requires checkpoint + GPU deps)
# ─────────────────────────────────────────────────────────────────────────────
def checkpoint_available() -> bool:
return CHECKPOINT_PATH.exists()
def device_info() -> str:
"""Human-readable compute device for display in the UI.
Reports 'GPU · <name>' when CUDA is available, else 'CPU'. Querying
cuda.is_available()/get_device_name does not initialise a CUDA context,
so this is cheap to call at startup.
"""
# On a ZeroGPU Space the GPU only exists inside @spaces.GPU calls; touching
# torch.cuda in the main process triggers a forbidden low-level CUDA init.
# Detect ZeroGPU via the `spaces` package FIRST and avoid torch.cuda there.
try:
import spaces # noqa: F401
return "ZeroGPU (attached on demand)"
except Exception:
pass
try:
import torch
if torch.cuda.is_available():
return f"GPU · {torch.cuda.get_device_name(0)}"
except Exception:
pass
return "CPU"
# ─── Module-level cache for SchenkerDiff inference ───────────────────────────
# Loading the checkpoint is slow (~5 s). We keep the model + helpers in memory
# so successive batches in generate_until_target reuse them.
_GEN_CACHE: dict = {}
# When True, generation is pinned to CPU regardless of what torch.cuda reports.
# Needed for the ZeroGPU CPU-fallback path: ZeroGPU's emulated torch.cuda says
# a GPU is available even in the main process, so acting on it there would
# trigger a forbidden low-level CUDA init. The fallback sets this before
# retrying on CPU.
_FORCE_CPU: bool = False
def _available_processed_idxs() -> list[int]:
"""Indices of processed .pt files actually on disk (for varied conditioning)."""
pdir = SCHENKER_DIR / "data/schenker/processed/heterdatacleaned/processed"
idxs = []
if not pdir.exists():
return idxs
for f in pdir.glob("*_processed.pt"):
try:
idxs.append(int(f.stem.split("_")[0]))
except ValueError:
continue
return sorted(idxs)
def _ensure_generation_setup(progress=None) -> dict:
"""Load (or return cached) SchenkerDiff model + helpers. Idempotent."""
if _GEN_CACHE.get("ready"):
return _GEN_CACHE
if not checkpoint_available():
raise RuntimeError(
f"SchenkerDiff checkpoint not found at {CHECKPOINT_PATH}.\n"
"Place last-v1.ckpt in the SchenkerDiff/ folder."
)
if progress:
progress(0.0, desc="Initialising SchenkerDiff (first-time setup)…")
# ── Workaround stubs for SchenkerDiff inference ─────────────────────────
# 1. graph_tool is only used by training-time evaluation metrics that we
# never invoke at inference, but its C++ binary may conflict with the
# env's libgomp. Stub it.
import types as _types
if "graph_tool" not in sys.modules:
_gt = _types.ModuleType("graph_tool")
_gt.all = _types.ModuleType("graph_tool.all")
sys.modules["graph_tool"] = _gt
sys.modules["graph_tool.all"] = _gt.all
# 2. PlanarSamplingMetrics is pickled into the checkpoint's module tree,
# so the stub must subclass nn.Module for unpickling to walk it.
if "src.analysis.spectre_utils" not in sys.modules:
import torch.nn as _nn
_spec_stub = _types.ModuleType("src.analysis.spectre_utils")
class _NoOpMetrics(_nn.Module):
def __init__(self, *a, **kw):
super().__init__()
def reset(self): pass
def forward(self, *a, **kw): return {}
_spec_stub.PlanarSamplingMetrics = _NoOpMetrics
sys.modules["src.analysis.spectre_utils"] = _spec_stub
old_dir = os.getcwd()
os.chdir(SCHENKER_DIR)
import torch
import torch.nn.functional as F
# 3. torch.load fixes, applied on both CPU and GPU:
# (a) torch>=2.6 flipped the weights_only default to True, which rejects
# the Lightning checkpoint's pickled config objects (omegaconf
# DictConfig, etc.). We trust our own checkpoint, so force False.
# (b) When no GPU is present, PL passes map_location=None, so default the
# load to CPU.
_orig_torch_load = torch.load
# _FORCE_CPU wins over the (possibly emulated) cuda.is_available() so the
# CPU-fallback path never initialises CUDA in the ZeroGPU main process.
use_cuda = (not _FORCE_CPU) and torch.cuda.is_available()
_no_cuda = not use_cuda
_cpu_dev = torch.device("cpu")
def _patched_load(f, *a, **kw):
kw["weights_only"] = False
if _no_cuda and kw.get("map_location") is None:
kw["map_location"] = _cpu_dev
return _orig_torch_load(f, *a, **kw)
torch.load = _patched_load
try:
from inference import initialize_model
from src.diffusion import diffusion_utils
from src.datasets.schenker_dataset import SchenkerDiffHeteroGraphData
from realization import realization # output_vis/realization.py
if progress:
progress(0.5, desc="Loading checkpoint…")
model = initialize_model()
# Pin the model + all generation tensors to one device chosen *now*.
# Under ZeroGPU, CUDA only becomes available inside the @spaces.GPU call
# (after import time), so we can't rely on the import-time config.DEVICE.
dev = torch.device("cuda") if use_cuda else _cpu_dev
model = model.to(dev)
edim = int(model.limit_dist.E.shape[0])
_GEN_CACHE.update(dict(
ready=True,
model=model,
edim=edim,
DEVICE=dev,
torch=torch,
F=F,
diffusion_utils=diffusion_utils,
HeteroData=SchenkerDiffHeteroGraphData,
realization=realization,
schenker_dir=str(SCHENKER_DIR),
available_idxs=_available_processed_idxs(),
))
if progress:
progress(1.0, desc=f"Model ready (Edim={edim}, device={dev}).")
return _GEN_CACHE
finally:
# Intentionally leave torch.load patched (weights_only=False) for the rest
# of the session: later loads of our own trusted .pt conditioning files
# (see _local_sample_r_E) also need it under torch>=2.6. All files loaded
# by this app are produced by us, so disabling the weights-only guard is safe.
os.chdir(old_dir)
def _local_sample_r_E(batch_size: int, edim: int, idxs: list[int]):
"""
Replacement for inference.sample_r_E that
• reads the actual edge_attr width from the model (Edim, e.g. 30),
• samples random idx values from the available .pt files so each
batch element gets *different* rhythm / edge conditioning.
"""
cache = _GEN_CACHE
torch_, F, HD = cache["torch"], cache["F"], cache["HeteroData"]
E_list, r_list, name_list, node_sizes = [], [], [], []
pool = idxs or [1]
proc_dir = SCHENKER_DIR / "data" / "schenker" / "processed" / "heterdatacleaned" / "processed"
for _ in range(batch_size):
idx = random.choice(pool)
fp = str(proc_dir / f"{idx}_processed.pt")
data_dict = torch_.load(fp)
data = HD.hetero_to_data(data_dict)
m = data.x.shape[0]
E_sample = torch_.zeros((m, m, edim))
for i in range(data.edge_index.shape[1]):
u = data.edge_index[0, i].item()
v = data.edge_index[1, i].item()
if u < m and v < m:
E_sample[u, v, :] = data.edge_attr[i, :edim]
dr = data.r.shape[1]
r_sample = torch_.zeros((m, dr))
r_sample[:m, :] = data.r[:m, :]
E_list.append(E_sample)
r_list.append(r_sample)
name_list.append(data_dict["name"])
node_sizes.append(m)
max_nodes = max(t.shape[0] for t in r_list)
E_pad = [F.pad(e, (0, 0, 0, max_nodes - e.shape[0], 0, max_nodes - e.shape[0])) for e in E_list]
r_pad = [F.pad(r, (0, 0, 0, max_nodes - r.shape[0])) for r in r_list]
return torch_.stack(E_pad, dim=0), torch_.stack(r_pad, dim=0), name_list, node_sizes
def _generate_one_batch(batch_size: int, progress=None, prog_lo=0.0, prog_hi=1.0) -> list[dict]:
"""Run one diffusion batch. Returns list of phrase dicts that passed rejection."""
cache = _ensure_generation_setup(progress=progress)
torch_ = cache["torch"]
model = cache["model"]
edim = cache["edim"]
DEVICE = cache["DEVICE"]
diff_u = cache["diffusion_utils"]
realization = cache["realization"]
span = prog_hi - prog_lo
def _p(frac, desc):
if progress:
progress(prog_lo + span * frac, desc=desc)
_p(0.02, "Sampling conditioning data…")
E, r, names, n_nodes_list = _local_sample_r_E(batch_size, edim, cache["available_idxs"])
num_nodes = torch_.tensor([int(x) for x in n_nodes_list]).to(model.device)
n_max = torch_.max(num_nodes).item()
arange = torch_.arange(n_max, device=model.device).unsqueeze(0).expand(batch_size, -1)
node_mask = arange < num_nodes.unsqueeze(1)
z_T = diff_u.sample_discrete_feature_noise(limit_dist=model.limit_dist, node_mask=node_mask)
X, _, y = z_T.X, z_T.E, z_T.y
E_t = E.permute(0, 2, 1, 3)
E = torch_.maximum(E, E_t).to(DEVICE)
r = r.to(DEVICE)
_p(0.05, "Running diffusion (100 steps)…")
for s_int in reversed(range(0, model.T)):
s_arr = s_int * torch_.ones((batch_size, 1)).type_as(y)
t_arr = s_arr + 1
sampled_s, _ = model.sample_p_zs_given_zt(s_arr / model.T, t_arr / model.T,
X, E, r, y, node_mask)
X, _, y = sampled_s.X, sampled_s.E, sampled_s.y
if s_int % 20 == 0:
_p(0.05 + 0.85 * (1 - s_int / model.T), f"Diffusion step {model.T - s_int}/{model.T}")
sampled_s = sampled_s.mask(node_mask, collapse=True)
X, _, y = sampled_s.X, sampled_s.E, sampled_s.y
E, _ = model.apply_node_mask_E_r(E, r, node_mask)
_p(0.92, "Realising + rejection sampling…")
tmp_dir = Path(tempfile.mkdtemp())
new_phrases = []
for i in range(batch_size):
n = num_nodes[i].item()
X_i = X[i, :n].cpu().numpy()
r_i = r[i, :n, :].cpu().numpy()
out_xml = str(tmp_dir / f"gen_{i}.xml")
try:
realization(X_i, r_i, output_file=out_xml, num_voices=2)
score, analysis = analyze_entire_phrase(out_xml)
check_illegal_harmonics_on_integer_beats(score)
check_bad_mode_mixture(score)
check_bad_counterpoint(score)
new_phrases.append({
"score": score,
"analysis": analysis,
"start_rn": analysis[0],
"end_rn": analysis[-1],
"mode": _detect_mode(analysis),
"quality": _quality_score(analysis),
"source": out_xml,
})
except (InvalidAnalysisException, Exception):
continue
_p(1.0, f"Batch done: {len(new_phrases)}/{batch_size} passed filters")
return new_phrases
def empty_phrases_data() -> dict:
"""Fresh, empty phrases_data dict (same shape as load_phrases output)."""
return dict(
scores=[], analyses=[], info=[],
starts=defaultdict(list), ends=defaultdict(list),
stats=dict(loaded=0, rejected=0, total=0, from_cache=False),
)
def _append_phrase(pool: dict, p: dict) -> None:
"""Append one generated phrase dict to a pool (mutates pool)."""
pid = len(pool["scores"])
pool["scores"].append(p["score"])
pool["analyses"].append(p["analysis"])
pool["info"].append({
"id": pid,
"start_rn": p["start_rn"],
"end_rn": p["end_rn"],
"mode": p["mode"],
"quality": p["quality"],
"source": p["source"],
})
pool["starts"][p["start_rn"]].append(pid)
pool["ends"][p["end_rn"]].append(pid)
def load_pregenerated(pool: dict | None = None, progress=None) -> dict:
"""
Load the pre-generated phrases shipped in ProGress_Supplement
(phrase_stitching/diffusion_output) and merge them into `pool`.
Works without the SchenkerDiff checkpoint, so the demo can run on the
bundled phrase set alone. Returns the (new or mutated) phrases_data dict.
"""
data = load_phrases(use_cache=True, progress=progress)
if pool is None or not pool.get("scores"):
return data
existing_sources = {p.get("source") for p in pool["info"]}
for i, score in enumerate(data["scores"]):
info = data["info"][i]
if info["source"] in existing_sources:
continue
_append_phrase(pool, {
"score": score,
"analysis": data["analyses"][i],
"start_rn": info["start_rn"],
"end_rn": info["end_rn"],
"mode": info["mode"],
"quality": info["quality"],
"source": info["source"],
})
pool["stats"]["loaded"] = len(pool["scores"])
return pool
def _gpu_decorator(duration: int = 120):
"""@spaces.GPU on a ZeroGPU Space; a no-op decorator everywhere else.
On ZeroGPU a GPU is attached only for the duration of the decorated call,
which is why the model is loaded lazily *inside* generation. Locally and on
CPU/standard-GPU Spaces the `spaces` package is absent and this is a no-op.
"""
try:
import spaces
return spaces.GPU(duration=duration)
except Exception:
return lambda fn: fn
def _run_generation(
target: int,
batch_size: int,
max_attempts_factor: int,
progress,
pool: dict | None,
) -> dict:
"""Core generation loop. Device is chosen in _ensure_generation_setup."""
if pool is None:
pool = empty_phrases_data()
attempts = 0
max_attempts = target * max_attempts_factor
batch_idx = 0
while len(pool["scores"]) < target and attempts < max_attempts:
n_have = len(pool["scores"])
if progress:
progress(
n_have / max(target, 1),
desc=f"Batch {batch_idx + 1}{n_have}/{target} valid so far (attempted {attempts})",
)
try:
batch = _generate_one_batch(batch_size)
except Exception as exc:
# Let device/GPU failures propagate so the caller can fall back to
# CPU; swallow only benign per-batch errors (e.g. a bad realisation).
if any(s in str(exc).lower()
for s in ("cuda", "gpu", "device-side", "out of memory", "nvml", "zerogpu")):
raise
if progress:
progress(n_have / max(target, 1),
desc=f"Batch {batch_idx + 1} failed: {exc}")
break
for p in batch:
_append_phrase(pool, p)
attempts += batch_size
batch_idx += 1
pool["stats"] = dict(
loaded=len(pool["scores"]),
rejected=max(attempts - len(pool["scores"]), 0),
total=attempts,
from_cache=False,
)
if progress:
progress(
1.0,
desc=f"Done – {pool['stats']['loaded']} valid phrases from {attempts} attempts",
)
return pool
@_gpu_decorator(duration=120)
def _run_generation_gpu(target, batch_size, max_attempts_factor, progress, pool):
return _run_generation(target, batch_size, max_attempts_factor, progress, pool)
def generate_until_target(
target: int = 100,
batch_size: int = 16,
max_attempts_factor: int = 5,
progress=None,
pool: dict | None = None,
) -> dict:
"""
Generate SchenkerDiff phrases in batches until `target` valid (post-rejection)
phrases are in the pool, or `target * max_attempts_factor` samples have been
attempted.
Runs on GPU via ZeroGPU's @spaces.GPU when available; if the GPU can't be
acquired or fails mid-run, transparently falls back to CPU.
Returns the (mutated or new) phrases_data dict.
"""
global _FORCE_CPU
try:
return _run_generation_gpu(target, batch_size, max_attempts_factor, progress, pool)
except Exception as gpu_err:
# GPU unavailable / failed → retry on CPU. Pin to CPU first: under
# ZeroGPU the main process must never touch CUDA (emulated is_available()
# reports True), so _FORCE_CPU stops setup from initialising the GPU here.
import traceback
print("GPU generation failed; falling back to CPU:\n" + traceback.format_exc(),
file=sys.stderr, flush=True)
_GEN_CACHE.clear()
_FORCE_CPU = True
if progress:
try:
progress(0.0, desc=f"GPU unavailable ({type(gpu_err).__name__}); running on CPU…")
except Exception:
pass
try:
return _run_generation(target, batch_size, max_attempts_factor, progress, pool)
finally:
_FORCE_CPU = False
# Keep the old function name as a thin wrapper for back-compat.
def generate_new_phrases(batch_size: int = 4, progress=None) -> list[dict]:
return _generate_one_batch(batch_size, progress=progress)
def append_generated_phrases(phrases_data: dict, new_phrases: list[dict]) -> dict:
"""Merge newly generated phrases into an existing phrases_data dict."""
for entry in new_phrases:
pid = len(phrases_data["scores"])
phrases_data["scores"].append(entry["score"])
phrases_data["analyses"].append(entry["analysis"])
meta = {
"id": pid,
"start_rn": entry["start_rn"],
"end_rn": entry["end_rn"],
"mode": entry["mode"],
"quality": entry["quality"],
"source": entry["source"],
}
phrases_data["info"].append(meta)
phrases_data["starts"][entry["start_rn"]].append(pid)
phrases_data["ends"][entry["end_rn"]].append(pid)
return phrases_data