Mercurial
comparison dictation/audio.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 from dataclasses import dataclass | |
| 4 from enum import Enum | |
| 5 | |
| 6 import numpy as np | |
| 7 | |
| 8 | |
| 9 class AudioEventKind(str, Enum): | |
| 10 SPEECH_STARTED = "speech_started" | |
| 11 PARTIAL_READY = "partial_ready" | |
| 12 FINAL_READY = "final_ready" | |
| 13 | |
| 14 | |
| 15 @dataclass(frozen=True) | |
| 16 class AudioEvent: | |
| 17 kind: AudioEventKind | |
| 18 samples: np.ndarray | None = None | |
| 19 | |
| 20 | |
| 21 class AudioSegmenter: | |
| 22 def __init__( | |
| 23 self, | |
| 24 *, | |
| 25 sample_rate: int = 16000, | |
| 26 speech_threshold: float = 0.012, | |
| 27 silence_ms: int = 700, | |
| 28 partial_interval_ms: int = 1200, | |
| 29 max_utterance_seconds: int = 30, | |
| 30 pre_roll_ms: int = 200, | |
| 31 ) -> None: | |
| 32 self.sample_rate = sample_rate | |
| 33 self.speech_threshold = speech_threshold | |
| 34 self.silence_samples = sample_rate * silence_ms // 1000 | |
| 35 self.partial_samples = sample_rate * partial_interval_ms // 1000 | |
| 36 self.max_samples = sample_rate * max_utterance_seconds | |
| 37 self.pre_roll_samples = sample_rate * pre_roll_ms // 1000 | |
| 38 self._pre_roll = np.empty(0, dtype=np.float32) | |
| 39 self._utterance = np.empty(0, dtype=np.float32) | |
| 40 self._silence_count = 0 | |
| 41 self._last_partial_size = 0 | |
| 42 self._speaking = False | |
| 43 | |
| 44 @property | |
| 45 def speaking(self) -> bool: | |
| 46 return self._speaking | |
| 47 | |
| 48 def feed(self, samples: np.ndarray) -> list[AudioEvent]: | |
| 49 chunk = np.asarray(samples, dtype=np.float32).reshape(-1) | |
| 50 if chunk.size == 0: | |
| 51 return [] | |
| 52 rms = float(np.sqrt(np.mean(np.square(chunk), dtype=np.float64))) | |
| 53 is_speech = rms >= self.speech_threshold | |
| 54 events: list[AudioEvent] = [] | |
| 55 | |
| 56 if not self._speaking: | |
| 57 if not is_speech: | |
| 58 self._append_pre_roll(chunk) | |
| 59 return events | |
| 60 self._speaking = True | |
| 61 self._utterance = np.concatenate((self._pre_roll, chunk)) | |
| 62 self._pre_roll = np.empty(0, dtype=np.float32) | |
| 63 self._silence_count = 0 | |
| 64 self._last_partial_size = 0 | |
| 65 events.append(AudioEvent(AudioEventKind.SPEECH_STARTED)) | |
| 66 else: | |
| 67 self._utterance = np.concatenate((self._utterance, chunk)) | |
| 68 | |
| 69 self._silence_count = 0 if is_speech else self._silence_count + chunk.size | |
| 70 utterance_size = self._utterance.size | |
| 71 | |
| 72 if ( | |
| 73 utterance_size - self._last_partial_size >= self.partial_samples | |
| 74 and self._silence_count < self.silence_samples | |
| 75 ): | |
| 76 self._last_partial_size = utterance_size | |
| 77 events.append( | |
| 78 AudioEvent( | |
| 79 AudioEventKind.PARTIAL_READY, | |
| 80 self._utterance.copy(), | |
| 81 ) | |
| 82 ) | |
| 83 | |
| 84 if ( | |
| 85 self._silence_count >= self.silence_samples | |
| 86 or utterance_size >= self.max_samples | |
| 87 ): | |
| 88 final = self._utterance.copy() | |
| 89 self._reset() | |
| 90 events.append(AudioEvent(AudioEventKind.FINAL_READY, final)) | |
| 91 | |
| 92 return events | |
| 93 | |
| 94 def flush(self) -> AudioEvent | None: | |
| 95 if not self._speaking or self._utterance.size == 0: | |
| 96 self._reset() | |
| 97 return None | |
| 98 final = self._utterance.copy() | |
| 99 self._reset() | |
| 100 return AudioEvent(AudioEventKind.FINAL_READY, final) | |
| 101 | |
| 102 def _append_pre_roll(self, chunk: np.ndarray) -> None: | |
| 103 self._pre_roll = np.concatenate((self._pre_roll, chunk)) | |
| 104 if self._pre_roll.size > self.pre_roll_samples: | |
| 105 self._pre_roll = self._pre_roll[-self.pre_roll_samples :] | |
| 106 | |
| 107 def _reset(self) -> None: | |
| 108 self._pre_roll = np.empty(0, dtype=np.float32) | |
| 109 self._utterance = np.empty(0, dtype=np.float32) | |
| 110 self._silence_count = 0 | |
| 111 self._last_partial_size = 0 | |
| 112 self._speaking = False |