nanobot-voice-interface/app.py
2026-03-06 22:51:19 -05:00

124 lines
3.6 KiB
Python

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"))