diff 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
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/webrtc_test.py	Mon Aug 17 10:58:47 2026 -0700
@@ -0,0 +1,142 @@
+import asyncio
+import json
+import math
+from pathlib import Path
+import tempfile
+import unittest
+import wave
+
+from aiortc import RTCPeerConnection, RTCSessionDescription
+from aiortc.contrib.media import MediaPlayer
+import numpy as np
+
+from dictation.config import DictationConfig
+from dictation.server import DictationService, Offer
+from dictation.transcriber import Transcript
+
+
+class FakeTranscriber:
+    async def warmup(self):
+        return None
+
+    async def transcribe(self, samples, *, final):
+        return Transcript(
+            "final transcript" if final else "partial transcript",
+            "en",
+            0.99,
+        )
+
+
+def write_test_audio(path: str) -> None:
+    sample_rate = 16000
+    speech = np.array(
+        [
+            int(1600 * math.sin(2 * math.pi * 440 * index / sample_rate))
+            for index in range(sample_rate * 2)
+        ],
+        dtype=np.int16,
+    )
+    silence = np.zeros(sample_rate, dtype=np.int16)
+    with wave.open(path, "wb") as output:
+        output.setnchannels(1)
+        output.setsampwidth(2)
+        output.setframerate(sample_rate)
+        output.writeframes(np.concatenate((speech, silence)).tobytes())
+
+
+def test_config() -> DictationConfig:
+    return DictationConfig(
+        host="127.0.0.1",
+        port=8090,
+        model_dir=Path("/tmp/model"),
+        compute_type="int8_float16",
+        max_sessions=1,
+        partial_interval_ms=500,
+        silence_ms=200,
+        max_utterance_seconds=5,
+        speech_threshold=0.01,
+    )
+
+
+class WebRtcTest(unittest.IsolatedAsyncioTestCase):
+    async def test_audio_track_returns_transcript_events(self):
+        service = DictationService(test_config(), FakeTranscriber())
+        await service.start()
+        client = RTCPeerConnection()
+        channel = client.createDataChannel("transcripts")
+        messages = []
+        final_received = asyncio.Event()
+        audio_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
+        audio_file.close()
+        write_test_audio(audio_file.name)
+        player = MediaPlayer(audio_file.name)
+
+        @channel.on("message")
+        def on_message(message):
+            event = json.loads(message)
+            messages.append(event)
+            if event["type"] == "transcript.final":
+                final_received.set()
+
+        client.addTrack(player.audio)
+        offer = await client.createOffer()
+        await client.setLocalDescription(offer)
+        answer = await service.accept_offer(
+            Offer(
+                sdp=client.localDescription.sdp,
+                type=client.localDescription.type,
+            )
+        )
+        await client.setRemoteDescription(
+            RTCSessionDescription(
+                sdp=answer["sdp"],
+                type=answer["type"],
+            )
+        )
+
+        try:
+            try:
+                await asyncio.wait_for(final_received.wait(), timeout=10)
+            except TimeoutError as error:
+                server_states = [
+                    {
+                        "connection": session.peer.connectionState,
+                        "ice": session.peer.iceConnectionState,
+                        "channel": (
+                            session.channel.readyState
+                            if session.channel is not None
+                            else None
+                        ),
+                        "audioTask": (
+                            "missing"
+                            if session.audio_task is None
+                            else (
+                                repr(session.audio_task.exception())
+                                if session.audio_task.done()
+                                and not session.audio_task.cancelled()
+                                else "running"
+                            )
+                        ),
+                    }
+                    for session in service.sessions.values()
+                ]
+                self.fail(
+                    "Timed out waiting for transcript: "
+                    f"client={client.connectionState}/"
+                    f"{client.iceConnectionState}, "
+                    f"server={server_states}, messages={messages}"
+                )
+            event_types = [message["type"] for message in messages]
+            self.assertIn("ready", event_types)
+            self.assertIn("speech.started", event_types)
+            self.assertIn("transcript.partial", event_types)
+            self.assertIn("transcript.final", event_types)
+        finally:
+            await client.close()
+            await service.close()
+            Path(audio_file.name).unlink(missing_ok=True)
+        self.assertEqual(service.sessions, {})
+
+
+if __name__ == "__main__":
+    unittest.main()