The actual HiFi/Monochrome bug isn't silence padding — it's a TRUNCATED file: the container claims the full length (e.g. 3:08) but only ~30s of audio decodes. silencedetect finds nothing (there's no silent audio, just missing audio) and ffmpeg's time= even reports 0 with no error, so the duration and quality guards all pass. Detect it by decoding and comparing the real audio length (astats sample count / sample rate) against the container duration: reject when the real audio covers < 85% of the claimed length. detect_broken_audio() runs this truncation check first, then the silence-ratio check. Wire it into the guard that runs at the integrity/length verification point. Verified on the real file: 'only ~30s actually decodes of a 188s file (16%)'; a normal 180s file is not flagged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
104 lines
3.4 KiB
Python
104 lines
3.4 KiB
Python
"""Audio-completeness guard — catches files whose container duration is
|
|
correct but whose real audio is far shorter (HiFi/Monochrome truncated files:
|
|
container claims 3:08 but only ~30s actually decodes) or mostly silence. Pure
|
|
parsers are tested here; the ffmpeg call is integration.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from core.imports.silence import (
|
|
silence_ratio_from_output,
|
|
is_mostly_silent_reason,
|
|
measured_duration_from_astats,
|
|
incomplete_audio_reason,
|
|
)
|
|
|
|
|
|
_ONE_LONG_TAIL = """
|
|
Input #0, flac, from 'song.flac':
|
|
[silencedetect @ 0x55] silence_start: 31.512
|
|
[silencedetect @ 0x55] silence_end: 210.300 | silence_duration: 178.788
|
|
"""
|
|
|
|
_TWO_GAPS = """
|
|
[silencedetect @ 0x55] silence_start: 0
|
|
[silencedetect @ 0x55] silence_end: 1.5 | silence_duration: 1.5
|
|
[silencedetect @ 0x55] silence_start: 200
|
|
[silencedetect @ 0x55] silence_end: 203 | silence_duration: 3.0
|
|
"""
|
|
|
|
_NO_SILENCE = "Input #0, flac\n[some other ffmpeg chatter]\n"
|
|
|
|
|
|
def test_ratio_single_long_trailing_silence():
|
|
r = silence_ratio_from_output(_ONE_LONG_TAIL, total_duration_s=210.3)
|
|
assert r == pytest.approx(178.788 / 210.3, rel=1e-3)
|
|
assert r > 0.8
|
|
|
|
|
|
def test_ratio_sums_multiple_silences():
|
|
r = silence_ratio_from_output(_TWO_GAPS, total_duration_s=210.0)
|
|
assert r == pytest.approx(4.5 / 210.0, rel=1e-3)
|
|
|
|
|
|
def test_ratio_no_silence_is_zero():
|
|
assert silence_ratio_from_output(_NO_SILENCE, total_duration_s=210.0) == 0.0
|
|
|
|
|
|
def test_ratio_zero_duration_is_zero():
|
|
assert silence_ratio_from_output(_ONE_LONG_TAIL, total_duration_s=0) == 0.0
|
|
|
|
|
|
def test_ratio_capped_at_one():
|
|
# Defensive: bogus silence longer than the track can't exceed 1.0.
|
|
out = "[silencedetect @ 0x] silence_end: 999 | silence_duration: 999\n"
|
|
assert silence_ratio_from_output(out, total_duration_s=210.0) == 1.0
|
|
|
|
|
|
def test_reason_when_mostly_silent():
|
|
reason = is_mostly_silent_reason(_ONE_LONG_TAIL, total_duration_s=210.3, threshold=0.5)
|
|
assert reason is not None
|
|
assert "silent" in reason.lower()
|
|
|
|
|
|
def test_no_reason_for_normal_song():
|
|
assert is_mostly_silent_reason(_TWO_GAPS, total_duration_s=210.0, threshold=0.5) is None
|
|
|
|
|
|
# ── truncation: real decoded duration vs container duration ────────────────
|
|
|
|
_ASTATS_TRUNCATED = """
|
|
[Parsed_astats_0 @ 0x55] Number of samples: 1318912
|
|
[Parsed_astats_0 @ 0x55] Number of NaNs: 0
|
|
"""
|
|
|
|
_ASTATS_FULL = "[Parsed_astats_0 @ 0x55] Number of samples: 9261000\n"
|
|
|
|
|
|
def test_measured_duration_from_samples():
|
|
# 1318912 samples / 44100 Hz ≈ 29.9s (the real Blossom file)
|
|
assert measured_duration_from_astats(_ASTATS_TRUNCATED, 44100) == pytest.approx(29.9, abs=0.1)
|
|
|
|
|
|
def test_measured_duration_none_without_samples():
|
|
assert measured_duration_from_astats("no stats here", 44100) is None
|
|
|
|
|
|
def test_measured_duration_none_without_sample_rate():
|
|
assert measured_duration_from_astats(_ASTATS_TRUNCATED, 0) is None
|
|
|
|
|
|
def test_incomplete_reason_for_truncated_file():
|
|
# 30s of real audio in a 188s container → truncated.
|
|
reason = incomplete_audio_reason(29.9, 188.4, min_ratio=0.85)
|
|
assert reason is not None
|
|
assert "30s" in reason and "188s" in reason
|
|
|
|
|
|
def test_no_incomplete_reason_for_full_file():
|
|
assert incomplete_audio_reason(187.5, 188.4, min_ratio=0.85) is None
|
|
|
|
|
|
def test_no_incomplete_reason_when_unmeasurable():
|
|
assert incomplete_audio_reason(None, 188.4, min_ratio=0.85) is None
|
|
assert incomplete_audio_reason(30.0, 0, min_ratio=0.85) is None
|