Mercurial
comparison dictation/webrtc_test.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 import asyncio | |
| 2 import json | |
| 3 import math | |
| 4 from pathlib import Path | |
| 5 import tempfile | |
| 6 import unittest | |
| 7 import wave | |
| 8 | |
| 9 from aiortc import RTCPeerConnection, RTCSessionDescription | |
| 10 from aiortc.contrib.media import MediaPlayer | |
| 11 import numpy as np | |
| 12 | |
| 13 from dictation.config import DictationConfig | |
| 14 from dictation.server import DictationService, Offer | |
| 15 from dictation.transcriber import Transcript | |
| 16 | |
| 17 | |
| 18 class FakeTranscriber: | |
| 19 async def warmup(self): | |
| 20 return None | |
| 21 | |
| 22 async def transcribe(self, samples, *, final): | |
| 23 return Transcript( | |
| 24 "final transcript" if final else "partial transcript", | |
| 25 "en", | |
| 26 0.99, | |
| 27 ) | |
| 28 | |
| 29 | |
| 30 def write_test_audio(path: str) -> None: | |
| 31 sample_rate = 16000 | |
| 32 speech = np.array( | |
| 33 [ | |
| 34 int(1600 * math.sin(2 * math.pi * 440 * index / sample_rate)) | |
| 35 for index in range(sample_rate * 2) | |
| 36 ], | |
| 37 dtype=np.int16, | |
| 38 ) | |
| 39 silence = np.zeros(sample_rate, dtype=np.int16) | |
| 40 with wave.open(path, "wb") as output: | |
| 41 output.setnchannels(1) | |
| 42 output.setsampwidth(2) | |
| 43 output.setframerate(sample_rate) | |
| 44 output.writeframes(np.concatenate((speech, silence)).tobytes()) | |
| 45 | |
| 46 | |
| 47 def test_config() -> DictationConfig: | |
| 48 return DictationConfig( | |
| 49 host="127.0.0.1", | |
| 50 port=8090, | |
| 51 model_dir=Path("/tmp/model"), | |
| 52 compute_type="int8_float16", | |
| 53 max_sessions=1, | |
| 54 partial_interval_ms=500, | |
| 55 silence_ms=200, | |
| 56 max_utterance_seconds=5, | |
| 57 speech_threshold=0.01, | |
| 58 ) | |
| 59 | |
| 60 | |
| 61 class WebRtcTest(unittest.IsolatedAsyncioTestCase): | |
| 62 async def test_audio_track_returns_transcript_events(self): | |
| 63 service = DictationService(test_config(), FakeTranscriber()) | |
| 64 await service.start() | |
| 65 client = RTCPeerConnection() | |
| 66 channel = client.createDataChannel("transcripts") | |
| 67 messages = [] | |
| 68 final_received = asyncio.Event() | |
| 69 audio_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) | |
| 70 audio_file.close() | |
| 71 write_test_audio(audio_file.name) | |
| 72 player = MediaPlayer(audio_file.name) | |
| 73 | |
| 74 @channel.on("message") | |
| 75 def on_message(message): | |
| 76 event = json.loads(message) | |
| 77 messages.append(event) | |
| 78 if event["type"] == "transcript.final": | |
| 79 final_received.set() | |
| 80 | |
| 81 client.addTrack(player.audio) | |
| 82 offer = await client.createOffer() | |
| 83 await client.setLocalDescription(offer) | |
| 84 answer = await service.accept_offer( | |
| 85 Offer( | |
| 86 sdp=client.localDescription.sdp, | |
| 87 type=client.localDescription.type, | |
| 88 ) | |
| 89 ) | |
| 90 await client.setRemoteDescription( | |
| 91 RTCSessionDescription( | |
| 92 sdp=answer["sdp"], | |
| 93 type=answer["type"], | |
| 94 ) | |
| 95 ) | |
| 96 | |
| 97 try: | |
| 98 try: | |
| 99 await asyncio.wait_for(final_received.wait(), timeout=10) | |
| 100 except TimeoutError as error: | |
| 101 server_states = [ | |
| 102 { | |
| 103 "connection": session.peer.connectionState, | |
| 104 "ice": session.peer.iceConnectionState, | |
| 105 "channel": ( | |
| 106 session.channel.readyState | |
| 107 if session.channel is not None | |
| 108 else None | |
| 109 ), | |
| 110 "audioTask": ( | |
| 111 "missing" | |
| 112 if session.audio_task is None | |
| 113 else ( | |
| 114 repr(session.audio_task.exception()) | |
| 115 if session.audio_task.done() | |
| 116 and not session.audio_task.cancelled() | |
| 117 else "running" | |
| 118 ) | |
| 119 ), | |
| 120 } | |
| 121 for session in service.sessions.values() | |
| 122 ] | |
| 123 self.fail( | |
| 124 "Timed out waiting for transcript: " | |
| 125 f"client={client.connectionState}/" | |
| 126 f"{client.iceConnectionState}, " | |
| 127 f"server={server_states}, messages={messages}" | |
| 128 ) | |
| 129 event_types = [message["type"] for message in messages] | |
| 130 self.assertIn("ready", event_types) | |
| 131 self.assertIn("speech.started", event_types) | |
| 132 self.assertIn("transcript.partial", event_types) | |
| 133 self.assertIn("transcript.final", event_types) | |
| 134 finally: | |
| 135 await client.close() | |
| 136 await service.close() | |
| 137 Path(audio_file.name).unlink(missing_ok=True) | |
| 138 self.assertEqual(service.sessions, {}) | |
| 139 | |
| 140 | |
| 141 if __name__ == "__main__": | |
| 142 unittest.main() |