cpyang's picture
Upload folder using huggingface_hub
7fb1e50 verified
Raw
History Blame Contribute Delete
75.5 kB
"""app.py – ProGress Music Generation Demo"""
from __future__ import annotations
# ZeroGPU: import `spaces` before torch so its CUDA-emulation hooks install
# first. No-op when not on ZeroGPU; absent (and skipped) in local dev.
try:
import spaces # noqa: F401
except Exception:
pass
import base64
import json
import os
# Disable Gradio's SSR (Node-proxy) mode everywhere, including Hugging Face which
# enables it by default. Under SSR the frontend can't build file URLs, so audio
# sources collapse to bare relative paths and the survey samples fail to play.
# Set before gradio reads it; reinforced by ssr_mode=False on launch() below.
os.environ["GRADIO_SSR_MODE"] = "False"
import random
import sys
import tempfile
from pathlib import Path
import gradio as gr
import pandas as pd
sys.path.insert(0, str(Path(__file__).parent))
import backend
# ── CSS ───────────────────────────────────────────────────────────────────────
CSS = """
/* Fonts (Fraunces + Inter) are loaded via a <link> in the document head (see
FONTS_HEAD) — NOT an @import here. Gradio injects this CSS through a
constructed stylesheet, where @import is disallowed and silently dropped
("@import rules are not allowed here"), so the web fonts never loaded. */
:root {
/* Warm "manuscript" palette — ink on paper with a restrained claret accent. */
--pg-ink: #221a10; /* headings / primary text */
--pg-body: #463a2c; /* body text */
--pg-muted: #8a7b66; /* secondary / captions */
--pg-line: #e7dcc6; /* hairline borders */
--pg-line-soft: #f1ead9; /* very light dividers */
--pg-surface: rgba(255, 253, 247, .66); /* frosted panel (translucent) */
--pg-surface-solid: #fdfaf3; /* opaque variant — menus/popovers */
--pg-tint: #faf5ea; /* page / subtle paper fill */
--pg-code: #fcf8f0; /* code panels — soft warm, a hair lighter than page */
--pg-accent: #8a2b2b; /* claret */
--pg-accent-dk: #6e2020;
--pg-field-border: #ddd1ba; /* inputs / secondary buttons */
--pg-hover: #f3ecda; /* secondary button hover */
--pg-row-hover: #f5ecdd; /* table row hover */
--pg-shadow: 0 1px 2px rgba(45,30,10,.04), 0 1px 3px rgba(45,30,10,.05);
--pg-shadow-md: 0 6px 20px rgba(45,30,10,.10);
--pg-serif: 'Fraunces', Georgia, 'Times New Roman', serif;
}
/* Dark theme = "candlelit manuscript": warm dark walnut, cream ink, a terracotta
accent. Gradio adds `.dark` to the app root, so redefining the palette here
flips every --pg-* surface/text; Gradio's own components follow their built-in
dark theme, so the two stay coherent. */
.dark {
--pg-ink: #f4ecdb;
--pg-body: #ddd0bb;
--pg-muted: #a8997f;
--pg-line: #3a3024;
--pg-line-soft: #2a2219;
--pg-surface: rgba(34, 27, 19, .55);
--pg-surface-solid: #241d15;
--pg-tint: #17120b;
--pg-code: #211a12;
--pg-accent: #d98b6a; /* warm terracotta pops on dark */
--pg-accent-dk: #c4734f;
--pg-field-border: #4a3d2d;
--pg-hover: #2c241a;
--pg-row-hover: #2e2519;
--pg-shadow: 0 1px 2px rgba(0,0,0,.35), 0 1px 3px rgba(0,0,0,.45);
--pg-shadow-md: 0 6px 20px rgba(0,0,0,.55);
}
/* ── Recolor Gradio's own blue accents to claret ───────────────────────────
The radios, checkboxes, sliders, the active workflow tab, links and the pale
label chips are driven by the theme's *primary* hue (blue) plus a few
component vars — NOT by our --pg-* tokens — so they stayed blue. Redefining
the primary ramp + those vars recolors them all in one place. (The theme
itself can't be .set()-customized — this Gradio build crashes on that — so the
override lives here in CSS, which Gradio injects after its own theme rules.) */
:root {
--primary-50: #fbf3f3; --primary-100: #f6e1e1; --primary-200: #ecc6c6;
--primary-300: #d99e9e; --primary-400: #c16c6c; --primary-500: #a23c3c;
--primary-600: #8a2b2b; --primary-700: #6e2020; --primary-800: #5a1c1c;
--primary-900: #4a1919; --primary-950: #2c0e0e;
--color-accent: #8a2b2b; --color-accent-soft: #f6e1e1;
--slider-color: #8a2b2b;
--checkbox-background-color-selected: #8a2b2b;
--checkbox-border-color-selected: #8a2b2b;
--block-label-text-color: #8a2b2b; --block-title-text-color: #8a2b2b;
--block-label-background-fill: #f6e1e1; --block-title-background-fill: #f6e1e1;
--link-text-color: #8a2b2b; --link-text-color-hover: #6e2020;
--link-text-color-active: #6e2020; --link-text-color-visited: #6e2020;
}
/* Dark mode: Gradio injects its (blue) dark palette AFTER this CSS, so these need
!important to win. The label chips also use --block-(label|title)-background-
fill, a hardcoded blue in the dark theme — override those too. */
.dark {
--primary-50: #2c1812 !important; --primary-100: #3a201a !important; --primary-200: #4e2c22 !important;
--primary-300: #6e3e30 !important; --primary-400: #9a5a45 !important; --primary-500: #c4734f !important;
--primary-600: #d98b6a !important; --primary-700: #e3a486 !important; --primary-800: #ecbda6 !important;
--primary-900: #f3d3c4 !important; --primary-950: #f9e8df !important;
--color-accent: #d98b6a !important; --color-accent-soft: #3a201a !important;
--slider-color: #d98b6a !important;
--checkbox-background-color-selected: #d98b6a !important;
--checkbox-border-color-selected: #d98b6a !important;
--block-label-background-fill: #3a201a !important; --block-title-background-fill: #3a201a !important;
--block-label-text-color: #e0a184 !important; --block-title-text-color: #e0a184 !important;
--link-text-color: #e0a184 !important; --link-text-color-hover: #ecbda6 !important;
--link-text-color-active: #ecbda6 !important; --link-text-color-visited: #ecbda6 !important;
}
/* Element-level fallbacks for accents whose variable plumbing varies by build:
the selected workflow tab (text + underline) and inline links. */
.tab-nav button.selected, .tab-container button.selected,
button[role="tab"].selected, button[role="tab"][aria-selected="true"] {
color: var(--pg-accent) !important;
border-bottom-color: var(--pg-accent) !important;
}
.gradio-container a:not(.button) { color: var(--pg-accent) !important; }
.gradio-container a:not(.button):hover { color: var(--pg-accent-dk) !important; }
/* ── Warm, translucent panels for Gradio's own surfaces ─────────────────────
Drive every block / input / radio group / accordion / slider / table through
Gradio's surface + border vars so none stay stark white or Gradio grey. A
frosted (translucent) fill lets the warm page show through; borders are warm
hairlines. Nested wrappers are flattened separately (see .pg-accordion) so
the translucency never doubles up into uneven patches. */
:root, .gradio-container {
--block-background-fill: rgba(255, 253, 247, .66);
--panel-background-fill: rgba(255, 253, 247, .66);
--background-fill-primary: rgba(255, 253, 247, .66);
--background-fill-secondary: rgba(255, 252, 246, .5);
--input-background-fill: rgba(255, 254, 250, .72);
--input-background-fill-focus: rgba(255, 255, 255, .92);
--checkbox-label-background-fill: transparent;
--code-background-fill: var(--pg-code);
--block-border-color: var(--pg-line);
--border-color-primary: var(--pg-line);
--input-border-color: var(--pg-field-border);
}
.dark, .dark .gradio-container {
--block-background-fill: rgba(34, 27, 19, .55);
--panel-background-fill: rgba(34, 27, 19, .55);
--background-fill-primary: rgba(34, 27, 19, .55);
--background-fill-secondary: rgba(24, 19, 12, .5);
--input-background-fill: rgba(46, 37, 26, .6);
--input-background-fill-focus: rgba(56, 45, 31, .85);
--checkbox-label-background-fill: transparent;
--code-background-fill: var(--pg-code);
--block-border-color: var(--pg-line);
--border-color-primary: var(--pg-line);
--input-border-color: var(--pg-field-border);
}
/* Center the whole app in a constrained column instead of edge-to-edge. */
.gradio-container, .gradio-container input, .gradio-container button,
.gradio-container textarea, .gradio-container select {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif !important;
}
.gradio-container {
font-size: 16px !important;
width: 100% !important; /* fill up to max-width; don't shrink to content */
max-width: 940px !important;
margin: 0 auto !important;
padding: 8px 28px 56px !important;
color: var(--pg-body);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Keep the page a stable width regardless of content: the container and its
inner wrappers fill the available width, and wide content (e.g. the BibTeX
block) scrolls instead of stretching the layout. Without this, opening the
citation accordion widened the page (and the full-width button), and the
Compose tab — with narrower content — made it shrink. */
.gradio-container > div, .gradio-container .main, .gradio-container .wrap {
width: 100% !important;
}
.gradio-container [class*="column"], .gradio-container [class*="row"] { min-width: 0 !important; }
.cm-editor, .cm-scroller, [data-testid="code"] { max-width: 100% !important; }
[data-testid="code"] { overflow-x: auto !important; }
/* Typographic scale — restrained, consistent. */
.gr-markdown p, .gr-markdown li, .prose p, .prose li { font-size: 1rem; line-height: 1.6; }
.gr-markdown ol li, .prose ol li { margin-bottom: 6px; }
.gr-markdown h2, .prose h2 {
font-family: var(--pg-serif) !important;
font-size: 1.72rem !important; font-weight: 600; color: var(--pg-ink);
letter-spacing: -.005em; margin: 6px 0 16px; padding-bottom: 11px;
border-bottom: 1px solid var(--pg-line);
}
.gr-markdown h3, .prose h3 {
font-family: var(--pg-serif) !important;
font-size: 1.22rem !important; font-weight: 600; color: var(--pg-ink);
letter-spacing: -.005em;
}
label, .gr-input-label { font-size: .95rem !important; color: var(--pg-body); }
/* Page background (handled in CSS because this Gradio build crashes on a
.set()-customized theme — see app.py launch() note).
IMPORTANT: do NOT blanket-style .block / .form here. In Gradio 6 those
classes sit on nearly every wrapper (markdown, the header, each slider…),
so adding borders/shadows there boxes the whole page into nested cards with
uneven margins. Surfaces come from the theme defaults plus the explicit
.stat-box / .section-card classes below. */
body, gradio-app, .gradio-container { background: var(--pg-tint) !important; }
/* Real panels (inputs, dataframe, accordions, code) sit on white; plain
text/markdown/button rows stay transparent on the page tint. */
.gr-input, .gr-box, .gr-panel,
.cm-editor, [data-testid="textbox"], [data-testid="dataframe"] {
border-radius: 10px !important;
}
input, textarea, select { border-radius: 9px !important; border-color: var(--pg-field-border) !important; }
/* Harmonic-structure dropdown: let the warm paper show through instead of a hard
white block. The block + closed field go translucent (a hairline border keeps
it reading as a control); the open options menu is forced opaque so it stays
readable over whatever is behind it. */
/* Dropdown: a subtle frosted panel like the other surfaces. The frosted fill
lives on the dropdown block (.pg-dropdown); the wrapper Gradio frames it in
(its .form / parent .block) is flattened so the panel never doubles up into a
darker box. The field uses the shared input fill; the menu stays opaque. */
.pg-dropdown {
background: var(--block-background-fill) !important;
border: 1px solid var(--pg-line) !important;
border-radius: 10px !important;
box-shadow: var(--pg-shadow) !important;
}
.form:has(.pg-dropdown),
.gradio-container [class*="form"]:has(.pg-dropdown),
.gradio-container .block:has(> .pg-dropdown) {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
.pg-dropdown .wrap, .pg-dropdown .wrap-inner, .pg-dropdown .secondary-wrap, .pg-dropdown input {
background: var(--input-background-fill) !important;
}
.pg-dropdown .wrap {
border: 1px solid var(--pg-field-border) !important; border-radius: 9px !important;
}
.pg-dropdown ul, .pg-dropdown ul.options, .gradio-container ul.options {
background: var(--pg-surface-solid) !important;
}
/* Reusable frosted panel (e.g. the Sort-by row): same look as the dropdown — one
frosted fill with a hairline frame and soft lift, with the inner component
blocks flattened so the fill stays a single even layer (no darker doubling). */
.pg-panel {
background: var(--block-background-fill) !important;
border: 1px solid var(--pg-line) !important;
border-radius: 10px !important;
box-shadow: var(--pg-shadow) !important;
padding: 10px 14px !important;
}
.pg-panel .block, .pg-panel .form {
background: transparent !important; border: none !important; box-shadow: none !important;
}
/* Code blocks (BibTeX) & accordions read as a soft inset on the paper rather
than bright white slabs: a warm parchment / transparent fill with a hairline
frame. Syntax tokens and open content stay fully legible. */
.pg-code { background: transparent !important; border: none !important; box-shadow: none !important; }
.pg-code [data-testid="code"] {
border: 1px solid var(--pg-line) !important; border-radius: 10px !important;
box-shadow: none !important;
}
.pg-code [data-testid="code"], .pg-code .cm-editor,
.pg-code .cm-scroller, .pg-code .cm-gutters {
background: var(--pg-code) !important;
}
.pg-code .cm-gutters { border-right: 1px solid var(--pg-line) !important; }
.pg-accordion {
background: var(--block-background-fill) !important;
border: 1px solid var(--pg-line) !important; border-radius: 10px !important;
box-shadow: none !important;
}
.pg-accordion .label-wrap { background: transparent !important; border: none !important; }
/* Flatten the accordion's inner wrappers so nested blocks/forms (e.g. the slider
row) don't stack a second frosted layer on top of the accordion's panel. */
.pg-accordion [data-testid="accordion-content"],
.pg-accordion [data-testid="accordion-content"] .block,
.pg-accordion [data-testid="accordion-content"] .form {
background: transparent !important;
}
/* Primary / secondary button colours */
button.primary {
background: var(--pg-accent) !important; color: #fff !important;
border: none !important;
}
button.primary:hover { background: var(--pg-accent-dk) !important; }
button.secondary {
background: var(--pg-surface) !important; color: var(--pg-body) !important;
border: 1px solid var(--pg-field-border) !important;
}
button.secondary:hover { background: var(--pg-hover) !important; }
/* Action buttons: medium weight, calm radius, subtle motion. Scoped to
primary/secondary so Gradio's internal buttons keep their layout. */
button.primary, button.secondary {
flex-grow: 0 !important;
font-size: .98rem !important;
font-weight: 600 !important;
min-height: 42px;
border-radius: 9px !important;
letter-spacing: .005em;
transition: transform .08s ease, box-shadow .15s ease, background .15s ease;
}
button.primary { box-shadow: var(--pg-shadow); }
button.primary:hover { box-shadow: var(--pg-shadow-md); transform: translateY(-1px); }
button.primary:active { transform: translateY(0); }
button.secondary:hover { transform: translateY(-1px); }
button.lg {
font-size: 1.02rem !important;
padding: 12px 26px !important;
min-height: 50px;
width: auto !important; min-width: 230px;
flex: 0 0 auto !important;
}
button.sm {
min-height: 34px; font-size: .9rem !important;
width: fit-content !important; border-radius: 8px !important;
}
/* Evenly space the three workflow tabs across the full width.
gradio 6 decides tab overflow (the "…" menu) by summing the button widths in a
HIDDEN ".tab-container.visually-hidden" measuring copy and comparing to the
wrapper width. So we must spread ONLY the visible nav and leave the hidden
measurer at its natural width — restyling that copy makes it over-measure and
push the last tab into the overflow menu. */
.tab-container:not(.visually-hidden) { width: 100% !important; }
.tab-container:not(.visually-hidden) > button {
flex: 1 1 0 !important; min-width: 0 !important;
justify-content: center !important;
}
/* Older gradio builds use .tab-nav (no hidden measuring copy). */
.tab-nav { display: flex !important; }
.tab-nav button { flex: 1 1 0 !important; justify-content: center !important; }
.tab-nav button, .tab-container button {
font-size: 1rem !important; font-weight: 550 !important;
}
/* Accordion headers (How it works, Generation parameters, Sections) */
.label-wrap span { font-size: 1.02rem !important; font-weight: 600; color: var(--pg-ink); }
/* Header — title-page treatment: tracked eyebrow, serif wordmark, short rule. */
#header { text-align:center; padding: 40px 0 26px; margin-bottom: 8px;
overflow-x: clip; } /* the overflowing "fancy" logo text can't cause page scroll */
#header .eyebrow {
margin: 0 0 12px; font-size: .74rem; font-weight: 600; color: var(--pg-muted);
text-transform: uppercase; letter-spacing: .24em;
}
#header h1 {
margin: 0; font-family: var(--pg-serif) !important; font-size: 3.1rem;
font-weight: 600; color: var(--pg-ink); letter-spacing: -.012em;
cursor: pointer; user-select: none; -webkit-user-select: none;
/* JS (sizeLogo) floors the hit area at min-width ≈ the width of "ProGress" so
the hover zone is stable while the text backspaces; width stays auto so the
longer "fancy" text grows the (centered) box symmetrically rather than
overflowing it to one side. white-space:nowrap keeps it on one line;
line-height + min-height stop the box collapsing (and the page jumping)
when the text is fully deleted. */
display: inline-block; text-align: center; white-space: nowrap;
line-height: 1.18; min-height: 1.18em;
}
#header .rule {
width: 52px; margin: 16px auto 0; border-top: 2px solid var(--pg-accent);
}
#header .tagline {
margin: 14px 0 0; font-family: var(--pg-serif) !important; font-style: italic;
font-size: 1.14rem; color: var(--pg-body); font-weight: 400;
}
#header .device-badge {
margin: 16px 0 0; font-size: .74rem; font-weight: 600; color: var(--pg-muted);
display: inline-block; padding: 4px 13px; border: 1px solid var(--pg-line);
border-radius: 999px; background: var(--pg-surface); letter-spacing: .08em;
text-transform: uppercase;
}
.step-sub { color: var(--pg-muted); font-size: 1rem; margin: 0 0 16px; }
/* Stat cards: clean white surface, soft shadow, gentle hover lift. */
.stat-row { display: flex; gap: 16px; margin: 4px 0; }
.stat-row .stat-box { flex: 1 1 0; }
.stat-box {
background: var(--pg-surface); border: 1px solid var(--pg-line);
border-radius: 12px; padding: 20px 18px; text-align: center;
box-shadow: var(--pg-shadow); transition: box-shadow .15s ease, transform .1s ease;
}
.stat-box:hover { box-shadow: var(--pg-shadow-md); transform: translateY(-2px); }
.stat-box .stat-num {
font-family: var(--pg-serif) !important;
font-size: 2.7rem; font-weight: 600; color: var(--pg-accent);
line-height: 1; letter-spacing: -.01em;
}
.stat-box .stat-lbl {
font-size: .82rem; color: var(--pg-muted); margin-top: 8px;
text-transform: uppercase; letter-spacing: .06em; font-weight: 600;
}
/* Section cards */
.section-card {
border: 1px solid var(--pg-line); border-radius: 12px;
padding: 16px 18px; margin-bottom: 14px; background: var(--pg-surface);
box-shadow: var(--pg-shadow);
}
.section-label { font-family: var(--pg-serif) !important; font-size: 1.18rem; font-weight: 600; color: var(--pg-ink); margin-bottom: 6px; }
.section-meta { font-size: .92rem; color: var(--pg-muted); margin-bottom: 10px; }
/* Data table: lighter grid, readable rows, clear hover affordance. */
table th {
font-size: .82rem !important; text-transform: uppercase; letter-spacing: .05em;
color: var(--pg-muted) !important; font-weight: 600 !important;
background: var(--pg-tint) !important; padding: 12px 14px !important;
}
table td { font-size: .98rem !important; padding: 11px 14px !important; color: var(--pg-body); }
table tbody tr { transition: background .1s ease; }
table tbody tr:hover td { background: var(--pg-row-hover) !important; cursor: pointer; }
/* Selected radio option: white text for contrast on the claret chip (only radios
— checkboxes sit on the page, so white would make them vanish). */
label:has(> input[type="radio"]:checked),
label:has(> input[type="radio"]:checked) span { color: #fff !important; }
/* Dataframe — make a click read as ROW selection, not a spreadsheet cell.
Gradio 6 uses a virtualized table: body cells are <div class="body-cell"> (NOT
<td>), the selected one is .cell-selected (it draws the accent "box" via a
box-shadow), and two floating <button class="selection-button …"> arrow tabs
plus a .cell-menu-button (⋮) appear at the cell. (Element names verified by
headless inspection.) Hide those widgets, drop the per-cell box, and tint the
whole selected row instead — the header copy/fullscreen toolbar is untouched. */
.pg-table .selection-button, .pg-table .cell-menu-button { display: none !important; }
.pg-table .cell-selected, .pg-table .cell-selected * {
box-shadow: none !important; outline: none !important;
}
.pg-table .virtual-row:has(.cell-selected) .body-cell {
background: var(--pg-row-hover) !important;
}
.pg-table .body-cell { cursor: pointer; }
/* WebKit/Safari renders the table un-virtualized (its virtual-scroll
ResizeObserver loops — "ResizeObserver loop … undelivered notifications"), so
.table-container — which scrolls (overflow:auto) but has NO max-height — grows
to the FULL table height (3000px+), shoving the preview/piano-roll far down.
Pinning a max-height forces it to clip + scroll on every engine. (On Chrome
the table is virtualized and already shorter than this, so it's a no-op.) */
.pg-table .table-container { max-height: 340px !important; }
/* Accordions shouldn't show a horizontal scrollbar for their text/sliders.
(accordion-content is a data-testid in Gradio 6, not a class) */
div[data-testid="accordion-content"],
div[data-testid="accordion-content"] *,
.block:has(> div[data-testid="accordion-content"]) {
overflow-x: clip !important;
}
/* Sheet music is rendered as black notation, so in dark mode keep it on a light
"paper" panel; otherwise it would be black-on-dark and invisible. */
.dark [data-osmd] { background: #fff; border-radius: 8px; padding: 6px 8px; }
/* ── Home / landing page ─────────────────────────────────────────────────── */
.home-intro { text-align: center; color: var(--pg-muted); font-size: 1.05rem;
margin: 2px 0 18px; }
.home-section-title { font-family: var(--pg-serif) !important; font-size: 1.55rem;
font-weight: 600; color: var(--pg-ink); letter-spacing: -.01em;
margin: 30px 0 4px; }
.home-section-sub { color: var(--pg-muted); margin: 0 0 14px; }
.sample-grid { display: grid; gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.sample-card { background: var(--pg-surface); border: 1px solid var(--pg-line);
border-radius: 10px; padding: 10px 12px; box-shadow: var(--pg-shadow);
transition: box-shadow .15s ease, transform .1s ease; }
.sample-card:hover { box-shadow: var(--pg-shadow-md); transform: translateY(-2px); }
.sample-label { display: block; font-size: .9rem; font-weight: 600;
color: var(--pg-body); margin-bottom: 6px; }
/* Custom survey-sample player (themed; the native <audio> timeline is fixed blue
in Safari). The range honours accent-color in every browser. */
.pg-audio { display: flex; align-items: center; gap: 10px; margin-top: 6px; }
.pg-audio audio { display: none; }
.pg-audio-play {
flex: 0 0 auto; width: 34px; height: 34px; padding: 0; border-radius: 50%;
border: 1px solid var(--pg-field-border); background: var(--pg-surface-solid);
color: var(--pg-accent); font-size: .82rem; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .15s ease, transform .08s ease;
}
.pg-audio-play:hover { background: var(--pg-hover); }
.pg-audio-play:active { transform: scale(.94); }
.pg-audio-seek {
flex: 1 1 auto; min-width: 0; height: 4px; cursor: pointer;
accent-color: var(--pg-accent);
}
.pg-audio-time {
flex: 0 0 auto; font-size: .8rem; color: var(--pg-muted);
font-variant-numeric: tabular-nums; min-width: 78px; text-align: right;
}
.sample-card.tango { border-color: var(--pg-accent); }
.home-abstract { max-width: 720px; margin: 10px auto 4px; text-align: left; }
.home-abstract p { font-family: var(--pg-serif) !important; color: var(--pg-body);
font-size: 1.08rem; line-height: 1.72; margin: 0 0 14px; }
/* The numbered contributions list. Gradio's base CSS resets list markers
(list-style:none on ol/ul); under Hugging Face's SSR mode that reset can load
AFTER our rules, so the numbers + indent vanished there while working locally.
Force the list styling explicitly and !important so it's order-independent. */
.home-abstract ol {
font-family: var(--pg-serif) !important; color: var(--pg-body);
font-size: 1.08rem; line-height: 1.65; margin: 0 0 14px;
padding-left: 1.6em !important;
list-style-position: outside !important;
list-style-type: decimal !important;
}
.home-abstract ol li {
margin-bottom: 7px;
display: list-item !important;
list-style-type: decimal !important;
}
.home-abstract u { text-decoration-thickness: 1.5px; text-underline-offset: 2px; }
/* Drop cap on the abstract's first body paragraph (the <p> right after the
"Abstract" title) — a classic typeset-page flourish. */
.home-abstract p.home-section-title + p::first-letter {
font-family: var(--pg-serif); font-weight: 600; font-size: 3.3em;
line-height: .82; float: left; margin: .04em .1em -.04em 0;
color: var(--pg-accent);
}
.home-authors { text-align: center; margin: 2px auto 20px; }
.home-authors .author-names { font-family: var(--pg-serif) !important; color: var(--pg-ink);
font-size: 1.18rem; font-weight: 500; margin: 0 0 4px; }
.home-authors .author-affil { color: var(--pg-body); margin: 0; }
.home-authors .author-emails { color: var(--pg-muted); font-size: .9rem; margin: 3px 0 0; }
.home-authors .author-note { color: var(--pg-muted); font-size: .82rem; margin: 5px 0 0; }
.home-authors sup { color: var(--pg-accent); font-weight: 600; }
"""
# Web fonts via a real <link> in <head> (injected through the header
# gr.HTML(head=…), the same reliable path as the player/score scripts) instead of
# an @import in the CSS, which Gradio's constructed-stylesheet injection drops.
FONTS_HEAD = (
'<link rel="preconnect" href="https://fonts.googleapis.com">'
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?'
'family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;'
'1,9..144,400;1,9..144,500&'
'family=Inter:wght@400;500;550;600;650;700&display=swap">'
)
# Survey-sample player. Each <div class="pg-audio"> holds a controls-less
# <audio data-b64="…"> plus a custom play button, range and time readout. Two
# reasons it's custom: (1) we embed the bytes (HF file serving is broken on this
# Space) and decode them to a blob: URL here; (2) the NATIVE <audio controls> UI
# can't be themed — its timeline is a fixed blue in Safari — whereas a styled
# <input type=range> honours accent-color in every browser, so the player matches
# the theme (claret / terracotta) instead of browser blue.
AUDIO_BLOB_HEAD = r"""
<script>
(function () {
if (window.__pgAudio) return; window.__pgAudio = true;
function fmt(t){ if(!isFinite(t)||t<0) t=0; t=Math.floor(t); return Math.floor(t/60)+':'+String(t%60).padStart(2,'0'); }
function initAudio(wrap) {
if (wrap.__pgInit) return; wrap.__pgInit = true;
var audio = wrap.querySelector('audio');
var play = wrap.querySelector('.pg-audio-play');
var seek = wrap.querySelector('.pg-audio-seek');
var time = wrap.querySelector('.pg-audio-time');
if (!audio || !play || !seek) return;
var b64 = audio.getAttribute('data-b64');
if (b64) { try {
var bin = atob(b64), n = bin.length, bytes = new Uint8Array(n);
for (var i = 0; i < n; i++) bytes[i] = bin.charCodeAt(i);
audio.src = URL.createObjectURL(new Blob([bytes], { type: 'audio/mpeg' }));
audio.removeAttribute('data-b64');
} catch (e) {} }
play.addEventListener('click', function(){ if (audio.paused) audio.play(); else audio.pause(); });
audio.addEventListener('play', function(){ play.textContent = '⏸'; });
audio.addEventListener('pause', function(){ play.textContent = '▶'; });
audio.addEventListener('ended', function(){ play.textContent = '▶'; seek.value = 0; });
audio.addEventListener('loadedmetadata', function(){ if (time) time.textContent = '0:00 / ' + fmt(audio.duration); });
audio.addEventListener('timeupdate', function(){
if (audio.duration) seek.value = (audio.currentTime / audio.duration) * 1000;
if (time) time.textContent = fmt(audio.currentTime) + ' / ' + fmt(audio.duration);
});
seek.addEventListener('input', function(){ if (audio.duration) audio.currentTime = (seek.value / 1000) * audio.duration; });
}
function scan(root){ if (root && root.querySelectorAll) root.querySelectorAll('.pg-audio').forEach(initAudio); }
function init() {
scan(document);
new MutationObserver(function (muts) {
muts.forEach(function (m) {
(m.addedNodes || []).forEach(function (node) {
if (node.nodeType !== 1) return;
if (node.matches && node.matches('.pg-audio')) initAudio(node);
scan(node);
});
});
}).observe(document.documentElement, { childList: true, subtree: true });
}
if (document.body) init();
else document.addEventListener('DOMContentLoaded', init);
})();
</script>
"""
HOW_IT_WORKS_MD = """
**ProGress** builds complete two-voice compositions in three steps:
1. **Generate** — *SchenkerDiff*, a discrete graph-diffusion model trained on
Schenkerian analyses, samples short phrases as note graphs. Each phrase is
screened by music-theory filters (illegal harmonics on strong beats, bad
mode mixture, bad counterpoint); only phrases that pass enter the pool.
You can also load the pre-generated phrase set bundled with the paper's
supplement instead of running the model.
2. **Browse & Select** — listen to phrases from the pool and pick any one to
be the locked *opening* of your piece (it need not start on the tonic).
3. **Compose** — a harmonic structure (e.g. I–IV–V–I) is chosen or randomly
drawn. The remaining sections are sampled from the pool, transposed to
follow the progression, joined with voice-leading–aware stitching, and
inner voices are filled in to complete the harmony. The result is shown
as sheet music and playable MIDI.
Paper: [ProGress: Structured Music Generation via Graph Diffusion and
Hierarchical Music Analysis](https://arxiv.org/abs/2510.10249)
"""
BIBTEX = """@article{nihahn2025progress,
title={ProGress: Structured Music Generation via Graph Diffusion and Hierarchical Music Analysis},
author={Ni-Hahn, Stephen and Yang, Chao P\\'eter and Ma, Mingchen and Rudin, Cynthia and Mak, Simon and Jiang, Yue},
journal={arXiv preprint arXiv:2510.10249},
year={2025}
}"""
# ── HTML helpers ──────────────────────────────────────────────────────────────
def _stat_html(num: str, lbl: str) -> str:
return (
f'<div class="stat-box">'
f'<div class="stat-num">{num}</div>'
f'<div class="stat-lbl">{lbl}</div>'
f'</div>'
)
def _section_card(label: str, pid: int | None, pool: dict | None) -> str:
if pid is None or pool is None:
return f"<div class='section-card'><div class='section-label'>{label}</div><em>—</em></div>"
info = pool["info"][pid]
try:
midi_bytes = backend.score_to_midi_bytes(pool["scores"][pid])
player = backend.midi_player_html(midi_bytes, height="60px", show_viz=False)
except Exception as e:
player = f"<em>Audio unavailable: {e}</em>"
mode = info["mode"].capitalize()
return (
f"<div class='section-card'>"
f"<div class='section-label'>{label}</div>"
f"<div class='section-meta'>Phrase #{pid} &nbsp;·&nbsp; {mode}</div>"
f"{player}</div>"
)
_TABLE_COLS = ["ID", "Mode", "Harmonic variety"]
def _phrases_df(pool: dict | None, sort_by: str = "Harmonic variety ↓",
tonic_only: bool = True) -> pd.DataFrame:
if pool is None or not pool.get("info"):
return pd.DataFrame(columns=_TABLE_COLS)
rows = [
{"ID": p["id"], "Mode": p["mode"].capitalize(),
"Harmonic variety": p["quality"]}
for p in pool["info"]
if not tonic_only or p["start_rn"] in ("I", "i")
]
if not rows:
return pd.DataFrame(columns=_TABLE_COLS)
df = pd.DataFrame(rows)
if sort_by == "Harmonic variety ↓":
df = df.sort_values("Harmonic variety", ascending=False)
elif sort_by == "Harmonic variety ↑":
df = df.sort_values("Harmonic variety", ascending=True)
elif sort_by == "ID":
df = df.sort_values("ID")
elif sort_by == "Mode":
df = df.sort_values(["Mode", "Harmonic variety"], ascending=[True, False])
return df.reset_index(drop=True)
def _stat_row(pool: dict | None) -> str:
"""All three stat cards as one HTML block; empty string when no pool."""
if not pool or not pool.get("scores"):
return ""
n = len(pool["scores"])
# Count by mode so Major + Minor = Total. The rare "mixed"/ambiguous phrases
# (see backend._detect_mode) are folded into Minor so the two still add up.
n_major = sum(1 for p in pool.get("info", []) if p["mode"] == "major")
n_minor = n - n_major
return (
'<div class="stat-row">'
+ _stat_html(str(n), "Total phrases")
+ _stat_html(str(n_major), "Major phrases")
+ _stat_html(str(n_minor), "Minor phrases")
+ '</div>'
)
def _selected_label(pid: int | None, info: dict | None) -> str:
if pid is None or info is None:
return "*No opening phrase selected yet.*"
return f"### Opening phrase: #{pid} ({info['mode'].capitalize()})"
# ── Easter eggs 🐱 ──────────────────────────────────────────────────────────────
# Injected via the header gr.HTML's head= (the same mechanism that loads the MIDI
# / OSMD scripts), because gradio 6's .load(js=) didn't run reliably here. The
# listeners are document-delegated, so they work the moment the page exists:
# (1) console greeting, (2) hover the logo → it retypes as "🐱 PuuurrrrrGressssss 🐱",
# (3) Konami code (↑↑↓↓←→←→ B A) → a shower of cats, (4) a cat strolls by.
CAT_EGGS_HEAD = r"""
<script>
(function () {
if (window.__catEggs) return; window.__catEggs = true;
console.log(
"%c /\\_/\\ ProGress\n( o.o ) cat-powered graph diffusion =^.^=\n > ^ < ",
"color:#2563eb;font-weight:bold;"
);
// Logo: hover for 3s → backspace "ProGress" then type "🐱 PuuurrrrrGressssss 🐱";
// reverse on mouse-out. Array.from keeps the cat emoji a single unit so the
// backspace never slices it in half.
var ORIG = Array.from('ProGress');
var FANCY = Array.from('🐱 PuuurrrrrGressssss 🐱');
var logoTimers = [], logoActive = false, logoDelay = null;
function logoClear() { logoTimers.forEach(clearTimeout); logoTimers = []; }
function logoAnim(h1, target) {
logoClear();
var cur = Array.from(h1.textContent);
(function back() {
if (cur.length) { cur.pop(); h1.textContent = cur.join(''); logoTimers.push(setTimeout(back, 75)); }
else { var i = 0; (function type() {
if (i < target.length) { i++; h1.textContent = target.slice(0, i).join(''); logoTimers.push(setTimeout(type, 60)); }
})(); }
})();
}
// Floor the hit area at the width of "ProGress" once the web font is ready.
// We set min-width (not width) and leave width:auto, so: the empty box can't
// collapse mid-backspace (stable hover zone, no layout jump), yet when the
// longer "fancy" text types in the box GROWS to fit it and the parent's
// text-align:center keeps it centered — it expands symmetrically instead of
// overflowing a fixed-width box off to one side.
function sizeLogo() {
var h1 = document.querySelector('#header h1');
if (!h1 || h1.textContent !== 'ProGress') return;
h1.style.minWidth = '0';
var w = h1.getBoundingClientRect().width;
if (w > 0) h1.style.minWidth = Math.round(w) + 'px';
}
if (document.fonts && document.fonts.ready) document.fonts.ready.then(sizeLogo);
setTimeout(sizeLogo, 1500);
document.addEventListener('mouseover', function (e) {
var h1 = e.target.closest ? e.target.closest('#header h1') : null;
if (!h1 || logoActive || logoDelay) return;
logoDelay = setTimeout(function () { // require a 3s deliberate hover
logoDelay = null; logoActive = true; logoAnim(h1, FANCY);
}, 3000);
});
document.addEventListener('mouseout', function (e) {
var h1 = e.target.closest ? e.target.closest('#header h1') : null;
if (!h1) return;
if (logoDelay) { clearTimeout(logoDelay); logoDelay = null; } // left before 3s
if (logoActive) { logoActive = false; logoAnim(h1, ORIG); }
});
// Konami code -> cat rain (e.key based)
var seq = ['arrowup','arrowup','arrowdown','arrowdown',
'arrowleft','arrowright','arrowleft','arrowright','b','a'];
var i = 0;
document.addEventListener('keydown', function (e) {
var key = (e.key || '').toLowerCase();
i = (key === seq[i]) ? i + 1 : (key === seq[0] ? 1 : 0);
if (i !== seq.length) return;
i = 0;
var GLYPHS = ['🐱','🐈','😺','😻','🐾','🎵','🎶','🎼','🎵','🎶'];
function dropCat() {
var c = document.createElement('div');
c.textContent = GLYPHS[Math.floor(Math.random()*GLYPHS.length)];
var dur = 2.6 + Math.random()*2.2; // each glyph falls at its own speed
c.style.cssText =
'position:fixed;top:-48px;left:' + (Math.random()*100) + 'vw;' +
'font-size:' + (20 + Math.random()*26) + 'px;z-index:99999;' +
'pointer-events:none;transition:transform ' + dur + 's linear,opacity ' + dur + 's;';
document.body.appendChild(c);
requestAnimationFrame(function () {
c.style.transform = 'translateY(' + (window.innerHeight + 96) +
'px) rotate(' + ((Math.random()-0.5)*420) + 'deg)';
c.style.opacity = '0';
});
setTimeout(function () { c.remove(); }, dur*1000 + 200);
}
// ~30 cats sprinkled over ~2.5s so it rains rather than dropping in one line
for (var n = 0; n < 30; n++) setTimeout(dropCat, Math.random()*2500);
});
// Wandering cat: an animated 🐈 trots across the bottom every few minutes.
// The bob/tilt walk-cycle is pure CSS (no sprite asset).
var walkStyle = document.createElement('style');
walkStyle.textContent =
'@keyframes pgCatBob{0%,100%{transform:translateY(0) rotate(-5deg);}' +
'50%{transform:translateY(-7px) rotate(5deg);}}';
document.head.appendChild(walkStyle);
function catStroll() {
if (!document.body) return;
var ltr = Math.random() < 0.5; // travel direction
var w = window.innerWidth + 90;
var from = ltr ? -90 : w, to = ltr ? w : -90;
var wrap = document.createElement('div');
wrap.style.cssText =
'position:fixed;bottom:6px;left:0;z-index:99998;pointer-events:none;will-change:transform;';
var facer = document.createElement('span'); // flip to face travel direction
facer.style.cssText = 'display:inline-block;font-size:34px;transform:scaleX(' + (ltr ? -1 : 1) + ');';
var bob = document.createElement('span'); // the walk-cycle bob/tilt
bob.textContent = '🐈';
bob.style.cssText = 'display:inline-block;animation:pgCatBob .45s ease-in-out infinite;';
facer.appendChild(bob); wrap.appendChild(facer); document.body.appendChild(wrap);
wrap.animate(
[{ transform: 'translateX(' + from + 'px)' }, { transform: 'translateX(' + to + 'px)' }],
{ duration: 11000 + Math.random() * 5000, easing: 'linear' }
).onfinish = function () { wrap.remove(); };
}
window.catStroll = catStroll; // trigger on demand from the console
(function loop() {
setTimeout(function () { catStroll(); loop(); }, 180000 + Math.random() * 120000); // every 3–5 min
})();
})();
</script>
"""
# ── Home / landing page ────────────────────────────────────────────────────────
# Previously generated samples from the ProGress demo site
# (https://anonymousforpeerreview.github.io/ProGressDemo/). The audio is bundled
# locally under assets/samples/ (converted to MP3) so the page is self-contained
# and does not depend on the anonymous demo site staying online.
SAMPLES_DIR = (Path(__file__).resolve().parent / "assets" / "samples")
gr.set_static_paths(paths=[str(SAMPLES_DIR)]) # serve the bundled audio directly
# Pre-encoded sample audio (base64), generated from the real MP3s into a PLAIN
# JSON. This is the runtime source of truth — NOT the .mp3 files: on Hugging Face
# *.mp3 is Git-LFS-tracked, so the container only ships the ~170-byte LFS pointer
# text (decodes to "version https://git-lfs…"), never the audio — which is why
# every serve/embed approach produced silent or unplayable players. A .json file
# is not LFS-tracked, so the real bytes ride along in the repo and reach the page.
try:
_SAMPLES_B64: dict = json.loads(
(Path(__file__).resolve().parent / "assets" / "samples_b64.json").read_text()
)
except Exception:
_SAMPLES_B64 = {} # fall back to reading the .mp3s directly (see _sample_b64)
def _sample_b64(filename: str) -> str:
# base64 from the bundled JSON; injected as a data-b64 attribute and turned
# into a blob: URL on the client (see AUDIO_BLOB_HEAD). Falls back to reading
# the file directly only if the JSON is missing an entry.
if filename in _SAMPLES_B64:
return _SAMPLES_B64[filename]
return base64.b64encode((SAMPLES_DIR / filename).read_bytes()).decode("ascii")
def _sample_card(label: str, filename: str, tango: bool = False) -> str:
cls = "sample-card tango" if tango else "sample-card"
return (
f'<div class="{cls}"><span class="sample-label">{label}</span>'
f'<div class="pg-audio">'
f'<audio preload="none" data-b64="{_sample_b64(filename)}"></audio>'
f'<button type="button" class="pg-audio-play" aria-label="Play / pause">&#9654;</button>'
f'<input type="range" class="pg-audio-seek" min="0" max="1000" value="0" step="1" aria-label="Seek">'
f'<span class="pg-audio-time">0:00</span>'
f'</div></div>'
)
def home_samples_html() -> str:
survey = "".join(
_sample_card(f"Example {i}", f"example_{i}.mp3") for i in range(1, 11)
)
tango = _sample_card("A Little Tango", "tango.mp3", tango=True)
return (
'<div>'
'<p class="home-section-title">ProGress survey examples</p>'
'<p class="home-section-sub">Pieces generated by ProGress, used in our listening study.</p>'
f'<div class="sample-grid">{survey}</div>'
'<p class="home-section-title">A Little Tango</p>'
'<p class="home-section-sub">Our Bach-trained model generating tango rhythms — '
'just changing the timbre made some nice results.</p>'
f'<div class="sample-grid">{tango}</div>'
'</div>'
)
AUTHORS_HTML = """
<div class="home-authors">
<p class="author-names">Stephen Ni-Hahn<sup>*</sup>, Chao P&eacute;ter Yang<sup>*</sup></p>
<p class="author-names">Mingchen Ma, Cynthia Rudin, Simon Mak, Yue Jiang</p>
<p class="author-affil">Duke University</p>
<p class="author-emails">stephen.hahn@duke.edu &middot; peter.yang@duke.edu</p>
<p class="author-note"><sup>*</sup>Equal contribution</p>
</div>
"""
# Abstract, verbatim from the ProGress demo site.
ABSTRACT_HTML = """
<div class="home-abstract">
<p class="home-section-title">Abstract</p>
<p>Artificial Intelligence (AI) for music generation is undergoing rapid
developments, with recent symbolic models leveraging sophisticated deep
learning and diffusion-model algorithms. One drawback of existing models is
that they lack structural cohesion, particularly with respect to
harmonic&ndash;melodic structure. Furthermore, such existing models are largely
&ldquo;black-box&rdquo; in nature and are not musically interpretable. This
paper addresses these limitations via a novel generative music framework that
incorporates concepts of Schenkerian analysis (SchA) in concert with a
diffusion modeling framework. This framework, which we call
<strong>ProGress</strong> (<u>Pro</u>longation-enhanced Di<u>Gress</u>), adapts
state-of-the-art deep models for discrete diffusion (in particular, the DiGress
model of Vignac et al., 2023) for interpretable and structured music
generation.</p>
<p>Concretely, our contributions include:</p>
<ol>
<li>Novel adaptations of the DiGress model for music generation;</li>
<li>A novel SchA-inspired phrase fusion methodology; and</li>
<li>A framework allowing users to control various aspects of the generation
process to create coherent musical compositions.</li>
</ol>
<p>Results from human experiments suggest superior performance to existing
state-of-the-art methods.</p>
</div>
"""
# ── App ───────────────────────────────────────────────────────────────────────
def create_app() -> gr.Blocks:
# NOTE: theme + css are passed to launch(), NOT here. Gradio 6 moved these
# off the Blocks constructor; on Hugging Face, passing them to Blocks is
# ignored (with a UserWarning) and the Space renders unstyled — so they must
# live on demo.launch() (see __main__).
with gr.Blocks(title="ProGress") as demo:
# ── State ──────────────────────────────────────────────────────────────
pool_state = gr.State(None) # phrases_data dict
starting_id = gr.State(None) # locked beginning phrase id
preview_pid = gr.State(None) # currently previewed phrase id
sorted_df_state = gr.State(None) # last Python-sorted df (for click lookup)
# ── Header ─────────────────────────────────────────────────────────────
gr.HTML(
'<div id="header">'
'<p class="eyebrow">Structured Music Generation</p>'
'<h1>ProGress</h1>'
'<div class="rule"></div>'
'<p class="tagline">Graph diffusion &amp; hierarchical music analysis</p>'
f'<p class="device-badge">Compute · {backend.device_info()}</p>'
'</div>',
head=FONTS_HEAD + AUDIO_BLOB_HEAD + backend.MIDI_PLAYER_HEAD
+ backend.OSMD_HEAD + CAT_EGGS_HEAD,
)
# ── Home / landing (samples + call-to-action) ──────────────────────────
with gr.Column(visible=True) as home_view:
gr.HTML(AUTHORS_HTML)
home_gen_btn = gr.Button("Generate New Music",
variant="primary", size="lg")
gr.HTML(ABSTRACT_HTML)
with gr.Accordion("Cite this work", open=False,
elem_classes="pg-accordion"):
gr.Code(BIBTEX, language=None, label=None, interactive=False,
elem_classes="pg-code")
# Survey samples as plain <audio> cards with inline base64 data: URIs
# (see _sample_src) — no file serving, so HF can't break them.
gr.HTML(home_samples_html())
# Shown only inside the generator, to return to the samples.
with gr.Row(visible=False) as back_row:
back_btn = gr.Button("← Back to samples", size="sm", scale=0)
with gr.Tabs(visible=False) as tabs:
# ═════════════════════════════════════════════════════════════════
# TAB 1 · Generate
# ═════════════════════════════════════════════════════════════════
with gr.Tab("1. Generate", id="generate"):
gr.Markdown(
"## Step 1 — Build your phrase pool\n"
"1. Click **Load pre-generated phrases** to start instantly "
"*(recommended)*, or **Generate** to sample fresh phrases with "
"the model.\n"
"2. Wait until the phrase counts appear below.\n"
"3. Press **Next** to go pick your opening phrase."
)
with gr.Accordion("How it works", open=False,
elem_classes="pg-accordion"):
gr.Markdown(HOW_IT_WORKS_MD)
ckpt_ok = backend.checkpoint_available()
if not ckpt_ok:
gr.Markdown(
f"**Checkpoint not found** at `{backend.CHECKPOINT_PATH}`. "
"Generation is disabled — you can still load the "
"pre-generated phrase set below."
)
with gr.Accordion("Generation parameters", open=False,
elem_classes="pg-accordion"):
with gr.Row():
target_slider = gr.Slider(
minimum=8, maximum=500, value=100, step=4,
label="Target phrases",
info="Total valid phrases to generate. More = better variety but slower (~10–15 s each on CPU).",
scale=3,
)
batch_slider = gr.Slider(
minimum=4, maximum=32, value=8, step=4,
label="Batch size",
info="Phrases per model call. Larger batches use more memory.",
scale=2,
)
gr.Markdown(
"> ⏳ **Generation takes a while.** The model is remarkably "
"compact and efficient — efficient enough in fact to run entirely on "
"CPU — but sampling is still slow at roughly "
"10–15 s per phrase, so building a full pool can take several "
"minutes. **Load pre-generated phrases** is instant."
)
with gr.Row():
load_btn = gr.Button("Load pre-generated phrases",
variant="primary", size="lg", scale=3)
gen_btn = gr.Button("Generate", size="lg",
interactive=ckpt_ok, scale=2)
with gr.Row():
reset_btn = gr.Button("Reset pool", size="sm",
variant="secondary", scale=0)
gen_status = gr.Markdown("")
# Single HTML block holding all three stat cards. Keeping them
# in one component means one loading state while phrases process
# (three separate gr.HTML outputs each render their own spinner —
# the "triplicated waiting icon").
stat_row = gr.HTML("")
next1_btn = gr.Button("Next: Browse & Select →",
variant="primary", size="lg", visible=False)
gr.Markdown("### Cite this work")
gr.Code(BIBTEX, language=None, label=None, interactive=False,
elem_classes="pg-code")
# ═════════════════════════════════════════════════════════════════
# TAB 2 · Browse & Select
# ═════════════════════════════════════════════════════════════════
with gr.Tab("2. Browse & Select", id="browse"):
gr.Markdown(
"## Step 2 — Choose your opening phrase\n"
"1. Click a row in the table to hear the phrase and see its score.\n"
"2. When you find one you like, press **Select and Compose!**"
)
with gr.Row(elem_classes="pg-panel"):
sort_radio = gr.Radio(
["Harmonic variety ↓", "Harmonic variety ↑", "ID", "Mode"],
value="Harmonic variety ↓",
label="Sort by",
scale=3,
)
tonic_check = gr.Checkbox(
value=False,
label="Tonic-start only",
info="Optionally narrow to phrases that begin on the tonic (I/i). Any phrase can be the opening.",
scale=2,
)
phrase_table = gr.Dataframe(
headers=_TABLE_COLS,
datatype=["number", "str", "number"],
interactive=False,
label="Click a row to preview",
max_height=300,
elem_classes="pg-table",
)
preview_player = gr.HTML("<em>Click a row above to preview a phrase.</em>",
js_on_load=backend.MIDI_VIZ_JS_ON_LOAD)
preview_score = gr.HTML("", js_on_load=backend.OSMD_JS_ON_LOAD)
preview_download = gr.DownloadButton("Download preview MIDI",
size="sm", visible=False)
confirm_btn = gr.Button("Select and Compose! →", variant="primary",
size="lg", interactive=False)
selection_md = gr.Markdown(_selected_label(None, None))
# ═════════════════════════════════════════════════════════════════
# TAB 3 · Compose
# ═════════════════════════════════════════════════════════════════
with gr.Tab("3. Compose", id="compose"):
gr.Markdown(
"## Step 3 — Compose the full piece\n"
"1. Pick a harmonic structure, or leave it on **Random**.\n"
"2. Press **Compose**. Not happy? **Resample** keeps your "
"opening and redraws the rest.\n"
"3. Listen, read the score, and download the MIDI."
)
selection_summary = gr.Markdown(_selected_label(None, None))
with gr.Row():
structure_dd = gr.Dropdown(
["Random"] + backend.STRUCTURE_NAMES,
value="Random",
label="Harmonic structure",
scale=3,
elem_classes="pg-dropdown",
)
stitch_btn = gr.Button("Compose", variant="primary",
size="lg", interactive=False, scale=1)
resample_btn = gr.Button("Resample", size="lg",
interactive=False, scale=1)
stitch_status = gr.Markdown("")
with gr.Accordion("Sections", open=False,
elem_classes="pg-accordion"):
opening_html = gr.HTML("<em>—</em>")
develop_html = gr.HTML("<em>—</em>")
expand_html = gr.HTML("<em>—</em>")
closing_html = gr.HTML("<em>—</em>")
gr.Markdown("### Full Composition")
full_score = gr.HTML("<em>—</em>", js_on_load=backend.OSMD_JS_ON_LOAD)
full_player = gr.HTML("<em>—</em>", js_on_load=backend.MIDI_VIZ_JS_ON_LOAD)
download_btn = gr.DownloadButton("Download MIDI", visible=False)
# ════════════════════════════════════════════════════════════════════
# Handlers
# ════════════════════════════════════════════════════════════════════
# ── Generate ──────────────────────────────────────────────────────────
def _generate(target, batch_size, sort_by, tonic_only, pool,
progress=gr.Progress()):
if not backend.checkpoint_available():
df = _phrases_df(None)
return (
pool, "Checkpoint missing.",
_stat_row(pool),
df, df,
gr.update(visible=False),
)
try:
pool = backend.generate_until_target(
int(target), int(batch_size), progress=progress, pool=pool,
)
except Exception as e:
n = len(pool["scores"]) if pool else 0
df = _phrases_df(pool, sort_by, tonic_only)
return (
pool, f"Error: {e}",
_stat_row(pool),
df, df,
gr.update(visible=n > 0),
)
n = len(pool["scores"])
df = _phrases_df(pool, sort_by, tonic_only)
return (
pool,
f"{n} phrases ready.",
_stat_row(pool),
df, df,
gr.update(visible=n > 0),
)
gen_outputs = [
pool_state, gen_status, stat_row,
phrase_table, sorted_df_state, next1_btn,
]
gen_btn.click(
fn=_generate,
inputs=[target_slider, batch_slider, sort_radio, tonic_check, pool_state],
outputs=gen_outputs,
)
# ── Load pre-generated phrases (ProGress_Supplement) ─────────────────
def _load_pregen(sort_by, tonic_only, pool, progress=gr.Progress()):
try:
pool = backend.load_pregenerated(pool=pool, progress=progress)
except Exception as e:
n = len(pool["scores"]) if pool else 0
df = _phrases_df(pool, sort_by, tonic_only)
return (
pool, f"Could not load pre-generated phrases: {e}",
_stat_row(pool),
df, df,
gr.update(visible=n > 0),
)
n = len(pool["scores"])
df = _phrases_df(pool, sort_by, tonic_only)
return (
pool,
f"{n} phrases ready (pre-generated set loaded).",
_stat_row(pool),
df, df,
gr.update(visible=n > 0),
)
load_btn.click(
fn=_load_pregen,
inputs=[sort_radio, tonic_check, pool_state],
outputs=gen_outputs,
)
# ── Reset ─────────────────────────────────────────────────────────────
def _reset():
empty = _phrases_df(None)
return (
None, "",
"", # stat row
empty, empty,
gr.update(visible=False), # next1_btn
None, # preview_pid
"<em>Pool cleared. Generate to start.</em>",
"", gr.update(visible=False), # preview_score, preview_download
None, # starting_id
_selected_label(None, None), # selection_md
_selected_label(None, None), # selection_summary
gr.update(interactive=False), # confirm_btn
gr.update(interactive=False), # stitch_btn
gr.update(interactive=False), # resample_btn
gr.update(choices=["Random"] + backend.STRUCTURE_NAMES,
value="Random"), # structure_dd — restore full list
)
reset_btn.click(
fn=_reset,
outputs=[
pool_state, gen_status, stat_row,
phrase_table, sorted_df_state, next1_btn,
preview_pid, preview_player, preview_score, preview_download,
starting_id, selection_md, selection_summary,
confirm_btn, stitch_btn, resample_btn, structure_dd,
],
)
# ── Sort / filter ─────────────────────────────────────────────────────
def _sort_table(pool, sort_by, tonic_only):
df = _phrases_df(pool, sort_by, tonic_only)
return df, df
sort_radio.change(
fn=_sort_table,
inputs=[pool_state, sort_radio, tonic_check],
outputs=[phrase_table, sorted_df_state],
)
tonic_check.change(
fn=_sort_table,
inputs=[pool_state, sort_radio, tonic_check],
outputs=[phrase_table, sorted_df_state],
)
# ── Preview (shared logic) ────────────────────────────────────────────
def _preview_fail(msg):
return (None, f"<em>{msg}</em>", "", gr.update(visible=False),
gr.update(interactive=False))
def _do_preview(pool, pid):
if pool is None:
return _preview_fail("Generate phrases first (Generate tab).")
if pid is None or pid < 0 or pid >= len(pool["scores"]):
return _preview_fail("Invalid phrase.")
try:
score = pool["scores"][pid]
midi_bytes = backend.score_to_midi_bytes(score)
info = pool["info"][pid]
tf = tempfile.NamedTemporaryFile(
suffix=".mid", delete=False, prefix=f"phrase_{pid}_"
)
tf.write(midi_bytes); tf.close()
header = (
f"<div style='font-size:1.3rem;font-weight:600;color:var(--pg-ink);margin-bottom:8px;'>"
f"Phrase #{pid} &nbsp;·&nbsp; {info['mode'].capitalize()}</div>"
)
score_html = backend.sheet_music_html(score)
return (
pid,
header + backend.midi_player_html(midi_bytes),
score_html,
gr.update(value=tf.name, visible=True),
gr.update(interactive=True),
)
except Exception as e:
import traceback
traceback.print_exc() # full traceback to server / Space logs
return _preview_fail(f"Preview error: {type(e).__name__}: {e}")
# ── Row click → preview ───────────────────────────────────────────────
def _on_row_click(evt: gr.SelectData, pool, df_state):
if pool is None or df_state is None:
return _preview_fail("Generate phrases first.")
row_idx = evt.index[0]
try:
if isinstance(df_state, pd.DataFrame):
pid = int(df_state.iloc[row_idx]["ID"])
else:
pid = int(df_state[row_idx][0])
except (IndexError, KeyError, ValueError):
return _preview_fail("Could not read selection.")
return _do_preview(pool, pid)
phrase_table.select(
fn=_on_row_click,
inputs=[pool_state, sorted_df_state],
outputs=[preview_pid, preview_player, preview_score,
preview_download, confirm_btn],
)
# ── Lock opening phrase ───────────────────────────────────────────────
def _confirm(pool, pid):
if pool is None or pid is None:
msg = _selected_label(None, None)
return (None, msg, msg,
gr.update(interactive=False), gr.update(interactive=False),
gr.update())
info = pool["info"][pid]
# Any phrase can open the piece — it need not start on the tonic.
# Offer the structures whose mode matches this phrase: a major phrase
# → Major progressions, a minor one → Minor.
structs = backend.structures_for_mode(info["mode"])
struct_update = gr.update(choices=["Random"] + structs, value="Random")
msg = _selected_label(pid, info)
return (pid, msg, msg,
gr.update(interactive=True), gr.update(interactive=True),
struct_update)
confirm_btn.click(
fn=_confirm,
inputs=[pool_state, preview_pid],
outputs=[starting_id, selection_md, selection_summary,
stitch_btn, resample_btn, structure_dd],
).then(
# After locking the opening phrase, jump straight to the Compose tab.
# confirm_btn is only enabled once a phrase is previewed, so a click
# always means a valid selection. (Updating a gr.Tabs layout from the
# backend crashes the Gradio 6 frontend, so we switch client-side by
# clicking the third tab header.)
fn=None,
js="""() => { document.querySelectorAll('button[role="tab"]')[2]?.click(); }""",
)
# ── Next-step navigation ──────────────────────────────────────────────
# Outputting an update to a gr.Tabs layout crashes the Gradio 6
# frontend, so tab switching is done client-side instead: each
# button just clicks the corresponding tab header in the DOM.
next1_btn.click(
fn=None,
js="() => { document.querySelectorAll('button[role=\"tab\"]')[1]?.click(); }",
)
# ── Stitch ────────────────────────────────────────────────────────────
def _stitch(pool, sid, structure, progress=gr.Progress()):
_blank = "<em>—</em>"
if pool is None or not pool.get("scores"):
msg = "Generate phrases first (Generate tab)."
return (msg, _blank, _blank, _blank, _blank, _blank, _blank, gr.update(visible=False))
if sid is None:
msg = "Select an opening phrase first (Browse & Select tab)."
return (msg, _blank, _blank, _blank, _blank, _blank, _blank, gr.update(visible=False))
chosen_structure = structure
if chosen_structure == "Random":
# Draw only from structures matching the locked phrase's mode.
info = pool["info"][int(sid)]
pool_structs = backend.structures_for_mode(info["mode"]) or backend.STRUCTURE_NAMES
chosen_structure = random.choice(pool_structs)
try:
progress(0.1, desc="Sampling sections…")
final, ids = backend.run_stitch(
chosen_structure, pool, fixed_beg=int(sid),
)
except ValueError as e:
return (f"Error: {e}", _blank, _blank, _blank, _blank, _blank, _blank, gr.update(visible=False))
try:
final = backend.format_satb(final)
except Exception:
pass # fall back to the raw layout rather than failing the run
beg_id, mid_id, mid2_id, end_id = ids
progress(0.5, desc="Rendering sections…")
open_h = _section_card("Opening", beg_id, pool)
dev_h = _section_card("Phrase 2", mid_id, pool)
exp_h = _section_card("Phrase 3", mid2_id, pool)
close_h = _section_card("Ending", end_id, pool)
progress(0.75, desc="Rendering score…")
try:
score_html = backend.sheet_music_html(final, height="480px")
except Exception as e:
score_html = f"<em>Score unavailable: {e}</em>"
progress(0.9, desc="Rendering audio…")
try:
full_bytes = backend.score_to_midi_bytes(final)
full_html = backend.midi_player_html(full_bytes, height="220px", show_viz=True)
except Exception as e:
full_bytes = None
full_html = f"<em>Audio unavailable: {e}</em>"
file_update = gr.update(visible=False)
if full_bytes is not None:
tf = tempfile.NamedTemporaryFile(
suffix=".mid", delete=False, prefix="progress_composition_"
)
tf.write(full_bytes); tf.close()
file_update = gr.update(value=tf.name, visible=True)
status = (
f"Composed with *{chosen_structure}* &nbsp;·&nbsp; "
f"#{beg_id} → #{mid_id} → #{mid2_id} → #{end_id}"
)
progress(1.0)
return status, open_h, dev_h, exp_h, close_h, score_html, full_html, file_update
stitch_outputs = [
stitch_status,
opening_html, develop_html, expand_html, closing_html,
full_score, full_player, download_btn,
]
stitch_btn.click(
fn=_stitch,
inputs=[pool_state, starting_id, structure_dd],
outputs=stitch_outputs,
)
resample_btn.click(
fn=_stitch,
inputs=[pool_state, starting_id, structure_dd],
outputs=stitch_outputs,
)
# ── Home ↔ generator navigation ────────────────────────────────────────
home_gen_btn.click(
lambda: (gr.update(visible=False), gr.update(visible=True), gr.update(visible=True)),
outputs=[home_view, back_row, tabs],
)
back_btn.click(
lambda: (gr.update(visible=True), gr.update(visible=False), gr.update(visible=False)),
outputs=[home_view, back_row, tabs],
)
return demo
# Build the app at import time so a host that *imports* this module — not only
# `python app.py` — still gets a ready `demo` object. On this Gradio-SDK Space
# the platform executes the file, so the __main__ launch below is what serves
# (the Hugging Face startup log confirms __main__ runs: the Gradio-6 deprecation
# warning fires from create_app(), which is only reached via __main__).
demo = create_app()
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
# Styling MUST be passed to launch(): Gradio 6 ignores theme/css on the Blocks
# constructor, and does NOT pick them up if set as attributes on the object
# (verified), so launch() is the only place they take effect. Keep the theme
# as the un-customized Soft(primary_hue="blue"): this build crashes ('str'
# object has no attribute 'name') on a .set()/neutral_hue-customized theme, so
# all surface/colour styling lives in CSS instead.
# ssr_mode=False: Hugging Face defaults to SSR (a Node proxy in front of
# Python). Under that proxy, Gradio's frontend fails to build file URLs —
# <audio>/gr.Audio sources collapse to bare relative paths like "2?__theme=
# system", which return the HTML page, so the survey samples error with
# "no supported sources". Disabling SSR makes the Space serve and resolve
# exactly like the local run (where audio works), and also removes the other
# SSR-only divergences (CSS-injection ordering, the asyncio shutdown spam).
demo.launch(
server_name="0.0.0.0",
server_port=7860,
show_error=True,
inbrowser=True,
ssr_mode=False,
css=CSS,
theme=gr.themes.Soft(primary_hue="blue"),
)