fix(stt): bundle whisper.cpp CPU for Windows ARM64

This commit is contained in:
Ryan Hill
2026-10-04 16:36:08 -07:00
committed by ethernet
parent b4bf19d814
commit 13978ae5db
6 changed files with 210 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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}"

View File

@@ -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:

View File

@@ -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