From b62d9b5b08447b13b7f2a0053922423165bc4f24 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 28 Jun 2026 11:45:16 -0700 Subject: [PATCH] quality: recognize DSD (.dsf/.dff) as lossless + stop the false "truncated" flag (#939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/imports/file_ops.py | 16 ++++++++++ core/imports/silence.py | 22 +++++++++++--- core/quality/model.py | 1 + core/quality/source_map.py | 3 ++ tests/imports/test_silence_guard.py | 47 +++++++++++++++++++++++++++++ tests/quality/test_model.py | 19 ++++++++++++ tests/quality/test_source_map.py | 1 + webui/index.html | 1 + webui/static/settings.js | 2 +- 9 files changed, 106 insertions(+), 6 deletions(-) diff --git a/core/imports/file_ops.py b/core/imports/file_ops.py index 747d65e2..2f9f245a 100644 --- a/core/imports/file_ops.py +++ b/core/imports/file_ops.py @@ -360,6 +360,22 @@ def probe_audio_quality(file_path: str): sample_rate=getattr(audio.info, 'sample_rate', None), ) + if ext in ('dsf', 'dff'): + # DSD (DSD Stream File / DSDIFF) — 1-bit hi-res lossless (#939). mutagen + # reads .dsf (rate/bitrate/bit_depth); .dff has no mutagen reader, so it + # still classifies as the lossless 'dsf' tier just without measured detail. + sr = bd = br = None + if ext == 'dsf': + try: + from mutagen.dsf import DSF + info = DSF(file_path).info + sr = getattr(info, 'sample_rate', None) + bd = getattr(info, 'bits_per_sample', None) + br = info.bitrate // 1000 if getattr(info, 'bitrate', None) else None + except Exception: # noqa: S110 — unreadable DSF still classifies lossless, just without measured detail + pass + return AudioQuality(format='dsf', bitrate=br, sample_rate=sr, bit_depth=bd) + return None except Exception as e: logger.debug("probe_audio_quality failed for %s: %s", file_path, e) diff --git a/core/imports/silence.py b/core/imports/silence.py index ecbe048f..5fb63017 100644 --- a/core/imports/silence.py +++ b/core/imports/silence.py @@ -18,6 +18,7 @@ run, so a tooling problem never blocks a legitimate import. from __future__ import annotations +import os import re import subprocess from typing import Optional @@ -157,6 +158,14 @@ def measured_duration_from_astats(astats_stderr: str, sample_rate: int) -> Optio 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], @@ -267,11 +276,14 @@ def detect_broken_audio( 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 + # 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) diff --git a/core/quality/model.py b/core/quality/model.py index 4fe1c0f9..498b0381 100644 --- a/core/quality/model.py +++ b/core/quality/model.py @@ -39,6 +39,7 @@ class AudioQuality: # matched target. Cross-format PRIORITY is decided solely by the user's # ranked-target list (target index), never by these numbers. format_base: dict[str, float] = { + 'dsf': 102.0, # DSD — 1-bit hi-res lossless, ranks at/above FLAC (#939) 'flac': 100.0, 'alac': 98.0, # lossless (Apple) 'wav': 95.0, diff --git a/core/quality/source_map.py b/core/quality/source_map.py index 9a834f4d..0af50eaa 100644 --- a/core/quality/source_map.py +++ b/core/quality/source_map.py @@ -44,6 +44,9 @@ _EXTENSION_FORMAT_MAP = { 'ogg': 'ogg', 'oga': 'ogg', 'opus': 'opus', 'wma': 'wma', + # DSD (DSD Stream File / DSDIFF) — 1-bit hi-res lossless (e.g. DSD64 ≈ 11 Mbps). + # Both container types map to the single 'dsf' tier (#939). + 'dsf': 'dsf', 'dff': 'dsf', } # Audio extensions worth probing/classifying at all — derived from the map so diff --git a/tests/imports/test_silence_guard.py b/tests/imports/test_silence_guard.py index fe0d9235..5d24375a 100644 --- a/tests/imports/test_silence_guard.py +++ b/tests/imports/test_silence_guard.py @@ -6,7 +6,10 @@ parsers are tested here; the ffmpeg call is integration. import pytest +import core.imports.silence as silence_mod from core.imports.silence import ( + detect_broken_audio, + is_dsd_path, silence_ratio_from_output, is_mostly_silent_reason, measured_duration_from_astats, @@ -102,3 +105,47 @@ def test_no_incomplete_reason_for_full_file(): def test_no_incomplete_reason_when_unmeasurable(): assert incomplete_audio_reason(None, 188.4, min_ratio=0.85) is None assert incomplete_audio_reason(30.0, 0, min_ratio=0.85) is None + + +# ── DSD (#939): the samples÷rate truncation math is invalid for DSD, so it must +# be skipped for .dsf/.dff (silence detection still applies). ── + +def test_is_dsd_path(): + assert is_dsd_path("/m/Album/01. Song.dsf") is True + assert is_dsd_path("/m/Album/01. Song.DFF") is True # case-insensitive + assert is_dsd_path("/m/Album/01. Song.flac") is False + assert is_dsd_path("") is False + assert is_dsd_path(None) is False + + +class _FakeProc: + def __init__(self, stderr): + self.stderr = stderr.encode("utf-8") + + +class _FakeInfo: + length = 330.0 # container says 330s + sample_rate = 44100 + + +def _patch_broken_pipeline(monkeypatch, astats_stderr): + """Make detect_broken_audio run against a canned 'truncated' ffmpeg result.""" + monkeypatch.setattr(silence_mod, "_ffmpeg_available", lambda: True) + monkeypatch.setattr("mutagen.File", lambda *_a, **_k: type("A", (), {"info": _FakeInfo()})()) + monkeypatch.setattr(silence_mod.subprocess, "run", lambda *_a, **_k: _FakeProc(astats_stderr)) + + +def test_truncation_flagged_for_normal_file(monkeypatch): + # ~40s decoded of a 330s container (12%) → a normal file IS flagged truncated. + astats = "[Parsed_astats_0 @ 0x55] Number of samples: 1764000\n" # 1764000/44100 ≈ 40s + _patch_broken_pipeline(monkeypatch, astats) + reason = detect_broken_audio("/m/Album/01. Song.flac", min_ratio=0.85) + assert reason and "Incomplete audio" in reason + + +def test_truncation_skipped_for_dsd(monkeypatch): + # Same 12%-decoding numbers, but a .dsf file must NOT be flagged — the math is + # invalid for DSD (ffmpeg decodes DSD to PCM at a different rate). #939 + astats = "[Parsed_astats_0 @ 0x55] Number of samples: 1764000\n" + _patch_broken_pipeline(monkeypatch, astats) + assert detect_broken_audio("/m/Album/01. Song.dsf", min_ratio=0.85) is None diff --git a/tests/quality/test_model.py b/tests/quality/test_model.py index a740f22c..f9aeed69 100644 --- a/tests/quality/test_model.py +++ b/tests/quality/test_model.py @@ -128,3 +128,22 @@ def test_v2_to_v3_preserves_order_and_maps_fields(): assert formats == ['flac', 'mp3'] # disabled mp3_192 omitted assert targets[0]['bit_depth'] == 24 assert targets[1]['min_bitrate'] == 320 + + +# ── DSD (#939): DSF must rank as lossless, never "Low Quality" below MP3 ── + +def test_dsf_ranks_in_lossless_range(): + dsf = AudioQuality('dsf', bitrate=11290).tier_score() + flac_cd = AudioQuality('flac', sample_rate=44100, bit_depth=16).tier_score() + mp3_320 = AudioQuality('mp3', bitrate=320).tier_score() + # DSD64 is hi-res lossless — at/above CD FLAC and well above any lossy format. + assert dsf >= flac_cd + assert dsf > mp3_320 + + +def test_dsf_without_measured_bitrate_still_lossless(): + # .dff has no mutagen reader, so it classifies as 'dsf' with no measured detail — + # it must still land in the lossless tier, not the 'unknown' floor. + dsf = AudioQuality('dsf').tier_score() + assert dsf > AudioQuality('mp3', bitrate=320).tier_score() + assert dsf > AudioQuality('unknown').tier_score() diff --git a/tests/quality/test_source_map.py b/tests/quality/test_source_map.py index b4d71c43..c17cd337 100644 --- a/tests/quality/test_source_map.py +++ b/tests/quality/test_source_map.py @@ -30,6 +30,7 @@ from core.quality.source_map import ( ("aiff", "wav"), ("aif", "wav"), # PCM → wav tier ("wma", "wma"), ("alac", "alac"), + ("dsf", "dsf"), (".dsf", "dsf"), ("dff", "dsf"), # DSD → dsf tier (#939) ("xyz", "unknown"), ("", "unknown"), (None, "unknown"), ]) def test_format_from_extension(ext, fmt): diff --git a/webui/index.html b/webui/index.html index cfc502c5..15a49c48 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5100,6 +5100,7 @@ + diff --git a/webui/static/settings.js b/webui/static/settings.js index adfceafe..3d91f4b5 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -2094,7 +2094,7 @@ function deleteRankedTarget(i) { // Lossless formats take bit-depth + sample-rate constraints; lossy take a // minimum bitrate. Single source of truth for the add-target field toggle. -const RT_LOSSLESS_FORMATS = ['flac', 'alac', 'wav']; +const RT_LOSSLESS_FORMATS = ['flac', 'alac', 'wav', 'dsf']; const RT_LOSSY_FORMATS = ['mp3', 'aac', 'ogg', 'opus', 'wma']; // "group:" selections are a UI convenience: picking one + constraints expands // into individual per-format targets at that slot (the backend still works