""" 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
via gr.HTML(head=...). # Loads html-midi-player + its dependencies once for the whole page. MIDI_PLAYER_HEAD = ( '' ) OSMD_HEAD = ( '' ) 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(