Spaces:
Running on Zero
Running on Zero
| """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} · {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">▶</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é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 · 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–melodic structure. Furthermore, such existing models are largely | |
| “black-box” 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 & 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} · {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}* · " | |
| 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"), | |
| ) | |