changeset 277:1d99147f520c

Merge Qwen services and infinite canvas heads
author MrJuneJune <me@mrjunejune.com>
date Mon, 17 Aug 2026 17:01:40 -0700
parents b55c22cff335 (current diff) 78699f810817 (diff)
children 8d560f50ed4c
files MODULE.bazel
diffstat 26 files changed, 2891 insertions(+), 41 deletions(-) [+]
line wrap: on
line diff
--- a/MODULE.bazel	Mon Aug 17 16:57:56 2026 -0700
+++ b/MODULE.bazel	Mon Aug 17 17:01:40 2026 -0700
@@ -2,10 +2,13 @@
 bazel_dep(name = "platforms", version = "1.0.0")
 bazel_dep(name = "bazel_skylib", version = "1.8.2")
 bazel_dep(name = "rules_shell", version = "0.6.1")
+
 bazel_dep(name = "aspect_rules_js", version = "2.9.2", dev_dependency = True)
 bazel_dep(name = "rules_nodejs", version = "6.7.3", dev_dependency = True)
+
 bazel_dep(name = "openssl", version = "3.3.1.bcr.7")
 bazel_dep(name = "rules_foreign_cc", version = "0.14.0")
+
 bazel_dep(name = "buildifier_prebuilt", version = "7.1.2", dev_dependency = True)
 bazel_dep(name = "aspect_bazel_lib", version = "2.19.2")
 bazel_dep(name = "apple_support", version = "1.24.2", repo_name = "build_bazel_apple_support")
@@ -18,44 +21,68 @@
 )
 
 http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
+
 http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
 
 http_archive(
-  name = "tectonic_linux_x86_64",
-  urls = ["https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%400.17.0/tectonic-0.17.0-x86_64-unknown-linux-musl.tar.gz"],
-  sha256 = "8533d07f9ccbd7a65824b9e0459041bca34af1eb33daba48f59215593753a3b7",
-  build_file_content = """
+    name = "tectonic_linux_x86_64",
+    build_file_content = """
 exports_files(
     ["tectonic"],
     visibility = ["//visibility:public"],
 )
 """,
+    sha256 = "8533d07f9ccbd7a65824b9e0459041bca34af1eb33daba48f59215593753a3b7",
+    urls = ["https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%400.17.0/tectonic-0.17.0-x86_64-unknown-linux-musl.tar.gz"],
 )
 
 http_archive(
-  name = "copilot_cli_linux_x86_64",
-  urls = ["https://github.com/github/copilot-cli/releases/download/v1.0.73/copilot-linux-x64.tar.gz"],
-  sha256 = "8f9bb5f7e364c267265d1e24ac2aea69ed559ddb956719c6db12a353de6c5970",
-  build_file_content = """
+    name = "copilot_cli_linux_x86_64",
+    build_file_content = """
 exports_files(
     ["copilot"],
     visibility = ["//visibility:public"],
 )
 """,
+    sha256 = "8f9bb5f7e364c267265d1e24ac2aea69ed559ddb956719c6db12a353de6c5970",
+    urls = ["https://github.com/github/copilot-cli/releases/download/v1.0.73/copilot-linux-x64.tar.gz"],
+)
+
+http_archive(
+    name = "llama_cpp_win_cuda_12_4_x64",
+    build_file_content = """
+filegroup(
+    name = "runtime",
+    srcs = glob(["**"]),
+    visibility = ["//visibility:public"],
+)
+""",
+    sha256 = "84b863f70a8b4c2873e93385d0b208f24776ecd1b946a2cb6d5cda863d143c3d",
+    urls = ["https://github.com/ggml-org/llama.cpp/releases/download/b10453/llama-b10453-bin-win-cuda-12.4-x64.zip"],
+)
+
+http_archive(
+    name = "llama_cpp_win_cuda_12_4_cudart_x64",
+    build_file_content = """
+filegroup(
+    name = "runtime",
+    srcs = glob(["**"]),
+    visibility = ["//visibility:public"],
+)
+""",
+    sha256 = "8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6",
+    urls = ["https://github.com/ggml-org/llama.cpp/releases/download/b10453/cudart-llama-bin-win-cuda-12.4-x64.zip"],
 )
 
 http_file(
-  name = "mercurial_cp311_wheel",
-  url = "https://files.pythonhosted.org/packages/ea/21/053406cf91b23bef2ac27a7023632efaf0d3c126134b322e2bb753626102/mercurial-7.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl",
-  sha256 = "33576d9747b7db7d40950cb122c5a87686d92ea1dfe0f1ef0498df077c852ba7",
+    name = "mercurial_cp311_wheel",
+    sha256 = "33576d9747b7db7d40950cb122c5a87686d92ea1dfe0f1ef0498df077c852ba7",
+    url = "https://files.pythonhosted.org/packages/ea/21/053406cf91b23bef2ac27a7023632efaf0d3c126134b322e2bb753626102/mercurial-7.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl",
 )
 
 http_archive(
-  name = "playwright_chromium_linux",
-  urls = ["https://playwright.azureedge.net/builds/chromium/1140/chromium-linux.zip"],
-  sha256 = "e78cda52fa7e847abcd36a48ffabda43e71e2220a3e9a398f7c9a179529570a5",
-  strip_prefix = "chrome-linux",
-  build_file_content = """
+    name = "playwright_chromium_linux",
+    build_file_content = """
 exports_files(["chrome"])
 
 filegroup(
@@ -64,28 +91,31 @@
     visibility = ["//visibility:public"],
 )
 """,
+    sha256 = "e78cda52fa7e847abcd36a48ffabda43e71e2220a3e9a398f7c9a179529570a5",
+    strip_prefix = "chrome-linux",
+    urls = ["https://playwright.azureedge.net/builds/chromium/1140/chromium-linux.zip"],
 )
 
 http_archive(
-  name = "libwebp_linux_x86_64",
-  urls = ["https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.6.0-linux-x86-64.tar.gz"],
-  sha256 = "1c5ffab71efecefa0e3c23516c3a3a1dccb45cc310ae1095c6f14ae268e38067",
-  strip_prefix = "libwebp-1.6.0-linux-x86-64",
-  build_file_content = """
+    name = "libwebp_linux_x86_64",
+    build_file_content = """
 exports_files(["bin/cwebp"])
 """,
+    sha256 = "1c5ffab71efecefa0e3c23516c3a3a1dccb45cc310ae1095c6f14ae268e38067",
+    strip_prefix = "libwebp-1.6.0-linux-x86-64",
+    urls = ["https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.6.0-linux-x86-64.tar.gz"],
 )
 
 http_archive(
-  name = "hlsjs",
-  urls = ["https://github.com/video-dev/hls.js/releases/download/v1.6.16/release.zip"],
-  sha256 = "09c827969e702e82be694da80dbf16dbdf5ff6819c33a374b1757e1bc0adb458",
-  build_file_content = """
+    name = "hlsjs",
+    build_file_content = """
 exports_files(
     ["dist/hls.min.js"],
     visibility = ["//visibility:public"],
 )
 """,
+    sha256 = "09c827969e702e82be694da80dbf16dbdf5ff6819c33a374b1757e1bc0adb458",
+    urls = ["https://github.com/video-dev/hls.js/releases/download/v1.6.16/release.zip"],
 )
 
 node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
@@ -94,28 +124,30 @@
 
 npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm")
 npm.npm_translate_lock(
-  name = "hg_web_npm",
-  data = ["//hg-web/e2e:package.json"],
-  npm_package_lock = "//hg-web/e2e:package-lock.json",
-  pnpm_lock = "//hg-web/e2e:pnpm-lock.yaml",
+    name = "hg_web_npm",
+    data = ["//hg-web/e2e:package.json"],
+    npm_package_lock = "//hg-web/e2e:package-lock.json",
+    pnpm_lock = "//hg-web/e2e:pnpm-lock.yaml",
 )
 use_repo(npm, "hg_web_npm")
 
 # Bun
 http_file(
-  name = "bun_darwin_arm64_zip",
-  url = "https://github.com/oven-sh/bun/releases/download/bun-v1.2.23/bun-darwin-aarch64.zip",
-  sha256 = "22f5fa3fff72b0d3b8e7e0f8051ecadf2e41920d474ac62db5279144809c9005",
+    name = "bun_darwin_arm64_zip",
+    sha256 = "22f5fa3fff72b0d3b8e7e0f8051ecadf2e41920d474ac62db5279144809c9005",
+    url = "https://github.com/oven-sh/bun/releases/download/bun-v1.2.23/bun-darwin-aarch64.zip",
 )
+
 http_file(
-  name = "bun_linux_aarch64_zip",
-  url = "https://github.com/oven-sh/bun/releases/download/bun-v1.2.23/bun-linux-aarch64.zip",
-  sha256 = "6a7a98c546d084a845deda62eb2a5b94a6a14a63ea81cf9186d46bf55bf910a9",
+    name = "bun_linux_aarch64_zip",
+    sha256 = "6a7a98c546d084a845deda62eb2a5b94a6a14a63ea81cf9186d46bf55bf910a9",
+    url = "https://github.com/oven-sh/bun/releases/download/bun-v1.2.23/bun-linux-aarch64.zip",
 )
+
 http_file(
-  name = "bun_linux_x86_zip",
-  url = "https://github.com/oven-sh/bun/releases/download/bun-v1.2.23/bun-linux-x64.zip",
-  sha256 = "cf0ed0a920799d576ffde4e0cae66d732bf23c2530407f26f59c7831dffe1f0e",
+    name = "bun_linux_x86_zip",
+    sha256 = "cf0ed0a920799d576ffde4e0cae66d732bf23c2530407f26f59c7831dffe1f0e",
+    url = "https://github.com/oven-sh/bun/releases/download/bun-v1.2.23/bun-linux-x64.zip",
 )
 
 # Bring in Python support
@@ -128,11 +160,18 @@
 
 pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")
 pip.parse(
-  hub_name = "inference_pip",
-  python_version = "3.11",
-  requirements_lock = "//mrjunejune/inference:requirements_lock.txt",
+    hub_name = "inference_pip",
+    python_version = "3.11",
+    requirements_lock = "//mrjunejune/inference:requirements_lock.txt",
 )
 use_repo(pip, "inference_pip")
 
 cef_repository = use_repo_rule("//third_party/cef:cef_repository.bzl", "cef_repository")
 cef_repository(name = "cef")
+
+pip.parse(
+    hub_name = "dictation_pip",
+    python_version = "3.11",
+    requirements_lock = "//dictation:requirements_lock.txt",
+)
+use_repo(pip, "dictation_pip")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/BUILD	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,126 @@
+load("@dictation_pip//:requirements.bzl", "requirement")
+load("@rules_python//python:pip.bzl", "compile_pip_requirements")
+load("@rules_python//python:py_binary.bzl", "py_binary")
+load("@rules_python//python:py_library.bzl", "py_library")
+load("@rules_python//python:py_test.bzl", "py_test")
+load("@rules_shell//shell:sh_binary.bzl", "sh_binary")
+
+compile_pip_requirements(
+    name = "requirements",
+    src = "requirements.in",
+    extra_args = ["--allow-unsafe"],
+    requirements_txt = "requirements_lock.txt",
+)
+
+py_library(
+    name = "dictation_lib",
+    srcs = [
+        "__init__.py",
+        "audio.py",
+        "config.py",
+        "server.py",
+        "session.py",
+        "transcriber.py",
+    ],
+    data = glob(["web/**"]),
+    deps = [
+        requirement("aiortc"),
+        requirement("av"),
+        requirement("fastapi"),
+        requirement("faster-whisper"),
+        requirement("huggingface-hub"),
+        requirement("numpy"),
+        requirement("uvicorn"),
+    ],
+)
+
+py_binary(
+    name = "dictation_bin",
+    srcs = ["main.py"],
+    data = glob(["web/**"]),
+    main = "main.py",
+    deps = [
+        ":dictation_lib",
+        requirement("ctranslate2"),
+        requirement("nvidia-cublas-cu12"),
+        requirement("nvidia-cudnn-cu12"),
+    ],
+)
+
+py_binary(
+    name = "webrtc_smoke",
+    srcs = ["webrtc_smoke.py"],
+    main = "webrtc_smoke.py",
+    deps = [
+        requirement("aiortc"),
+        requirement("httpx"),
+    ],
+)
+
+[
+    sh_binary(
+        name = command,
+        srcs = ["launcher.sh"],
+        args = [
+            "$(rootpath :dictation_bin)",
+            command,
+        ],
+        data = [
+            ":dictation_bin",
+            requirement("nvidia-cublas-cu12"),
+            requirement("nvidia-cudnn-cu12"),
+        ],
+    )
+    for command in [
+        "server",
+        "preflight",
+        "download_model",
+        "transcribe",
+    ]
+]
+
+py_test(
+    name = "audio_test",
+    srcs = ["audio_test.py"],
+    deps = [
+        ":dictation_lib",
+        requirement("numpy"),
+    ],
+)
+
+py_test(
+    name = "config_test",
+    srcs = ["config_test.py"],
+    deps = [":dictation_lib"],
+)
+
+py_test(
+    name = "server_test",
+    srcs = ["server_test.py"],
+    data = glob(["web/**"]),
+    deps = [
+        ":dictation_lib",
+        requirement("httpx"),
+    ],
+)
+
+py_test(
+    name = "webrtc_test",
+    srcs = ["webrtc_test.py"],
+    deps = [
+        ":dictation_lib",
+        requirement("aiortc"),
+        requirement("av"),
+        requirement("numpy"),
+    ],
+)
+
+test_suite(
+    name = "all_tests",
+    tests = [
+        ":audio_test",
+        ":config_test",
+        ":server_test",
+        ":webrtc_test",
+    ],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/README.md	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,46 @@
+# WebRTC dictation
+
+This Bazel package runs a local WebRTC speech-to-text service using aiortc and
+faster-whisper. The default multilingual Whisper `small` model uses the RTX
+4070 Ti through native WSL CUDA with `int8_float16` compute.
+
+## Setup
+
+```bash
+bazel run //dictation:preflight
+bazel run //dictation:download_model
+bazel run //dictation:server
+```
+
+Open <http://127.0.0.1:8090>, grant microphone permission, and start
+dictation. Audio is carried by WebRTC and partial/final transcript events are
+returned on the `transcripts` RTC data channel.
+
+To transcribe an existing audio file:
+
+```bash
+bazel run //dictation:transcribe -- /path/to/audio.wav
+```
+
+To exercise the running HTTP signaling and WebRTC path with an audio file:
+
+```bash
+bazel run //dictation:webrtc_smoke -- /path/to/audio.wav
+```
+
+The model is stored under `~/.cache/zenbu/faster-whisper-small` by default.
+Override it with `DICTATION_MODEL_DIR`.
+
+Useful tuning:
+
+```bash
+DICTATION_COMPUTE_TYPE=int8_float16 \
+DICTATION_MAX_SESSIONS=1 \
+DICTATION_PARTIAL_INTERVAL_MS=1200 \
+DICTATION_SILENCE_MS=700 \
+  bazel run //dictation:server
+```
+
+The first version intentionally binds to loopback and does not configure STUN
+or TURN. Remote WebRTC access requires HTTPS, authentication, origin policy,
+and usually a TURN service.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/__init__.py	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,1 @@
+"""Local WebRTC dictation service."""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/audio.py	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+
+import numpy as np
+
+
+class AudioEventKind(str, Enum):
+    SPEECH_STARTED = "speech_started"
+    PARTIAL_READY = "partial_ready"
+    FINAL_READY = "final_ready"
+
+
+@dataclass(frozen=True)
+class AudioEvent:
+    kind: AudioEventKind
+    samples: np.ndarray | None = None
+
+
+class AudioSegmenter:
+    def __init__(
+        self,
+        *,
+        sample_rate: int = 16000,
+        speech_threshold: float = 0.012,
+        silence_ms: int = 700,
+        partial_interval_ms: int = 1200,
+        max_utterance_seconds: int = 30,
+        pre_roll_ms: int = 200,
+    ) -> None:
+        self.sample_rate = sample_rate
+        self.speech_threshold = speech_threshold
+        self.silence_samples = sample_rate * silence_ms // 1000
+        self.partial_samples = sample_rate * partial_interval_ms // 1000
+        self.max_samples = sample_rate * max_utterance_seconds
+        self.pre_roll_samples = sample_rate * pre_roll_ms // 1000
+        self._pre_roll = np.empty(0, dtype=np.float32)
+        self._utterance = np.empty(0, dtype=np.float32)
+        self._silence_count = 0
+        self._last_partial_size = 0
+        self._speaking = False
+
+    @property
+    def speaking(self) -> bool:
+        return self._speaking
+
+    def feed(self, samples: np.ndarray) -> list[AudioEvent]:
+        chunk = np.asarray(samples, dtype=np.float32).reshape(-1)
+        if chunk.size == 0:
+            return []
+        rms = float(np.sqrt(np.mean(np.square(chunk), dtype=np.float64)))
+        is_speech = rms >= self.speech_threshold
+        events: list[AudioEvent] = []
+
+        if not self._speaking:
+            if not is_speech:
+                self._append_pre_roll(chunk)
+                return events
+            self._speaking = True
+            self._utterance = np.concatenate((self._pre_roll, chunk))
+            self._pre_roll = np.empty(0, dtype=np.float32)
+            self._silence_count = 0
+            self._last_partial_size = 0
+            events.append(AudioEvent(AudioEventKind.SPEECH_STARTED))
+        else:
+            self._utterance = np.concatenate((self._utterance, chunk))
+
+        self._silence_count = 0 if is_speech else self._silence_count + chunk.size
+        utterance_size = self._utterance.size
+
+        if (
+            utterance_size - self._last_partial_size >= self.partial_samples
+            and self._silence_count < self.silence_samples
+        ):
+            self._last_partial_size = utterance_size
+            events.append(
+                AudioEvent(
+                    AudioEventKind.PARTIAL_READY,
+                    self._utterance.copy(),
+                )
+            )
+
+        if (
+            self._silence_count >= self.silence_samples
+            or utterance_size >= self.max_samples
+        ):
+            final = self._utterance.copy()
+            self._reset()
+            events.append(AudioEvent(AudioEventKind.FINAL_READY, final))
+
+        return events
+
+    def flush(self) -> AudioEvent | None:
+        if not self._speaking or self._utterance.size == 0:
+            self._reset()
+            return None
+        final = self._utterance.copy()
+        self._reset()
+        return AudioEvent(AudioEventKind.FINAL_READY, final)
+
+    def _append_pre_roll(self, chunk: np.ndarray) -> None:
+        self._pre_roll = np.concatenate((self._pre_roll, chunk))
+        if self._pre_roll.size > self.pre_roll_samples:
+            self._pre_roll = self._pre_roll[-self.pre_roll_samples :]
+
+    def _reset(self) -> None:
+        self._pre_roll = np.empty(0, dtype=np.float32)
+        self._utterance = np.empty(0, dtype=np.float32)
+        self._silence_count = 0
+        self._last_partial_size = 0
+        self._speaking = False
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/audio_test.py	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,50 @@
+import unittest
+
+import numpy as np
+
+from dictation.audio import AudioEventKind, AudioSegmenter
+
+
+class AudioSegmenterTest(unittest.TestCase):
+    def test_emits_partial_and_final_events(self):
+        segmenter = AudioSegmenter(
+            sample_rate=1000,
+            speech_threshold=0.01,
+            silence_ms=200,
+            partial_interval_ms=500,
+            max_utterance_seconds=5,
+            pre_roll_ms=100,
+        )
+        silence = np.zeros(100, dtype=np.float32)
+        speech = np.full(250, 0.2, dtype=np.float32)
+
+        self.assertEqual(segmenter.feed(silence), [])
+        events = segmenter.feed(speech)
+        self.assertEqual(events[0].kind, AudioEventKind.SPEECH_STARTED)
+        events = segmenter.feed(speech)
+        self.assertIn(AudioEventKind.PARTIAL_READY, [event.kind for event in events])
+        events = segmenter.feed(np.zeros(200, dtype=np.float32))
+        self.assertEqual(events[-1].kind, AudioEventKind.FINAL_READY)
+        self.assertFalse(segmenter.speaking)
+
+    def test_max_duration_bounds_utterance(self):
+        segmenter = AudioSegmenter(
+            sample_rate=100,
+            speech_threshold=0.01,
+            silence_ms=500,
+            partial_interval_ms=10000,
+            max_utterance_seconds=3,
+            pre_roll_ms=0,
+        )
+        events = []
+        for _ in range(3):
+            events.extend(
+                segmenter.feed(np.full(100, 0.2, dtype=np.float32))
+            )
+        final = [event for event in events if event.kind == AudioEventKind.FINAL_READY]
+        self.assertEqual(len(final), 1)
+        self.assertLessEqual(final[0].samples.size, 300)
+
+
+if __name__ == "__main__":
+    unittest.main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/config.py	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import os
+from pathlib import Path
+
+
+MODEL_REPOSITORY = "Systran/faster-whisper-small"
+MODEL_REVISION = "536b0662742c02347bc0e980a01041f333bce120"
+
+
+def _integer(name: str, default: int, minimum: int, maximum: int) -> int:
+    raw = os.environ.get(name)
+    if raw is None:
+        return default
+    try:
+        value = int(raw)
+    except ValueError as error:
+        raise ValueError(f"{name} must be an integer") from error
+    if value < minimum or value > maximum:
+        raise ValueError(f"{name} must be between {minimum} and {maximum}")
+    return value
+
+
+def _float(name: str, default: float, minimum: float, maximum: float) -> float:
+    raw = os.environ.get(name)
+    if raw is None:
+        return default
+    try:
+        value = float(raw)
+    except ValueError as error:
+        raise ValueError(f"{name} must be a number") from error
+    if value < minimum or value > maximum:
+        raise ValueError(f"{name} must be between {minimum} and {maximum}")
+    return value
+
+
+@dataclass(frozen=True)
+class DictationConfig:
+    host: str
+    port: int
+    model_dir: Path
+    compute_type: str
+    max_sessions: int
+    partial_interval_ms: int
+    silence_ms: int
+    max_utterance_seconds: int
+    speech_threshold: float
+
+    @classmethod
+    def from_environment(cls) -> "DictationConfig":
+        cache_root = Path(
+            os.environ.get(
+                "XDG_CACHE_HOME",
+                str(Path.home() / ".cache"),
+            )
+        )
+        model_dir = Path(
+            os.environ.get(
+                "DICTATION_MODEL_DIR",
+                str(cache_root / "zenbu" / "faster-whisper-small"),
+            )
+        ).expanduser()
+        compute_type = os.environ.get(
+            "DICTATION_COMPUTE_TYPE",
+            "int8_float16",
+        )
+        if compute_type not in {
+            "float16",
+            "int8_float16",
+            "int8",
+        }:
+            raise ValueError(
+                "DICTATION_COMPUTE_TYPE must be float16, int8_float16, or int8"
+            )
+        host = os.environ.get("DICTATION_HOST", "127.0.0.1")
+        if not host:
+            raise ValueError("DICTATION_HOST must not be empty")
+        return cls(
+            host=host,
+            port=_integer("DICTATION_PORT", 8090, 1, 65535),
+            model_dir=model_dir,
+            compute_type=compute_type,
+            max_sessions=_integer("DICTATION_MAX_SESSIONS", 1, 1, 8),
+            partial_interval_ms=_integer(
+                "DICTATION_PARTIAL_INTERVAL_MS",
+                1200,
+                500,
+                10000,
+            ),
+            silence_ms=_integer("DICTATION_SILENCE_MS", 700, 200, 5000),
+            max_utterance_seconds=_integer(
+                "DICTATION_MAX_UTTERANCE_SECONDS",
+                30,
+                3,
+                120,
+            ),
+            speech_threshold=_float(
+                "DICTATION_SPEECH_THRESHOLD",
+                0.012,
+                0.001,
+                0.5,
+            ),
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/config_test.py	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,39 @@
+import os
+from pathlib import Path
+import unittest
+from unittest.mock import patch
+
+from dictation.config import DictationConfig
+
+
+class DictationConfigTest(unittest.TestCase):
+    def test_defaults_are_local_and_bounded(self):
+        with patch.dict(os.environ, {}, clear=True):
+            config = DictationConfig.from_environment()
+        self.assertEqual(config.host, "127.0.0.1")
+        self.assertEqual(config.port, 8090)
+        self.assertEqual(config.max_sessions, 1)
+        self.assertEqual(config.compute_type, "int8_float16")
+        self.assertTrue(str(config.model_dir).endswith("faster-whisper-small"))
+
+    def test_rejects_invalid_session_limit(self):
+        with patch.dict(
+            os.environ,
+            {"DICTATION_MAX_SESSIONS": "0"},
+            clear=True,
+        ):
+            with self.assertRaisesRegex(ValueError, "between 1 and 8"):
+                DictationConfig.from_environment()
+
+    def test_accepts_model_override(self):
+        with patch.dict(
+            os.environ,
+            {"DICTATION_MODEL_DIR": "/tmp/dictation-model"},
+            clear=True,
+        ):
+            config = DictationConfig.from_environment()
+        self.assertEqual(config.model_dir, Path("/tmp/dictation-model"))
+
+
+if __name__ == "__main__":
+    unittest.main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/launcher.sh	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+binary="$1"
+command="$2"
+shift 2
+
+if [[ -z "${RUNFILES_DIR:-}" && -d "$0.runfiles" ]]; then
+  RUNFILES_DIR="$0.runfiles"
+fi
+if [[ -z "${RUNFILES_DIR:-}" ]]; then
+  echo "Bazel runfiles are unavailable." >&2
+  exit 1
+fi
+
+find_library_dir() {
+  local name="$1"
+  local path
+  path="$(find -L "$RUNFILES_DIR" -name "$name" -print -quit)"
+  if [[ -z "$path" ]]; then
+    echo "Unable to find $name in Bazel runfiles." >&2
+    exit 1
+  fi
+  dirname "$path"
+}
+
+cublas_dir="$(find_library_dir libcublas.so.12)"
+cudnn_dir="$(find_library_dir libcudnn.so.9)"
+export LD_LIBRARY_PATH="$cublas_dir:$cudnn_dir${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+
+if [[ "$binary" != /* ]]; then
+  binary="$RUNFILES_DIR/_main/$binary"
+fi
+
+exec "$binary" "$command" "$@"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/main.py	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import os
+from pathlib import Path
+import sys
+
+from dictation.config import (
+    DictationConfig,
+    MODEL_REPOSITORY,
+    MODEL_REVISION,
+)
+
+
+def download_model(config: DictationConfig) -> None:
+    from huggingface_hub import snapshot_download
+
+    config.model_dir.mkdir(parents=True, exist_ok=True)
+    snapshot_download(
+        repo_id=MODEL_REPOSITORY,
+        revision=MODEL_REVISION,
+        local_dir=config.model_dir,
+        allow_patterns=[
+            "config.json",
+            "model.bin",
+            "tokenizer.json",
+            "vocabulary.txt",
+        ],
+    )
+    marker = config.model_dir / ".model-revision"
+    marker.write_text(f"{MODEL_REPOSITORY}@{MODEL_REVISION}\n", encoding="ascii")
+    print(f"Model ready: {config.model_dir}")
+
+
+def preflight(config: DictationConfig) -> None:
+    import ctranslate2
+
+    compute_types = sorted(ctranslate2.get_supported_compute_types("cuda"))
+    if config.compute_type not in compute_types:
+        raise RuntimeError(
+            f"{config.compute_type} is unsupported on CUDA; "
+            f"available: {', '.join(compute_types)}"
+        )
+    result = {
+        "cudaDeviceCount": ctranslate2.get_cuda_device_count(),
+        "computeTypes": compute_types,
+        "selectedComputeType": config.compute_type,
+        "modelDir": str(config.model_dir),
+        "modelPresent": (config.model_dir / "model.bin").is_file(),
+    }
+    if result["cudaDeviceCount"] < 1:
+        raise RuntimeError("CTranslate2 did not detect a CUDA device")
+    print(json.dumps(result, indent=2))
+
+
+async def transcribe_file(config: DictationConfig, path: Path) -> None:
+    from dictation.transcriber import FasterWhisperTranscriber
+
+    transcriber = FasterWhisperTranscriber(
+        config.model_dir,
+        config.compute_type,
+    )
+    try:
+        from av import open as av_open
+        from av.audio.resampler import AudioResampler
+        import numpy as np
+
+        samples = []
+        resampler = AudioResampler(format="s16", layout="mono", rate=16000)
+        with av_open(str(path)) as container:
+            for frame in container.decode(audio=0):
+                for converted in resampler.resample(frame):
+                    samples.append(converted.to_ndarray().reshape(-1))
+        if not samples:
+            raise RuntimeError("Audio file contains no decodable samples")
+        audio = np.concatenate(samples).astype(np.float32) / 32768.0
+        result = await transcriber.transcribe(audio, final=True)
+        print(
+            json.dumps(
+                {
+                    "text": result.text,
+                    "language": result.language,
+                    "probability": result.probability,
+                },
+                ensure_ascii=False,
+            )
+        )
+    finally:
+        transcriber.close()
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        "command",
+        choices=["server", "preflight", "download_model", "transcribe"],
+    )
+    parser.add_argument("path", nargs="?")
+    args = parser.parse_args()
+    try:
+        config = DictationConfig.from_environment()
+        if args.command == "download_model":
+            download_model(config)
+        elif args.command == "preflight":
+            preflight(config)
+        elif args.command == "transcribe":
+            if not args.path:
+                parser.error("transcribe requires an audio file path")
+            asyncio.run(transcribe_file(config, Path(args.path)))
+        else:
+            import uvicorn
+            from dictation.server import create_app
+
+            uvicorn.run(
+                create_app(config),
+                host=config.host,
+                port=config.port,
+                access_log=False,
+            )
+    except Exception as error:
+        print(f"dictation: {error}", file=sys.stderr)
+        raise SystemExit(1) from error
+
+
+if __name__ == "__main__":
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/requirements.in	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,9 @@
+aiortc==1.15.0
+fastapi==0.141.1
+faster-whisper==1.2.1
+huggingface-hub==1.27.0
+httpx==0.28.1
+nvidia-cublas-cu12==12.9.2.10
+nvidia-cudnn-cu12==9.24.0.43
+setuptools==84.0.0
+uvicorn==0.52.3
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/requirements_lock.txt	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,798 @@
+#
+# This file is autogenerated by pip-compile with Python 3.11
+# by the following command:
+#
+#    bazel run //dictation:requirements.update
+#
+aioice==0.10.2 \
+    --hash=sha256:14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf \
+    --hash=sha256:bf236c6829ee33c8e540535d31cd5a066b531cb56de2be94c46be76d68b1a806
+    # via aiortc
+aiortc==1.15.0 \
+    --hash=sha256:4e1e54bff31a9c2cb654c7b7edc068085a7df53365e5df24a5cb24168e3f95f7 \
+    --hash=sha256:ee6c0757ca070cf6d6bee441936d6ede24eef51211bbff6653409c540f72e625
+    # via -r dictation/requirements.in
+annotated-doc==0.0.5 \
+    --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
+    --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
+    # via fastapi
+annotated-types==0.8.0 \
+    --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
+    --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
+    # via pydantic
+anyio==4.14.2 \
+    --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \
+    --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f
+    # via
+    #   httpx
+    #   starlette
+av==17.1.0 \
+    --hash=sha256:1284addf3c0dd939887a9722dc30df2241a97471ad52c3c507e31583ae22ff02 \
+    --hash=sha256:1370b11a697eb3f2555906f8ab3519b0cfe48425d7830a3996ad42e6bffafda5 \
+    --hash=sha256:19264c9bb4bee404accc7ce9ec461f2044b7f577a70234d29aafde31ed17de46 \
+    --hash=sha256:19c84fd72af5ef81a20f18fbc6f9aedff9e1455e53a7062c1d4c95926d73da4e \
+    --hash=sha256:22dff0ae582d10ef08c75c2150a4fd27cfc26653b54930c7c27b9f7b3aa20723 \
+    --hash=sha256:3453b06075c7bb973fdb6de52563f7692ff05cbc64c0bb45f4fd6e8709131f2f \
+    --hash=sha256:3dcd41e53f53f9a3260751d9c3c11d34e93d70d61e506c81f13dbc1e3606e07b \
+    --hash=sha256:43ebbe977f19a7f2d2bd1a4e119675a0b15e05852cf7309846b6ab922ba7ffe9 \
+    --hash=sha256:5327807c1219293803ef0c5d1578ff3ae1cf638c09e5998962026e1a554ec240 \
+    --hash=sha256:58f7593726437cda5bd19793027e027768450b5c4a594777bf487798a33db702 \
+    --hash=sha256:5df5c1172ef1cf65a1529d612f7da7798ce2cf82c1ff7212466b538a6cc7214c \
+    --hash=sha256:6a20658ec7d96a70e14b1196eff00b7cdd8831ac3b99868e16b8ba8b24090847 \
+    --hash=sha256:6c9b71fe5c0c5a8d303b1588d4d8ce9397d6b023f467cfef95000ba1f75507fa \
+    --hash=sha256:7f1e71ff621b66253333926f948e00faae11d855b2442133c65128bca64cdeb3 \
+    --hash=sha256:90c49bc9608377d01e82e747377505419a229464873341db18202d5dddecce5a \
+    --hash=sha256:9514cfda85180554c430695282faf4be3ffdf95775d8519733821244eecb58e0 \
+    --hash=sha256:ad7b4aa011093324b7118245f50ac6db244cfe9900d4072508a5245a2b0d3f41 \
+    --hash=sha256:b41647e42884bf543b8e8d0a1dabd4d1b006c99183eb1a2d7afc5b01f73eeff4 \
+    --hash=sha256:bbab058bd965309f39962e53caac8126987c68c0be094fc4f9427e5615b0218f \
+    --hash=sha256:bff8896454b38fcb785a70e5ae0485d7021cb776303a5849393128a30b8f850b \
+    --hash=sha256:cc5a5247622cb77e24c342364eb68f88c1442ddfaab60c1f1f483359d3cc7879 \
+    --hash=sha256:e1c90f85cd7431ede95b11e8e711571a896ebea433f298849c2c0f1594c8d86e \
+    --hash=sha256:ec630be6321b04e317862f6082e84812bbd801e55a3c2298312e3fc8a0a4af4f \
+    --hash=sha256:ee98534242a74da847af78624779ac5a3177dc7c69f956a4da9e6f0fdb37d7f6 \
+    --hash=sha256:efe9b1397300b67b644ad220c89df4892a76f2debe70f16bae1749fa20526e63 \
+    --hash=sha256:f997e3351bdf51127c07a74e21741a2996e9230cbeb2d81c14acde761b116c9c \
+    --hash=sha256:f9a65d1f48b818323fb411e80358f89d77dec340b01d27c6b2dfbb9cbf4b779f \
+    --hash=sha256:fa64e1f1500d01c4a98e7a41dc1a9a35fb4dfe71f5de0389264ec1192200c76a \
+    --hash=sha256:ff457ed419348e5b8e8c811d341389b052c5e4d5839da3794d019b125b9fe830 \
+    --hash=sha256:ffbd78d73d2c9bf31e9a007c992faec3991428b2941a3b085b84fb82e8c32d19
+    # via
+    #   aiortc
+    #   faster-whisper
+certifi==2026.7.22 \
+    --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
+    --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
+    # via
+    #   httpcore
+    #   httpx
+cffi==2.1.1 \
+    --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
+    --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
+    --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \
+    --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \
+    --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \
+    --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \
+    --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \
+    --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \
+    --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \
+    --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \
+    --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \
+    --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \
+    --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \
+    --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \
+    --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \
+    --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \
+    --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \
+    --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \
+    --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \
+    --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \
+    --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \
+    --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \
+    --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \
+    --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \
+    --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \
+    --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \
+    --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \
+    --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \
+    --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \
+    --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \
+    --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \
+    --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \
+    --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \
+    --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \
+    --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \
+    --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \
+    --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \
+    --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \
+    --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \
+    --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \
+    --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \
+    --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \
+    --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \
+    --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \
+    --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \
+    --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \
+    --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \
+    --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \
+    --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \
+    --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \
+    --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \
+    --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \
+    --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \
+    --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \
+    --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \
+    --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \
+    --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \
+    --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \
+    --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \
+    --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \
+    --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \
+    --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \
+    --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \
+    --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \
+    --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \
+    --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \
+    --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \
+    --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \
+    --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \
+    --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \
+    --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \
+    --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \
+    --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \
+    --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \
+    --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \
+    --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \
+    --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \
+    --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \
+    --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \
+    --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \
+    --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \
+    --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \
+    --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \
+    --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \
+    --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \
+    --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \
+    --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \
+    --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \
+    --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \
+    --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \
+    --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \
+    --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \
+    --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \
+    --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \
+    --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \
+    --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \
+    --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \
+    --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \
+    --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \
+    --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264
+    # via
+    #   cryptography
+    #   pylibsrtp
+click==8.4.2 \
+    --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
+    --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
+    # via
+    #   huggingface-hub
+    #   uvicorn
+cryptography==50.0.0 \
+    --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \
+    --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \
+    --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \
+    --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \
+    --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \
+    --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \
+    --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \
+    --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \
+    --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \
+    --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \
+    --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \
+    --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \
+    --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \
+    --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \
+    --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \
+    --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \
+    --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \
+    --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \
+    --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \
+    --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \
+    --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \
+    --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \
+    --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \
+    --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \
+    --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \
+    --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \
+    --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \
+    --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \
+    --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \
+    --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \
+    --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \
+    --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \
+    --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \
+    --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \
+    --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \
+    --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \
+    --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \
+    --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \
+    --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \
+    --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \
+    --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \
+    --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \
+    --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \
+    --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \
+    --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \
+    --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645
+    # via
+    #   aiortc
+    #   pyopenssl
+ctranslate2==4.8.1 \
+    --hash=sha256:0030670278a73cae09dff9bca72cdd248af61f9367257f18db9b3b94fbb3a50d \
+    --hash=sha256:079976cbce3a68de04bf9948d08c96beb86df44e5cd2974e4187bc9c9bb388f3 \
+    --hash=sha256:12dbb5b756d0614d3943aa27b36da242ce173e9d21d90c7ec59592654f56105c \
+    --hash=sha256:1910752ec541980644191fa3b407bc61dee00e88070b0aed29b4cef75010b3ea \
+    --hash=sha256:2665cee79ba69a5f5f9a71b7821988f032ff13feab23fb2390b7b0399bf2a11e \
+    --hash=sha256:4242a7f8e285f922525f4cffd5b1fb43cbacc61d0611cf54832e9c447d030840 \
+    --hash=sha256:449ab612cdbc8615776b36bfa88fc1daa2d8d17e32a938bffac4743a79f80cba \
+    --hash=sha256:49f96e861b57301f0b76a082109bde2cac8204a6b4fedc870883008271e82251 \
+    --hash=sha256:4c2db633a06e3b34bbfb72fd26eee58053d9df1f9c1610ac4df3a6a1e25af7d7 \
+    --hash=sha256:5ca144b93035b9f53e6d67b7cdf5802c3fffca9aa0247940eecbd4592c68ce2f \
+    --hash=sha256:6a9e750fc9df4682d580891ef2b53c05f16c8da2c2b4baaa78fe8267d1dbfcfa \
+    --hash=sha256:71bd3bda759726f84869bb7d1e196daf6499f0f530395fc07b6deb89fb3d908e \
+    --hash=sha256:74bae0a8dc9f98c5a6100bf1c17a91782b384ea53b83e2606030ebf9f25318fe \
+    --hash=sha256:7a28c5889585cd17ee3649dfd46d9002ddf50204173f8bff476b9f76d6585795 \
+    --hash=sha256:82982f07a7d615d2248d17d6ec4c43cd50e534b094aa27cda62125a5e3a6e3fc \
+    --hash=sha256:82e0e6eb7d4301fd79a714495c8faf34242e09542cef04c9e9794c3fe90014a1 \
+    --hash=sha256:84723cae6f802551bbf2438e5e4810722631a2183b89a82c31df26566b54821d \
+    --hash=sha256:85ef15ce0b2172ec471975b8a30d5c5bc71e7cffcd163ad6c07ea32f1943d940 \
+    --hash=sha256:911a5cdef8a405c1804330613a1865f616eb9c092a0e932ee4648128eb20b627 \
+    --hash=sha256:9de0dddd91ae68da0a7323441e90708d14b31d31cd443004dda0e1198b5bf11e \
+    --hash=sha256:a2dcce0a57beee984a691d9daa8fc3fd389f5b6cada2644c34571011833bd5b1 \
+    --hash=sha256:aa0e71c5f1b509f8c972e2b80fa2ef2cef7fcf3344135fd4eb997b9fd5ff5577 \
+    --hash=sha256:ae3e18ee44c6ead07d86174ecb911a8445c0a531e6c40073a1a4ff6d672961cf \
+    --hash=sha256:b4c3246aa4a7f309109a841ca743a72cc4abad4f93c0bf7da691023323215621 \
+    --hash=sha256:ba628835e6ad4ad399261ab6cb51bf152de563e6b122a9e8eb0c61e69f925931 \
+    --hash=sha256:c0a584c17f21779eb9035bcbc1ec280998f90b36725b70a5ff911f33e343199a \
+    --hash=sha256:c90eb0bd67b6bb183712cc3fd14bf01ec4f622cd625c5b33cc6c56be7d1c9c34 \
+    --hash=sha256:c989f747789e8619cbc2e06443b3674c31bc71bad0369652485bd894b627360a \
+    --hash=sha256:d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827 \
+    --hash=sha256:d5fe13a18dfecb9d5a23d2d5cfa0f21db37aff18c46a2b1fe16495a11f1799b3 \
+    --hash=sha256:dacc408f716ebc73b3b3c6ddd937700e776c4c68b6d9c81862990150ff0f6af6 \
+    --hash=sha256:dc9f1abef55579cc02cdc74b3a55df38491ec56d177d6e6039609d61d09ed30e \
+    --hash=sha256:e3e3aef4670a6c8dcea367401675f82b49b02c18f5837221bcd7cca90b1707a8 \
+    --hash=sha256:e42ce3affb03ffc661e8f35d6e7845f16e6463884b9ba7857396d649520da819 \
+    --hash=sha256:f3172c29470c1280ebb9f4a53aa4a913650e091f95740c732cd13b4b910c734d
+    # via faster-whisper
+dnspython==2.8.0 \
+    --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \
+    --hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f
+    # via aioice
+fastapi==0.141.1 \
+    --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
+    --hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
+    # via -r dictation/requirements.in
+faster-whisper==1.2.1 \
+    --hash=sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7
+    # via -r dictation/requirements.in
+filelock==3.32.3 \
+    --hash=sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f \
+    --hash=sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09
+    # via huggingface-hub
+flatbuffers==25.12.19 \
+    --hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4
+    # via onnxruntime
+fsspec==2026.7.0 \
+    --hash=sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279 \
+    --hash=sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88
+    # via huggingface-hub
+google-crc32c==1.8.0 \
+    --hash=sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8 \
+    --hash=sha256:01f126a5cfddc378290de52095e2c7052be2ba7656a9f0caf4bcd1bfb1833f8a \
+    --hash=sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff \
+    --hash=sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288 \
+    --hash=sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411 \
+    --hash=sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a \
+    --hash=sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15 \
+    --hash=sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb \
+    --hash=sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa \
+    --hash=sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962 \
+    --hash=sha256:3d488e98b18809f5e322978d4506373599c0c13e6c5ad13e53bb44758e18d215 \
+    --hash=sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b \
+    --hash=sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27 \
+    --hash=sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113 \
+    --hash=sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f \
+    --hash=sha256:61f58b28e0b21fcb249a8247ad0db2e64114e201e2e9b4200af020f3b6242c9f \
+    --hash=sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d \
+    --hash=sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2 \
+    --hash=sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092 \
+    --hash=sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7 \
+    --hash=sha256:87b0072c4ecc9505cfa16ee734b00cd7721d20a0f595be4d40d3d21b41f65ae2 \
+    --hash=sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93 \
+    --hash=sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8 \
+    --hash=sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21 \
+    --hash=sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79 \
+    --hash=sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2 \
+    --hash=sha256:ba6aba18daf4d36ad4412feede6221414692f44d17e5428bdd81ad3fc1eee5dc \
+    --hash=sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454 \
+    --hash=sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2 \
+    --hash=sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733 \
+    --hash=sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697 \
+    --hash=sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651 \
+    --hash=sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c
+    # via aiortc
+h11==0.16.0 \
+    --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
+    --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
+    # via
+    #   httpcore
+    #   uvicorn
+hf-xet==1.6.0 \
+    --hash=sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7 \
+    --hash=sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675 \
+    --hash=sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef \
+    --hash=sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9 \
+    --hash=sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3 \
+    --hash=sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb \
+    --hash=sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e \
+    --hash=sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338 \
+    --hash=sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d \
+    --hash=sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d \
+    --hash=sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765 \
+    --hash=sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c \
+    --hash=sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522 \
+    --hash=sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f \
+    --hash=sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a \
+    --hash=sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b \
+    --hash=sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b
+    # via huggingface-hub
+httpcore==1.0.9 \
+    --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
+    --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
+    # via httpx
+httpx==0.28.1 \
+    --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
+    --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
+    # via
+    #   -r dictation/requirements.in
+    #   huggingface-hub
+huggingface-hub==1.27.0 \
+    --hash=sha256:7df6827c2f956c60fbaa64646e979e566db76f619dd0a9729dfb8c5a3eb4f68d \
+    --hash=sha256:c1fed40ea82a6b41b477f5243546549b792ae0a93abcea608cff66089bf8f8df
+    # via
+    #   -r dictation/requirements.in
+    #   faster-whisper
+    #   tokenizers
+idna==3.18 \
+    --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
+    --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
+    # via
+    #   anyio
+    #   httpx
+ifaddr==0.2.0 \
+    --hash=sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748 \
+    --hash=sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4
+    # via aioice
+numpy==2.4.6 \
+    --hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
+    --hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
+    --hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
+    --hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
+    --hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
+    --hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
+    --hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
+    --hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
+    --hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
+    --hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
+    --hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
+    --hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
+    --hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
+    --hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
+    --hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
+    --hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
+    --hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
+    --hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
+    --hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
+    --hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
+    --hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
+    --hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
+    --hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
+    --hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
+    --hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
+    --hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
+    --hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
+    --hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
+    --hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
+    --hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
+    --hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
+    --hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
+    --hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
+    --hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
+    --hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
+    --hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
+    --hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
+    --hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
+    --hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
+    --hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
+    --hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
+    --hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
+    --hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
+    --hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
+    --hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
+    --hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
+    --hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
+    --hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
+    --hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
+    --hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
+    --hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
+    --hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
+    --hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
+    --hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
+    --hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
+    --hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
+    --hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
+    --hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
+    --hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
+    --hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
+    --hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
+    --hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
+    --hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
+    --hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
+    --hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
+    --hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
+    --hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
+    --hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
+    --hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
+    --hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
+    --hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
+    --hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
+    # via
+    #   ctranslate2
+    #   onnxruntime
+nvidia-cublas-cu12==12.9.2.10 \
+    --hash=sha256:5572131a59c3eebeeb1c4c8144f772d49372c20124916e072a0e3fc30df421d5 \
+    --hash=sha256:623f43027d40d44ceadf0043f002bd25cf353e8f13ce90b9a87057019f560661 \
+    --hash=sha256:e4f53a8ca8c5d6e8c492d0d0a3d565ecb59a751b19cfdaa4f6da0ab2104c1702
+    # via
+    #   -r dictation/requirements.in
+    #   nvidia-cudnn-cu12
+nvidia-cuda-nvrtc-cu12==12.9.86 \
+    --hash=sha256:096d4de6bda726415dfaf3198d4f5c522b8e70139c97feef5cd2ca6d4cd9cead \
+    --hash=sha256:210cf05005a447e29214e9ce50851e83fc5f4358df8b453155d5e1918094dcb4 \
+    --hash=sha256:72972ebdcf504d69462d3bcd67e7b81edd25d0fb85a2c46d3ea3517666636349
+    # via nvidia-cublas-cu12
+nvidia-cudnn-cu12==9.24.0.43 \
+    --hash=sha256:a42996943f0cd78ddfd61c8bf59361672a19b63e0491aa22a53d6fe63a3f854a \
+    --hash=sha256:cbd41a0ab084422c936dc9fb2fc89be5ea9a85bc421c6f23d0243bdfc945fbef \
+    --hash=sha256:f424192dd85e7d29f44be18df2dae4c80d32c67a29c0d42f5c283c40cfdf871c
+    # via -r dictation/requirements.in
+onnxruntime==1.28.0 \
+    --hash=sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f \
+    --hash=sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c \
+    --hash=sha256:0d650aeee29368414367b65529e90afe4bf1bab76254789063b8b2f7ea3013c8 \
+    --hash=sha256:0faf85fb447a663c9cdadc39bd6b19bdf7bedded6699e45731b9b36c46fd993d \
+    --hash=sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58 \
+    --hash=sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da \
+    --hash=sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e \
+    --hash=sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c \
+    --hash=sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e \
+    --hash=sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe \
+    --hash=sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a \
+    --hash=sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f \
+    --hash=sha256:8d66f9ceb29909c70839e4e4fb3435c7b490050d8f162bd5f3aba4ca01ee517f \
+    --hash=sha256:a166b78ee04f3a37fa1ef82034b6a3ce96d9684e582d4d30b296de83e9998bb5 \
+    --hash=sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031 \
+    --hash=sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b \
+    --hash=sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8 \
+    --hash=sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7 \
+    --hash=sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02 \
+    --hash=sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af \
+    --hash=sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5 \
+    --hash=sha256:f5c5daabd28aad610f83fdcf32acec8fb57e6adc6c6a39fe2a3c755db957b410 \
+    --hash=sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135 \
+    --hash=sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28
+    # via faster-whisper
+packaging==26.3 \
+    --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
+    --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
+    # via
+    #   huggingface-hub
+    #   onnxruntime
+protobuf==7.35.1 \
+    --hash=sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799 \
+    --hash=sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87 \
+    --hash=sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6 \
+    --hash=sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30 \
+    --hash=sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9 \
+    --hash=sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4 \
+    --hash=sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4 \
+    --hash=sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a
+    # via onnxruntime
+pycparser==3.0 \
+    --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
+    --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
+    # via cffi
+pydantic==2.13.4 \
+    --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \
+    --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6
+    # via fastapi
+pydantic-core==2.46.4 \
+    --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \
+    --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \
+    --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \
+    --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \
+    --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \
+    --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \
+    --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \
+    --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \
+    --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \
+    --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \
+    --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \
+    --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \
+    --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \
+    --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \
+    --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \
+    --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \
+    --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \
+    --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \
+    --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \
+    --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \
+    --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \
+    --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \
+    --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \
+    --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \
+    --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \
+    --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \
+    --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \
+    --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \
+    --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \
+    --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \
+    --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \
+    --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \
+    --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \
+    --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \
+    --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \
+    --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \
+    --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \
+    --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \
+    --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \
+    --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \
+    --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \
+    --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \
+    --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \
+    --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \
+    --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \
+    --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \
+    --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \
+    --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \
+    --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \
+    --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \
+    --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \
+    --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \
+    --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \
+    --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \
+    --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \
+    --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \
+    --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \
+    --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \
+    --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \
+    --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \
+    --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \
+    --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \
+    --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \
+    --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \
+    --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \
+    --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \
+    --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \
+    --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \
+    --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \
+    --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \
+    --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \
+    --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \
+    --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \
+    --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \
+    --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \
+    --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \
+    --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \
+    --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \
+    --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \
+    --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \
+    --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \
+    --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \
+    --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \
+    --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \
+    --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \
+    --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \
+    --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \
+    --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \
+    --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \
+    --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \
+    --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \
+    --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \
+    --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \
+    --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \
+    --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \
+    --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \
+    --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \
+    --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \
+    --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \
+    --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \
+    --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \
+    --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \
+    --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \
+    --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \
+    --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \
+    --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \
+    --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \
+    --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \
+    --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \
+    --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \
+    --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \
+    --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \
+    --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \
+    --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \
+    --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \
+    --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \
+    --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \
+    --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \
+    --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \
+    --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae
+    # via pydantic
+pyee==13.0.1 \
+    --hash=sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8 \
+    --hash=sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228
+    # via aiortc
+pylibsrtp==1.0.0 \
+    --hash=sha256:2696bdb2180d53ac55d0eb7b58048a2aa30cd4836dd2ca683669889137a94d2a \
+    --hash=sha256:293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc \
+    --hash=sha256:4ddb562e443cf2e557ea2dfaeef0d7e6b90e96dd38eb079b4ab2c8e34a79f50b \
+    --hash=sha256:81fb8879c2e522021a7cbd3f4bda1b37c192e1af939dfda3ff95b4723b329663 \
+    --hash=sha256:822c30ea9e759b333dc1f56ceac778707c51546e97eb874de98d7d378c000122 \
+    --hash=sha256:8d6527c4a78a39a8d397f8862a8b7cdad4701ee866faf9de4ab8c70be61fd34d \
+    --hash=sha256:9209b86e662ebbd17c8a9e8549ba57eca92a3e87fb5ba8c0e27b8c43cd08a767 \
+    --hash=sha256:aaad74e5c8cbc1c32056c3767fea494c1e62b3aea2c908eda2a1051389fdad76 \
+    --hash=sha256:b39dff075b263a8ded5377f2490c60d2af452c9f06c4d061c7a2b640612b34d4 \
+    --hash=sha256:bae377c3b402b17b9bbfbfe2534c2edba17aa13bea4c64ce440caacbe0858b55 \
+    --hash=sha256:c134fa09e7b80a5b7fed626230c5bc257fd771bd6978e754343e7a61d96bc7e6 \
+    --hash=sha256:f02e616c9dfab2b03b32d8cc7b748f9d91814c0211086f987629a60f05f6e2cc
+    # via aiortc
+pyopenssl==26.4.0 \
+    --hash=sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7 \
+    --hash=sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c
+    # via aiortc
+pyyaml==6.0.3 \
+    --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
+    --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
+    --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
+    --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
+    --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
+    --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
+    --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
+    --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
+    --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
+    --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
+    --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
+    --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
+    --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
+    --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
+    --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
+    --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
+    --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
+    --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
+    --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
+    --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
+    --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
+    --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
+    --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
+    --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
+    --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
+    --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
+    --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
+    --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
+    --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
+    --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
+    --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
+    --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
+    --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
+    --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
+    --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
+    --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
+    --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
+    --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
+    --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
+    --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
+    --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
+    --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
+    --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
+    --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
+    --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
+    --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
+    --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
+    --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
+    --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
+    --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
+    --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
+    --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
+    --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
+    --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
+    --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
+    --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
+    --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
+    --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
+    --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
+    --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
+    --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
+    --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
+    --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
+    --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
+    --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
+    --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
+    --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
+    --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
+    --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
+    --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
+    --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
+    --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
+    --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
+    # via
+    #   ctranslate2
+    #   huggingface-hub
+starlette==1.6.0 \
+    --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
+    --hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
+    # via fastapi
+tokenizers==0.23.1 \
+    --hash=sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4 \
+    --hash=sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288 \
+    --hash=sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3 \
+    --hash=sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa \
+    --hash=sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4 \
+    --hash=sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51 \
+    --hash=sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a \
+    --hash=sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948 \
+    --hash=sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e \
+    --hash=sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9 \
+    --hash=sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959 \
+    --hash=sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224 \
+    --hash=sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e \
+    --hash=sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96 \
+    --hash=sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7 \
+    --hash=sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a \
+    --hash=sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6
+    # via faster-whisper
+tqdm==4.70.0 \
+    --hash=sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220 \
+    --hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953
+    # via
+    #   faster-whisper
+    #   huggingface-hub
+typing-extensions==4.16.0 \
+    --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
+    --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
+    # via
+    #   anyio
+    #   fastapi
+    #   huggingface-hub
+    #   pydantic
+    #   pydantic-core
+    #   pyee
+    #   pyopenssl
+    #   starlette
+    #   typing-inspection
+typing-inspection==0.4.4 \
+    --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \
+    --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
+    # via
+    #   fastapi
+    #   pydantic
+uvicorn==0.52.3 \
+    --hash=sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c \
+    --hash=sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58
+    # via -r dictation/requirements.in
+
+# The following packages are considered to be unsafe in a requirements file:
+setuptools==84.0.0 \
+    --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \
+    --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73
+    # via
+    #   -r dictation/requirements.in
+    #   ctranslate2
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/server.py	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+import asyncio
+from pathlib import Path
+import uuid
+
+from aiortc import RTCPeerConnection, RTCSessionDescription
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.responses import FileResponse, JSONResponse
+from pydantic import BaseModel, Field
+
+from dictation.config import DictationConfig
+from dictation.session import DictationSession
+from dictation.transcriber import FasterWhisperTranscriber, Transcriber
+
+
+class Offer(BaseModel):
+    sdp: str = Field(min_length=1, max_length=256_000)
+    type: str
+
+
+class DictationService:
+    def __init__(
+        self,
+        config: DictationConfig,
+        transcriber: Transcriber,
+    ) -> None:
+        self.config = config
+        self.transcriber = transcriber
+        self.sessions: dict[str, DictationSession] = {}
+        self.ready = False
+        self.startup_error: str | None = None
+        self._lock = asyncio.Lock()
+
+    async def start(self) -> None:
+        try:
+            await self.transcriber.warmup()
+            self.ready = True
+        except Exception as error:
+            self.startup_error = str(error)
+            raise
+
+    async def accept_offer(self, offer: Offer) -> dict[str, str]:
+        if offer.type != "offer":
+            raise HTTPException(422, "SDP type must be offer")
+        async with self._lock:
+            if len(self.sessions) >= self.config.max_sessions:
+                raise HTTPException(429, "Dictation session limit reached")
+            peer = RTCPeerConnection()
+            session_id = str(uuid.uuid4())
+            session = DictationSession(
+                session_id,
+                peer,
+                self.transcriber,
+                self.config,
+            )
+            self.sessions[session_id] = session
+
+        @peer.on("datachannel")
+        def on_datachannel(channel) -> None:
+            session.attach_channel(channel)
+
+        @peer.on("track")
+        def on_track(track) -> None:
+            session.attach_track(track)
+
+        @peer.on("connectionstatechange")
+        async def on_connectionstatechange() -> None:
+            if peer.connectionState in {"failed", "closed"}:
+                await self.close_session(session_id)
+
+        try:
+            await peer.setRemoteDescription(
+                RTCSessionDescription(sdp=offer.sdp, type=offer.type)
+            )
+            answer = await peer.createAnswer()
+            await peer.setLocalDescription(answer)
+            return {
+                "sdp": peer.localDescription.sdp,
+                "type": peer.localDescription.type,
+                "sessionId": session_id,
+            }
+        except Exception:
+            await self.close_session(session_id)
+            raise
+
+    async def close_session(self, session_id: str) -> bool:
+        async with self._lock:
+            session = self.sessions.pop(session_id, None)
+        if not session:
+            return False
+        await session.close()
+        return True
+
+    async def close(self) -> None:
+        for session_id in list(self.sessions):
+            await self.close_session(session_id)
+        close = getattr(self.transcriber, "close", None)
+        if close:
+            close()
+
+
+def create_app(
+    config: DictationConfig | None = None,
+    transcriber: Transcriber | None = None,
+) -> FastAPI:
+    resolved_config = config or DictationConfig.from_environment()
+    resolved_transcriber = transcriber or FasterWhisperTranscriber(
+        resolved_config.model_dir,
+        resolved_config.compute_type,
+    )
+    service = DictationService(resolved_config, resolved_transcriber)
+    web_root = Path(__file__).parent / "web"
+
+    @asynccontextmanager
+    async def lifespan(app: FastAPI):
+        app.state.dictation = service
+        try:
+            await service.start()
+            yield
+        finally:
+            await service.close()
+
+    app = FastAPI(
+        title="Zenbu WebRTC Dictation",
+        lifespan=lifespan,
+    )
+
+    @app.get("/")
+    async def index():
+        return FileResponse(web_root / "index.html")
+
+    @app.get("/dictation.js")
+    async def javascript():
+        return FileResponse(web_root / "dictation.js")
+
+    @app.get("/dictation.css")
+    async def stylesheet():
+        return FileResponse(web_root / "dictation.css")
+
+    @app.get("/health")
+    async def health(request: Request):
+        current: DictationService = request.app.state.dictation
+        status = 200 if current.ready else 503
+        return JSONResponse(
+            {
+                "status": "ok" if current.ready else "unavailable",
+                "model": str(current.config.model_dir),
+                "computeType": current.config.compute_type,
+                "activeSessions": len(current.sessions),
+                "maxSessions": current.config.max_sessions,
+                "error": current.startup_error,
+            },
+            status_code=status,
+        )
+
+    @app.post("/api/webrtc/offer")
+    async def offer(body: Offer, request: Request):
+        current: DictationService = request.app.state.dictation
+        if not current.ready:
+            raise HTTPException(503, "Dictation model is unavailable")
+        return await current.accept_offer(body)
+
+    @app.post("/api/webrtc/session/{session_id}/close")
+    async def close_session(session_id: str, request: Request):
+        current: DictationService = request.app.state.dictation
+        await current.close_session(session_id)
+        return {"closed": True}
+
+    return app
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/server_test.py	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,60 @@
+from pathlib import Path
+import unittest
+
+from fastapi.testclient import TestClient
+
+from dictation.config import DictationConfig
+from dictation.server import create_app
+from dictation.transcriber import Transcript
+
+
+class FakeTranscriber:
+    async def warmup(self):
+        return None
+
+    async def transcribe(self, samples, *, final):
+        return Transcript("test transcript", "en", 0.99)
+
+
+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=1200,
+        silence_ms=700,
+        max_utterance_seconds=30,
+        speech_threshold=0.012,
+    )
+
+
+class ServerTest(unittest.TestCase):
+    def test_health_and_assets(self):
+        with TestClient(create_app(test_config(), FakeTranscriber())) as client:
+            health = client.get("/health")
+            self.assertEqual(health.status_code, 200)
+            self.assertEqual(health.json()["status"], "ok")
+            self.assertIn("Zenbu Dictation", client.get("/").text)
+            self.assertIn("RTCPeerConnection", client.get("/dictation.js").text)
+
+    def test_rejects_non_offer_sdp(self):
+        with TestClient(create_app(test_config(), FakeTranscriber())) as client:
+            response = client.post(
+                "/api/webrtc/offer",
+                json={"sdp": "not-sdp", "type": "answer"},
+            )
+            self.assertEqual(response.status_code, 422)
+
+    def test_close_is_idempotent(self):
+        with TestClient(create_app(test_config(), FakeTranscriber())) as client:
+            response = client.post(
+                "/api/webrtc/session/00000000-0000-0000-0000-000000000000/close"
+            )
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(response.json(), {"closed": True})
+
+
+if __name__ == "__main__":
+    unittest.main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/session.py	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,177 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from typing import Any
+
+from aiortc.mediastreams import MediaStreamError
+from av.audio.resampler import AudioResampler
+import numpy as np
+
+from dictation.audio import AudioEventKind, AudioSegmenter
+from dictation.config import DictationConfig
+from dictation.transcriber import Transcriber
+
+
+class DictationSession:
+    def __init__(
+        self,
+        session_id: str,
+        peer: Any,
+        transcriber: Transcriber,
+        config: DictationConfig,
+    ) -> None:
+        self.session_id = session_id
+        self.peer = peer
+        self.transcriber = transcriber
+        self.segmenter = AudioSegmenter(
+            speech_threshold=config.speech_threshold,
+            silence_ms=config.silence_ms,
+            partial_interval_ms=config.partial_interval_ms,
+            max_utterance_seconds=config.max_utterance_seconds,
+        )
+        self.channel = None
+        self.audio_task: asyncio.Task | None = None
+        self.partial_task: asyncio.Task | None = None
+        self.final_tasks: set[asyncio.Task] = set()
+        self.closed = False
+        self.stopping = False
+        self._resampler = AudioResampler(
+            format="s16",
+            layout="mono",
+            rate=16000,
+        )
+
+    def attach_channel(self, channel: Any) -> None:
+        if channel.label != "transcripts":
+            channel.close()
+            return
+        self.channel = channel
+
+        @channel.on("open")
+        def on_open() -> None:
+            self.send("ready", sessionId=self.session_id)
+
+        @channel.on("message")
+        def on_message(message: Any) -> None:
+            if message == "stop":
+                self.stopping = True
+                event = self.segmenter.flush()
+                if event and event.samples is not None:
+                    self._start_final(event.samples)
+                else:
+                    self.send("speech.ended")
+
+        if channel.readyState == "open":
+            self.send("ready", sessionId=self.session_id)
+
+    def attach_track(self, track: Any) -> None:
+        if track.kind != "audio" or self.audio_task is not None:
+            return
+        self.audio_task = asyncio.create_task(self._consume_audio(track))
+
+    async def _consume_audio(self, track: Any) -> None:
+        try:
+            while not self.closed:
+                frame = await track.recv()
+                for converted in self._resampler.resample(frame):
+                    if self.stopping:
+                        continue
+                    samples = converted.to_ndarray().reshape(-1)
+                    normalized = samples.astype(np.float32) / 32768.0
+                    for event in self.segmenter.feed(normalized):
+                        if event.kind == AudioEventKind.SPEECH_STARTED:
+                            self.send("speech.started")
+                        elif (
+                            event.kind == AudioEventKind.PARTIAL_READY
+                            and event.samples is not None
+                        ):
+                            self._start_partial(event.samples)
+                        elif (
+                            event.kind == AudioEventKind.FINAL_READY
+                            and event.samples is not None
+                        ):
+                            self._start_final(event.samples)
+        except MediaStreamError:
+            if not self.stopping:
+                event = self.segmenter.flush()
+                if event and event.samples is not None:
+                    self._start_final(event.samples)
+                else:
+                    self.send("speech.ended")
+        except asyncio.CancelledError:
+            raise
+        except Exception as error:
+            if not self.closed:
+                self.send(
+                    "error",
+                    code="audio_track_failed",
+                    message=str(error),
+                )
+
+    def _start_partial(self, samples: np.ndarray) -> None:
+        if self.partial_task and not self.partial_task.done():
+            return
+        self.partial_task = asyncio.create_task(
+            self._transcribe(samples, final=False)
+        )
+
+    def _start_final(self, samples: np.ndarray) -> None:
+        task = asyncio.create_task(self._transcribe(samples, final=True))
+        self.final_tasks.add(task)
+        task.add_done_callback(self.final_tasks.discard)
+
+    async def _transcribe(self, samples: np.ndarray, *, final: bool) -> None:
+        try:
+            transcript = await self.transcriber.transcribe(
+                samples,
+                final=final,
+            )
+            if transcript.text:
+                self.send(
+                    "transcript.final" if final else "transcript.partial",
+                    text=transcript.text,
+                    language=transcript.language,
+                    probability=transcript.probability,
+                )
+            if final:
+                self.send("speech.ended")
+        except asyncio.CancelledError:
+            raise
+        except Exception as error:
+            self.send(
+                "error",
+                code="transcription_failed",
+                message=str(error),
+            )
+
+    def send(self, event_type: str, **payload: Any) -> None:
+        if not self.channel or self.channel.readyState != "open":
+            return
+        self.channel.send(
+            json.dumps(
+                {"type": event_type, **payload},
+                separators=(",", ":"),
+            )
+        )
+
+    async def close(self) -> None:
+        if self.closed:
+            return
+        self.closed = True
+        tasks = [
+            task
+            for task in [
+                self.audio_task,
+                self.partial_task,
+                *self.final_tasks,
+            ]
+            if task is not None and not task.done()
+        ]
+        for task in tasks:
+            task.cancel()
+        if tasks:
+            await asyncio.gather(*tasks, return_exceptions=True)
+        if self.channel and self.channel.readyState != "closed":
+            self.channel.close()
+        await self.peer.close()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/transcriber.py	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Protocol
+
+import numpy as np
+
+
+@dataclass(frozen=True)
+class Transcript:
+    text: str
+    language: str
+    probability: float
+
+
+class Transcriber(Protocol):
+    async def warmup(self) -> None:
+        ...
+
+    async def transcribe(
+        self,
+        samples: np.ndarray,
+        *,
+        final: bool,
+    ) -> Transcript:
+        ...
+
+
+class FasterWhisperTranscriber:
+    def __init__(self, model_dir: Path, compute_type: str) -> None:
+        self._model_dir = model_dir
+        self._compute_type = compute_type
+        self._executor = ThreadPoolExecutor(
+            max_workers=1,
+            thread_name_prefix="dictation",
+        )
+        self._model = None
+        self._load_lock = asyncio.Lock()
+
+    async def warmup(self) -> None:
+        if self._model is not None:
+            return
+        async with self._load_lock:
+            if self._model is not None:
+                return
+            loop = asyncio.get_running_loop()
+            self._model = await loop.run_in_executor(
+                self._executor,
+                self._load,
+            )
+
+    def _load(self):
+        from faster_whisper import WhisperModel
+
+        if not (self._model_dir / "model.bin").is_file():
+            raise FileNotFoundError(
+                f"Model not found at {self._model_dir}. "
+                "Run: bazel run //dictation:download_model"
+            )
+        return WhisperModel(
+            str(self._model_dir),
+            device="cuda",
+            compute_type=self._compute_type,
+            local_files_only=True,
+        )
+
+    async def transcribe(
+        self,
+        samples: np.ndarray,
+        *,
+        final: bool,
+    ) -> Transcript:
+        await self.warmup()
+        loop = asyncio.get_running_loop()
+        return await loop.run_in_executor(
+            self._executor,
+            self._transcribe_sync,
+            np.asarray(samples, dtype=np.float32),
+            final,
+        )
+
+    def _transcribe_sync(
+        self,
+        samples: np.ndarray,
+        final: bool,
+    ) -> Transcript:
+        segments, info = self._model.transcribe(
+            samples,
+            beam_size=5 if final else 1,
+            best_of=5 if final else 1,
+            condition_on_previous_text=False,
+            vad_filter=False,
+        )
+        text = "".join(segment.text for segment in segments).strip()
+        return Transcript(
+            text=text,
+            language=info.language or "",
+            probability=float(info.language_probability or 0.0),
+        )
+
+    def close(self) -> None:
+        self._executor.shutdown(wait=False, cancel_futures=True)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/web/dictation.css	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,68 @@
+:root {
+  color-scheme: dark;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  background: #090d12;
+  color: #e7f7ef;
+}
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  display: grid;
+  place-items: center;
+}
+
+main {
+  width: min(42rem, calc(100% - 2rem));
+  padding: 2rem;
+  border: 1px solid #2b5d4a;
+  background: #0e1718;
+  box-shadow: 0 1rem 4rem #0008;
+}
+
+.eyebrow {
+  color: #71e6af;
+  letter-spacing: 0.15em;
+}
+
+.controls {
+  display: flex;
+  gap: 0.75rem;
+  margin: 1.5rem 0;
+}
+
+button {
+  padding: 0.7rem 1rem;
+  border: 1px solid #71e6af;
+  background: transparent;
+  color: inherit;
+  cursor: pointer;
+}
+
+button:disabled {
+  opacity: 0.45;
+  cursor: default;
+}
+
+.status[data-state="error"] {
+  color: #ff8c8c;
+}
+
+.status[data-state="ready"] {
+  color: #71e6af;
+}
+
+.partial {
+  min-height: 1.5rem;
+  color: #a7c9bb;
+}
+
+textarea {
+  box-sizing: border-box;
+  width: 100%;
+  resize: vertical;
+  border: 1px solid #315448;
+  padding: 0.75rem;
+  background: #080d0e;
+  color: inherit;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/web/dictation.js	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,151 @@
+const startButton = document.querySelector("[data-start]");
+const stopButton = document.querySelector("[data-stop]");
+const status = document.querySelector("[data-status]");
+const partial = document.querySelector("[data-partial]");
+const finalText = document.querySelector("[data-final]");
+
+let peer = null;
+let channel = null;
+let stream = null;
+let sessionId = null;
+let stopResolver = null;
+
+function setStatus(message, state = "") {
+  status.textContent = message;
+  status.dataset.state = state;
+}
+
+function waitForIceGathering(connection) {
+  if (connection.iceGatheringState === "complete") return Promise.resolve();
+  return new Promise(resolve => {
+    const listener = () => {
+      if (connection.iceGatheringState !== "complete") return;
+      connection.removeEventListener("icegatheringstatechange", listener);
+      resolve();
+    };
+    connection.addEventListener("icegatheringstatechange", listener);
+  });
+}
+
+function handleTranscript(message) {
+  let event;
+  try {
+    event = JSON.parse(message.data);
+  } catch {
+    setStatus("Received an invalid server event", "error");
+    return;
+  }
+  switch (event.type) {
+    case "ready":
+      setStatus("Listening", "ready");
+      break;
+    case "speech.started":
+      setStatus("Speech detected", "working");
+      break;
+    case "transcript.partial":
+      partial.textContent = event.text;
+      break;
+    case "transcript.final":
+      finalText.value += `${finalText.value ? " " : ""}${event.text}`;
+      partial.textContent = "Waiting for speech...";
+      break;
+    case "speech.ended":
+      setStatus("Listening", "ready");
+      stopResolver?.();
+      stopResolver = null;
+      break;
+    case "error":
+      setStatus(event.message || "Transcription failed", "error");
+      break;
+  }
+}
+
+async function start() {
+  if (peer) return;
+  startButton.disabled = true;
+  setStatus("Requesting microphone access...", "working");
+  try {
+    stream = await navigator.mediaDevices.getUserMedia({
+      audio: {
+        channelCount: 1,
+        echoCancellation: true,
+        noiseSuppression: true,
+      },
+      video: false,
+    });
+    peer = new RTCPeerConnection();
+    channel = peer.createDataChannel("transcripts", { ordered: true });
+    channel.addEventListener("message", handleTranscript);
+    channel.addEventListener("close", () => {
+      if (peer) setStatus("Transcript channel closed", "error");
+    });
+    peer.addEventListener("connectionstatechange", () => {
+      if (peer?.connectionState === "failed") {
+        setStatus("WebRTC connection failed", "error");
+        void stop();
+      }
+    });
+    for (const track of stream.getTracks()) peer.addTrack(track, stream);
+
+    const offer = await peer.createOffer();
+    await peer.setLocalDescription(offer);
+    await waitForIceGathering(peer);
+    const response = await fetch("/api/webrtc/offer", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(peer.localDescription),
+    });
+    if (!response.ok) {
+      const detail = await response.text();
+      throw new Error(`Signaling failed (${response.status}): ${detail}`);
+    }
+    const answer = await response.json();
+    sessionId = answer.sessionId;
+    await peer.setRemoteDescription({
+      sdp: answer.sdp,
+      type: answer.type,
+    });
+    stopButton.disabled = false;
+    setStatus("Connecting...", "working");
+  } catch (error) {
+    setStatus(error.message || "Unable to start dictation", "error");
+    await stop();
+  }
+}
+
+async function stop(waitForFinal = true) {
+  const closingSession = sessionId;
+  sessionId = null;
+  const tracks = stream?.getTracks() || [];
+  if (channel?.readyState === "open" && waitForFinal) {
+    const ended = new Promise(resolve => {
+      stopResolver = resolve;
+    });
+    channel.send("stop");
+    for (const track of tracks) track.enabled = false;
+    await Promise.race([
+      ended,
+      new Promise(resolve => window.setTimeout(resolve, 10000)),
+    ]);
+    stopResolver = null;
+  }
+  for (const track of tracks) track.stop();
+  stream = null;
+  channel?.close();
+  channel = null;
+  peer?.close();
+  peer = null;
+  if (closingSession) {
+    await fetch(`/api/webrtc/session/${encodeURIComponent(closingSession)}/close`, {
+      method: "POST",
+      keepalive: true,
+    }).catch(() => {});
+  }
+  startButton.disabled = false;
+  stopButton.disabled = true;
+  setStatus("Idle");
+}
+
+startButton.addEventListener("click", () => void start());
+stopButton.addEventListener("click", () => void stop());
+window.addEventListener("pagehide", () => void stop(false));
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/web/index.html	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,30 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Zenbu Dictation</title>
+    <link rel="stylesheet" href="/dictation.css">
+    <script type="module" src="/dictation.js"></script>
+  </head>
+  <body>
+    <main>
+      <p class="eyebrow">LOCAL WEBRTC</p>
+      <h1>Zenbu Dictation</h1>
+      <p>Microphone audio stays local and is transcribed on this machine.</p>
+      <div class="controls">
+        <button type="button" data-start>Start dictation</button>
+        <button type="button" data-stop disabled>Stop</button>
+      </div>
+      <p class="status" data-status role="status">Idle</p>
+      <section aria-labelledby="partial-heading">
+        <h2 id="partial-heading">Live</h2>
+        <p class="partial" data-partial>Waiting for speech...</p>
+      </section>
+      <section aria-labelledby="final-heading">
+        <h2 id="final-heading">Transcript</h2>
+        <textarea data-final rows="10" aria-label="Final transcript"></textarea>
+      </section>
+    </main>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/webrtc_smoke.py	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+
+from aiortc import RTCPeerConnection, RTCSessionDescription
+from aiortc.contrib.media import MediaPlayer
+import httpx
+
+
+async def run(server_url: str, audio_path: str, timeout: float) -> None:
+    peer = RTCPeerConnection()
+    channel = peer.createDataChannel("transcripts")
+    player = MediaPlayer(audio_path)
+    if player.audio is None:
+        raise RuntimeError("Input file does not contain an audio track")
+    peer.addTrack(player.audio)
+    final_received = asyncio.Event()
+    session_id = None
+
+    @channel.on("message")
+    def on_message(message):
+        event = json.loads(message)
+        print(json.dumps(event, ensure_ascii=False), flush=True)
+        if event.get("type") == "transcript.final":
+            final_received.set()
+
+    try:
+        offer = await peer.createOffer()
+        await peer.setLocalDescription(offer)
+        async with httpx.AsyncClient(timeout=timeout) as client:
+            response = await client.post(
+                f"{server_url}/api/webrtc/offer",
+                json={
+                    "sdp": peer.localDescription.sdp,
+                    "type": peer.localDescription.type,
+                },
+            )
+            response.raise_for_status()
+            answer = response.json()
+            session_id = answer["sessionId"]
+            await peer.setRemoteDescription(
+                RTCSessionDescription(
+                    sdp=answer["sdp"],
+                    type=answer["type"],
+                )
+            )
+            await asyncio.wait_for(final_received.wait(), timeout=timeout)
+            await client.post(
+                f"{server_url}/api/webrtc/session/{session_id}/close"
+            )
+    finally:
+        await peer.close()
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("audio")
+    parser.add_argument(
+        "--server",
+        default="http://127.0.0.1:8090",
+    )
+    parser.add_argument("--timeout", type=float, default=60.0)
+    args = parser.parse_args()
+    asyncio.run(run(args.server.rstrip("/"), args.audio, args.timeout))
+
+
+if __name__ == "__main__":
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dictation/webrtc_test.py	Mon Aug 17 17:01:40 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()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qwen3_vl/BUILD	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,66 @@
+load("@rules_shell//shell:sh_binary.bzl", "sh_binary")
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
+load(":runtime.bzl", "cuda_runtime")
+
+cuda_runtime(
+    name = "llama_cpp_cuda_runtime",
+    cuda_runtime = "@llama_cpp_win_cuda_12_4_cudart_x64//:runtime",
+    llama_runtime = "@llama_cpp_win_cuda_12_4_x64//:runtime",
+)
+
+sh_binary(
+    name = "model",
+    srcs = ["qwen3_vl.sh"],
+    args = [
+        "$(rootpath :llama_cpp_cuda_runtime)",
+        "config",
+    ],
+    data = [":llama_cpp_cuda_runtime"],
+)
+
+sh_binary(
+    name = "download_model",
+    srcs = ["qwen3_vl.sh"],
+    args = [
+        "$(rootpath :llama_cpp_cuda_runtime)",
+        "download",
+    ],
+    data = [":llama_cpp_cuda_runtime"],
+)
+
+sh_binary(
+    name = "preflight",
+    srcs = ["qwen3_vl.sh"],
+    args = [
+        "$(rootpath :llama_cpp_cuda_runtime)",
+        "preflight",
+    ],
+    data = [":llama_cpp_cuda_runtime"],
+)
+
+sh_binary(
+    name = "chat",
+    srcs = ["qwen3_vl.sh"],
+    args = [
+        "$(rootpath :llama_cpp_cuda_runtime)",
+        "chat",
+    ],
+    data = [":llama_cpp_cuda_runtime"],
+)
+
+sh_binary(
+    name = "serve",
+    srcs = ["qwen3_vl.sh"],
+    args = [
+        "$(rootpath :llama_cpp_cuda_runtime)",
+        "serve",
+    ],
+    data = [":llama_cpp_cuda_runtime"],
+)
+
+sh_test(
+    name = "qwen3_vl_test",
+    srcs = ["qwen3_vl_test.sh"],
+    data = ["qwen3_vl.sh"],
+    deps = ["@bazel_tools//tools/bash/runfiles"],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qwen3_vl/README.md	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,69 @@
+# Qwen3-VL 30B A3B
+
+This package runs `Qwen3-VL-30B-A3B-Instruct` through a Bazel-pinned
+llama.cpp CUDA runtime. It is tailored to the current WSL machine: an RTX 4070
+Ti with 12 GB VRAM and 32 GB host RAM.
+
+The default model is Unsloth's 11.26 GB `Q2_K` GGUF plus the 1.08 GB FP16
+vision projector. This lower quantization is intentional: the official
+18.56 GB `Q4_K_M` model cannot fit on this GPU and is too large for the
+current WSL memory allocation. Model weights are downloaded to
+`~/.cache/zenbu/qwen3-vl-30b-a3b`, not the repository or Bazel cache.
+
+## Setup
+
+Verify that Bazel's pinned runtime can see the GPU:
+
+```bash
+bazel run //qwen3_vl:preflight
+```
+
+Download and verify the model files:
+
+```bash
+bazel run //qwen3_vl:download_model
+```
+
+Start an interactive image prompt:
+
+```bash
+bazel run //qwen3_vl:chat -- \
+  --image /path/to/image.jpg \
+  --prompt "Describe this image." \
+  --n-predict 256
+```
+
+Start the OpenAI-compatible server:
+
+```bash
+bazel run //qwen3_vl:serve
+curl.exe http://127.0.0.1:8080/health
+```
+
+The pinned CUDA executable runs on the Windows side of WSL interop. Windows
+browsers and clients can use `http://127.0.0.1:8080`; use `curl.exe`, rather
+than Linux `curl`, when checking it from a WSL shell.
+
+Runtime tuning is available through environment variables:
+
+```bash
+QWEN3_VL_GPU_LAYERS=16 \
+QWEN3_VL_CONTEXT_SIZE=1024 \
+  bazel run //qwen3_vl:chat -- --prompt "Hello" --n-predict 64
+```
+
+Increase `QWEN3_VL_GPU_LAYERS` until GPU memory is nearly full. Reduce it if
+llama.cpp reports an allocation failure. Keep the context small on this
+machine because the KV cache also consumes GPU and system memory.
+
+The WSL instance currently exposes about 15 GB of RAM even though the host has
+32 GB. If model loading fails under memory pressure, create `%UserProfile%\.wslconfig`
+on Windows with:
+
+```ini
+[wsl2]
+memory=28GB
+swap=16GB
+```
+
+Then run `wsl --shutdown` from Windows before retrying.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qwen3_vl/qwen3_vl.sh	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,166 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+runtime="$1"
+command="$2"
+shift 2
+
+model_repo="unsloth/Qwen3-VL-30B-A3B-Instruct-GGUF"
+model_name="Qwen3-VL-30B-A3B-Instruct-Q2_K.gguf"
+model_sha256="46d1ea7d691746e1ab66b7ccefbeb6b9d49af9d2a689a24c94411ef46800dee3"
+model_size=11258611424
+mmproj_name="mmproj-F16.gguf"
+mmproj_sha256="9f248089357599a08a23af40cb5ce0030de14a2e119b7ef57f66cb339bd20819"
+mmproj_size=1083500224
+cache_root="${XDG_CACHE_HOME:-${HOME:?HOME must be set}/.cache}"
+model_dir="${QWEN3_VL_MODEL_DIR:-$cache_root/zenbu/qwen3-vl-30b-a3b}"
+model_path="$model_dir/$model_name"
+mmproj_path="$model_dir/$mmproj_name"
+
+download_file() {
+  local name="$1"
+  local sha256="$2"
+  local expected_size="$3"
+  local destination="$model_dir/$name"
+  local partial="$destination.part"
+  local url="https://huggingface.co/$model_repo/resolve/main/$name"
+
+  if [[ -f "$destination" ]] &&
+      printf '%s  %s\n' "$sha256" "$destination" | sha256sum --check --status; then
+    echo "Already verified: $destination"
+    return
+  fi
+
+  mkdir -p "$model_dir"
+  local present=0
+  if [[ -f "$partial" ]]; then
+    present="$(stat --format=%s "$partial")"
+  fi
+  local required=$((expected_size - present))
+  local available
+  available="$(df --output=avail -B1 "$model_dir" | tail -1 | tr -d ' ')"
+  if (( available < required + 1073741824 )); then
+    echo "Insufficient disk space for $name." >&2
+    echo "Required: $required bytes plus 1 GiB headroom; available: $available bytes." >&2
+    exit 1
+  fi
+
+  echo "Downloading $name to $model_dir"
+  curl \
+    --location \
+    --fail \
+    --show-error \
+    --retry 5 \
+    --retry-all-errors \
+    --continue-at - \
+    --output "$partial" \
+    "$url"
+  printf '%s  %s\n' "$sha256" "$partial" | sha256sum --check
+  mv "$partial" "$destination"
+}
+
+require_model() {
+  if [[ ! -f "$model_path" || ! -f "$mmproj_path" ]]; then
+    echo "Qwen3-VL model files are missing." >&2
+    echo "Run: bazel run //qwen3_vl:download_model" >&2
+    exit 1
+  fi
+}
+
+require_wsl() {
+  if ! command -v wslpath >/dev/null 2>&1; then
+    echo "This CUDA runtime target requires WSL with Windows interop enabled." >&2
+    exit 1
+  fi
+}
+
+windows_path() {
+  local path="$1"
+  if [[ "$path" != /* &&
+        -n "${BUILD_WORKSPACE_DIRECTORY:-}" &&
+        -e "$BUILD_WORKSPACE_DIRECTORY/$path" ]]; then
+    path="$BUILD_WORKSPACE_DIRECTORY/$path"
+  fi
+  if [[ ! -e "$path" ]]; then
+    echo "Path does not exist: $1" >&2
+    return 1
+  fi
+  wslpath -w "$(realpath "$path")"
+}
+
+normalize_image_args() {
+  NORMALIZED_ARGS=()
+  while (( $# > 0 )); do
+    case "$1" in
+      --image)
+        if (( $# < 2 )); then
+          echo "--image requires a path." >&2
+          exit 2
+        fi
+        NORMALIZED_ARGS+=("$1" "$(windows_path "$2")")
+        shift 2
+        ;;
+      --image=*)
+        NORMALIZED_ARGS+=("--image=$(windows_path "${1#--image=}")")
+        shift
+        ;;
+      *)
+        NORMALIZED_ARGS+=("$1")
+        shift
+        ;;
+    esac
+  done
+}
+
+case "$command" in
+  config)
+    cat <<EOF
+model_repo=$model_repo
+model=$model_path
+mmproj=$mmproj_path
+gpu_layers=${QWEN3_VL_GPU_LAYERS:-20}
+context_size=${QWEN3_VL_CONTEXT_SIZE:-2048}
+EOF
+    ;;
+  download)
+    download_file "$model_name" "$model_sha256" "$model_size"
+    download_file "$mmproj_name" "$mmproj_sha256" "$mmproj_size"
+    echo "Model ready: $model_dir"
+    ;;
+  preflight)
+    require_wsl
+    "$runtime/llama-mtmd-cli.exe" --version
+    "$runtime/llama-mtmd-cli.exe" --list-devices
+    ;;
+  chat)
+    require_wsl
+    require_model
+    normalize_image_args "$@"
+    exec "$runtime/llama-mtmd-cli.exe" \
+      --model "$(windows_path "$model_path")" \
+      --mmproj "$(windows_path "$mmproj_path")" \
+      --ctx-size "${QWEN3_VL_CONTEXT_SIZE:-2048}" \
+      --n-gpu-layers "${QWEN3_VL_GPU_LAYERS:-20}" \
+      --temp "${QWEN3_VL_TEMPERATURE:-0.7}" \
+      --top-k 20 \
+      --top-p 0.8 \
+      "${NORMALIZED_ARGS[@]}"
+    ;;
+  serve)
+    require_wsl
+    require_model
+    exec "$runtime/llama-server.exe" \
+      --model "$(windows_path "$model_path")" \
+      --mmproj "$(windows_path "$mmproj_path")" \
+      --host "${QWEN3_VL_HOST:-127.0.0.1}" \
+      --port "${QWEN3_VL_PORT:-8080}" \
+      --ctx-size "${QWEN3_VL_CONTEXT_SIZE:-2048}" \
+      --parallel 1 \
+      --n-gpu-layers "${QWEN3_VL_GPU_LAYERS:-20}" \
+      "$@"
+    ;;
+  *)
+    echo "Unknown command: $command" >&2
+    exit 2
+    ;;
+esac
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qwen3_vl/qwen3_vl_test.sh	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ -n "${RUNFILES_DIR:-}" ]]; then
+  source "$RUNFILES_DIR/bazel_tools/tools/bash/runfiles/runfiles.bash"
+elif [[ -n "${RUNFILES_MANIFEST_FILE:-}" ]]; then
+  runfiles_library="$(
+    grep -sm1 '^bazel_tools/tools/bash/runfiles/runfiles.bash ' \
+      "$RUNFILES_MANIFEST_FILE" | cut -d' ' -f2-
+  )"
+  source "$runfiles_library"
+else
+  echo "Bazel runfiles are unavailable." >&2
+  exit 1
+fi
+
+launcher="$(rlocation _main/qwen3_vl/qwen3_vl.sh)"
+home="$TEST_TMPDIR/home"
+mkdir -p "$home"
+
+config="$(HOME="$home" bash "$launcher" "$TEST_TMPDIR/runtime" config)"
+grep -F "model=$home/.cache/zenbu/qwen3-vl-30b-a3b/" <<<"$config"
+grep -F "gpu_layers=20" <<<"$config"
+grep -F "context_size=2048" <<<"$config"
+
+custom="$TEST_TMPDIR/models"
+config="$(
+  HOME="$home" \
+  QWEN3_VL_MODEL_DIR="$custom" \
+  QWEN3_VL_GPU_LAYERS=7 \
+  QWEN3_VL_CONTEXT_SIZE=1024 \
+    bash "$launcher" "$TEST_TMPDIR/runtime" config
+)"
+grep -F "model=$custom/" <<<"$config"
+grep -F "gpu_layers=7" <<<"$config"
+grep -F "context_size=1024" <<<"$config"
+
+if HOME="$home" bash "$launcher" "$TEST_TMPDIR/runtime" chat \
+    >"$TEST_TMPDIR/stdout" 2>"$TEST_TMPDIR/stderr"; then
+  echo "Chat unexpectedly succeeded without model files." >&2
+  exit 1
+fi
+grep -F "bazel run //qwen3_vl:download_model" "$TEST_TMPDIR/stderr"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/qwen3_vl/runtime.bzl	Mon Aug 17 17:01:40 2026 -0700
@@ -0,0 +1,45 @@
+def _cuda_runtime_impl(ctx):
+    output = ctx.actions.declare_directory(ctx.label.name)
+    inputs = ctx.files.llama_runtime + ctx.files.cuda_runtime
+    args = ctx.actions.args()
+    args.add(output.path)
+    args.add_all([file.path for file in inputs])
+
+    ctx.actions.run_shell(
+        inputs = inputs,
+        outputs = [output],
+        arguments = [args],
+        command = """
+set -euo pipefail
+output="$1"
+shift
+mkdir -p "$output"
+for input in "$@"; do
+  cp -L "$input" "$output/$(basename "$input")"
+done
+chmod 0555 "$output"/*.exe
+chmod 0444 "$output"/*.dll
+""",
+        progress_message = "Assembling llama.cpp CUDA runtime",
+    )
+
+    return [
+        DefaultInfo(
+            files = depset([output]),
+            runfiles = ctx.runfiles(files = [output]),
+        ),
+    ]
+
+cuda_runtime = rule(
+    implementation = _cuda_runtime_impl,
+    attrs = {
+        "llama_runtime": attr.label(
+            allow_files = True,
+            mandatory = True,
+        ),
+        "cuda_runtime": attr.label(
+            allow_files = True,
+            mandatory = True,
+        ),
+    },
+)