diff --git a/pyproject.toml b/pyproject.toml index e8faf8a24b5..55818d97e4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,7 @@ voice = [ # so keep it out of the base install for source-build packagers like Homebrew. "faster-whisper==1.2.1", "sounddevice==0.5.5", - "numpy==2.4.3", + "numpy<2", ] pty = [ "ptyprocess==0.7.0; sys_platform != 'win32'", diff --git a/tests/tools/test_transcription_tools.py b/tests/tools/test_transcription_tools.py index 7f83565b5d8..223679cbc2e 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -509,6 +509,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" @@ -532,6 +577,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): @@ -570,6 +616,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): diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index d741530d358..0c2f18e98cf 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -28,6 +28,7 @@ Usage:: import logging import os +import platform import shlex import shutil import subprocess @@ -371,6 +372,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): """Load faster-whisper with graceful CUDA → CPU fallback. @@ -384,7 +421,22 @@ def _load_local_whisper_model(model_name: str): We try ``auto`` 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="auto", compute_type="auto") except Exception as exc: