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 @@
+