perf(imports): single-pass ffmpeg audio guard + opt-in toggle (default off)

The audio-completeness guard (detect_broken_audio) is the only post-processing
step that fully DECODES the file with ffmpeg, making it the most CPU-heavy step.
Two changes reduce and gate that cost:

1. Single ffmpeg pass: astats (truncation) + silencedetect (silence) now run in
   one chained -af filter over a single decode, instead of two full decodes.
   ~50% less CPU, no detection lost. Pure parsers unchanged.

2. Opt-in toggle: new post_processing.audio_completeness_check (default False).
   The decode now only runs when the user enables it under
   Settings → Post-processing → Core Features. Most preview/truncation cases are
   already caught at the source (HiFi/Qobuz have their own guards), so the
   expensive whole-file decode stays off unless explicitly turned on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nick2000713 2026-06-23 12:07:28 +02:00
parent 63374b32f1
commit 7186d24120
4 changed files with 69 additions and 4 deletions

View file

@ -362,7 +362,14 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
# Monochrome HLS assembly) or a mostly-silent file is caught regardless
# of its quality verdict and gets the right reason. Same quarantine +
# next-candidate retry pattern as the integrity check.
_skip_audio = _should_skip_quarantine_check(context, 'silence')
#
# Opt-in (default OFF): this is the one check that fully DECODES the file
# with ffmpeg, so it is the most CPU-expensive step in post-processing.
# Most preview/truncation cases are already caught at the source
# (HiFi/Qobuz have their own guards), so it stays off unless the user
# turns it on under Settings → Post-processing.
_audio_guard_enabled = config_manager.get('post_processing.audio_completeness_check', False)
_skip_audio = (not _audio_guard_enabled) or _should_skip_quarantine_check(context, 'silence')
audio_reason = None if _skip_audio else detect_broken_audio(file_path)
if audio_reason:
logger.error(f"[AudioGuard] Rejected {_basename}: {audio_reason}")

View file

@ -217,12 +217,61 @@ def detect_incomplete_audio(
return incomplete_audio_reason(measured_s, container_s, min_ratio=min_ratio)
def detect_broken_audio(file_path: str) -> Optional[str]:
def detect_broken_audio(
file_path: str,
*,
min_ratio: float = DEFAULT_MIN_DURATION_RATIO,
threshold: float = DEFAULT_THRESHOLD,
noise_db: int = DEFAULT_NOISE_DB,
min_silence_s: float = DEFAULT_MIN_SILENCE_S,
) -> Optional[str]:
"""Combined post-download audio guard: reject a file that is truncated
(real audio far shorter than the container) or mostly silence. Returns the
first failure reason, or None when the audio looks complete.
Runs a SINGLE ffmpeg decode pass with both the ``astats`` (truncation) and
``silencedetect`` (silence) filters chained one decode of the file feeds
both checks instead of two full decodes. Halves the CPU cost versus running
``detect_incomplete_audio`` and ``detect_mostly_silent`` back to back.
Fails open: returns None when ffmpeg/mutagen are unavailable or error, so a
tooling problem never quarantines a legitimate file.
"""
reason = detect_incomplete_audio(file_path)
if not _ffmpeg_available():
logger.debug("audio guard skipped — ffmpeg not available")
return None
try:
from mutagen import File as MutagenFile
audio = MutagenFile(file_path)
if not (audio and audio.info):
return None
container_s = float(getattr(audio.info, "length", 0) or 0)
sample_rate = int(getattr(audio.info, "sample_rate", 0) or 0)
except Exception as exc: # pragma: no cover - defensive
logger.debug("container probe failed for %s: %s", file_path, exc)
return None
try:
proc = subprocess.run(
[
"ffmpeg", "-hide_banner", "-nostats", "-i", file_path,
"-af", f"astats=metadata=1,silencedetect=noise={noise_db}dB:d={min_silence_s}",
"-f", "null", "-",
],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=120,
)
except (subprocess.SubprocessError, OSError) as exc:
logger.debug("audio guard ffmpeg run failed for %s: %s", file_path, exc)
return None
stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
# Truncation check first (real audio far shorter than the container).
measured_s = measured_duration_from_astats(stderr, sample_rate)
reason = incomplete_audio_reason(measured_s, container_s, min_ratio=min_ratio)
if reason:
return reason
return detect_mostly_silent(file_path)
# Then silence-padding (mostly-silent file).
return is_mostly_silent_reason(stderr, container_s, threshold=threshold)

View file

@ -5650,6 +5650,13 @@
<input type="number" id="duration-tolerance-seconds" min="0" max="60" step="0.5" value="0" style="width: 100px;">
<small class="settings-hint">Maximum drift between the file's actual length and the metadata source's expected length before the file is quarantined. <strong>0 = auto</strong> (3s normal, 5s for tracks &gt;10min). Raise this if tracks routinely quarantine for being a few seconds off (live recordings, alternate masterings, etc). Capped at 60s.</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="audio-completeness-check">
Verify real audio with ffmpeg (detect truncated / silent files)
</label>
<small class="settings-hint">Decodes every downloaded file with ffmpeg to catch fake 30s previews padded to full length and mostly-silent files — the kind whose header lies about the length so mutagen can't catch them. <strong>Off by default:</strong> this fully decodes each file, the most CPU-heavy post-processing step. Most sources (HiFi, Qobuz) already catch previews on their own, so only turn this on if you see padded/silent files slip through. Applies to all download sources.</small>
</div>
</div>
<!-- Tag Embedding — per-tag toggles grouped by source -->

View file

@ -1249,6 +1249,7 @@ async function loadSettingsData() {
document.getElementById('single-to-album-enabled').checked = settings.metadata_enhancement?.single_to_album === true;
document.getElementById('lrclib-enabled').checked = settings.metadata_enhancement?.lrclib_enabled !== false;
document.getElementById('replaygain-enabled').checked = settings.post_processing?.replaygain_enabled === true;
document.getElementById('audio-completeness-check').checked = settings.post_processing?.audio_completeness_check === true;
document.getElementById('duration-tolerance-seconds').value = settings.post_processing?.duration_tolerance_seconds ?? 0;
document.getElementById('retry-next-candidate').checked = settings.post_processing?.retry_next_candidate_on_mismatch !== false;
document.getElementById('retry-exhaustive').checked = settings.post_processing?.retry_exhaustive === true;
@ -3168,6 +3169,7 @@ async function saveSettings(quiet = false) {
},
post_processing: {
replaygain_enabled: document.getElementById('replaygain-enabled').checked,
audio_completeness_check: document.getElementById('audio-completeness-check').checked,
duration_tolerance_seconds: parseFloat(document.getElementById('duration-tolerance-seconds').value) || 0,
retry_next_candidate_on_mismatch: document.getElementById('retry-next-candidate').checked,
retry_exhaustive: document.getElementById('retry-exhaustive').checked,