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:
2026-09-13 14:16:08 -05:00
co-authored by Claude Opus 5
parent 56a54c2b9a
commit 5042d73770
3 changed files with 422 additions and 0 deletions
+184
View File
@@ -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)
+66
View File
@@ -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
+172
View File
@@ -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()