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 # Monochrome HLS assembly) or a mostly-silent file is caught regardless
# of its quality verdict and gets the right reason. Same quarantine + # of its quality verdict and gets the right reason. Same quarantine +
# next-candidate retry pattern as the integrity check. # 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) audio_reason = None if _skip_audio else detect_broken_audio(file_path)
if audio_reason: if audio_reason:
logger.error(f"[AudioGuard] Rejected {_basename}: {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) 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 """Combined post-download audio guard: reject a file that is truncated
(real audio far shorter than the container) or mostly silence. Returns the (real audio far shorter than the container) or mostly silence. Returns the
first failure reason, or None when the audio looks complete. 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: if reason:
return 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;"> <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> <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>
<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> </div>
<!-- Tag Embedding — per-tag toggles grouped by source --> <!-- 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('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('lrclib-enabled').checked = settings.metadata_enhancement?.lrclib_enabled !== false;
document.getElementById('replaygain-enabled').checked = settings.post_processing?.replaygain_enabled === true; 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('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-next-candidate').checked = settings.post_processing?.retry_next_candidate_on_mismatch !== false;
document.getElementById('retry-exhaustive').checked = settings.post_processing?.retry_exhaustive === true; document.getElementById('retry-exhaustive').checked = settings.post_processing?.retry_exhaustive === true;
@ -3168,6 +3169,7 @@ async function saveSettings(quiet = false) {
}, },
post_processing: { post_processing: {
replaygain_enabled: document.getElementById('replaygain-enabled').checked, 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, duration_tolerance_seconds: parseFloat(document.getElementById('duration-tolerance-seconds').value) || 0,
retry_next_candidate_on_mismatch: document.getElementById('retry-next-candidate').checked, retry_next_candidate_on_mismatch: document.getElementById('retry-next-candidate').checked,
retry_exhaustive: document.getElementById('retry-exhaustive').checked, retry_exhaustive: document.getElementById('retry-exhaustive').checked,