"""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 ' ) 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) @api.post("/api/broadcast") 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)) @api.post("/api/call") 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)) @api.post("/api/seek") def api_seek(req: SeekReq): return {"stage": arc.meter_to_stage(req.meter)} @api.get("/api/songs") 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} @api.post("/api/segment") 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)) @api.post("/api/locale") 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}) @api.post("/api/song_card") 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}) @api.get("/api/stalls") 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