Files
OpenJarvis/tests/speech/test_faster_whisper.py
T
ScottyAmrandGitHub d9725fbb6a fix(speech): close temp file before transcribing to fix Windows EACCES in faster-whisper backend (#638)
faster-whisper's transcribe() was handed the path of a still-open NamedTemporaryFile; on Windows the open handle is exclusive, so PyAV's reopen failed with EACCES and local STT was broken. Switch to delete=False, close before transcribing, and unlink in a finally. The write is wrapped in the file's context manager so the handle closes even if write() raises, and unlink failures are logged at debug.
2026-07-16 17:09:45 -07:00

186 lines
5.8 KiB
Python

"""Tests for Faster-Whisper speech backend."""
from unittest.mock import MagicMock, patch
import pytest
from openjarvis.core.registry import SpeechRegistry
from openjarvis.speech.faster_whisper import FasterWhisperBackend
@pytest.fixture(autouse=True)
def _register_faster_whisper():
"""Re-register after any registry clear."""
if not SpeechRegistry.contains("faster-whisper"):
SpeechRegistry.register_value("faster-whisper", FasterWhisperBackend)
def test_faster_whisper_backend_registers():
"""Backend registers itself in SpeechRegistry."""
assert SpeechRegistry.contains("faster-whisper")
def test_faster_whisper_transcribe():
"""Transcribe returns a TranscriptionResult."""
from openjarvis.speech._stubs import TranscriptionResult
mock_model = MagicMock()
mock_segment = MagicMock()
mock_segment.text = " Hello world"
mock_segment.start = 0.0
mock_segment.end = 1.2
mock_segment.avg_logprob = -0.3
mock_info = MagicMock()
mock_info.language = "en"
mock_info.language_probability = 0.95
mock_info.duration = 1.5
mock_model.transcribe.return_value = ([mock_segment], mock_info)
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
return_value=mock_model,
):
from openjarvis.speech.faster_whisper import FasterWhisperBackend
backend = FasterWhisperBackend(model_size="base", device="cpu")
result = backend.transcribe(b"fake audio bytes")
assert isinstance(result, TranscriptionResult)
assert result.text == "Hello world"
assert result.language == "en"
assert result.duration_seconds == 1.5
def test_faster_whisper_transcribe_temp_file_reopenable_and_removed():
"""The temp file must be closed before the model reads it, and gone after.
On Windows, an open NamedTemporaryFile holds an exclusive handle, so
PyAV's reopen of the path inside model.transcribe() fails with EACCES
unless the file is closed first. Opening the path inside the mocked
transcribe reproduces that failure mode on Windows.
"""
import os
mock_info = MagicMock()
mock_info.language = "en"
mock_info.language_probability = 0.95
mock_info.duration = 1.5
seen = {}
def fake_transcribe(path, **kwargs):
seen["path"] = path
with open(path, "rb") as fh:
seen["content"] = fh.read()
return iter(()), mock_info
mock_model = MagicMock()
mock_model.transcribe.side_effect = fake_transcribe
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
return_value=mock_model,
):
backend = FasterWhisperBackend(model_size="base", device="cpu")
backend.transcribe(b"fake audio bytes")
assert seen["content"] == b"fake audio bytes"
assert not os.path.exists(seen["path"])
def test_faster_whisper_transcribe_removes_temp_file_on_error():
"""The temp file is cleaned up even when transcription fails."""
import os
seen = {}
def fake_transcribe(path, **kwargs):
seen["path"] = path
raise RuntimeError("decode failed")
mock_model = MagicMock()
mock_model.transcribe.side_effect = fake_transcribe
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
return_value=mock_model,
):
backend = FasterWhisperBackend(model_size="base", device="cpu")
with pytest.raises(RuntimeError, match="decode failed"):
backend.transcribe(b"fake audio bytes")
assert "path" in seen
assert not os.path.exists(seen["path"])
assert "decode failed" in (backend.last_error() or "")
def test_faster_whisper_falls_back_from_unsupported_float16():
mock_model = MagicMock()
with (
patch(
"openjarvis.speech.faster_whisper.WhisperModel",
return_value=mock_model,
) as mock_whisper,
patch(
"openjarvis.speech.faster_whisper.ctranslate2",
MagicMock(
get_supported_compute_types=MagicMock(return_value={"float32", "int8"})
),
),
):
backend = FasterWhisperBackend(
model_size="base",
device="cpu",
compute_type="float16",
)
assert backend._ensure_model() is mock_model
mock_whisper.assert_called_once_with("base", device="cpu", compute_type="int8")
def test_faster_whisper_missing_dependency_hint_uses_desktop_extra():
with patch("openjarvis.speech.faster_whisper.WhisperModel", new=None):
backend = FasterWhisperBackend()
with pytest.raises(ImportError) as excinfo:
backend._ensure_model()
assert "uv sync --extra desktop" in str(excinfo.value)
assert "uv sync --extra speech" not in str(excinfo.value)
def test_faster_whisper_health_no_model():
"""Health returns False before model is loaded."""
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
new=None,
):
backend = FasterWhisperBackend()
assert backend.health() is False
assert "uv sync --extra desktop" in (backend.last_error() or "")
def test_faster_whisper_health_captures_load_error():
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
side_effect=RuntimeError("missing cublas64_12.dll"),
):
backend = FasterWhisperBackend()
assert backend.health() is False
assert "missing cublas64_12.dll" in (backend.last_error() or "")
def test_faster_whisper_supported_formats():
"""Backend supports standard audio formats."""
with patch("openjarvis.speech.faster_whisper.WhisperModel"):
from openjarvis.speech.faster_whisper import FasterWhisperBackend
backend = FasterWhisperBackend.__new__(FasterWhisperBackend)
formats = backend.supported_formats()
assert "wav" in formats
assert "mp3" in formats
assert "webm" in formats