Mercurial
view dictation/server.py @ 275:78699f810817
Add Qwen3-VL and WebRTC dictation services
Add Bazel targets for the CUDA-backed Qwen3-VL server and a local WebRTC faster-whisper dictation service.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: e3d8cb06-6c95-4ae0-9757-651d3796ab00
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 17 Aug 2026 10:58:47 -0700 |
| parents | |
| children |
line wrap: on
line source
from __future__ import annotations from contextlib import asynccontextmanager import asyncio from pathlib import Path import uuid from aiortc import RTCPeerConnection, RTCSessionDescription from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse from pydantic import BaseModel, Field from dictation.config import DictationConfig from dictation.session import DictationSession from dictation.transcriber import FasterWhisperTranscriber, Transcriber class Offer(BaseModel): sdp: str = Field(min_length=1, max_length=256_000) type: str class DictationService: def __init__( self, config: DictationConfig, transcriber: Transcriber, ) -> None: self.config = config self.transcriber = transcriber self.sessions: dict[str, DictationSession] = {} self.ready = False self.startup_error: str | None = None self._lock = asyncio.Lock() async def start(self) -> None: try: await self.transcriber.warmup() self.ready = True except Exception as error: self.startup_error = str(error) raise async def accept_offer(self, offer: Offer) -> dict[str, str]: if offer.type != "offer": raise HTTPException(422, "SDP type must be offer") async with self._lock: if len(self.sessions) >= self.config.max_sessions: raise HTTPException(429, "Dictation session limit reached") peer = RTCPeerConnection() session_id = str(uuid.uuid4()) session = DictationSession( session_id, peer, self.transcriber, self.config, ) self.sessions[session_id] = session @peer.on("datachannel") def on_datachannel(channel) -> None: session.attach_channel(channel) @peer.on("track") def on_track(track) -> None: session.attach_track(track) @peer.on("connectionstatechange") async def on_connectionstatechange() -> None: if peer.connectionState in {"failed", "closed"}: await self.close_session(session_id) try: await peer.setRemoteDescription( RTCSessionDescription(sdp=offer.sdp, type=offer.type) ) answer = await peer.createAnswer() await peer.setLocalDescription(answer) return { "sdp": peer.localDescription.sdp, "type": peer.localDescription.type, "sessionId": session_id, } except Exception: await self.close_session(session_id) raise async def close_session(self, session_id: str) -> bool: async with self._lock: session = self.sessions.pop(session_id, None) if not session: return False await session.close() return True async def close(self) -> None: for session_id in list(self.sessions): await self.close_session(session_id) close = getattr(self.transcriber, "close", None) if close: close() def create_app( config: DictationConfig | None = None, transcriber: Transcriber | None = None, ) -> FastAPI: resolved_config = config or DictationConfig.from_environment() resolved_transcriber = transcriber or FasterWhisperTranscriber( resolved_config.model_dir, resolved_config.compute_type, ) service = DictationService(resolved_config, resolved_transcriber) web_root = Path(__file__).parent / "web" @asynccontextmanager async def lifespan(app: FastAPI): app.state.dictation = service try: await service.start() yield finally: await service.close() app = FastAPI( title="Zenbu WebRTC Dictation", lifespan=lifespan, ) @app.get("/") async def index(): return FileResponse(web_root / "index.html") @app.get("/dictation.js") async def javascript(): return FileResponse(web_root / "dictation.js") @app.get("/dictation.css") async def stylesheet(): return FileResponse(web_root / "dictation.css") @app.get("/health") async def health(request: Request): current: DictationService = request.app.state.dictation status = 200 if current.ready else 503 return JSONResponse( { "status": "ok" if current.ready else "unavailable", "model": str(current.config.model_dir), "computeType": current.config.compute_type, "activeSessions": len(current.sessions), "maxSessions": current.config.max_sessions, "error": current.startup_error, }, status_code=status, ) @app.post("/api/webrtc/offer") async def offer(body: Offer, request: Request): current: DictationService = request.app.state.dictation if not current.ready: raise HTTPException(503, "Dictation model is unavailable") return await current.accept_offer(body) @app.post("/api/webrtc/session/{session_id}/close") async def close_session(session_id: str, request: Request): current: DictationService = request.app.state.dictation await current.close_session(session_id) return {"closed": True} return app