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
|
# 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}")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 >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 >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 -->
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue