Mercurial
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, + ), + }, +)