From 13978ae5dbd8de3dd8593ebd4e188f5aa3709e12 Mon Sep 17 00:00:00 2001 From: Ryan Hill <38674843+RyanUnderhill@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:36:08 -0700 Subject: [PATCH] fix(stt): bundle whisper.cpp CPU for Windows ARM64 --- hermes_platform/resolver/whisper.py | 10 ++ pm/lock.json | 9 ++ pm/packages.py | 13 +++ tests/tools/test_transcription_whisper_cpp.py | 95 +++++++++++++++++++ tools/transcription_local.py | 8 ++ tools/transcription_whisper_cpp.py | 75 +++++++++++++++ 6 files changed, 210 insertions(+) create mode 100644 hermes_platform/resolver/whisper.py create mode 100644 tests/tools/test_transcription_whisper_cpp.py create mode 100644 tools/transcription_whisper_cpp.py diff --git a/hermes_platform/resolver/whisper.py b/hermes_platform/resolver/whisper.py new file mode 100644 index 0000000000..72daa37cc5 --- /dev/null +++ b/hermes_platform/resolver/whisper.py @@ -0,0 +1,10 @@ +"""Passive lookup of the managed whisper.cpp executable; never provisions it.""" + +from pathlib import Path + + +def whisper_cpp_binary() -> Path | None: + from pm import installed_package + + installed = installed_package("whispercpp-cpu") + return installed.binary if installed else None diff --git a/pm/lock.json b/pm/lock.json index c1b4beb6e3..1ef3cbea84 100644 --- a/pm/lock.json +++ b/pm/lock.json @@ -743,6 +743,15 @@ } }, "version": "0.12.3" + }, + "whispercpp-cpu": { + "artifacts": { + "win32-arm64": { + "sha256": "799543b926ab5b6c2d60cab269a2092e0ae8d27820e9e15429e59de3699546fc", + "url": "https://github.com/ggml-org/whisper.cpp/releases/download/b5130/whisper-bin-win-cpu-arm64.zip" + } + }, + "version": "b5130" } }, "schema": 1 diff --git a/pm/packages.py b/pm/packages.py index 9981f5aaf1..6ea498ad2a 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -1266,3 +1266,16 @@ class LlamaCppCpu(LlamaCpp): "darwin-x64": "macos-x64", "darwin-arm64": "macos-arm64", } + + +@register +class WhisperCppCpu(BinaryPackage): + """Native local STT for Windows ARM64, where faster-whisper has no wheel.""" + + name = "whispercpp-cpu" + optional = True + gaps = {target: "uses the existing faster-whisper provider" for target in ALL_TARGETS + if target != "win32-arm64"} + binary_rel = {"win32-arm64": "whisper-cli.exe"} + probe_args = ["--help"] + url = "https://github.com/ggml-org/whisper.cpp/releases/download/{version}/whisper-bin-win-cpu-arm64.zip" diff --git a/tests/tools/test_transcription_whisper_cpp.py b/tests/tools/test_transcription_whisper_cpp.py new file mode 100644 index 0000000000..9c7039e3c4 --- /dev/null +++ b/tests/tools/test_transcription_whisper_cpp.py @@ -0,0 +1,95 @@ +"""The managed command remains passive until transcription and caches verified models.""" + +import hashlib +import threading +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + + +def test_models_download_once_per_profile_and_reject_bad_bytes(tmp_path, monkeypatch): + from pm.downloader import HashError + from pm import downloader + from tools import transcription_whisper_cpp as cpp + + served = tmp_path / "served" + served.mkdir() + data = b"model fixture" + (served / "model.bin").write_bytes(data) + requests = [] + + class Handler(SimpleHTTPRequestHandler): + def log_message(self, fmt, *args): + requests.append(args) + + server = ThreadingHTTPServer(("127.0.0.1", 0), partial(Handler, directory=str(served))) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + url = f"http://127.0.0.1:{server.server_port}/model.bin" + digest = hashlib.sha256(data).hexdigest() + monkeypatch.setattr(cpp, "_MODEL_FILES", (("model.bin", url, digest),)) + hashes = [] + original_hash = downloader._sha256_file + def hash_file(path): + hashes.append(path) + return original_hash(path) + monkeypatch.setattr(downloader, "_sha256_file", hash_file) + try: + for profile in ("A", "B", "A"): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / profile)) + before = len(requests) + hashes_before = len(hashes) + cached = cpp._model_dir() / "model.bin" + was_cached = cached.exists() + cpp.ensure_whisper_cpp_models("base") + assert cached.read_bytes() == data + assert (len(requests) == before) == was_cached + assert (len(hashes) == hashes_before) == was_cached + cached.write_bytes(b"corrupt") + monkeypatch.setattr(cpp, "_MODEL_FILES", (("model.bin", url, "0" * 64),)) + with pytest.raises(HashError): + cpp.ensure_whisper_cpp_models("base") + assert cached.read_bytes() == b"corrupt" # failed transfer was never published + with pytest.raises(ValueError, match="currently supports"): + cpp.ensure_whisper_cpp_models("small") + finally: + server.shutdown() + thread.join() + server.server_close() + + +def test_local_resolution_is_passive_and_command_override_wins(tmp_path, monkeypatch): + from tools import transcription_local as local + from tools import transcription_tools as stt + from tools import transcription_whisper_cpp as cpp + from pm.packages import WhisperCppCpu + from pm.store import ALL_TARGETS + + package = WhisperCppCpu() + assert [t for t in ALL_TARGETS if package.missing_reason(t) is None] == ["win32-arm64"] + monkeypatch.delenv("HERMES_LOCAL_STT_COMMAND", raising=False) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profile with spaces")) + monkeypatch.setattr(cpp, "whisper_cpp_binary", lambda: tmp_path / "program files" / "whisper-cli.exe") + monkeypatch.setattr(stt, "_HAS_FASTER_WHISPER", False) + calls = [] + monkeypatch.setattr(cpp, "ensure_whisper_cpp_models", lambda model: calls.append(model)) + assert stt._detect_local_backend() == "local_command" + assert calls == [] + assert not cpp._model_dir().exists() + audio = tmp_path / "voice note.wav" + audio.write_bytes(b"RIFF") + + def run(command, **kwargs): + assert command[command.index("-f") + 1] == str(audio) + assert command[command.index("-l") + 1] == "en" + assert "-ng" in command and "--vad" in command + Path(command[command.index("-of") + 1] + ".txt").write_text("hello") + + monkeypatch.setattr(local, "_run_quiet", run) + result = local._transcribe_local_command(str(audio), "base", language="en") + assert result["success"] and result["transcript"] == "hello" + assert calls == ["base"] + monkeypatch.setenv("HERMES_LOCAL_STT_COMMAND", "custom-command {input_path}") + assert local._get_local_command_template() == "custom-command {input_path}" diff --git a/tools/transcription_local.py b/tools/transcription_local.py index ca7893b65b..858013a327 100644 --- a/tools/transcription_local.py +++ b/tools/transcription_local.py @@ -32,6 +32,10 @@ def _get_local_command_template() -> Optional[str]: configured = os.getenv(LOCAL_STT_COMMAND_ENV, "").strip() if configured: return configured + from tools.transcription_whisper_cpp import whisper_cpp_command + managed_command = whisper_cpp_command() + if managed_command: + return managed_command whisper_binary = _find_whisper_binary() return (f"{shlex.quote(whisper_binary)} {{input_path}} --model {{model}} --output_format txt " "--output_dir {output_dir} --language {language}") if whisper_binary else None @@ -274,6 +278,10 @@ def _transcribe_local_command( language = language or _resolve_stt_language("local") or DEFAULT_LOCAL_STT_LANGUAGE normalized_model = _normalize_local_model(model_name) try: + if not os.getenv(LOCAL_STT_COMMAND_ENV, "").strip(): + from tools.transcription_whisper_cpp import ensure_whisper_cpp_models, whisper_cpp_command + if command_template == whisper_cpp_command(): + ensure_whisper_cpp_models(normalized_model) with tempfile.TemporaryDirectory(prefix="hermes-local-stt-") as output_dir: prepared_input, prep_error = _prepare_local_audio(file_path, output_dir) if prep_error: diff --git a/tools/transcription_whisper_cpp.py b/tools/transcription_whisper_cpp.py new file mode 100644 index 0000000000..0ab9616b61 --- /dev/null +++ b/tools/transcription_whisper_cpp.py @@ -0,0 +1,75 @@ +"""Managed whisper.cpp command and first-use, verified model acquisition.""" + +from __future__ import annotations + +import logging +import shlex +from pathlib import Path + +from hermes_constants import get_hermes_home +from hermes_platform.resolver.whisper import whisper_cpp_binary + +logger = logging.getLogger("tools.transcription_tools") + +# Keys include the profile's absolute file path; a served profile never inherits +# another profile's verification. Changed/replaced files must be checked again. +_verified_files: dict[Path, tuple] = {} + +# Model artifacts stay outside PM's bundled tool closure: only first transcription +# downloads them, into writable profile state even when the program is in WindowsApps. +_MODEL_FILES = ( + ("ggml-base.bin", + "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base.bin", + "60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe"), + ("ggml-silero-v6.2.0.bin", + "https://huggingface.co/ggml-org/whisper-vad/resolve/9ffd54a1e1ee413ddf265af9913beaf518d1639b/ggml-silero-v6.2.0.bin", + "2aa269b785eeb53a82983a20501ddf7c1d9c48e33ab63a41391ac6c9f7fb6987"), +) + + +def _model_dir() -> Path: + return get_hermes_home() / "cache" / "whisper.cpp" + + +def _file_signature(path: Path, digest: str) -> tuple | None: + try: + stat = path.stat() + except FileNotFoundError: + return None + return (digest, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns) + + +def whisper_cpp_command() -> str | None: + binary = whisper_cpp_binary() + if binary is None: + return None + models = _model_dir() + # The existing command adapter tokenizes with shlex, including on Windows. + quote = lambda path: shlex.quote(path.as_posix()) + return (f"{quote(binary)} -m {quote(models / 'ggml-base.bin')} " + "-f {input_path} -l {language} -t 4 -bs 5 -bo 5 -ng -nt -otxt " + "-of {output_dir}/transcript --vad " + f"-vm {quote(models / 'ggml-silero-v6.2.0.bin')} -vsd 500") + + +def ensure_whisper_cpp_models(model_name: str) -> None: + if model_name != "base": + raise ValueError("Windows ARM64 whisper.cpp currently supports stt.local.model: base; " + f"requested {model_name!r}") + from pm.downloader import Download, Source + + destination = _model_dir() + sources = [Source(url, destination / name, digest) for name, url, digest in _MODEL_FILES] + pending = [source for source in sources + if (signature := _file_signature(source.dest, source.sha256)) is None + or _verified_files.get(source.dest) != signature] + if not pending: + return + logger.info("Preparing whisper.cpp Base and VAD models (first use downloads about 149 MB)") + # PM owns resumable partials, concurrent-download locking, SHA256 verification, + # and atomic publication. A complete cache is checked without network access. + Download(pending, partials_dir=destination / "partials").run() + for source in pending: + signature = _file_signature(source.dest, source.sha256) + if signature is not None: + _verified_files[source.dest] = signature