| """Gradio app surface for Midnight Static.""" |
|
|
| from __future__ import annotations |
|
|
| import html |
| import json |
| from pathlib import Path |
|
|
| from .fonts import INLINE_FONTS_CSS |
| from .pipeline import run_fixture_pipeline |
| from .schema import Genre |
| from .showcases import default_showcase, load_showcase, showcase_labels |
|
|
| STATIONS = { |
| Genre.NOIR: {"call": "KNOX", "frequency": "87.9", "label": "Noir", "left": 8}, |
| Genre.WEIRD: {"call": "WEIRD", "frequency": "91.5", "label": "Weird", "left": 25}, |
| Genre.WESTERN: {"call": "GOLD", "frequency": "94.7", "label": "Western", "left": 42}, |
| Genre.ROMANCE: {"call": "HEART", "frequency": "98.3", "label": "Romance", "left": 58}, |
| Genre.COMEDY: {"call": "LAFF", "frequency": "101.1", "label": "Comedy", "left": 75}, |
| Genre.HINDI_MELODRAMA: { |
| "call": "RAAT", |
| "frequency": "104.7", |
| "label": "Hindi melodrama", |
| "left": 92, |
| }, |
| } |
|
|
| GENRE_CHOICES = [(station["label"], genre.value) for genre, station in STATIONS.items()] |
| PREMISE_PLACEHOLDER = "a train conductor hears tomorrow's crash over the radio" |
| ON_AIR_STAGE_TEXT = "87.9 WRITING ... 91.5 CASTING ... 94.7 FOLEY ... 98.3 SCORING ... 101.1 ON AIR" |
|
|
| RADIO_CSS = """ |
| /* Fonts are self-hosted (inlined as data: URIs) via INLINE_FONTS_CSS — no |
| runtime network fetch, keeping the off-the-grid claim clean. */ |
| |
| :root { |
| --ms-bg: #0c0907; |
| --ms-wood: #4a2f1d; |
| --ms-wood-light: #6f4527; |
| --ms-wood-dark: #200e06; |
| --ms-brass: #c9a86a; |
| --ms-brass-light: #f3d495; |
| --ms-brass-dark: #8f6f35; |
| --ms-cream: #f3e9d2; |
| --ms-amber: #ffb84d; |
| --ms-amber-hot: #ff7e2e; |
| --ms-red: #b5341f; |
| --ms-ink: #21170f; |
| --ms-font-deco: "Limelight", "Georgia", serif; |
| --ms-font-ident: "Oswald", "Helvetica Neue", sans-serif; |
| --ms-font-body: "EB Garamond", Georgia, "Times New Roman", serif; |
| --ms-font-script: "Special Elite", "Courier New", Courier, monospace; |
| } |
| |
| body, |
| .gradio-container { |
| background: |
| radial-gradient(circle at 50% 8%, rgba(255, 184, 77, 0.18), transparent 34rem), |
| radial-gradient(ellipse at 50% 120%, rgba(201, 168, 106, 0.10), transparent 46rem), |
| radial-gradient(circle at 50% 50%, #15100b 0%, #0c0907 62%, #060403 100%), |
| #0c0907 !important; |
| color: var(--ms-cream); |
| font-family: var(--ms-font-body); |
| } |
| |
| /* Soft vignette to pull focus toward the radio. */ |
| body::before { |
| content: ""; |
| position: fixed; |
| inset: 0; |
| z-index: 1; |
| pointer-events: none; |
| background: radial-gradient(ellipse at 50% 38%, transparent 38%, rgba(0, 0, 0, 0.55) 100%); |
| animation: ms-flicker 6s ease-in-out infinite; |
| } |
| |
| /* Film-grain overlay for analog texture. */ |
| body::after { |
| content: ""; |
| position: fixed; |
| inset: -50%; |
| z-index: 9998; |
| pointer-events: none; |
| opacity: 0.05; |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); |
| animation: ms-grain 600ms steps(4) infinite; |
| } |
| |
| .gradio-container > * { |
| position: relative; |
| z-index: 2; |
| } |
| |
| .gradio-container { |
| max-width: 880px !important; |
| margin: 0 auto !important; |
| padding: 24px 16px 40px !important; |
| } |
| |
| /* ---- Tame Gradio's default light chrome into the walnut room ---- */ |
| gradio-app, |
| .gradio-container, |
| .gradio-container .main, |
| .gradio-container .wrap, |
| .gradio-container .contain { |
| background: transparent !important; |
| } |
| |
| .gradio-container { |
| --body-background-fill: transparent; |
| --background-fill-primary: transparent; |
| --background-fill-secondary: transparent; |
| --block-background-fill: rgba(18, 12, 8, 0.60); |
| --block-border-color: rgba(201, 168, 106, 0.22); |
| --block-label-background-fill: transparent; |
| --block-label-text-color: #e9d9b6; |
| --block-title-text-color: #e9d9b6; |
| --block-info-text-color: rgba(243, 233, 210, 0.62); |
| --body-text-color: var(--ms-cream); |
| --body-text-color-subdued: rgba(243, 233, 210, 0.6); |
| --border-color-primary: rgba(201, 168, 106, 0.28); |
| --border-color-accent: rgba(201, 168, 106, 0.45); |
| --color-accent-soft: rgba(255, 184, 77, 0.14); |
| --input-background-fill: #f3e9d2; |
| --input-background-fill-focus: #fffaf0; |
| --input-border-color: rgba(143, 111, 53, 0.55); |
| --input-border-color-focus: var(--ms-amber); |
| --input-text-color: #21170f; |
| --input-placeholder-color: rgba(88, 52, 24, 0.66); |
| --checkbox-background-color: #f3e9d2; |
| --checkbox-background-color-selected: var(--ms-red); |
| --checkbox-border-color: rgba(143, 111, 53, 0.6); |
| --checkbox-border-color-selected: var(--ms-red); |
| --checkbox-label-background-fill: rgba(243, 233, 210, 0.06); |
| --checkbox-label-background-fill-selected: rgba(255, 184, 77, 0.18); |
| --checkbox-label-background-fill-hover: rgba(255, 184, 77, 0.1); |
| --checkbox-label-text-color: var(--ms-cream); |
| --checkbox-label-border-color: rgba(201, 168, 106, 0.3); |
| --neutral-950: #0c0907; |
| } |
| |
| /* Remove leftover white panels behind grouped blocks. */ |
| .gradio-container .gap, |
| .gradio-container .form, |
| .gradio-container .panel { |
| background: transparent !important; |
| } |
| |
| /* Hide the multipage navbar on the styled radio (this CSS only loads here); |
| navigation to the plain feed is offered via the footer link instead. */ |
| .nav-holder { |
| display: none !important; |
| } |
| |
| .ms-stage { |
| display: grid; |
| place-items: center; |
| margin: 0 auto 22px; |
| } |
| |
| .ms-radio { |
| width: min(780px, 100%); |
| min-height: 440px; |
| border-radius: 42px 42px 26px 26px; |
| position: relative; |
| overflow: hidden; |
| padding: 30px; |
| background: |
| linear-gradient(90deg, rgba(255,255,255,0.06), transparent 16%, rgba(0,0,0,0.22) 54%, rgba(255,255,255,0.05)), |
| repeating-linear-gradient(86deg, rgba(255,255,255,0.03) 0 2px, transparent 2px 7px, rgba(0,0,0,0.06) 7px 9px), |
| radial-gradient(ellipse at 28% 18%, rgba(255, 220, 160, 0.16), transparent 50%), |
| linear-gradient(135deg, var(--ms-wood-light), var(--ms-wood) 44%, var(--ms-wood-dark)); |
| box-shadow: |
| inset 0 0 0 3px rgba(255, 225, 160, 0.10), |
| inset 0 2px 1px rgba(255, 235, 190, 0.18), |
| inset 0 -20px 34px rgba(0,0,0,0.40), |
| 0 2px 0 rgba(255,235,190,0.06), |
| 0 40px 90px rgba(0,0,0,0.62); |
| } |
| |
| .ms-radio::before { |
| content: ""; |
| position: absolute; |
| inset: 15px; |
| border: 2px solid rgba(201, 168, 106, 0.46); |
| border-radius: 30px 30px 18px 18px; |
| box-shadow: inset 0 0 22px rgba(0,0,0,0.28), 0 0 1px rgba(255,225,160,0.4); |
| pointer-events: none; |
| } |
| |
| /* Brass cabinet screws. */ |
| .ms-screw { |
| position: absolute; |
| width: 11px; |
| height: 11px; |
| border-radius: 999px; |
| background: radial-gradient(circle at 38% 32%, var(--ms-brass-light), var(--ms-brass) 45%, var(--ms-brass-dark) 90%); |
| box-shadow: inset 0 0 2px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.55); |
| z-index: 3; |
| } |
| .ms-screw::after { |
| content: ""; |
| position: absolute; |
| inset: 3px; |
| border-top: 1.4px solid rgba(60, 40, 16, 0.7); |
| transform: rotate(34deg); |
| } |
| .ms-screw.tl { top: 22px; left: 22px; } |
| .ms-screw.tr { top: 22px; right: 22px; } |
| .ms-screw.bl { bottom: 22px; left: 22px; } |
| .ms-screw.br { bottom: 22px; right: 22px; } |
| |
| .ms-brand { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| gap: 16px; |
| margin-bottom: 24px; |
| padding: 0 8px; |
| } |
| |
| .ms-brand-name { |
| font-family: var(--ms-font-deco); |
| color: var(--ms-brass-light); |
| letter-spacing: 0.14em; |
| text-transform: uppercase; |
| font-size: clamp(1.3rem, 3vw, 2.15rem); |
| text-shadow: |
| 0 1px 0 rgba(60, 38, 14, 0.9), |
| 0 0 18px rgba(255, 184, 77, 0.32); |
| } |
| |
| .ms-onair { |
| display: inline-flex; |
| align-items: center; |
| gap: 9px; |
| font-family: var(--ms-font-ident); |
| font-weight: 600; |
| letter-spacing: 0.22em; |
| font-size: 0.72rem; |
| color: #ffcaa0; |
| padding: 5px 12px 5px 11px; |
| border: 1px solid rgba(181, 52, 31, 0.55); |
| border-radius: 4px; |
| background: linear-gradient(180deg, rgba(60, 14, 8, 0.6), rgba(20, 6, 4, 0.5)); |
| box-shadow: inset 0 0 10px rgba(0,0,0,0.5); |
| } |
| |
| .ms-lamp { |
| width: 12px; |
| height: 12px; |
| border-radius: 999px; |
| background: radial-gradient(circle at 35% 30%, #ffe3b0, var(--ms-amber) 55%, var(--ms-amber-hot)); |
| box-shadow: 0 0 12px var(--ms-amber), 0 0 26px rgba(255,184,77,0.55); |
| animation: ms-neon 3.2s ease-in-out infinite; |
| } |
| |
| .ms-radio.is-scanning .ms-lamp { |
| animation: ms-lamp-pulse 700ms ease-in-out infinite alternate; |
| } |
| |
| .ms-face { |
| display: grid; |
| grid-template-columns: minmax(0, 1.1fr) minmax(250px, 0.9fr); |
| gap: 28px; |
| align-items: stretch; |
| } |
| |
| .ms-speaker { |
| position: relative; |
| min-height: 252px; |
| border-radius: 18px; |
| border: 2px solid rgba(201, 168, 106, 0.5); |
| overflow: hidden; |
| background: |
| radial-gradient(circle at 50% 46%, rgba(255, 150, 60, 0.14), transparent 58%), |
| repeating-radial-gradient(circle at 50% 46%, rgba(35, 24, 16, 0.0) 0 6px, rgba(0,0,0,0.22) 6px 7px), |
| repeating-linear-gradient(90deg, rgba(35, 24, 16, 0.9) 0 7px, rgba(243, 233, 210, 0.13) 7px 10px), |
| #20140d; |
| box-shadow: inset 0 0 40px rgba(0,0,0,0.72), inset 0 0 0 1px rgba(0,0,0,0.4); |
| } |
| |
| /* Warm cone glow behind the grille; intensifies while broadcasting. */ |
| .ms-speaker::after { |
| content: ""; |
| position: absolute; |
| left: 50%; |
| top: 46%; |
| width: 64%; |
| aspect-ratio: 1; |
| transform: translate(-50%, -50%); |
| border-radius: 999px; |
| background: radial-gradient(circle, rgba(255, 138, 46, 0.28), rgba(255, 138, 46, 0.07) 42%, transparent 68%); |
| filter: blur(2px); |
| animation: ms-cone 5s ease-in-out infinite; |
| } |
| |
| .ms-radio.is-scanning .ms-speaker::after { |
| animation: ms-cone-live 900ms ease-in-out infinite; |
| } |
| |
| .ms-panel { |
| display: grid; |
| gap: 18px; |
| } |
| |
| .ms-dial { |
| min-height: 118px; |
| border-radius: 14px; |
| border: 2px solid rgba(201, 168, 106, 0.85); |
| background: |
| linear-gradient(180deg, rgba(255,255,255,0.45), transparent 26%), |
| repeating-linear-gradient(90deg, rgba(90, 58, 24, 0.30) 0 1px, transparent 1px 7.6%), |
| radial-gradient(ellipse at 50% 40%, #fff3d6, var(--ms-cream) 40%, #d9c187 100%); |
| color: var(--ms-ink); |
| box-shadow: |
| inset 0 0 26px rgba(55, 35, 10, 0.32), |
| inset 0 0 0 1px rgba(255,255,255,0.4), |
| 0 0 26px rgba(255,184,77,0.20); |
| position: relative; |
| overflow: hidden; |
| padding: 16px 18px 14px; |
| } |
| |
| /* Curved glass reflection across the tuning window. */ |
| .ms-dial::before { |
| content: ""; |
| position: absolute; |
| left: -20%; |
| top: -60%; |
| width: 60%; |
| height: 200%; |
| transform: rotate(18deg); |
| background: linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent); |
| pointer-events: none; |
| } |
| |
| .ms-frequency { |
| display: flex; |
| justify-content: space-between; |
| gap: 8px; |
| font-family: var(--ms-font-ident); |
| font-weight: 500; |
| font-size: 0.76rem; |
| letter-spacing: 0.08em; |
| position: relative; |
| z-index: 1; |
| } |
| |
| .ms-frequency span { |
| color: #3a2410; |
| opacity: 0.92; |
| } |
| |
| .ms-frequency span.is-tuned { |
| color: #8a2114; |
| font-weight: 800; |
| opacity: 1; |
| } |
| |
| .ms-needle { |
| position: absolute; |
| left: var(--ms-needle-left, 25%); |
| top: 10px; |
| bottom: 10px; |
| width: 2px; |
| z-index: 2; |
| background: linear-gradient(180deg, var(--ms-red), #e2502f, var(--ms-red)); |
| box-shadow: 0 0 10px rgba(226, 80, 47, 0.7), 0 0 22px rgba(226, 80, 47, 0.4); |
| transition: left 360ms cubic-bezier(.18, .89, .32, 1.28); |
| } |
| |
| .ms-needle::before { |
| content: ""; |
| position: absolute; |
| top: -4px; |
| left: 50%; |
| transform: translateX(-50%); |
| width: 9px; |
| height: 9px; |
| border-radius: 999px; |
| background: radial-gradient(circle at 38% 32%, #ff8a5a, var(--ms-red)); |
| box-shadow: 0 0 10px rgba(226, 80, 47, 0.8); |
| } |
| |
| .ms-radio.is-scanning .ms-needle { |
| animation: ms-needle-scan 1.35s ease-in-out infinite alternate; |
| } |
| |
| .ms-station { |
| position: absolute; |
| left: 18px; |
| right: 18px; |
| bottom: 13px; |
| z-index: 1; |
| color: #4a2c12; |
| font-family: var(--ms-font-ident); |
| font-weight: 600; |
| font-size: 0.82rem; |
| letter-spacing: 0.2em; |
| text-align: center; |
| } |
| |
| .ms-station strong { |
| color: var(--ms-red); |
| font-weight: 700; |
| text-shadow: 0 0 10px rgba(226, 80, 47, 0.35); |
| } |
| |
| .ms-scanner { |
| position: absolute; |
| left: 18px; |
| right: 18px; |
| bottom: 13px; |
| z-index: 1; |
| color: var(--ms-red); |
| font-family: var(--ms-font-ident); |
| font-weight: 600; |
| letter-spacing: 0.14em; |
| text-align: center; |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| |
| .ms-radio.is-scanning .ms-frequency span { |
| animation: ms-frequency-glow 1.1s ease-in-out infinite; |
| } |
| |
| .ms-radio.is-scanning .ms-frequency span:nth-child(2) { |
| animation-delay: 140ms; |
| } |
| |
| .ms-radio.is-scanning .ms-frequency span:nth-child(3) { |
| animation-delay: 280ms; |
| } |
| |
| .ms-radio.is-scanning .ms-frequency span:nth-child(4) { |
| animation-delay: 420ms; |
| } |
| |
| .ms-radio.is-scanning .ms-frequency span:nth-child(5) { |
| animation-delay: 560ms; |
| } |
| |
| .ms-radio.is-scanning .ms-frequency span:nth-child(6) { |
| animation-delay: 700ms; |
| } |
| |
| .ms-knobs { |
| display: flex; |
| justify-content: space-between; |
| gap: 18px; |
| } |
| |
| .ms-knob { |
| position: relative; |
| width: 84px; |
| aspect-ratio: 1; |
| border-radius: 999px; |
| background: |
| radial-gradient(circle at 35% 28%, var(--ms-brass-light), var(--ms-brass) 38%, var(--ms-brass-dark) 72%, #6a5024 100%); |
| box-shadow: |
| inset -9px -11px 18px rgba(0,0,0,0.34), |
| inset 6px 7px 12px rgba(255, 235, 190, 0.32), |
| 0 12px 22px rgba(0,0,0,0.32); |
| } |
| |
| .ms-knob::before { |
| content: ""; |
| position: absolute; |
| inset: 18%; |
| border-radius: 999px; |
| background: repeating-conic-gradient(rgba(60,40,16,0.0) 0deg 8deg, rgba(60,40,16,0.32) 8deg 10deg); |
| -webkit-mask: radial-gradient(circle, transparent 56%, #000 57%); |
| mask: radial-gradient(circle, transparent 56%, #000 57%); |
| } |
| |
| .ms-knob::after { |
| content: ""; |
| position: absolute; |
| top: 13%; |
| left: 50%; |
| width: 3px; |
| height: 26%; |
| transform: translateX(-50%); |
| border-radius: 2px; |
| background: rgba(40, 26, 10, 0.8); |
| } |
| |
| .ms-telegram { |
| max-width: 780px; |
| margin: 0 auto !important; |
| border-radius: 10px; |
| border: 1px solid rgba(201, 168, 106, 0.3) !important; |
| background: |
| radial-gradient(ellipse at 50% 0%, rgba(255, 184, 77, 0.06), transparent 60%), |
| linear-gradient(180deg, rgba(40, 28, 19, 0.92), rgba(22, 15, 10, 0.92)) !important; |
| padding: 20px !important; |
| box-shadow: inset 0 0 0 1px rgba(255, 235, 190, 0.04), 0 18px 36px rgba(0,0,0,0.42); |
| } |
| |
| .ms-telegram label, |
| .ms-telegram .wrap label, |
| .ms-telegram span { |
| color: #f3e9d2 !important; |
| } |
| |
| .ms-telegram textarea, |
| .ms-telegram input, |
| .ms-telegram select { |
| background: #f3e9d2 !important; |
| color: #21170f !important; |
| border-color: rgba(201, 168, 106, 0.72) !important; |
| } |
| |
| /* ---- Showcase "rerun" tuner: a clearly-themed dropdown ---- */ |
| .ms-showcase { |
| margin-bottom: 16px !important; |
| } |
| .ms-showcase label > span { |
| font-family: var(--ms-font-ident) !important; |
| letter-spacing: 0.18em !important; |
| text-transform: uppercase !important; |
| font-size: 0.72rem !important; |
| color: var(--ms-brass-light) !important; |
| } |
| /* The collapsed field reads like a brass-framed station selector. */ |
| .ms-showcase .wrap, |
| .ms-showcase .secondary-wrap { |
| background: linear-gradient(180deg, #f7efda, #e7d8b2) !important; |
| border: 1px solid var(--ms-brass) !important; |
| border-radius: 8px !important; |
| box-shadow: inset 0 1px 2px rgba(255,255,255,0.6), inset 0 -3px 8px rgba(90,60,20,0.18) !important; |
| } |
| .ms-showcase input { |
| background: transparent !important; |
| color: #21170f !important; |
| font-family: var(--ms-font-body) !important; |
| font-weight: 600 !important; |
| border: none !important; |
| } |
| /* Brass chevron so it's obviously a control. */ |
| .ms-showcase .icon-wrap, |
| .ms-showcase svg { |
| color: var(--ms-brass-dark) !important; |
| fill: var(--ms-brass-dark) !important; |
| opacity: 1 !important; |
| } |
| /* The open options list: dark walnut so it sits on the cabinet, not a white slab. */ |
| .ms-showcase ul.options, |
| .ms-telegram ul.options { |
| background: linear-gradient(180deg, rgba(34,23,15,0.98), rgba(22,15,10,0.98)) !important; |
| border: 1px solid var(--ms-brass) !important; |
| border-radius: 8px !important; |
| box-shadow: 0 14px 28px rgba(0,0,0,0.5) !important; |
| color: #f3e9d2 !important; |
| z-index: 40 !important; |
| } |
| .ms-showcase ul.options li, |
| .ms-telegram ul.options li { |
| color: #f3e9d2 !important; |
| background: transparent !important; |
| } |
| .ms-showcase ul.options li:hover, |
| .ms-showcase ul.options li.selected, |
| .ms-showcase ul.options li.active, |
| .ms-telegram ul.options li:hover, |
| .ms-telegram ul.options li.active { |
| background: rgba(255, 184, 77, 0.18) !important; |
| color: #fff3d8 !important; |
| } |
| |
| /* Only the ON AIR button is the big brass lozenge. (Scoping to .primary keeps |
| the audio recorder/player's own controls — play, skip, trim, clear — as their |
| compact default icon buttons instead of bloated brass pills.) */ |
| .ms-telegram button.primary { |
| position: relative !important; |
| border-radius: 999px !important; |
| background: |
| radial-gradient(circle at 50% 30%, var(--ms-brass-light), var(--ms-brass) 42%, var(--ms-brass-dark) 88%) !important; |
| color: #2a1709 !important; |
| border: 1px solid #f3d99a !important; |
| font-family: var(--ms-font-ident) !important; |
| font-weight: 700 !important; |
| letter-spacing: 0.28em !important; |
| text-transform: uppercase !important; |
| padding: 12px 22px 12px 38px !important; |
| box-shadow: |
| inset 0 2px 2px rgba(255, 245, 215, 0.6), |
| inset 0 -5px 10px rgba(60, 40, 14, 0.4), |
| 0 8px 18px rgba(0,0,0,0.4) !important; |
| transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease !important; |
| } |
| |
| /* Audio recorder + player controls: small, neutral icon buttons that read on |
| the slip — never brass lozenges. */ |
| .ms-callin button:not(.primary), |
| .ms-output button:not(.primary) { |
| border-radius: 7px !important; |
| letter-spacing: normal !important; |
| text-transform: none !important; |
| font-family: var(--ms-font-body) !important; |
| font-weight: 500 !important; |
| padding: 4px 9px !important; |
| background: rgba(243, 233, 210, 0.9) !important; |
| color: #2a1709 !important; |
| border: 1px solid rgba(201, 168, 106, 0.55) !important; |
| box-shadow: none !important; |
| } |
| .ms-callin button:not(.primary):hover, |
| .ms-output button:not(.primary):hover { |
| background: #fffaf0 !important; |
| border-color: var(--ms-brass) !important; |
| } |
| |
| /* Red transmit lamp embedded in the ON AIR button. */ |
| .ms-telegram button.primary::before { |
| content: ""; |
| position: absolute; |
| left: 16px; |
| top: 50%; |
| transform: translateY(-50%); |
| width: 11px; |
| height: 11px; |
| border-radius: 999px; |
| background: radial-gradient(circle at 36% 30%, #ff9b6e, var(--ms-red) 60%, #6e1a0e); |
| box-shadow: 0 0 10px rgba(226, 80, 47, 0.85), inset 0 0 3px rgba(0,0,0,0.5); |
| animation: ms-neon 2.6s ease-in-out infinite; |
| } |
| |
| .ms-telegram button.primary:hover { |
| filter: brightness(1.06); |
| transform: translateY(-1px); |
| box-shadow: |
| inset 0 2px 2px rgba(255, 245, 215, 0.7), |
| inset 0 -5px 10px rgba(60, 40, 14, 0.4), |
| 0 12px 24px rgba(0,0,0,0.46), |
| 0 0 22px rgba(255, 184, 77, 0.3) !important; |
| } |
| |
| .ms-telegram button.primary:active { |
| transform: translateY(1px); |
| box-shadow: inset 0 3px 8px rgba(40, 24, 8, 0.55) !important; |
| } |
| |
| .ms-status { |
| border-radius: 5px; |
| border: 1px solid rgba(255, 184, 77, 0.34); |
| background: |
| repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0 1px, transparent 1px 3px), |
| linear-gradient(180deg, rgba(18, 12, 8, 0.72), rgba(8, 6, 4, 0.7)); |
| color: #ffd99a; |
| font-family: var(--ms-font-ident); |
| font-weight: 500; |
| font-size: 0.8rem; |
| letter-spacing: 0.22em; |
| margin-top: 12px; |
| min-height: 44px; |
| padding: 13px 15px; |
| text-transform: uppercase; |
| text-shadow: 0 0 12px rgba(255, 184, 77, 0.4); |
| } |
| |
| .ms-status.is-live { |
| border-color: rgba(255, 184, 77, 0.6); |
| box-shadow: inset 0 0 18px rgba(255, 184, 77, 0.12), 0 0 22px rgba(255, 184, 77, 0.22); |
| animation: ms-flicker 4s ease-in-out infinite; |
| } |
| |
| .ms-slip { |
| position: relative; |
| overflow: hidden; |
| border-radius: 5px !important; |
| border: 1px solid rgba(111, 72, 30, 0.48) !important; |
| background: |
| linear-gradient(90deg, rgba(125, 83, 38, 0.08) 0 1px, transparent 1px 100%), |
| repeating-linear-gradient(0deg, transparent 0 29px, rgba(89, 55, 24, 0.16) 29px 30px), |
| #f3e9d2 !important; |
| padding: 14px 16px 16px !important; |
| box-shadow: inset 0 0 24px rgba(72, 40, 16, 0.12), 0 10px 22px rgba(0,0,0,0.18) !important; |
| } |
| |
| .ms-slip::after { |
| content: ""; |
| position: absolute; |
| inset: 0; |
| pointer-events: none; |
| background: |
| radial-gradient(circle at 14% 24%, rgba(83, 51, 23, 0.08), transparent 9rem), |
| linear-gradient(90deg, transparent 0 72%, rgba(95, 58, 25, 0.08)); |
| mix-blend-mode: multiply; |
| } |
| |
| .ms-slip-head { |
| position: relative; |
| z-index: 1; |
| display: grid; |
| grid-template-columns: 1fr auto; |
| gap: 12px; |
| align-items: end; |
| color: #6b4220; |
| font-family: var(--ms-font-script); |
| font-size: 0.72rem; |
| letter-spacing: 0.1em; |
| text-transform: uppercase; |
| margin-bottom: 10px; |
| padding-bottom: 8px; |
| border-bottom: 1.5px dashed rgba(111, 72, 30, 0.4); |
| } |
| |
| .ms-slip-head strong { |
| display: block; |
| color: var(--ms-red); |
| font-size: 1rem; |
| letter-spacing: 0.14em; |
| } |
| |
| .ms-slip-head span:last-child { |
| text-align: right; |
| } |
| |
| .ms-slip .form, |
| .ms-slip .wrap, |
| .ms-slip label, |
| .ms-slip textarea { |
| position: relative; |
| z-index: 1; |
| } |
| |
| .ms-slip label > span { |
| color: #583418 !important; |
| letter-spacing: 0.08em; |
| text-transform: uppercase; |
| } |
| |
| .ms-slip textarea { |
| min-height: 86px !important; |
| resize: vertical !important; |
| border: 0 !important; |
| border-radius: 0 !important; |
| background: transparent !important; |
| color: #21170f !important; |
| box-shadow: none !important; |
| font-family: var(--ms-font-script) !important; |
| font-size: 0.98rem !important; |
| line-height: 1.85 !important; |
| } |
| |
| .ms-slip textarea::placeholder { |
| color: rgba(88, 52, 24, 0.66) !important; |
| } |
| |
| .ms-genre label { |
| color: #f3e9d2 !important; |
| } |
| |
| /* ---- Radio option pills (Genre / Render mode / Writer): make the |
| selected station unmistakable — a lit brass pill with dark ink. ---- */ |
| .ms-telegram .wrap label { |
| border: 1px solid rgba(201, 168, 106, 0.38) !important; |
| border-radius: 999px !important; |
| background: rgba(0, 0, 0, 0.28) !important; |
| padding: 6px 14px !important; |
| transition: background 120ms ease, box-shadow 120ms ease, border-color 120ms ease !important; |
| } |
| .ms-telegram .wrap label:hover { |
| border-color: var(--ms-brass) !important; |
| background: rgba(255, 184, 77, 0.12) !important; |
| } |
| .ms-telegram .wrap label.selected, |
| .ms-telegram .wrap label:has(input:checked) { |
| background: |
| radial-gradient(circle at 50% 28%, var(--ms-brass-light), var(--ms-brass) 62%, var(--ms-brass-dark) 100%) !important; |
| border-color: var(--ms-brass-light) !important; |
| box-shadow: |
| inset 0 1px 1px rgba(255, 245, 215, 0.6), |
| 0 0 12px rgba(255, 184, 77, 0.45) !important; |
| } |
| .ms-telegram .wrap label.selected span, |
| .ms-telegram .wrap label:has(input:checked) span { |
| color: #2a1709 !important; |
| font-weight: 700 !important; |
| } |
| /* Recolor the native radio dot so the checked state reads on the brass pill. */ |
| .ms-telegram .wrap label input[type="radio"] { |
| accent-color: var(--ms-red) !important; |
| } |
| |
| .ms-output { |
| max-width: 780px; |
| margin: 18px auto 0 !important; |
| border-radius: 10px; |
| border: 1px solid rgba(201, 168, 106, 0.24) !important; |
| background: linear-gradient(180deg, rgba(20, 14, 9, 0.78), rgba(10, 7, 5, 0.78)) !important; |
| padding: 18px !important; |
| } |
| |
| .ms-script { |
| position: relative; |
| overflow: hidden; |
| border-radius: 5px; |
| border: 1px solid rgba(111, 72, 30, 0.48); |
| background: |
| repeating-linear-gradient(0deg, transparent 0 27px, rgba(89, 55, 24, 0.12) 27px 28px), |
| #f3e9d2; |
| color: #21170f; |
| padding: 22px 24px 24px; |
| font-family: var(--ms-font-script); |
| box-shadow: inset 0 0 24px rgba(72, 40, 16, 0.12), 0 10px 22px rgba(0,0,0,0.22); |
| } |
| |
| /* Red "ON AIR" cue stamp in the script corner. */ |
| .ms-script::after { |
| content: "ON AIR"; |
| position: absolute; |
| top: 18px; |
| right: 20px; |
| transform: rotate(7deg); |
| background: rgba(181, 52, 31, 0.12); |
| color: rgba(181, 52, 31, 0.66); |
| border: 2px solid rgba(181, 52, 31, 0.5); |
| border-radius: 4px; |
| font-family: var(--ms-font-ident); |
| font-weight: 700; |
| letter-spacing: 0.22em; |
| font-size: 0.72rem; |
| padding: 5px 12px; |
| pointer-events: none; |
| } |
| |
| .ms-script-head { |
| border-bottom: 2px solid rgba(89, 55, 24, 0.42); |
| padding-bottom: 12px; |
| margin-bottom: 14px; |
| } |
| |
| .ms-script-title { |
| margin: 0; |
| font-family: var(--ms-font-deco); |
| font-size: clamp(1.25rem, 2.8vw, 1.85rem); |
| letter-spacing: 0.04em; |
| color: #5a1d10; |
| } |
| |
| .ms-script-logline { |
| margin: 8px 0 0; |
| font-family: var(--ms-font-body); |
| font-style: italic; |
| font-size: 1.05rem; |
| color: #4a3320; |
| } |
| |
| .ms-script-meta { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 8px; |
| margin-top: 12px; |
| font-family: var(--ms-font-ident); |
| font-size: 0.68rem; |
| letter-spacing: 0.14em; |
| text-transform: uppercase; |
| } |
| |
| .ms-script-tag { |
| border: 1px solid rgba(111, 72, 30, 0.5); |
| border-radius: 999px; |
| padding: 2px 10px; |
| color: #5a3a1c; |
| } |
| |
| .ms-script-cast { |
| display: grid; |
| gap: 4px; |
| margin-bottom: 16px; |
| font-size: 0.84rem; |
| } |
| |
| .ms-script-cast-row strong { |
| color: #5a1d10; |
| letter-spacing: 0.04em; |
| } |
| |
| .ms-script-cast-row span { |
| color: #6a4a2c; |
| } |
| |
| .ms-scene-slug { |
| margin: 14px 0 8px; |
| font-weight: 700; |
| letter-spacing: 0.14em; |
| text-transform: uppercase; |
| color: #5a3a1c; |
| } |
| |
| .ms-scene-sfx { |
| color: #8a5a2c; |
| font-style: italic; |
| margin: 4px 0 10px; |
| font-size: 0.82rem; |
| } |
| |
| .ms-line { |
| display: grid; |
| grid-template-columns: minmax(86px, 22%) 1fr; |
| gap: 10px; |
| padding: 3px 0; |
| line-height: 1.5; |
| } |
| |
| .ms-script, |
| .ms-line-text, |
| .ms-script-cast-row { |
| color: #21170f; |
| } |
| |
| .ms-line-who { |
| text-align: right; |
| font-weight: 700; |
| color: #5a1d10; |
| letter-spacing: 0.04em; |
| } |
| |
| .ms-line-delivery { |
| color: #9a6a34; |
| font-style: italic; |
| } |
| |
| .ms-script-empty { |
| color: #6a4a2c; |
| font-style: italic; |
| } |
| |
| .ms-footer { |
| max-width: 780px; |
| margin: 16px auto 0; |
| text-align: center; |
| font-family: var(--ms-font-ident); |
| font-size: 0.74rem; |
| letter-spacing: 0.12em; |
| text-transform: uppercase; |
| color: rgba(243, 233, 210, 0.5); |
| } |
| |
| .ms-footer a { |
| color: var(--ms-brass); |
| text-decoration: none; |
| border-bottom: 1px dotted rgba(201, 168, 106, 0.5); |
| } |
| |
| .ms-footer a:hover { |
| color: var(--ms-amber); |
| } |
| |
| @media (max-width: 760px) { |
| .ms-line { |
| grid-template-columns: 1fr; |
| gap: 1px; |
| padding: 6px 0; |
| } |
| .ms-line-who { |
| text-align: left; |
| } |
| } |
| |
| @media (max-width: 760px) { |
| .gradio-container { |
| padding: 14px 10px 28px !important; |
| } |
| .ms-radio { |
| min-height: 0; |
| padding: 20px; |
| border-radius: 24px 24px 18px 18px; |
| } |
| .ms-brand { |
| align-items: flex-start; |
| flex-direction: column; |
| letter-spacing: 0.12em; |
| } |
| .ms-face { |
| grid-template-columns: 1fr; |
| } |
| .ms-speaker { |
| min-height: 170px; |
| } |
| .ms-scanner, |
| .ms-station { |
| font-size: 0.72rem; |
| letter-spacing: 0.08em; |
| } |
| } |
| |
| @keyframes ms-lamp-pulse { |
| from { |
| opacity: 0.62; |
| box-shadow: 0 0 10px var(--ms-amber), 0 0 18px rgba(255,184,77,0.35); |
| } |
| to { |
| opacity: 1; |
| box-shadow: 0 0 18px var(--ms-amber), 0 0 44px rgba(255,184,77,0.68); |
| } |
| } |
| |
| @keyframes ms-needle-scan { |
| from { |
| left: 8%; |
| } |
| to { |
| left: 92%; |
| } |
| } |
| |
| @keyframes ms-frequency-glow { |
| 0%, 100% { |
| color: #583418; |
| opacity: 0.55; |
| } |
| 50% { |
| color: #9f2f1c; |
| opacity: 1; |
| } |
| } |
| |
| @keyframes ms-flicker { |
| 0%, 100% { opacity: 1; } |
| 48% { opacity: 1; } |
| 50% { opacity: 0.94; } |
| 52% { opacity: 1; } |
| 78% { opacity: 0.97; } |
| } |
| |
| @keyframes ms-grain { |
| 0% { transform: translate(0, 0); } |
| 25% { transform: translate(-6%, 4%); } |
| 50% { transform: translate(4%, -6%); } |
| 75% { transform: translate(-4%, -3%); } |
| 100% { transform: translate(5%, 5%); } |
| } |
| |
| @keyframes ms-neon { |
| 0%, 100% { |
| opacity: 1; |
| box-shadow: 0 0 12px var(--ms-amber), 0 0 26px rgba(255,184,77,0.55); |
| } |
| 43% { opacity: 1; } |
| 45% { |
| opacity: 0.55; |
| box-shadow: 0 0 6px var(--ms-amber), 0 0 12px rgba(255,184,77,0.3); |
| } |
| 47% { opacity: 1; } |
| 92% { opacity: 1; } |
| 94% { opacity: 0.7; } |
| } |
| |
| @keyframes ms-cone { |
| 0%, 100% { opacity: 0.5; transform: translate(-50%, -50%) scale(1); } |
| 50% { opacity: 0.78; transform: translate(-50%, -50%) scale(1.06); } |
| } |
| |
| @keyframes ms-cone-live { |
| 0%, 100% { opacity: 0.7; transform: translate(-50%, -50%) scale(0.96); } |
| 50% { opacity: 1; transform: translate(-50%, -50%) scale(1.12); } |
| } |
| |
| /* ============================================================ |
| LEGIBILITY HARDENING |
| The dial glass and the script paper are LIGHT insets inside the dark |
| walnut room. Gradio paints HTML text with --body-text-color, which we set |
| to cream for the dark chrome — that cream leaked onto these light surfaces |
| and made the frequencies, station line, and script text invisible. Force |
| ink on those surfaces with !important (Gradio's variable rule wins |
| otherwise), then re-assert the few accent colors so they survive. |
| ============================================================ */ |
| .ms-dial, |
| .ms-dial div, |
| .ms-dial span, |
| .ms-script, |
| .ms-script p, |
| .ms-script div, |
| .ms-script span, |
| .ms-script strong { |
| color: #21170f !important; |
| } |
| |
| /* Accent colors re-asserted after the blanket ink rule above. */ |
| .ms-frequency span.is-tuned { color: #8a2114 !important; } |
| .ms-station strong { color: var(--ms-red) !important; } |
| .ms-scanner { color: var(--ms-red) !important; } |
| .ms-script-title, |
| .ms-line-who, |
| .ms-script-cast-row strong { color: #5a1d10 !important; } |
| .ms-script-logline { color: #4a3320 !important; } |
| .ms-scene-sfx { color: #8a5a2c !important; } |
| .ms-line-delivery { color: #9a6a34 !important; } |
| """ |
|
|
|
|
| def _radio_shell_html( |
| genre: Genre | str = Genre.WEIRD, |
| state: str = "idle", |
| scanner_text: str = ON_AIR_STAGE_TEXT, |
| ) -> str: |
| tuned = _coerce_genre(genre) |
| current = STATIONS[tuned] |
| is_scanning = state == "scanning" |
| frequencies = "".join( |
| f'<span class="{"is-tuned" if station_genre == tuned else ""}">' |
| f'{station["frequency"]}</span>' |
| for station_genre, station in STATIONS.items() |
| ) |
| station_line = " · ".join( |
| f'<strong>{station["call"]}</strong>' if station_genre == tuned else station["call"] |
| for station_genre, station in STATIONS.items() |
| ) |
| dial_message = ( |
| f'<div class="ms-scanner">{scanner_text}</div>' |
| if is_scanning |
| else f'<div class="ms-station">{station_line}</div>' |
| ) |
| radio_class = "ms-radio is-scanning" if is_scanning else "ms-radio" |
| return f""" |
| <div class="ms-stage"> |
| <div class="{radio_class}" aria-label="Midnight Static radio cabinet"> |
| <span class="ms-screw tl"></span> |
| <span class="ms-screw tr"></span> |
| <span class="ms-screw bl"></span> |
| <span class="ms-screw br"></span> |
| <div class="ms-brand"> |
| <span class="ms-brand-name">Midnight Static</span> |
| <span class="ms-onair"><span class="ms-lamp"></span>ON AIR</span> |
| </div> |
| <div class="ms-face"> |
| <div class="ms-speaker"></div> |
| <div class="ms-panel"> |
| <div class="ms-dial" style="--ms-needle-left: {current["left"]}%;"> |
| <div class="ms-frequency"> |
| {frequencies} |
| </div> |
| <div class="ms-needle"></div> |
| {dial_message} |
| </div> |
| <div class="ms-knobs" aria-hidden="true"> |
| <div class="ms-knob"></div> |
| <div class="ms-knob"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| """ |
|
|
|
|
| def _coerce_genre(value: Genre | str) -> Genre: |
| try: |
| return Genre(value) |
| except ValueError: |
| return Genre.WEIRD |
|
|
|
|
| def generate_broadcast( |
| premise: str, |
| mode: str = "placeholder", |
| writer: str = "fixture", |
| genre: str = Genre.WEIRD.value, |
| output_dir: str = "outputs", |
| ) -> tuple[str, str]: |
| """Generate a broadcast and return `(audio_path, script_json)` for Gradio.""" |
| result = run_fixture_pipeline( |
| premise=premise, |
| output_dir=Path(output_dir), |
| mode=mode, |
| writer_name=writer, |
| genre=genre, |
| ) |
| script_path = Path(result["script"]) |
| return result["audio"], script_path.read_text(encoding="utf-8") |
|
|
|
|
| def transcribe_call_in(audio_path): |
| """Transcribe a call-in recording into the premise box for confirm/edit. |
| |
| Never auto-submits: the transcript only populates the editable premise field; |
| the caller reviews/edits it and presses ON AIR themselves. Degrades to a |
| status message if ASR is unavailable or no speech is heard. |
| """ |
| import gradio as gr |
|
|
| if not audio_path: |
| return gr.update(), status_html("CALL IN: NOTHING RECORDED") |
| try: |
| from .asr import transcribe |
|
|
| text = transcribe(audio_path) |
| except Exception: |
| return gr.update(), status_html("CALL IN UNAVAILABLE - TYPE YOUR PREMISE") |
| if not text: |
| return gr.update(), status_html("CALL IN: NO SPEECH HEARD - TYPE YOUR PREMISE") |
| return text, status_html("CALL IN TRANSCRIBED - REVIEW, THEN PRESS ON AIR") |
|
|
|
|
| def radio_shell_for_genre(genre: str) -> str: |
| return _radio_shell_html(genre) |
|
|
|
|
| def on_air_start(genre: str) -> tuple[str, str]: |
| return _radio_shell_html(genre, state="scanning"), status_html( |
| "WRITING ... CASTING ... FOLEY ... SCORING ..." |
| ) |
|
|
|
|
| def status_html(message: str = "RERUN READY") -> str: |
| live_class = " is-live" if message != "RERUN READY" else "" |
| return f'<div class="ms-status{live_class}">{message}</div>' |
|
|
|
|
| def script_display_html(script_json: str) -> str: |
| """Render a script JSON string as a readable, in-world broadcast script.""" |
| try: |
| payload = json.loads(script_json) |
| except (json.JSONDecodeError, TypeError): |
| return '<div class="ms-script"><p class="ms-script-empty">No script on air yet.</p></div>' |
|
|
| def esc(value: object) -> str: |
| return html.escape(str(value)) |
|
|
| title = esc(payload.get("title", "Untitled Broadcast")) |
| logline = esc(payload.get("logline", "")) |
| genre = str(payload.get("genre", "")) |
| station = STATIONS.get(_coerce_genre(genre)) if genre else None |
| seconds = payload.get("estimated_seconds") |
|
|
| cast = payload.get("cast", []) or [] |
| cast_names = {str(member.get("id")): str(member.get("name", member.get("id", "?"))) for member in cast} |
|
|
| meta_tags = [] |
| if station: |
| meta_tags.append(f'{esc(station["frequency"])} {esc(station["call"])}') |
| if genre: |
| meta_tags.append(esc(genre.replace("_", " "))) |
| if seconds: |
| meta_tags.append(f"~{esc(seconds)}s") |
| meta = "".join(f'<span class="ms-script-tag">{tag}</span>' for tag in meta_tags) |
|
|
| cast_rows = "".join( |
| f'<div class="ms-script-cast-row"><strong>{esc(member.get("name", member.get("id", "?")))}</strong>' |
| f' — <span>{esc(member.get("description", "")) or esc(member.get("voice", ""))}</span></div>' |
| for member in cast |
| ) |
|
|
| scene_blocks = [] |
| for scene in payload.get("scenes", []) or []: |
| slug = esc(scene.get("title", "Scene")) |
| block = [f'<div class="ms-scene-slug">{slug}</div>'] |
| sfx = [esc(item) for item in (scene.get("sfx") or [])] |
| if sfx: |
| block.append(f'<div class="ms-scene-sfx">SFX: {", ".join(sfx)}</div>') |
| for line in scene.get("lines", []) or []: |
| who = esc(cast_names.get(str(line.get("cast")), line.get("cast", "?"))) |
| text = esc(line.get("text", "")) |
| delivery = str(line.get("delivery", "neutral")) |
| delivery_tag = ( |
| f' <span class="ms-line-delivery">({esc(delivery)})</span>' |
| if delivery and delivery != "neutral" |
| else "" |
| ) |
| block.append( |
| f'<div class="ms-line"><div class="ms-line-who">{who}</div>' |
| f'<div class="ms-line-text">{text}{delivery_tag}</div></div>' |
| ) |
| scene_blocks.append("".join(block)) |
|
|
| body = "".join(scene_blocks) or '<p class="ms-script-empty">Tune in for the next broadcast.</p>' |
| cast_section = f'<div class="ms-script-cast">{cast_rows}</div>' if cast_rows else "" |
|
|
| return f""" |
| <div class="ms-script"> |
| <div class="ms-script-head"> |
| <h3 class="ms-script-title">{title}</h3> |
| <p class="ms-script-logline">{logline}</p> |
| <div class="ms-script-meta">{meta}</div> |
| </div> |
| {cast_section} |
| {body} |
| </div> |
| """ |
|
|
|
|
| def load_showcase_for_ui(label: str) -> tuple[str | None, str, str, str, str, str]: |
| audio_path, script_json = load_showcase(label) |
| payload = json.loads(script_json) |
| genre = str(payload.get("genre", Genre.WEIRD.value)) |
| return ( |
| audio_path, |
| script_display_html(script_json), |
| script_json, |
| genre, |
| _radio_shell_html(genre), |
| status_html("RERUN READY"), |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| FORCE_LIGHT_THEME_HEAD = """ |
| <script> |
| (function () { |
| try { |
| var url = new URL(window.location.href); |
| if (url.searchParams.get('__theme') !== 'light') { |
| url.searchParams.set('__theme', 'light'); |
| window.location.replace(url.href); |
| } |
| } catch (e) {} |
| })(); |
| </script> |
| """ |
|
|
|
|
| def _nemotron_runs_here() -> bool: |
| """True when the local Nemotron runtime (llama.cpp) is installed. |
| |
| The hosted cpu-basic Space can't package llama-cpp-python (musl/glibc + the |
| SDK builder can't compile it), so Nemotron falls back to the fixture there. |
| The local and GPU builds have it, so the writer note adapts to the truth of |
| whichever environment is running. |
| """ |
| import importlib.util |
|
|
| return importlib.util.find_spec("llama_cpp") is not None |
|
|
|
|
| def _writer_radio_kwargs() -> dict: |
| if _nemotron_runs_here(): |
| return { |
| "choices": [ |
| ("Fixture · instant", "fixture"), |
| ("Nemotron 4B · local, ~2 min", "nemotron"), |
| ], |
| "value": "fixture", |
| "label": "Writer", |
| "info": "Nemotron 4B writes an original script on CPU (~1–2 min); fixture is instant.", |
| } |
| return { |
| "choices": [ |
| ("Fixture · instant", "fixture"), |
| ("Nemotron 4B · full build only", "nemotron"), |
| ], |
| "value": "fixture", |
| "label": "Writer", |
| "info": ( |
| "The real Nemotron 4B writer runs in the local & GPU builds (shown in the demo " |
| "video). On this free CPU Space it falls back to the instant fixture writer." |
| ), |
| } |
|
|
|
|
| def create_radio_app(): |
| """Create the primary styled radio Gradio Blocks app (served at `/`).""" |
| import gradio as gr |
|
|
| showcase_audio, showcase_script = default_showcase() |
| labels = showcase_labels() |
| with gr.Blocks(title="Midnight Static") as app: |
| |
| |
| |
| |
| |
| gr.HTML(f"<style>{INLINE_FONTS_CSS}{RADIO_CSS}</style>") |
| radio_shell = gr.HTML(_radio_shell_html()) |
| with gr.Group(elem_classes=["ms-telegram"]): |
| showcase = gr.Dropdown( |
| choices=labels, |
| value=labels[0] if labels else None, |
| label="Showcase rerun", |
| elem_classes=["ms-showcase"], |
| ) |
| with gr.Row(): |
| with gr.Group(elem_classes=["ms-slip"]): |
| gr.HTML( |
| """ |
| <div class="ms-slip-head"> |
| <span><strong>Telegram</strong>Dispatch to the midnight desk</span> |
| <span>Max 300 chars</span> |
| </div> |
| """ |
| ) |
| premise = gr.Textbox( |
| label="Premise", |
| value="a phone booth that rings under the sea", |
| placeholder=PREMISE_PLACEHOLDER, |
| max_length=300, |
| max_lines=3, |
| elem_classes=["ms-premise"], |
| ) |
| call_in = gr.Audio( |
| sources=["microphone"], |
| type="filepath", |
| label="CALL IN — speak a premise (transcript fills the slip; review before ON AIR)", |
| elem_classes=["ms-callin"], |
| ) |
| with gr.Row(): |
| genre = gr.Radio( |
| choices=GENRE_CHOICES, |
| value=Genre.WEIRD.value, |
| label="Genre station", |
| elem_classes=["ms-genre"], |
| ) |
| mode = gr.Radio( |
| choices=["crude", "placeholder"], |
| value="crude", |
| label="Render mode", |
| ) |
| writer = gr.Radio(**_writer_radio_kwargs()) |
| generate = gr.Button("ON AIR", variant="primary") |
| status = gr.HTML(status_html()) |
| with gr.Group(elem_classes=["ms-output"]): |
| audio = gr.Audio(label="Broadcast", type="filepath", value=showcase_audio) |
| script_view = gr.HTML(script_display_html(showcase_script)) |
| with gr.Accordion("Script JSON", open=False): |
| script = gr.Code(label="Script JSON", language="json", value=showcase_script) |
| gr.HTML( |
| '<div class="ms-footer">Trouble tuning in? ' |
| '<a href="/plain/">Switch to the plain studio feed</a></div>' |
| ) |
|
|
| def _run(premise_value: str, mode_value: str, writer_value: str, genre_value: str): |
| audio_path, script_json = generate_broadcast( |
| premise=premise_value, |
| mode=mode_value, |
| writer=writer_value, |
| genre=genre_value, |
| ) |
| parsed = json.loads(script_json) |
| tuned_genre = str(parsed.get("genre", genre_value)) |
| pretty_json = json.dumps(parsed, indent=2) |
| return ( |
| audio_path, |
| script_display_html(pretty_json), |
| pretty_json, |
| _radio_shell_html(tuned_genre), |
| status_html("ON AIR - BROADCAST READY"), |
| ) |
|
|
| generate_event = generate.click( |
| on_air_start, |
| inputs=[genre], |
| outputs=[radio_shell, status], |
| ) |
| generate_event.then( |
| _run, |
| inputs=[premise, mode, writer, genre], |
| outputs=[audio, script_view, script, radio_shell, status], |
| ) |
| genre.change(radio_shell_for_genre, inputs=[genre], outputs=[radio_shell]) |
| showcase.change( |
| load_showcase_for_ui, |
| inputs=[showcase], |
| outputs=[audio, script_view, script, genre, radio_shell, status], |
| ) |
| |
| |
| call_in.stop_recording( |
| transcribe_call_in, inputs=[call_in], outputs=[premise, status] |
| ) |
|
|
| |
| |
| |
| |
| with app.route("Plain", "/plain", show_in_navbar=False): |
| _build_fallback_ui() |
| return app |
|
|
|
|
| |
| create_plain_app = create_radio_app |
|
|
|
|
| def _build_fallback_ui(): |
| """Build the minimal, CSS-free fallback controls into the current context. |
| |
| Usable inside a standalone ``gr.Blocks`` or a ``Blocks.route`` page. Uses |
| only default Gradio components so the station stays usable if the styled |
| radio UI fails to render (custom fonts/CSS blocked, old browser, etc.). |
| """ |
| import gradio as gr |
|
|
| showcase_audio, showcase_script = default_showcase() |
| labels = showcase_labels() |
|
|
| gr.Markdown( |
| "# Midnight Static — plain studio feed\n" |
| "Minimal fallback interface. The full vintage radio lives on " |
| "[the main page](/)." |
| ) |
| showcase = gr.Dropdown( |
| choices=labels, |
| value=labels[0] if labels else None, |
| label="Showcase rerun", |
| ) |
| premise = gr.Textbox( |
| label="Premise", |
| value="a phone booth that rings under the sea", |
| placeholder=PREMISE_PLACEHOLDER, |
| max_length=300, |
| max_lines=3, |
| ) |
| with gr.Row(): |
| genre = gr.Dropdown( |
| choices=GENRE_CHOICES, |
| value=Genre.WEIRD.value, |
| label="Genre station", |
| ) |
| mode = gr.Radio(choices=["crude", "placeholder"], value="crude", label="Render mode") |
| writer = gr.Radio(**_writer_radio_kwargs()) |
| generate = gr.Button("Generate broadcast", variant="primary") |
| audio = gr.Audio(label="Broadcast", type="filepath", value=showcase_audio) |
| script = gr.Code(label="Script JSON", language="json", value=showcase_script) |
|
|
| def _run(premise_value: str, mode_value: str, writer_value: str, genre_value: str): |
| audio_path, script_json = generate_broadcast( |
| premise=premise_value, |
| mode=mode_value, |
| writer=writer_value, |
| genre=genre_value, |
| ) |
| return audio_path, json.dumps(json.loads(script_json), indent=2) |
|
|
| generate.click(_run, inputs=[premise, mode, writer, genre], outputs=[audio, script]) |
| showcase.change(load_showcase, inputs=[showcase], outputs=[audio, script]) |
|
|
|
|
| def create_fallback_app(): |
| """Standalone Gradio Blocks wrapping the CSS-free fallback (REQ-02 insurance).""" |
| import gradio as gr |
|
|
| with gr.Blocks(title="Midnight Static — Plain", analytics_enabled=False) as app: |
| _build_fallback_ui() |
| return app |
|
|