diff --git a/src/openjarvis/speech/faster_whisper.py b/src/openjarvis/speech/faster_whisper.py index e3728ec5..8955fc5a 100644 --- a/src/openjarvis/speech/faster_whisper.py +++ b/src/openjarvis/speech/faster_whisper.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import os import tempfile from typing import List, Optional @@ -105,11 +106,15 @@ class FasterWhisperBackend(SpeechBackend): try: model = self._ensure_model() - # Write audio to a temp file (faster-whisper needs a file path) + # Write audio to a temp file (faster-whisper needs a file path). + # delete=False + manual unlink: on Windows an open + # NamedTemporaryFile holds an exclusive handle, so PyAV's reopen + # of tmp.name inside model.transcribe() fails with EACCES. suffix = f".{format}" if not format.startswith(".") else format - with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp: - tmp.write(audio) - tmp.flush() + tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) + try: + with tmp: + tmp.write(audio) kwargs = {} if language: @@ -117,6 +122,15 @@ class FasterWhisperBackend(SpeechBackend): segments_iter, info = model.transcribe(tmp.name, **kwargs) segments_list = list(segments_iter) + finally: + try: + os.unlink(tmp.name) + except OSError as unlink_exc: + logger.debug( + "Could not remove temp audio file %s: %s", + tmp.name, + unlink_exc, + ) except Exception as exc: self._last_error = str(exc) raise diff --git a/tests/speech/test_faster_whisper.py b/tests/speech/test_faster_whisper.py index ea5bfa6d..e78d5dd2 100644 --- a/tests/speech/test_faster_whisper.py +++ b/tests/speech/test_faster_whisper.py @@ -53,6 +53,69 @@ def test_faster_whisper_transcribe(): 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()