nanobot-voice-interface/app.py

125 lines
3.6 KiB
Python
Raw Normal View History

2026-02-28 22:12:04 -05:00
import asyncio
import contextlib
import json
from pathlib import Path
from typing import Any, Awaitable, Callable
2026-03-06 22:51:19 -05:00
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
2026-03-04 08:20:42 -05:00
from fastapi.staticfiles import StaticFiles
2026-02-28 22:12:04 -05:00
from supertonic_gateway import SuperTonicGateway
from voice_rtc import WebRTCVoiceSession
BASE_DIR = Path(__file__).resolve().parent
2026-03-06 22:51:19 -05:00
DIST_DIR = BASE_DIR / "frontend" / "dist"
2026-02-28 22:12:04 -05:00
app = FastAPI(title="Nanobot SuperTonic Wisper Web")
2026-03-06 22:51:19 -05:00
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
2026-02-28 22:12:04 -05:00
gateway = SuperTonicGateway()
2026-03-06 22:51:19 -05:00
# 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
2026-03-04 08:20:42 -05:00
2026-02-28 22:12:04 -05:00
@app.get("/health")
async def health() -> JSONResponse:
return JSONResponse({"status": "ok"})
2026-03-06 22:51:19 -05:00
@app.post("/rtc/offer")
async def rtc_offer(request: Request) -> JSONResponse:
global _active_session, _active_queue, _sender_task
2026-02-28 22:12:04 -05:00
2026-03-06 22:51:19 -05:00
payload = await request.json()
2026-02-28 22:12:04 -05:00
2026-03-06 22:51:19 -05:00
# 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
2026-02-28 22:12:04 -05:00
queue = await gateway.subscribe()
2026-03-06 22:51:19 -05:00
_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)
2026-02-28 22:12:04 -05:00
@app.on_event("shutdown")
async def on_shutdown() -> None:
2026-03-06 22:51:19 -05:00
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)
2026-02-28 22:12:04 -05:00
await gateway.shutdown()
async def _sender_loop(
queue: asyncio.Queue,
2026-03-06 22:51:19 -05:00
voice_session: "WebRTCVoiceSession",
2026-02-28 22:12:04 -05:00
) -> None:
while True:
event = await queue.get()
if event.role == "nanobot-tts":
await voice_session.queue_output_text(event.text)
continue
2026-03-06 22:51:19 -05:00
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"))