fix: avoid local STT crash on Apple Silicon

Force CPU (int8) for faster-whisper on Apple Silicon / Rosetta, where
ctranslate2's device=auto path can hard-abort in native code. Salvaged
from PR #28624 without the numpy pin change (main already moved on).

(cherry picked from commit 7edf2d5196, pyproject.toml hunk dropped)
This commit is contained in:
AnthonyAssistantAi 2026-07-28 09:25:50 -07:00 committed by Teknium
parent 766e856118
commit 884900ffd6
2 changed files with 99 additions and 0 deletions

View file

@ -696,6 +696,51 @@ class TestTranscribeLocalExtended:
assert result["success"] is True
assert result["transcript"] == "Hello world"
def test_apple_silicon_forces_cpu_without_auto_probe(self, tmp_path):
"""Apple Silicon/Rosetta should skip device='auto' to avoid SIGABRT."""
audio = tmp_path / "test.ogg"
audio.write_bytes(b"fake")
seg = MagicMock()
seg.text = "safe"
info = MagicMock()
info.language = "en"
info.duration = 1.0
cpu_model = MagicMock()
cpu_model.transcribe.return_value = ([seg], info)
mock_whisper_cls = MagicMock(return_value=cpu_model)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \
patch("tools.transcription_tools._should_force_faster_whisper_cpu", return_value=True), \
patch("faster_whisper.WhisperModel", mock_whisper_cls), \
patch("tools.transcription_tools._local_model", None), \
patch("tools.transcription_tools._local_model_name", None):
from tools.transcription_tools import _transcribe_local
result = _transcribe_local(str(audio), "base")
assert result["success"] is True
assert result["transcript"] == "safe"
mock_whisper_cls.assert_called_once_with("base", device="cpu", compute_type="int8")
def test_force_cpu_detects_rosetta_on_apple_silicon(self):
from tools.transcription_tools import _should_force_faster_whisper_cpu
with patch("tools.transcription_tools.platform.system", return_value="Darwin"), \
patch("tools.transcription_tools.platform.machine", return_value="x86_64"), \
patch("tools.transcription_tools._sysctl_value", side_effect=lambda key: {
"sysctl.proc_translated": "1",
"hw.optional.arm64": "1",
}.get(key, "")):
assert _should_force_faster_whisper_cpu() is True
def test_force_cpu_false_on_intel_macos(self):
from tools.transcription_tools import _should_force_faster_whisper_cpu
with patch("tools.transcription_tools.platform.system", return_value="Darwin"), \
patch("tools.transcription_tools.platform.machine", return_value="x86_64"), \
patch("tools.transcription_tools._sysctl_value", return_value="0"):
assert _should_force_faster_whisper_cpu() is False
def test_load_time_cuda_lib_failure_falls_back_to_cpu(self, tmp_path):
"""Missing libcublas at load time → reload on CPU, succeed."""
audio = tmp_path / "test.ogg"
@ -719,6 +764,7 @@ class TestTranscribeLocalExtended:
return cpu_model
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \
patch("tools.transcription_tools._should_force_faster_whisper_cpu", return_value=False), \
patch("faster_whisper.WhisperModel", side_effect=fake_whisper), \
patch("tools.transcription_tools._local_model", None), \
patch("tools.transcription_tools._local_model_name", None):
@ -757,6 +803,7 @@ class TestTranscribeLocalExtended:
return models.pop(0)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \
patch("tools.transcription_tools._should_force_faster_whisper_cpu", return_value=False), \
patch("faster_whisper.WhisperModel", side_effect=fake_whisper), \
patch("tools.transcription_tools._local_model", None), \
patch("tools.transcription_tools._local_model_name", None):

View file

@ -29,6 +29,7 @@ Usage::
import logging
import os
import platform
import shlex
import shutil
import subprocess
@ -1161,6 +1162,42 @@ def _looks_like_cuda_lib_error(exc: BaseException) -> bool:
return any(marker in msg for marker in _CUDA_LIB_ERROR_MARKERS)
def _sysctl_value(name: str) -> str:
"""Return a sysctl value, or an empty string when unavailable."""
try:
return subprocess.check_output(
["/usr/sbin/sysctl", "-n", name],
stderr=subprocess.DEVNULL,
text=True,
timeout=2,
).strip()
except Exception:
return ""
def _should_force_faster_whisper_cpu() -> bool:
"""Avoid faster-whisper device autodetection paths known to hard-abort.
On Apple Silicon, especially when Python is running as x86_64 under
Rosetta, ctranslate2's ``device=\"auto\"`` path can abort inside native
code before Python can catch an exception. Force CPU so local STT remains
reliable for gateway voice messages.
"""
if platform.system() != "Darwin":
return False
machine = platform.machine().lower()
if machine in {"arm64", "aarch64"}:
return True
# Under Rosetta, platform.machine() reports x86_64. sysctl.proc_translated
# tells us this process is translated, while hw.optional.arm64 distinguishes
# Apple Silicon hosts from Intel Macs.
if _sysctl_value("sysctl.proc_translated") == "1":
return True
return _sysctl_value("hw.optional.arm64") == "1"
def _load_local_whisper_model(model_name: str, device: str = "auto", compute_type: str = "auto"):
"""Load faster-whisper with graceful CUDA → CPU fallback.
@ -1178,7 +1215,22 @@ def _load_local_whisper_model(model_name: str, device: str = "auto", compute_typ
We try the requested config first (fast CUDA path when it works), and on
any CUDA library load failure fall back to CPU + int8.
"""
force_cpu = _should_force_faster_whisper_cpu()
if force_cpu:
# Importing ctranslate2/faster-whisper itself can abort on some
# Apple Silicon/Rosetta installs because multiple Intel OpenMP runtimes
# are already loaded. Set this before importing faster_whisper so the
# gateway survives, then keep inference on CPU to avoid device probing.
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
from faster_whisper import WhisperModel
if force_cpu:
logger.info(
"Apple Silicon/Rosetta detected — loading faster-whisper on CPU "
"(int8) to avoid native device autodetection crashes"
)
return WhisperModel(model_name, device="cpu", compute_type="int8")
try:
return WhisperModel(model_name, device=device, compute_type=compute_type)
except Exception as exc: