import asyncio import contextlib import json from pathlib import Path from typing import Any, Awaitable, Callable from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from supertonic_gateway import SuperTonicGateway from voice_rtc import WebRTCVoiceSession BASE_DIR = Path(__file__).resolve().parent DIST_DIR = BASE_DIR / "frontend" / "dist" app = FastAPI(title="Nanobot SuperTonic Wisper Web") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) gateway = SuperTonicGateway() # Session store: one voice session per connection (keyed by a simple counter). # For this single-user app we keep at most one active session at a time. _active_session: WebRTCVoiceSession | None = None _active_queue: asyncio.Queue | None = None _sender_task: asyncio.Task | None = None @app.get("/health") async def health() -> JSONResponse: return JSONResponse({"status": "ok"}) @app.post("/rtc/offer") async def rtc_offer(request: Request) -> JSONResponse: global _active_session, _active_queue, _sender_task payload = await request.json() # Tear down any previous session cleanly. if _active_session is not None: await _active_session.close() _active_session = None if _active_queue is not None: await gateway.unsubscribe(_active_queue) _active_queue = None if _sender_task is not None: _sender_task.cancel() with contextlib.suppress(asyncio.CancelledError): await _sender_task _sender_task = None queue = await gateway.subscribe() _active_queue = queue voice_session = WebRTCVoiceSession(gateway=gateway) _active_session = voice_session _sender_task = asyncio.create_task( _sender_loop(queue, voice_session), name="rtc-sender", ) answer = await voice_session.handle_offer(payload) if answer is None: return JSONResponse( {"error": "WebRTC backend unavailable on host (aiortc is not installed)."}, status_code=503, ) # Connect to nanobot if not already connected. await gateway.spawn_tui() return JSONResponse(answer) @app.on_event("shutdown") async def on_shutdown() -> None: global _active_session, _active_queue, _sender_task if _sender_task is not None: _sender_task.cancel() with contextlib.suppress(asyncio.CancelledError): await _sender_task if _active_session is not None: await _active_session.close() if _active_queue is not None: await gateway.unsubscribe(_active_queue) await gateway.shutdown() async def _sender_loop( queue: asyncio.Queue, voice_session: "WebRTCVoiceSession", ) -> None: while True: event = await queue.get() if event.role == "nanobot-tts": await voice_session.queue_output_text(event.text) continue voice_session.send_to_datachannel(event.to_dict()) # Serve the Vite-built frontend as static files. # This must come AFTER all API routes so the API endpoints are not shadowed. if DIST_DIR.exists(): # Mount assets sub-directory (hashed JS/CSS) assets_dir = DIST_DIR / "assets" if assets_dir.exists(): app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets") @app.get("/{full_path:path}") async def spa_fallback(full_path: str) -> FileResponse: candidate = DIST_DIR / full_path if candidate.is_file(): return FileResponse(str(candidate)) return FileResponse(str(DIST_DIR / "index.html"))