quality: recognize DSD (.dsf/.dff) as lossless + stop the false "truncated" flag (#939)
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.
This commit is contained in:
parent
bcf99d76d3
commit
b62d9b5b08
9 changed files with 106 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -5100,6 +5100,7 @@
|
|||
<option value="flac">FLAC</option>
|
||||
<option value="alac">ALAC</option>
|
||||
<option value="wav">WAV / AIFF</option>
|
||||
<option value="dsf">DSD (DSF / DFF)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Lossy">
|
||||
<option value="group:lossy">All lossy</option>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue