feat(media): add ffmpeg decode, probe and FLAC extraction helpers
Lifts decode() out of scripts/smoke.py, where it was duplicated verbatim in scripts/bench.py, and adds the probe and extraction calls the pipeline needs. Command construction is separate from execution so argument lists can be asserted directly instead of by monkeypatching internals. flac_command always passes -sample_fmt s16: the FLAC encoder accepts only s16/s32 while AAC and Opus decode to fltp, so leaving it to filter-graph negotiation can fail. The default profile keeps the source rate and channels, since lossless extraction is the point of choosing FLAC; downmixing to 16 kHz mono happens at decode time instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""ffmpeg/ffprobe helpers.
|
||||
|
||||
Decoding is done by shelling out rather than through a Python audio library, which
|
||||
keeps librosa/numba out of the dependency tree. Lifted from scripts/smoke.py, where
|
||||
the same call was duplicated in scripts/bench.py.
|
||||
|
||||
Command construction is separated from execution so the argument lists can be
|
||||
asserted on directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ccn_transcribe import errors
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import numpy.typing as npt
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SAMPLE_RATE = 16_000
|
||||
AudioProfile = Literal["source", "whisper"]
|
||||
PROFILES: tuple[str, ...] = ("source", "whisper")
|
||||
|
||||
REQUIRED_BINARIES = ("ffmpeg", "ffprobe")
|
||||
|
||||
|
||||
def _run(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[bytes]:
|
||||
try:
|
||||
return subprocess.run(args, capture_output=True, check=check)
|
||||
except FileNotFoundError as exc:
|
||||
raise errors.PreflightError(
|
||||
f"{args[0]} is not installed or not on PATH",
|
||||
hint="Install ffmpeg (Fedora: sudo dnf install ffmpeg-free).",
|
||||
) from exc
|
||||
|
||||
|
||||
def _stderr_tail(exc: subprocess.CalledProcessError, lines: int = 20) -> str:
|
||||
raw: bytes = exc.stderr or b""
|
||||
return "\n".join(raw.decode("utf-8", "replace").splitlines()[-lines:])
|
||||
|
||||
|
||||
def has_flac_encoder() -> bool:
|
||||
return b" flac " in _run(["ffmpeg", "-hide_banner", "-encoders"], check=False).stdout
|
||||
|
||||
|
||||
def require_binaries() -> None:
|
||||
"""Fail before any job starts rather than mid-batch."""
|
||||
for name in REQUIRED_BINARIES:
|
||||
if shutil.which(name) is None:
|
||||
raise errors.PreflightError(
|
||||
f"{name} is not installed or not on PATH",
|
||||
hint="Install ffmpeg (Fedora: sudo dnf install ffmpeg-free).",
|
||||
)
|
||||
if not has_flac_encoder():
|
||||
raise errors.PreflightError(
|
||||
"this ffmpeg build has no flac encoder",
|
||||
hint="Install a build with FLAC support (Fedora: ffmpeg-free includes it).",
|
||||
)
|
||||
|
||||
|
||||
def probe_command(path: Path, entry: str, select: str | None = None) -> list[str]:
|
||||
args = ["ffprobe", "-v", "error"]
|
||||
if select is not None:
|
||||
args += ["-select_streams", select]
|
||||
return [*args, "-show_entries", entry, "-of", "default=nw=1:nk=1", str(path)]
|
||||
|
||||
|
||||
def probe_field(path: Path, entry: str, select: str | None = None) -> str | None:
|
||||
try:
|
||||
out = _run(probe_command(path, entry, select)).stdout
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise errors.ProbeError(
|
||||
f"ffprobe could not read {path.name}",
|
||||
hint="The file may be truncated or not a media file.",
|
||||
) from exc
|
||||
lines = out.decode("utf-8", "replace").strip().splitlines()
|
||||
return lines[0] if lines else None
|
||||
|
||||
|
||||
def probe_duration(path: Path) -> float | None:
|
||||
"""Duration in seconds, or None when the container does not declare one."""
|
||||
for entry, select in (("format=duration", None), ("stream=duration", "a:0")):
|
||||
raw = probe_field(path, entry, select)
|
||||
if raw and raw != "N/A":
|
||||
return float(raw)
|
||||
return None
|
||||
|
||||
|
||||
def has_audio_stream(path: Path) -> bool:
|
||||
try:
|
||||
return probe_field(path, "stream=index", "a:0") is not None
|
||||
except errors.ProbeError:
|
||||
return False
|
||||
|
||||
|
||||
def flac_command(
|
||||
src: Path, dst: Path, *, profile: AudioProfile | str = "source", compression: int = 5
|
||||
) -> list[str]:
|
||||
if profile not in PROFILES:
|
||||
raise ValueError(f"unknown audio profile {profile!r}; expected one of {PROFILES}")
|
||||
args = [
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
str(src),
|
||||
"-vn",
|
||||
"-map",
|
||||
"0:a:0",
|
||||
"-c:a",
|
||||
"flac",
|
||||
# The FLAC encoder accepts only s16/s32, and AAC/Opus decode to fltp.
|
||||
"-sample_fmt",
|
||||
"s16",
|
||||
"-compression_level",
|
||||
str(compression),
|
||||
]
|
||||
if profile == "whisper":
|
||||
args += ["-ar", str(SAMPLE_RATE), "-ac", "1"]
|
||||
return [*args, str(dst)]
|
||||
|
||||
|
||||
def decode_command(path: Path) -> list[str]:
|
||||
return [
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(path),
|
||||
"-f",
|
||||
"f32le",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
str(SAMPLE_RATE),
|
||||
"-",
|
||||
]
|
||||
|
||||
|
||||
def extract_flac(
|
||||
src: Path, dst: Path, *, profile: AudioProfile | str = "source", compression: int = 5
|
||||
) -> None:
|
||||
"""Extract the first audio track losslessly to FLAC.
|
||||
|
||||
``source`` keeps the original rate and channel count, which is the point of
|
||||
choosing FLAC; ``whisper`` writes the compact 16 kHz mono variant.
|
||||
"""
|
||||
args = flac_command(src, dst, profile=profile, compression=compression)
|
||||
if not has_audio_stream(src):
|
||||
raise errors.NoAudioStreamError(
|
||||
f"{src.name} has no audio track",
|
||||
hint="Nothing to transcribe. Check the source, or pick a format with audio.",
|
||||
)
|
||||
try:
|
||||
_run(args)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise errors.DecodeError(
|
||||
f"ffmpeg failed to extract audio from {src.name}",
|
||||
hint=_stderr_tail(exc) or "Run ffmpeg manually on the file to see why.",
|
||||
) from exc
|
||||
|
||||
|
||||
def decode_16k_mono(path: Path) -> npt.NDArray[np.float32]:
|
||||
"""Decode to the 16 kHz mono float32 Whisper expects."""
|
||||
try:
|
||||
raw = _run(decode_command(path)).stdout
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise errors.DecodeError(
|
||||
f"ffmpeg could not decode {path.name}",
|
||||
hint=_stderr_tail(exc) or "The file may be truncated or not a media file.",
|
||||
) from exc
|
||||
return np.frombuffer(raw, dtype=np.float32)
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _run(args: list[str]) -> None:
|
||||
subprocess.run(args, capture_output=True, check=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def sine_wav(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""One second of 440 Hz at 44.1 kHz stereo -- a real file, not a mock."""
|
||||
path = tmp_path_factory.mktemp("media") / "sine.wav"
|
||||
_run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=1:sample_rate=44100",
|
||||
"-ac",
|
||||
"2",
|
||||
str(path),
|
||||
]
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def silent_video(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""A video with no audio track at all."""
|
||||
path = tmp_path_factory.mktemp("media") / "mute.mkv"
|
||||
_run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=black:s=64x64:d=1",
|
||||
"-r",
|
||||
"5",
|
||||
str(path),
|
||||
]
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def not_media(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "notes.txt"
|
||||
path.write_text("this is definitely not a media file")
|
||||
return path
|
||||
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ccn_transcribe import errors
|
||||
from ccn_transcribe.media import ffmpeg
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestProbeDuration:
|
||||
def test_reads_duration_of_a_real_file(self, sine_wav: Path) -> None:
|
||||
assert ffmpeg.probe_duration(sine_wav) == pytest.approx(1.0, abs=0.05)
|
||||
|
||||
def test_raises_on_a_non_media_file(self, not_media: Path) -> None:
|
||||
with pytest.raises(errors.ProbeError):
|
||||
ffmpeg.probe_duration(not_media)
|
||||
|
||||
def test_raises_on_a_missing_file(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(errors.ProbeError):
|
||||
ffmpeg.probe_duration(tmp_path / "nope.wav")
|
||||
|
||||
def test_returns_none_when_the_container_declares_no_duration(
|
||||
self, sine_wav: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Some webm/mkv muxes report format.duration=N/A with no stream duration.
|
||||
def na(*_a: object, **_k: object) -> str:
|
||||
return "N/A"
|
||||
|
||||
monkeypatch.setattr(ffmpeg, "probe_field", na)
|
||||
assert ffmpeg.probe_duration(sine_wav) is None
|
||||
|
||||
|
||||
class TestHasAudioStream:
|
||||
def test_true_for_audio(self, sine_wav: Path) -> None:
|
||||
assert ffmpeg.has_audio_stream(sine_wav) is True
|
||||
|
||||
def test_false_for_a_video_without_audio(self, silent_video: Path) -> None:
|
||||
assert ffmpeg.has_audio_stream(silent_video) is False
|
||||
|
||||
def test_false_for_a_non_media_file(self, not_media: Path) -> None:
|
||||
assert ffmpeg.has_audio_stream(not_media) is False
|
||||
|
||||
|
||||
class TestCommandBuilders:
|
||||
def test_flac_command_always_forces_s16(self, tmp_path: Path) -> None:
|
||||
# The FLAC encoder accepts only s16/s32; AAC/Opus sources decode to fltp,
|
||||
# so leaving the sample format to filter-graph negotiation can fail.
|
||||
cmd = ffmpeg.flac_command(tmp_path / "in.mkv", tmp_path / "out.flac")
|
||||
assert cmd[cmd.index("-sample_fmt") + 1] == "s16"
|
||||
|
||||
def test_flac_command_maps_the_first_audio_track_only(self, tmp_path: Path) -> None:
|
||||
cmd = ffmpeg.flac_command(tmp_path / "in.mkv", tmp_path / "out.flac")
|
||||
assert cmd[cmd.index("-map") + 1] == "0:a:0"
|
||||
assert "-vn" in cmd
|
||||
|
||||
def test_source_profile_does_not_resample(self, tmp_path: Path) -> None:
|
||||
cmd = ffmpeg.flac_command(tmp_path / "in.mkv", tmp_path / "out.flac")
|
||||
assert "-ar" not in cmd
|
||||
assert "-ac" not in cmd
|
||||
|
||||
def test_whisper_profile_resamples_to_16k_mono(self, tmp_path: Path) -> None:
|
||||
cmd = ffmpeg.flac_command(tmp_path / "i.mkv", tmp_path / "o.flac", profile="whisper")
|
||||
assert cmd[cmd.index("-ar") + 1] == "16000"
|
||||
assert cmd[cmd.index("-ac") + 1] == "1"
|
||||
|
||||
def test_rejects_an_unknown_profile(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(ValueError, match="profile"):
|
||||
ffmpeg.flac_command(tmp_path / "i.mkv", tmp_path / "o.flac", profile="nope")
|
||||
|
||||
def test_decode_command_requests_16k_mono_float32(self, tmp_path: Path) -> None:
|
||||
cmd = ffmpeg.decode_command(tmp_path / "a.flac")
|
||||
assert cmd[cmd.index("-f") + 1] == "f32le"
|
||||
assert cmd[cmd.index("-ar") + 1] == "16000"
|
||||
assert cmd[cmd.index("-ac") + 1] == "1"
|
||||
|
||||
def test_probe_command_omits_stream_selection_when_not_given(self, tmp_path: Path) -> None:
|
||||
assert "-select_streams" not in ffmpeg.probe_command(tmp_path / "a", "format=duration")
|
||||
|
||||
def test_probe_command_includes_stream_selection_when_given(self, tmp_path: Path) -> None:
|
||||
cmd = ffmpeg.probe_command(tmp_path / "a", "stream=index", "a:0")
|
||||
assert cmd[cmd.index("-select_streams") + 1] == "a:0"
|
||||
|
||||
|
||||
class TestExtractFlac:
|
||||
def test_produces_a_playable_flac(self, sine_wav: Path, tmp_path: Path) -> None:
|
||||
out = tmp_path / "audio.flac"
|
||||
ffmpeg.extract_flac(sine_wav, out)
|
||||
assert ffmpeg.probe_duration(out) == pytest.approx(1.0, abs=0.05)
|
||||
|
||||
def test_preserves_source_rate_and_channels_by_default(
|
||||
self, sine_wav: Path, tmp_path: Path
|
||||
) -> None:
|
||||
out = tmp_path / "audio.flac"
|
||||
ffmpeg.extract_flac(sine_wav, out)
|
||||
assert ffmpeg.probe_field(out, "stream=sample_rate", "a:0") == "44100"
|
||||
assert ffmpeg.probe_field(out, "stream=channels", "a:0") == "2"
|
||||
|
||||
def test_whisper_profile_downmixes(self, sine_wav: Path, tmp_path: Path) -> None:
|
||||
out = tmp_path / "audio.flac"
|
||||
ffmpeg.extract_flac(sine_wav, out, profile="whisper")
|
||||
assert ffmpeg.probe_field(out, "stream=sample_rate", "a:0") == "16000"
|
||||
assert ffmpeg.probe_field(out, "stream=channels", "a:0") == "1"
|
||||
|
||||
def test_rejects_a_source_with_no_audio(self, silent_video: Path, tmp_path: Path) -> None:
|
||||
with pytest.raises(errors.NoAudioStreamError):
|
||||
ffmpeg.extract_flac(silent_video, tmp_path / "a.flac")
|
||||
|
||||
def test_raises_decode_error_when_ffmpeg_fails(
|
||||
self, not_media: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Bypass the audio-stream gate so the ffmpeg failure itself is exercised.
|
||||
def yes(_p: Path) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(ffmpeg, "has_audio_stream", yes)
|
||||
with pytest.raises(errors.DecodeError):
|
||||
ffmpeg.extract_flac(not_media, tmp_path / "a.flac")
|
||||
|
||||
|
||||
class TestDecode16kMono:
|
||||
def test_returns_float32_at_16k(self, sine_wav: Path) -> None:
|
||||
pcm = ffmpeg.decode_16k_mono(sine_wav)
|
||||
assert pcm.dtype == np.float32
|
||||
assert len(pcm) == pytest.approx(16000, abs=800)
|
||||
|
||||
def test_audio_is_not_silent(self, sine_wav: Path) -> None:
|
||||
assert float(np.abs(ffmpeg.decode_16k_mono(sine_wav)).max()) > 0.1
|
||||
|
||||
def test_raises_decode_error_on_garbage(self, not_media: Path) -> None:
|
||||
with pytest.raises(errors.DecodeError):
|
||||
ffmpeg.decode_16k_mono(not_media)
|
||||
|
||||
|
||||
class TestPreflight:
|
||||
def test_passes_when_binaries_exist(self) -> None:
|
||||
ffmpeg.require_binaries()
|
||||
|
||||
def test_reports_the_real_flac_encoder_state(self) -> None:
|
||||
assert ffmpeg.has_flac_encoder() is True
|
||||
|
||||
def test_raises_when_a_binary_is_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def missing(_name: str, _mode: int = 0, _path: str | None = None) -> str | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(shutil, "which", missing)
|
||||
with pytest.raises(errors.PreflightError, match="ffmpeg"):
|
||||
ffmpeg.require_binaries()
|
||||
|
||||
def test_raises_when_the_flac_encoder_is_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def no_flac() -> bool:
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(ffmpeg, "has_flac_encoder", no_flac)
|
||||
with pytest.raises(errors.PreflightError, match="flac"):
|
||||
ffmpeg.require_binaries()
|
||||
|
||||
def test_a_missing_binary_surfaces_as_preflight_not_oserror(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def boom(*_a: object, **_k: object) -> None:
|
||||
raise FileNotFoundError("ffprobe")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", boom)
|
||||
with pytest.raises(errors.PreflightError):
|
||||
ffmpeg.has_flac_encoder()
|
||||
Reference in New Issue
Block a user