comparison 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
comparison
equal deleted inserted replaced
274:c9be578316a6 275:78699f810817
1 from __future__ import annotations
2
3 from contextlib import asynccontextmanager
4 import asyncio
5 from pathlib import Path
6 import uuid
7
8 from aiortc import RTCPeerConnection, RTCSessionDescription
9 from fastapi import FastAPI, HTTPException, Request
10 from fastapi.responses import FileResponse, JSONResponse
11 from pydantic import BaseModel, Field
12
13 from dictation.config import DictationConfig
14 from dictation.session import DictationSession
15 from dictation.transcriber import FasterWhisperTranscriber, Transcriber
16
17
18 class Offer(BaseModel):
19 sdp: str = Field(min_length=1, max_length=256_000)
20 type: str
21
22
23 class DictationService:
24 def __init__(
25 self,
26 config: DictationConfig,
27 transcriber: Transcriber,
28 ) -> None:
29 self.config = config
30 self.transcriber = transcriber
31 self.sessions: dict[str, DictationSession] = {}
32 self.ready = False
33 self.startup_error: str | None = None
34 self._lock = asyncio.Lock()
35
36 async def start(self) -> None:
37 try:
38 await self.transcriber.warmup()
39 self.ready = True
40 except Exception as error:
41 self.startup_error = str(error)
42 raise
43
44 async def accept_offer(self, offer: Offer) -> dict[str, str]:
45 if offer.type != "offer":
46 raise HTTPException(422, "SDP type must be offer")
47 async with self._lock:
48 if len(self.sessions) >= self.config.max_sessions:
49 raise HTTPException(429, "Dictation session limit reached")
50 peer = RTCPeerConnection()
51 session_id = str(uuid.uuid4())
52 session = DictationSession(
53 session_id,
54 peer,
55 self.transcriber,
56 self.config,
57 )
58 self.sessions[session_id] = session
59
60 @peer.on("datachannel")
61 def on_datachannel(channel) -> None:
62 session.attach_channel(channel)
63
64 @peer.on("track")
65 def on_track(track) -> None:
66 session.attach_track(track)
67
68 @peer.on("connectionstatechange")
69 async def on_connectionstatechange() -> None:
70 if peer.connectionState in {"failed", "closed"}:
71 await self.close_session(session_id)
72
73 try:
74 await peer.setRemoteDescription(
75 RTCSessionDescription(sdp=offer.sdp, type=offer.type)
76 )
77 answer = await peer.createAnswer()
78 await peer.setLocalDescription(answer)
79 return {
80 "sdp": peer.localDescription.sdp,
81 "type": peer.localDescription.type,
82 "sessionId": session_id,
83 }
84 except Exception:
85 await self.close_session(session_id)
86 raise
87
88 async def close_session(self, session_id: str) -> bool:
89 async with self._lock:
90 session = self.sessions.pop(session_id, None)
91 if not session:
92 return False
93 await session.close()
94 return True
95
96 async def close(self) -> None:
97 for session_id in list(self.sessions):
98 await self.close_session(session_id)
99 close = getattr(self.transcriber, "close", None)
100 if close:
101 close()
102
103
104 def create_app(
105 config: DictationConfig | None = None,
106 transcriber: Transcriber | None = None,
107 ) -> FastAPI:
108 resolved_config = config or DictationConfig.from_environment()
109 resolved_transcriber = transcriber or FasterWhisperTranscriber(
110 resolved_config.model_dir,
111 resolved_config.compute_type,
112 )
113 service = DictationService(resolved_config, resolved_transcriber)
114 web_root = Path(__file__).parent / "web"
115
116 @asynccontextmanager
117 async def lifespan(app: FastAPI):
118 app.state.dictation = service
119 try:
120 await service.start()
121 yield
122 finally:
123 await service.close()
124
125 app = FastAPI(
126 title="Zenbu WebRTC Dictation",
127 lifespan=lifespan,
128 )
129
130 @app.get("/")
131 async def index():
132 return FileResponse(web_root / "index.html")
133
134 @app.get("/dictation.js")
135 async def javascript():
136 return FileResponse(web_root / "dictation.js")
137
138 @app.get("/dictation.css")
139 async def stylesheet():
140 return FileResponse(web_root / "dictation.css")
141
142 @app.get("/health")
143 async def health(request: Request):
144 current: DictationService = request.app.state.dictation
145 status = 200 if current.ready else 503
146 return JSONResponse(
147 {
148 "status": "ok" if current.ready else "unavailable",
149 "model": str(current.config.model_dir),
150 "computeType": current.config.compute_type,
151 "activeSessions": len(current.sessions),
152 "maxSessions": current.config.max_sessions,
153 "error": current.startup_error,
154 },
155 status_code=status,
156 )
157
158 @app.post("/api/webrtc/offer")
159 async def offer(body: Offer, request: Request):
160 current: DictationService = request.app.state.dictation
161 if not current.ready:
162 raise HTTPException(503, "Dictation model is unavailable")
163 return await current.accept_offer(body)
164
165 @app.post("/api/webrtc/session/{session_id}/close")
166 async def close_session(session_id: str, request: Request):
167 current: DictationService = request.app.state.dictation
168 await current.close_session(session_id)
169 return {"closed": True}
170
171 return app