diegocade1: DSD files (.dsf, ~500MB DSD64) were labeled "Low Quality" and nagged to upgrade.
two independent causes, both fixed (additive — no existing format/behaviour changed):
1) DSF was an unrecognized format -> bottom 'unknown' tier -> "Low Quality":
- source_map: map .dsf/.dff -> 'dsf' (also lights it up in AUDIO_EXTENSIONS, so Soulseek can
match a DSF if one exists)
- model.tier_score: 'dsf' base 102 (just above FLAC) — lands in the lossless range
- probe_audio_quality: add a DSD branch returning format='dsf' (mutagen.dsf for .dsf detail;
.dff classifies lossless without measured detail) instead of None
- settings UI: DSD in RT_LOSSLESS_FORMATS + a "DSD (DSF / DFF)" option in the profile dropdown
2) the actual cause of the screenshot's findings — the truncation guard falsely called DSF
"broken (only ~12% decodes)": ffmpeg decodes DSD to PCM at a different rate than the DSD
container's 2.8 MHz, so astats samples ÷ container-rate massively under-counts. now
detect_broken_audio skips the truncation check for DSD (silence detection still applies).
8 seam tests: dsf/dff -> 'dsf'; dsf tier in lossless range (with + without measured bitrate);
is_dsd_path; and a contrast pair proving the same 12%-decode numbers flag a .flac but skip a
.dsf. 230 quality/import/silence tests green, ruff + JS integrity clean.
289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""Audio-completeness guard — detect files whose container duration looks
|
|
right but whose REAL audio is far shorter, or mostly silence.
|
|
|
|
Motivating bug: HiFi/Monochrome HLS assembly can yield a file whose container
|
|
claims the full track length (e.g. 3:08) while only ~30s of audio actually
|
|
decodes — the rest is missing. The duration-agreement and quality guards both
|
|
pass (mutagen reads the container's 3:08 and the format/bitrate are fine), so
|
|
nothing catches it until you listen. ffmpeg's ``time=`` even reports 0 with no
|
|
error on such a file, so the robust signal is to DECODE the audio and compare
|
|
the real duration (sample count / sample rate, via ``astats``) against the
|
|
container duration. A separate ``silencedetect`` pass also flags genuine
|
|
silence-padding.
|
|
|
|
The parsers here are pure and unit-tested; the ffmpeg invocations are
|
|
integration glue that fails open (returns None) when ffmpeg or mutagen can't
|
|
run, so a tooling problem never blocks a legitimate import.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from typing import Optional
|
|
|
|
from utils.logging_config import get_logger
|
|
|
|
logger = get_logger("imports.silence")
|
|
|
|
# Real decoded audio must cover at least this fraction of the container
|
|
# duration. A legit file decodes to ~100% (encoder padding aside); a truncated
|
|
# file decodes to a small fraction (the Blossom file: 30s of a 188s container
|
|
# = 16%). 0.85 leaves generous headroom against false positives.
|
|
DEFAULT_MIN_DURATION_RATIO = 0.85
|
|
|
|
_SAMPLES_RE = re.compile(r"Number of samples:\s*([0-9]+)")
|
|
|
|
# Defaults: treat audio below -50 dB lasting >= 2s as silence, and reject when
|
|
# more than half the track is silent. A normal song — even with quiet intros/
|
|
# outros — sits far below 0.5; a 30s-real + padded-silence file sits near 0.85.
|
|
DEFAULT_NOISE_DB = -50
|
|
DEFAULT_MIN_SILENCE_S = 2.0
|
|
DEFAULT_THRESHOLD = 0.5
|
|
|
|
_SILENCE_DURATION_RE = re.compile(r"silence_duration:\s*([0-9]+(?:\.[0-9]+)?)")
|
|
|
|
|
|
def silence_ratio_from_output(ffmpeg_stderr: str, total_duration_s: float) -> float:
|
|
"""Fraction of *total_duration_s* covered by detected silence.
|
|
|
|
Sums every ``silence_duration: X`` reported by ffmpeg ``silencedetect``
|
|
and divides by the track length. Capped at 1.0; returns 0.0 when the
|
|
duration is unknown/zero or no silence was reported.
|
|
"""
|
|
if not total_duration_s or total_duration_s <= 0:
|
|
return 0.0
|
|
total_silence = sum(float(m) for m in _SILENCE_DURATION_RE.findall(ffmpeg_stderr or ""))
|
|
if total_silence <= 0:
|
|
return 0.0
|
|
return min(total_silence / total_duration_s, 1.0)
|
|
|
|
|
|
def is_mostly_silent_reason(
|
|
ffmpeg_stderr: str,
|
|
total_duration_s: float,
|
|
*,
|
|
threshold: float = DEFAULT_THRESHOLD,
|
|
) -> Optional[str]:
|
|
"""Return a rejection reason when the silent fraction meets *threshold*."""
|
|
ratio = silence_ratio_from_output(ffmpeg_stderr, total_duration_s)
|
|
if ratio >= threshold:
|
|
pct = round(ratio * 100)
|
|
audible_s = round(total_duration_s * (1 - ratio))
|
|
return (
|
|
f"Audio is mostly silent: {pct}% silence (only ~{audible_s}s audible of "
|
|
f"{round(total_duration_s)}s) — likely a truncated/preview file padded "
|
|
f"to full length"
|
|
)
|
|
return None
|
|
|
|
|
|
def _ffmpeg_available() -> bool:
|
|
try:
|
|
subprocess.run(
|
|
["ffmpeg", "-version"],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
timeout=10, check=True,
|
|
)
|
|
return True
|
|
except (FileNotFoundError, subprocess.SubprocessError, OSError):
|
|
return False
|
|
|
|
|
|
def _probe_duration_s(file_path: str) -> Optional[float]:
|
|
try:
|
|
from mutagen import File as MutagenFile
|
|
audio = MutagenFile(file_path)
|
|
if audio and audio.info and getattr(audio.info, "length", None):
|
|
return float(audio.info.length)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
logger.debug("silence guard duration probe failed for %s: %s", file_path, exc)
|
|
return None
|
|
|
|
|
|
def detect_mostly_silent(
|
|
file_path: str,
|
|
*,
|
|
threshold: float = DEFAULT_THRESHOLD,
|
|
noise_db: int = DEFAULT_NOISE_DB,
|
|
min_silence_s: float = DEFAULT_MIN_SILENCE_S,
|
|
) -> Optional[str]:
|
|
"""Run ffmpeg ``silencedetect`` over *file_path* and return a rejection
|
|
reason when the file is mostly silence, else None.
|
|
|
|
Fails open: returns None when ffmpeg/mutagen are unavailable or error, so
|
|
a tooling problem never quarantines a legitimate file.
|
|
"""
|
|
if not _ffmpeg_available():
|
|
logger.debug("silence guard skipped — ffmpeg not available")
|
|
return None
|
|
|
|
total_duration_s = _probe_duration_s(file_path)
|
|
if not total_duration_s:
|
|
return None
|
|
|
|
try:
|
|
proc = subprocess.run(
|
|
[
|
|
"ffmpeg", "-hide_banner", "-nostats", "-i", file_path,
|
|
"-af", f"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("silence guard ffmpeg run failed for %s: %s", file_path, exc)
|
|
return None
|
|
|
|
stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
|
|
return is_mostly_silent_reason(stderr, total_duration_s, threshold=threshold)
|
|
|
|
|
|
# ── Truncation: real decoded duration vs container duration ────────────────
|
|
|
|
def measured_duration_from_astats(astats_stderr: str, sample_rate: int) -> Optional[float]:
|
|
"""Real decoded audio duration in seconds from ffmpeg ``astats`` output.
|
|
|
|
``astats`` reports the per-channel ``Number of samples``; dividing by the
|
|
sample rate gives the true decoded length. Returns None when the sample
|
|
count or sample rate is unavailable.
|
|
"""
|
|
if not sample_rate or sample_rate <= 0:
|
|
return None
|
|
m = _SAMPLES_RE.search(astats_stderr or "")
|
|
if not m:
|
|
return None
|
|
return int(m.group(1)) / float(sample_rate)
|
|
|
|
|
|
def is_dsd_path(file_path: str) -> bool:
|
|
"""True for DSD audio (.dsf / .dff). The decoded-samples truncation check is
|
|
invalid for DSD: ffmpeg decodes DSD to PCM at a different rate than the DSD
|
|
container's 2.8 MHz, so samples ÷ container-sample-rate massively under-counts
|
|
and would falsely report the file as truncated (#939)."""
|
|
return os.path.splitext(str(file_path or ''))[1].lower() in ('.dsf', '.dff')
|
|
|
|
|
|
def incomplete_audio_reason(
|
|
measured_s: Optional[float],
|
|
container_s: Optional[float],
|
|
*,
|
|
min_ratio: float = DEFAULT_MIN_DURATION_RATIO,
|
|
) -> Optional[str]:
|
|
"""Return a rejection reason when the real decoded duration falls short of
|
|
the container duration (a truncated file whose metadata over-states length).
|
|
"""
|
|
if not measured_s or not container_s or container_s <= 0:
|
|
return None
|
|
if measured_s >= container_s * min_ratio:
|
|
return None
|
|
pct = round(measured_s / container_s * 100)
|
|
return (
|
|
f"Incomplete audio: only ~{round(measured_s)}s actually decodes of a "
|
|
f"{round(container_s)}s file ({pct}%) — truncated/broken download "
|
|
f"(container duration over-states the real audio)"
|
|
)
|
|
|
|
|
|
def _measured_audio_duration_s(file_path: str, sample_rate: int) -> Optional[float]:
|
|
try:
|
|
proc = subprocess.run(
|
|
["ffmpeg", "-hide_banner", "-nostats", "-i", file_path,
|
|
"-af", "astats=metadata=1", "-f", "null", "-"],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=120,
|
|
)
|
|
except (subprocess.SubprocessError, OSError) as exc:
|
|
logger.debug("astats run failed for %s: %s", file_path, exc)
|
|
return None
|
|
stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
|
|
return measured_duration_from_astats(stderr, sample_rate)
|
|
|
|
|
|
def detect_incomplete_audio(
|
|
file_path: str,
|
|
*,
|
|
min_ratio: float = DEFAULT_MIN_DURATION_RATIO,
|
|
) -> Optional[str]:
|
|
"""Decode the file and reject when the real audio is far shorter than the
|
|
container claims. Fails open when ffmpeg/mutagen are unavailable.
|
|
"""
|
|
if not _ffmpeg_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
|
|
|
|
measured_s = _measured_audio_duration_s(file_path, sample_rate)
|
|
return incomplete_audio_reason(measured_s, container_s, min_ratio=min_ratio)
|
|
|
|
|
|
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.
|
|
"""
|
|
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) — but
|
|
# NOT for DSD: the astats sample-count ÷ DSD-rate math is invalid there and
|
|
# would always false-positive (#939). Silence detection below still applies.
|
|
if not is_dsd_path(file_path):
|
|
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
|
|
|
|
# Then silence-padding (mostly-silent file).
|
|
return is_mostly_silent_reason(stderr, container_s, threshold=threshold)
|