Mercurial
comparison dictation/session.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 | 49e9e591c9bb |
comparison
equal
deleted
inserted
replaced
| 274:c9be578316a6 | 275:78699f810817 |
|---|---|
| 1 from __future__ import annotations | |
| 2 | |
| 3 import asyncio | |
| 4 import json | |
| 5 from typing import Any | |
| 6 | |
| 7 from aiortc.mediastreams import MediaStreamError | |
| 8 from av.audio.resampler import AudioResampler | |
| 9 import numpy as np | |
| 10 | |
| 11 from dictation.audio import AudioEventKind, AudioSegmenter | |
| 12 from dictation.config import DictationConfig | |
| 13 from dictation.transcriber import Transcriber | |
| 14 | |
| 15 | |
| 16 class DictationSession: | |
| 17 def __init__( | |
| 18 self, | |
| 19 session_id: str, | |
| 20 peer: Any, | |
| 21 transcriber: Transcriber, | |
| 22 config: DictationConfig, | |
| 23 ) -> None: | |
| 24 self.session_id = session_id | |
| 25 self.peer = peer | |
| 26 self.transcriber = transcriber | |
| 27 self.segmenter = AudioSegmenter( | |
| 28 speech_threshold=config.speech_threshold, | |
| 29 silence_ms=config.silence_ms, | |
| 30 partial_interval_ms=config.partial_interval_ms, | |
| 31 max_utterance_seconds=config.max_utterance_seconds, | |
| 32 ) | |
| 33 self.channel = None | |
| 34 self.audio_task: asyncio.Task | None = None | |
| 35 self.partial_task: asyncio.Task | None = None | |
| 36 self.final_tasks: set[asyncio.Task] = set() | |
| 37 self.closed = False | |
| 38 self.stopping = False | |
| 39 self._resampler = AudioResampler( | |
| 40 format="s16", | |
| 41 layout="mono", | |
| 42 rate=16000, | |
| 43 ) | |
| 44 | |
| 45 def attach_channel(self, channel: Any) -> None: | |
| 46 if channel.label != "transcripts": | |
| 47 channel.close() | |
| 48 return | |
| 49 self.channel = channel | |
| 50 | |
| 51 @channel.on("open") | |
| 52 def on_open() -> None: | |
| 53 self.send("ready", sessionId=self.session_id) | |
| 54 | |
| 55 @channel.on("message") | |
| 56 def on_message(message: Any) -> None: | |
| 57 if message == "stop": | |
| 58 self.stopping = True | |
| 59 event = self.segmenter.flush() | |
| 60 if event and event.samples is not None: | |
| 61 self._start_final(event.samples) | |
| 62 else: | |
| 63 self.send("speech.ended") | |
| 64 | |
| 65 if channel.readyState == "open": | |
| 66 self.send("ready", sessionId=self.session_id) | |
| 67 | |
| 68 def attach_track(self, track: Any) -> None: | |
| 69 if track.kind != "audio" or self.audio_task is not None: | |
| 70 return | |
| 71 self.audio_task = asyncio.create_task(self._consume_audio(track)) | |
| 72 | |
| 73 async def _consume_audio(self, track: Any) -> None: | |
| 74 try: | |
| 75 while not self.closed: | |
| 76 frame = await track.recv() | |
| 77 for converted in self._resampler.resample(frame): | |
| 78 if self.stopping: | |
| 79 continue | |
| 80 samples = converted.to_ndarray().reshape(-1) | |
| 81 normalized = samples.astype(np.float32) / 32768.0 | |
| 82 for event in self.segmenter.feed(normalized): | |
| 83 if event.kind == AudioEventKind.SPEECH_STARTED: | |
| 84 self.send("speech.started") | |
| 85 elif ( | |
| 86 event.kind == AudioEventKind.PARTIAL_READY | |
| 87 and event.samples is not None | |
| 88 ): | |
| 89 self._start_partial(event.samples) | |
| 90 elif ( | |
| 91 event.kind == AudioEventKind.FINAL_READY | |
| 92 and event.samples is not None | |
| 93 ): | |
| 94 self._start_final(event.samples) | |
| 95 except MediaStreamError: | |
| 96 if not self.stopping: | |
| 97 event = self.segmenter.flush() | |
| 98 if event and event.samples is not None: | |
| 99 self._start_final(event.samples) | |
| 100 else: | |
| 101 self.send("speech.ended") | |
| 102 except asyncio.CancelledError: | |
| 103 raise | |
| 104 except Exception as error: | |
| 105 if not self.closed: | |
| 106 self.send( | |
| 107 "error", | |
| 108 code="audio_track_failed", | |
| 109 message=str(error), | |
| 110 ) | |
| 111 | |
| 112 def _start_partial(self, samples: np.ndarray) -> None: | |
| 113 if self.partial_task and not self.partial_task.done(): | |
| 114 return | |
| 115 self.partial_task = asyncio.create_task( | |
| 116 self._transcribe(samples, final=False) | |
| 117 ) | |
| 118 | |
| 119 def _start_final(self, samples: np.ndarray) -> None: | |
| 120 task = asyncio.create_task(self._transcribe(samples, final=True)) | |
| 121 self.final_tasks.add(task) | |
| 122 task.add_done_callback(self.final_tasks.discard) | |
| 123 | |
| 124 async def _transcribe(self, samples: np.ndarray, *, final: bool) -> None: | |
| 125 try: | |
| 126 transcript = await self.transcriber.transcribe( | |
| 127 samples, | |
| 128 final=final, | |
| 129 ) | |
| 130 if transcript.text: | |
| 131 self.send( | |
| 132 "transcript.final" if final else "transcript.partial", | |
| 133 text=transcript.text, | |
| 134 language=transcript.language, | |
| 135 probability=transcript.probability, | |
| 136 ) | |
| 137 if final: | |
| 138 self.send("speech.ended") | |
| 139 except asyncio.CancelledError: | |
| 140 raise | |
| 141 except Exception as error: | |
| 142 self.send( | |
| 143 "error", | |
| 144 code="transcription_failed", | |
| 145 message=str(error), | |
| 146 ) | |
| 147 | |
| 148 def send(self, event_type: str, **payload: Any) -> None: | |
| 149 if not self.channel or self.channel.readyState != "open": | |
| 150 return | |
| 151 self.channel.send( | |
| 152 json.dumps( | |
| 153 {"type": event_type, **payload}, | |
| 154 separators=(",", ":"), | |
| 155 ) | |
| 156 ) | |
| 157 | |
| 158 async def close(self) -> None: | |
| 159 if self.closed: | |
| 160 return | |
| 161 self.closed = True | |
| 162 tasks = [ | |
| 163 task | |
| 164 for task in [ | |
| 165 self.audio_task, | |
| 166 self.partial_task, | |
| 167 *self.final_tasks, | |
| 168 ] | |
| 169 if task is not None and not task.done() | |
| 170 ] | |
| 171 for task in tasks: | |
| 172 task.cancel() | |
| 173 if tasks: | |
| 174 await asyncio.gather(*tasks, return_exceptions=True) | |
| 175 if self.channel and self.channel.readyState != "closed": | |
| 176 self.channel.close() | |
| 177 await self.peer.close() |