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