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:
parent
63374b32f1
commit
7186d24120
4 changed files with 69 additions and 4 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 >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 -->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue