WhisperX transcription called `whisperx.load_audio()`, which shells out to a literal `"ffmpeg"` resolved against the OS PATH. On Windows that resolves to a WindowsApps alias stub or a corrupt/wrong-arch binary — passing `which` but exploding at spawn with `[WinError 193] %1 is not a valid Win32 application`. whisperx only catches `CalledProcessError`, so the spawn-time `OSError` escaped and the dub/batch path reported the opaque "Transcription produced no segments". #377 added ffmpeg validation but only for the dub-export path; the transcription path never went through the validated resolver. Since WhisperX is a default ASR engine, this is a P0 platform-parity break (works on mac/Linux, fails on Windows). Fix: decode the audio ourselves in `WhisperXBackend.transcribe` via `find_ffmpeg()` (which `-version`-probes each candidate and returns the bundled imageio-ffmpeg / Tauri sidecar) and hand WhisperX the array — bypassing the bare PATH lookup entirely. This is more robust than a PATH-prepend, which couldn't fix the imageio case (its binary is named `ffmpeg-<plat>-vN.exe`, not `ffmpeg`). If no runnable ffmpeg exists, raise a clear, locale-independent error instead of "no segments". Fixes both the dub and batch transcription paths. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
mergetest
Claude Opus 4.8
parent
4e3136c1e0
commit
dd092c95cb
@@ -31,6 +31,59 @@ from abc import ABC, abstractmethod
|
||||
logger = logging.getLogger("omnivoice.asr")
|
||||
|
||||
|
||||
def _decode_audio_16k_mono(audio_path: str):
|
||||
"""Decode `audio_path` to a 16 kHz mono float32 waveform using OmniVoice's
|
||||
*validated* ffmpeg, instead of whisperx.load_audio's bare ``"ffmpeg"`` PATH
|
||||
lookup.
|
||||
|
||||
whisperx (and openai-whisper) shell out to a literal ``"ffmpeg"`` resolved
|
||||
against the OS PATH. On Windows that resolves to whatever the system finds
|
||||
first — a WindowsApps alias stub or a corrupt/wrong-arch download — which
|
||||
passes `which` but explodes at spawn with ``[WinError 193] %1 is not a valid
|
||||
Win32 application``. whisperx only catches `CalledProcessError`, so the
|
||||
spawn-time `OSError` escapes and the dub/batch path reports the opaque
|
||||
"Transcription produced no segments" (#479). ``find_ffmpeg()`` probes each
|
||||
candidate with ``-version`` and returns a runnable binary (the bundled
|
||||
imageio-ffmpeg / Tauri sidecar) — or None, so we can raise an actionable
|
||||
error. This also fixes the imageio case a PATH-prepend can't: its binary is
|
||||
named ``ffmpeg-<plat>-vN.exe``, not ``ffmpeg``, so bare lookup never finds
|
||||
it. Mirrors whisperx.audio.load_audio's command exactly (16 kHz, mono, s16le).
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
import numpy as np
|
||||
|
||||
from services.ffmpeg_utils import find_ffmpeg
|
||||
|
||||
ffmpeg = find_ffmpeg()
|
||||
if not ffmpeg:
|
||||
raise RuntimeError(
|
||||
"Cannot transcribe: ffmpeg is missing or not runnable. Install "
|
||||
"ffmpeg (or let OmniVoice's bundled binary download), then retry. "
|
||||
"On Windows a '[WinError 193]' here means the ffmpeg binary is "
|
||||
"corrupt or the wrong architecture — reinstall it or clear the "
|
||||
"imageio-ffmpeg cache."
|
||||
)
|
||||
cmd = [
|
||||
ffmpeg, "-nostdin", "-threads", "0", "-i", audio_path,
|
||||
"-f", "s16le", "-ac", "1", "-acodec", "pcm_s16le", "-ar", "16000", "-",
|
||||
]
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, check=True).stdout
|
||||
except OSError as e:
|
||||
# Belt-and-suspenders: find_ffmpeg() already -version-validated this
|
||||
# binary, so a WinError 193 here is unexpected — surface it clearly
|
||||
# rather than letting it become "no segments".
|
||||
raise RuntimeError(
|
||||
f"ffmpeg at {ffmpeg!r} could not be executed ({e}). Reinstall "
|
||||
"ffmpeg or clear the imageio-ffmpeg cache."
|
||||
) from e
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr = (e.stderr or b"").decode(errors="replace")[:500]
|
||||
raise RuntimeError(f"Failed to decode audio for transcription: {stderr}") from e
|
||||
return np.frombuffer(out, np.int16).flatten().astype(np.float32) / 32768.0
|
||||
|
||||
|
||||
# ── Protocol ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -326,10 +379,13 @@ class WhisperXBackend(ASRBackend):
|
||||
return None
|
||||
|
||||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||||
import whisperx
|
||||
import whisperx # used for whisperx.align() below
|
||||
self._ensure_asr()
|
||||
logger.info("whisperx transcribing %s (word_timestamps=%s)", audio_path, word_timestamps)
|
||||
audio = whisperx.load_audio(audio_path)
|
||||
# Decode via OmniVoice's validated ffmpeg, NOT whisperx.load_audio's bare
|
||||
# "ffmpeg" PATH lookup which yields [WinError 193] -> "no segments" on
|
||||
# Windows (#479). Same 16 kHz mono s16le array whisperx expects.
|
||||
audio = _decode_audio_16k_mono(audio_path)
|
||||
try:
|
||||
result = self._asr.transcribe(audio)
|
||||
except IndexError:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Regression tests for #479 — WhisperX transcription must decode audio through
|
||||
OmniVoice's *validated* ffmpeg, never whisperx.load_audio's bare ``"ffmpeg"``
|
||||
PATH lookup (which yields ``[WinError 193] -> "no segments"`` on Windows).
|
||||
|
||||
These are pure unit tests — no real ffmpeg or whisperx needed — so they run
|
||||
identically on macOS/Windows/Linux in CI. Placed at top-level ``tests/`` (not
|
||||
``tests/backend/``) to avoid the sys.modules-isolation collection-order leak.
|
||||
|
||||
NOTE: modules are imported at *test runtime* and find_ffmpeg is patched by its
|
||||
dotted string path, so the patch and the helper's lazy
|
||||
``from services.ffmpeg_utils import find_ffmpeg`` always resolve the SAME
|
||||
sys.modules entry even after another test purges ``services.*``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _decode():
|
||||
import services.asr_backend as asr
|
||||
return asr._decode_audio_16k_mono
|
||||
|
||||
|
||||
def test_decode_raises_actionable_error_when_no_ffmpeg(monkeypatch):
|
||||
"""find_ffmpeg() -> None must raise a clear, actionable error (with the
|
||||
locale-independent WinError 193 hint), not silently yield empty audio."""
|
||||
monkeypatch.setattr("services.ffmpeg_utils.find_ffmpeg", lambda: None)
|
||||
with pytest.raises(RuntimeError) as ei:
|
||||
_decode()("/tmp/whatever.wav")
|
||||
msg = str(ei.value)
|
||||
assert "ffmpeg" in msg.lower()
|
||||
assert "WinError 193" in msg # matched on the code, not the OS-translated text
|
||||
|
||||
|
||||
def test_decode_uses_validated_binary_and_returns_float32(monkeypatch):
|
||||
"""The decode must invoke the *validated* binary path (not a bare
|
||||
``"ffmpeg"``) with whisperx's exact 16 kHz/mono/s16le args, and return a
|
||||
float32 waveform."""
|
||||
import numpy as np
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr("services.ffmpeg_utils.find_ffmpeg", lambda: "/opt/validated/ffmpeg")
|
||||
pcm = np.array([0, 16384, -32768, 32767], dtype=np.int16).tobytes()
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
assert kwargs.get("check") is True
|
||||
return types.SimpleNamespace(stdout=pcm, stderr=b"")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
out = _decode()("/tmp/in.mp4")
|
||||
cmd = captured["cmd"]
|
||||
assert cmd[0] == "/opt/validated/ffmpeg" # validated binary, NOT bare "ffmpeg"
|
||||
assert "/tmp/in.mp4" in cmd
|
||||
for token in ("16000", "s16le", "pcm_s16le", "-ac", "1"):
|
||||
assert token in cmd
|
||||
assert out.dtype == np.float32
|
||||
assert len(out) == 4
|
||||
assert out[0] == pytest.approx(0.0)
|
||||
assert out[2] == pytest.approx(-1.0) # -32768 / 32768.0
|
||||
|
||||
|
||||
def test_winerror193_at_decode_becomes_clear_runtimeerror(monkeypatch):
|
||||
"""If the validated binary still fails to spawn (OSError/WinError 193), it
|
||||
must surface as a clear RuntimeError, not propagate as the opaque
|
||||
'no segments' the dub path would otherwise show."""
|
||||
monkeypatch.setattr("services.ffmpeg_utils.find_ffmpeg", lambda: "/opt/validated/ffmpeg")
|
||||
|
||||
def boom(cmd, **kwargs):
|
||||
raise OSError("[WinError 193] %1 is not a valid Win32 application")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", boom)
|
||||
with pytest.raises(RuntimeError) as ei:
|
||||
_decode()("/tmp/in.mp4")
|
||||
assert "could not be executed" in str(ei.value)
|
||||
Reference in New Issue
Block a user