Spaces:
Sleeping
Sleeping
| """FastAPI app factory for the NIGHTWAVE Space orchestrator. | |
| Exposes the same-origin proxy routes the browser radio calls: | |
| POST /api/broadcast {stage, meter, topic?} | |
| POST /api/call {stage, meter, audio_b64} | |
| POST /api/seek {meter} | |
| Every proxy call is wrapped in try/except so a Modal hiccup (or cold-start | |
| timeout) NEVER 500s the radio -- we fall back to a canned, stage-appropriate | |
| line and a short silent WAV. The radio must never go silent. | |
| Also provides radio_iframe_html(): reads space/radio.html from disk and wraps | |
| it in a sandboxed <iframe srcdoc=...> for embedding inside the Gradio Blocks | |
| page (gr.HTML does not execute <script>, so the radio must live in an iframe). | |
| """ | |
| import html | |
| import os | |
| from typing import Any, Dict, Optional | |
| from fastapi import FastAPI, Request | |
| from fastapi.responses import JSONResponse | |
| from pydantic import BaseModel | |
| import arc | |
| import content | |
| import proxy | |
| _HERE = os.path.dirname(os.path.abspath(__file__)) | |
| _RADIO_HTML_PATH = os.path.join(_HERE, "radio.html") | |
| # --------------------------------------------------------------------------- | |
| # Request models | |
| # --------------------------------------------------------------------------- | |
| class BroadcastReq(BaseModel): | |
| stage: str | |
| meter: int | |
| topic: Optional[str] = None | |
| class CallReq(BaseModel): | |
| stage: str | |
| meter: int | |
| audio_b64: str | |
| class SeekReq(BaseModel): | |
| meter: int | |
| class SegmentReq(BaseModel): | |
| kind: str # station_id | thought | weather | local_weather | dedication | song_intro | rejoin | caller_intro | |
| ctx: Optional[Dict[str, Any]] = None | |
| class LocaleReq(BaseModel): | |
| lat: float | |
| lon: float | |
| class SongCardReq(BaseModel): | |
| ctx: Optional[Dict[str, Any]] = None | |
| # --------------------------------------------------------------------------- | |
| # Graceful fallbacks (never let the radio go silent) | |
| # --------------------------------------------------------------------------- | |
| _FALLBACK_LINES = [ | |
| "It's Sam Dusk -- we had a little hiccup on the signal there, but I'm still with " | |
| "you. Let's let this next one play.", | |
| "Sam Dusk here. The line crackled for a second, friend; pay it no mind and stay " | |
| "with me a while.", | |
| "You're back with Sam Dusk on NIGHTWAVE. A small bump in the broadcast -- nothing " | |
| "the night can't smooth over.", | |
| ] | |
| def _fallback_payload(stage: str = "", include_caller: bool = False) -> Dict[str, Any]: | |
| import random | |
| payload: Dict[str, Any] = { | |
| "text": random.choice(_FALLBACK_LINES), | |
| "mood": "warm", | |
| "arc_cue": "none", | |
| "audio_b64": proxy._silent_wav_b64(), | |
| "words": [], | |
| "wtimes": [], | |
| "wdurations": [], | |
| } | |
| if include_caller: | |
| payload["caller_text"] = "" | |
| payload["meter_delta"] = 0 | |
| payload["memory_patch"] = None | |
| payload["queue_dedication"] = False | |
| return payload | |
| # --------------------------------------------------------------------------- | |
| # Radio iframe embedding (for the Gradio Blocks page) | |
| # --------------------------------------------------------------------------- | |
| def radio_iframe_html() -> str: | |
| """Read radio.html and return it wrapped in a sandboxed iframe srcdoc.""" | |
| with open(_RADIO_HTML_PATH, "r", encoding="utf-8") as fh: | |
| doc = fh.read() | |
| escaped = html.escape(doc, quote=True) | |
| return ( | |
| '<iframe srcdoc="' + escaped + '" ' | |
| 'width="100%" height="860" ' | |
| 'allow="microphone; autoplay" ' | |
| 'style="border:0;display:block" ' | |
| 'referrerpolicy="no-referrer"></iframe>' | |
| ) | |
| def read_radio_html() -> str: | |
| """Return the raw radio document (used by devserver to serve at top level).""" | |
| with open(_RADIO_HTML_PATH, "r", encoding="utf-8") as fh: | |
| return fh.read() | |
| # --------------------------------------------------------------------------- | |
| # App factory | |
| # --------------------------------------------------------------------------- | |
| def create_api() -> FastAPI: | |
| api = FastAPI(title="NIGHTWAVE", docs_url=None, redoc_url=None) | |
| def api_broadcast(req: BroadcastReq): | |
| stage = arc.meter_to_stage(req.meter) | |
| try: | |
| return proxy.broadcast_turn(stage, req.meter, req.topic) | |
| except Exception: | |
| return JSONResponse(_fallback_payload(stage)) | |
| def api_call(req: CallReq): | |
| stage = arc.meter_to_stage(req.meter) | |
| try: | |
| return proxy.call_turn(stage, req.meter, req.audio_b64) | |
| except Exception: | |
| return JSONResponse(_fallback_payload(stage, include_caller=True)) | |
| def api_seek(req: SeekReq): | |
| return {"stage": arc.meter_to_stage(req.meter)} | |
| def api_songs(): | |
| # The curated song bank: the browser renders the generative music + the | |
| # now-playing plate from each song's musical params, and song_intro uses | |
| # the title/artist. Single source of truth (space/content.py). | |
| return {"songs": content.SONGS} | |
| def api_segment(req: SegmentReq): | |
| # One DJ show segment (text + audio). Never break the show: on any failure | |
| # return a templated segment with a short silent clip (the bed covers it). | |
| try: | |
| return proxy.segment_turn(req.kind, req.ctx) | |
| except Exception: | |
| return JSONResponse(proxy.segment_fallback(req.kind, req.ctx)) | |
| def api_locale(req: LocaleReq): | |
| # Resolve real local weather/time/city for the listener. On any failure, | |
| # report unresolved so the client cleanly falls back to fictional weather. | |
| try: | |
| return proxy.resolve_locale(req.lat, req.lon) | |
| except Exception: | |
| return JSONResponse({"resolved": False}) | |
| def api_song_card(req: SongCardReq): | |
| # AI invents a fictional record (validated musical params). On any failure, | |
| # return {card: None} so the client simply skips it. | |
| try: | |
| return {"card": proxy.make_song_card(req.ctx)} | |
| except Exception: | |
| return JSONResponse({"card": None}) | |
| def api_stalls(): | |
| # Pre-synthesized filler clips the radio plays instantly while a real | |
| # call reply generates (so the call never has dead air). | |
| try: | |
| return {"stalls": proxy.get_stalls()} | |
| except Exception: | |
| return {"stalls": []} | |
| return api | |