fix(stt): bundle whisper.cpp CPU for Windows ARM64
This commit is contained in:
10
hermes_platform/resolver/whisper.py
Normal file
10
hermes_platform/resolver/whisper.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
95
tests/tools/test_transcription_whisper_cpp.py
Normal file
95
tests/tools/test_transcription_whisper_cpp.py
Normal 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}"
|
||||
@@ -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:
|
||||
|
||||
75
tools/transcription_whisper_cpp.py
Normal file
75
tools/transcription_whisper_cpp.py
Normal 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
|
||||
Reference in New Issue
Block a user