Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This relaxes the voice extra, but local STT is also installed through the lazy dependency table on current main (tools/lazy_deps.py:110-114), which still pins numpy==2.4.3. Please update that sibling install path too so gateway lazy installs receive the same compatibility fix.

"ptyprocess==0.7.0; sys_platform != 'win32'",
Expand Down
47 changes: 47 additions & 0 deletions tests/tools/test_transcription_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,51 @@ def test_multiple_segments_joined(self, tmp_path):
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")

Comment on lines +512 to +516
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"
Expand All @@ -532,6 +577,7 @@ def fake_whisper(model_name, device, compute_type):
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):
Expand Down Expand Up @@ -570,6 +616,7 @@ def fake_whisper(model_name, device, compute_type):
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):
Expand Down
52 changes: 52 additions & 0 deletions tools/transcription_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

import logging
import os
import platform
import shlex
import shutil
import subprocess
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand Down