feat: add qbz-dsd crate (DSF/DFF demux, DSD to PCM, DoP, native DSD)
New frontend-agnostic crate for DSD support. In-house DSF and DFF demuxers (up to 5.1, DST rejected with a typed error), embedded ID3 tags and artwork via the id3 crate, a verbatim BSD port of Sebastian Gesemann's dsd2pcm table engine plus a half-band decimation chain (uniform 88.2 kHz output, ITU stereo downmix for multichannel), DoP frame packing per the DoP 1.1 standard (marker alternation, 0x69 silence), and native DSD_U32 packing (BE/LE) for kernel-granted devices. 20 tests over synthesized fixtures. cc
This commit is contained in:
11 files changed
+1670
No files matched your search
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "qbz-dsd"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DSD (DSF/DFF) demuxing and DSD-to-PCM conversion"
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
id3 = "1"
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Streaming DSD → 88.2 kHz PCM conversion chain.
|
||||
//!
|
||||
//! dsd2pcm decimates 8:1 (one float per DSD byte), leaving DSD64 at
|
||||
//! 352.8 kHz, DSD128 at 705.6 kHz, … A chain of half-band ÷2 stages then
|
||||
//! brings every rate down to a uniform 88.2 kHz.
|
||||
//!
|
||||
//! Why 88.2 kHz (v1 policy, revised after the first smoke): the original
|
||||
//! 176.4 kHz target is NOT universally supported — the owner's DacMagic
|
||||
//! Plus USB interface exposes 44.1/48/88.2/96/192 only, so the player fell
|
||||
//! back to rodio's linear resampler, which has no anti-alias filter: the
|
||||
//! DSD ultrasonic noise shelf (huge between 44k–88k at a 176.4k container
|
||||
//! rate) folded straight into the audible band as loud hiss. At 88.2 kHz
|
||||
//! every DAC that matters accepts the rate natively (no resampler in the
|
||||
//! path), it is an exact 44.1k-family division, and the half-band chain —
|
||||
//! which DOES anti-alias properly — removes all DSD noise above 44.1 kHz.
|
||||
//! A per-device higher-rate policy can come with the Phase-2 capability
|
||||
//! model (see qbz-nix-docs/dsd-support/).
|
||||
|
||||
use crate::demux::{DsdDemuxer, DsdError};
|
||||
use crate::dsd2pcm::Dsd2Pcm;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Uniform PCM output rate for converted DSD.
|
||||
pub const OUTPUT_RATE: u32 = 88_200;
|
||||
|
||||
/// Default conversion gain. DSD program material can exceed 0 dBFS when
|
||||
/// low-passed to PCM; the customary −6 dB trim prevents clipping.
|
||||
pub const DEFAULT_GAIN_DB: f32 = -6.0;
|
||||
|
||||
/// DSD bytes requested from the demuxer per conversion block, per channel.
|
||||
/// 64 KiB ≈ 0.19 s of DSD64 — small enough to stream, big enough to be cheap.
|
||||
const BLOCK_BYTES_PER_CH: usize = 64 * 1024;
|
||||
|
||||
const HALFBAND_TAPS: usize = 63;
|
||||
|
||||
/// Symmetric half-band low-pass (cutoff fs/4) for ÷2 decimation, generated
|
||||
/// once: windowed sinc (Blackman), odd length, even taps (except center)
|
||||
/// exactly zero by construction of sinc(n/2).
|
||||
fn halfband_taps() -> &'static [f32; HALFBAND_TAPS] {
|
||||
static TAPS: OnceLock<[f32; HALFBAND_TAPS]> = OnceLock::new();
|
||||
TAPS.get_or_init(|| {
|
||||
let m = (HALFBAND_TAPS - 1) as f64 / 2.0; // 31
|
||||
let mut taps = [0.0f32; HALFBAND_TAPS];
|
||||
let mut sum = 0.0f64;
|
||||
for (n, t) in taps.iter_mut().enumerate() {
|
||||
let x = n as f64 - m;
|
||||
let sinc = if x == 0.0 {
|
||||
0.5
|
||||
} else {
|
||||
(std::f64::consts::PI * 0.5 * x).sin() / (std::f64::consts::PI * x)
|
||||
};
|
||||
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * n as f64 / (HALFBAND_TAPS - 1) as f64).cos()
|
||||
+ 0.08 * (4.0 * std::f64::consts::PI * n as f64 / (HALFBAND_TAPS - 1) as f64).cos();
|
||||
let v = sinc * w;
|
||||
*t = v as f32;
|
||||
sum += v;
|
||||
}
|
||||
// Normalize to unity DC gain.
|
||||
let scale = (1.0 / sum) as f32;
|
||||
for t in taps.iter_mut() {
|
||||
*t *= scale;
|
||||
}
|
||||
taps
|
||||
})
|
||||
}
|
||||
|
||||
/// Streaming FIR decimate-by-2 stage with history carry-over.
|
||||
struct HalfBand {
|
||||
/// Pending input: last TAPS-1 samples of the previous call + new input.
|
||||
carry: Vec<f32>,
|
||||
/// Read cursor parity is preserved across calls via drain bookkeeping.
|
||||
next_center: usize,
|
||||
}
|
||||
|
||||
impl HalfBand {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
// Prime with zeros so the first outputs are filter warm-up, not
|
||||
// garbage; keeps output counting deterministic (len/2 per input).
|
||||
carry: vec![0.0; HALFBAND_TAPS - 1],
|
||||
next_center: HALFBAND_TAPS - 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed `input`, append decimated output to `out`.
|
||||
fn process(&mut self, input: &[f32], out: &mut Vec<f32>) {
|
||||
let taps = halfband_taps();
|
||||
self.carry.extend_from_slice(input);
|
||||
let mut i = self.next_center;
|
||||
while i < self.carry.len() {
|
||||
let window = &self.carry[i + 1 - HALFBAND_TAPS..=i];
|
||||
let mut acc = 0.0f32;
|
||||
// Half-band: even-indexed taps are zero except the center.
|
||||
let mut j = 0;
|
||||
while j < HALFBAND_TAPS {
|
||||
acc += taps[j] * window[HALFBAND_TAPS - 1 - j];
|
||||
j += 2;
|
||||
}
|
||||
acc += taps[HALFBAND_TAPS / 2] * window[HALFBAND_TAPS / 2];
|
||||
out.push(acc);
|
||||
i += 2;
|
||||
}
|
||||
// Keep the last TAPS-1 samples; remember cursor parity.
|
||||
let keep_from = self.carry.len().saturating_sub(HALFBAND_TAPS - 1);
|
||||
let overshoot = i - self.carry.len(); // 0 or 1
|
||||
self.carry.drain(..keep_from);
|
||||
self.next_center = (HALFBAND_TAPS - 1) + overshoot;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole-file streaming converter: demuxer → per-channel dsd2pcm →
|
||||
/// half-band chain → interleaved f32 blocks at [`OUTPUT_RATE`].
|
||||
pub struct DsdPcmConverter {
|
||||
demux: Box<dyn DsdDemuxer>,
|
||||
channels: usize,
|
||||
lsb_first: bool,
|
||||
dsd2pcm: Vec<Dsd2Pcm>,
|
||||
stages: Vec<Vec<HalfBand>>, // stages[stage][channel]
|
||||
gain: f32,
|
||||
total_frames: u64,
|
||||
frames_emitted: u64,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl DsdPcmConverter {
|
||||
pub fn new(demux: Box<dyn DsdDemuxer>, gain_db: f32) -> Result<Self, DsdError> {
|
||||
let info = demux.info().clone();
|
||||
let ratio = info.dsd_rate / OUTPUT_RATE; // 32 / 64 / 128 / 256
|
||||
if info.dsd_rate % OUTPUT_RATE != 0 || !(ratio / 8).is_power_of_two() || ratio < 16 {
|
||||
return Err(DsdError::UnsupportedRate(info.dsd_rate));
|
||||
}
|
||||
let n_stages = (ratio / 8).trailing_zeros() as usize; // 2..=5
|
||||
let channels = info.channels as usize;
|
||||
let stages = (0..n_stages)
|
||||
.map(|_| (0..channels).map(|_| HalfBand::new()).collect())
|
||||
.collect();
|
||||
Ok(Self {
|
||||
demux,
|
||||
channels,
|
||||
lsb_first: info.lsb_first,
|
||||
dsd2pcm: (0..channels).map(|_| Dsd2Pcm::new()).collect(),
|
||||
stages,
|
||||
gain: 10f32.powf(gain_db / 20.0),
|
||||
total_frames: info.sample_count / ratio as u64,
|
||||
frames_emitted: 0,
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn output_rate(&self) -> u32 {
|
||||
OUTPUT_RATE
|
||||
}
|
||||
/// PCM output channels: ALWAYS stereo. Mono sources are duplicated;
|
||||
/// multichannel (up to 5.1) sources are downmixed (ITU-R BS.775
|
||||
/// coefficients, LFE discarded, normalized against clipping).
|
||||
pub fn channels(&self) -> u16 {
|
||||
2
|
||||
}
|
||||
/// Exact number of interleaved PCM frames this converter will emit in
|
||||
/// total (used to size the WAV header up front).
|
||||
pub fn total_frames(&self) -> u64 {
|
||||
self.total_frames
|
||||
}
|
||||
|
||||
/// Produce the next interleaved f32 block, `None` when done. The overall
|
||||
/// emitted frame count is exactly [`Self::total_frames`]: the final block
|
||||
/// is silence-padded or truncated as needed so the container size always
|
||||
/// matches the header.
|
||||
pub fn next_block(&mut self) -> Result<Option<Vec<f32>>, DsdError> {
|
||||
if self.finished {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut planar: Vec<Vec<u8>> = (0..self.channels).map(|_| Vec::new()).collect();
|
||||
let got = self.demux.read_planar(&mut planar, BLOCK_BYTES_PER_CH)?;
|
||||
if got == 0 {
|
||||
// EOF: pad with silence if the filter latency left us short.
|
||||
self.finished = true;
|
||||
let missing = self.total_frames - self.frames_emitted;
|
||||
if missing == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
self.frames_emitted = self.total_frames;
|
||||
return Ok(Some(vec![0.0; (missing as usize) * 2]));
|
||||
}
|
||||
|
||||
let mut per_ch: Vec<Vec<f32>> = Vec::with_capacity(self.channels);
|
||||
for ch in 0..self.channels {
|
||||
let mut buf = Vec::new();
|
||||
self.dsd2pcm[ch].translate(&planar[ch], self.lsb_first, &mut buf);
|
||||
for stage in self.stages.iter_mut() {
|
||||
let mut down = Vec::with_capacity(buf.len() / 2 + 1);
|
||||
stage[ch].process(&buf, &mut down);
|
||||
buf = down;
|
||||
}
|
||||
per_ch.push(buf);
|
||||
}
|
||||
|
||||
let frames = per_ch.iter().map(|c| c.len()).min().unwrap_or(0) as u64;
|
||||
let frames = frames.min(self.total_frames - self.frames_emitted) as usize;
|
||||
if frames == 0 {
|
||||
// Nothing usable this round (filters still priming) — recurse to
|
||||
// pull more input; bounded by file size.
|
||||
return self.next_block();
|
||||
}
|
||||
// Fold to stereo. DSF/DFF positional channel order:
|
||||
// 3ch = FL FR C · 4ch = FL FR BL BR · 5ch = FL FR C BL BR ·
|
||||
// 6ch = FL FR C LFE BL BR.
|
||||
// ITU-R BS.775 downmix (center/surrounds at −3 dB, LFE discarded),
|
||||
// normalized by the worst-case coefficient sum against clipping.
|
||||
const K: f32 = std::f32::consts::FRAC_1_SQRT_2;
|
||||
let (ci, sli, sri) = match self.channels {
|
||||
3 => (Some(2), None, None),
|
||||
4 => (None, Some(2), Some(3)),
|
||||
5 => (Some(2), Some(3), Some(4)),
|
||||
6 => (Some(2), Some(4), Some(5)),
|
||||
_ => (None, None, None),
|
||||
};
|
||||
let norm = 1.0
|
||||
/ (1.0
|
||||
+ if ci.is_some() { K } else { 0.0 }
|
||||
+ if sli.is_some() { K } else { 0.0 });
|
||||
let mut out = Vec::with_capacity(frames * 2);
|
||||
for f in 0..frames {
|
||||
let (l, r) = match self.channels {
|
||||
1 => (per_ch[0][f], per_ch[0][f]),
|
||||
2 => (per_ch[0][f], per_ch[1][f]),
|
||||
_ => {
|
||||
let mut l = per_ch[0][f];
|
||||
let mut r = per_ch[1][f];
|
||||
if let Some(i) = ci {
|
||||
l += K * per_ch[i][f];
|
||||
r += K * per_ch[i][f];
|
||||
}
|
||||
if let Some(i) = sli {
|
||||
l += K * per_ch[i][f];
|
||||
}
|
||||
if let Some(i) = sri {
|
||||
r += K * per_ch[i][f];
|
||||
}
|
||||
(l * norm, r * norm)
|
||||
}
|
||||
};
|
||||
out.push(l * self.gain);
|
||||
out.push(r * self.gain);
|
||||
}
|
||||
self.frames_emitted += frames as u64;
|
||||
if self.frames_emitted >= self.total_frames {
|
||||
self.finished = true;
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn halfband_dc_gain_is_unity() {
|
||||
let mut hb = HalfBand::new();
|
||||
let mut out = Vec::new();
|
||||
hb.process(&vec![0.5f32; 8192], &mut out);
|
||||
assert_eq!(out.len(), 8192 / 2);
|
||||
let tail = &out[256..];
|
||||
for &s in tail {
|
||||
assert!((s - 0.5).abs() < 1e-3, "DC not preserved: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halfband_output_count_is_half_input_across_calls() {
|
||||
let mut hb = HalfBand::new();
|
||||
let mut out = Vec::new();
|
||||
// Odd-sized chunks exercise the parity carry-over.
|
||||
for chunk in [333usize, 1000, 77, 4096, 1] {
|
||||
hb.process(&vec![0.1f32; chunk], &mut out);
|
||||
}
|
||||
let total: usize = 333 + 1000 + 77 + 4096 + 1;
|
||||
// Centers sit on fixed parity from the zero prefix → ceil(total/2).
|
||||
assert_eq!(out.len(), total.div_ceil(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
//! DSF and DFF (DSDIFF) demuxers.
|
||||
//!
|
||||
//! Both containers carry raw 1-bit DSD grouped in bytes; they differ in
|
||||
//! layout and bit order:
|
||||
//! - DSF (Sony spec): per-channel BLOCKS of `block_size` (4096) bytes,
|
||||
//! `[ch0 block][ch1 block][ch0 block]…`; bit order declared in the header
|
||||
//! (`bits_per_sample`: 1 = LSB-first, 8 = MSB-first; LSB-first in practice).
|
||||
//! Standard ID3v2 tag at the header-declared `metadata_ptr` offset.
|
||||
//! - DFF (Philips DSDIFF 1.5): frame-interleaved, ONE byte per channel round
|
||||
//! robin, always MSB-first. Big-endian chunk sizes, chunks padded to even
|
||||
//! offsets. No standard tagging; a trailing nonstandard "ID3 " chunk is
|
||||
//! honored when present. DST-compressed DFF is detected and rejected.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
/// Errors from DSD demuxing/conversion. `UnsupportedDst` / `UnsupportedChannels`
|
||||
/// are expected user-facing cases (toast + skip), not bugs.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DsdError {
|
||||
#[error("DST-compressed DFF is not supported")]
|
||||
UnsupportedDst,
|
||||
#[error("unsupported channel count: {0} (mono, stereo and up to 5.1 supported)")]
|
||||
UnsupportedChannels(u16),
|
||||
#[error("unsupported DSD rate: {0} Hz")]
|
||||
UnsupportedRate(u32),
|
||||
#[error("corrupt or invalid DSD file: {0}")]
|
||||
Corrupt(String),
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Tag subset read from the container (DSF: embedded ID3v2; DFF: trailing
|
||||
/// "ID3 " chunk when present, otherwise empty — callers fall back to
|
||||
/// filename-derived metadata).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DsdTags {
|
||||
pub title: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
pub track_number: Option<u32>,
|
||||
pub disc_number: Option<u32>,
|
||||
pub year: Option<i32>,
|
||||
/// First embedded picture (front cover preferred), raw bytes.
|
||||
pub artwork: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DsdStreamInfo {
|
||||
/// DSD bit rate per channel (2 822 400 = DSD64, …).
|
||||
pub dsd_rate: u32,
|
||||
pub channels: u16,
|
||||
/// Total DSD bits per channel.
|
||||
pub sample_count: u64,
|
||||
/// Bit order inside each byte: true = LSB is temporally first (DSF
|
||||
/// default), false = MSB first (DFF, and DSF with bits_per_sample = 8).
|
||||
pub lsb_first: bool,
|
||||
pub tags: DsdTags,
|
||||
}
|
||||
|
||||
impl DsdStreamInfo {
|
||||
pub fn duration_secs(&self) -> u64 {
|
||||
if self.dsd_rate == 0 {
|
||||
0
|
||||
} else {
|
||||
self.sample_count / self.dsd_rate as u64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait DsdDemuxer: Send {
|
||||
fn info(&self) -> &DsdStreamInfo;
|
||||
/// Append up to `max_bytes_per_ch` DSD bytes per channel to `out[ch]`
|
||||
/// (planar). Returns the byte count appended to EACH channel (always
|
||||
/// equal across channels); 0 means end of stream.
|
||||
fn read_planar(
|
||||
&mut self,
|
||||
out: &mut [Vec<u8>],
|
||||
max_bytes_per_ch: usize,
|
||||
) -> Result<usize, DsdError>;
|
||||
}
|
||||
|
||||
const VALID_RATES: [u32; 4] = [2_822_400, 5_644_800, 11_289_600, 22_579_200];
|
||||
|
||||
/// Open a DSD file, sniffing DSF vs DFF from the leading magic.
|
||||
pub fn open_dsd(path: &Path) -> Result<Box<dyn DsdDemuxer>, DsdError> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut magic = [0u8; 4];
|
||||
file.read_exact(&mut magic)?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
match &magic {
|
||||
b"DSD " => Ok(Box::new(DsfReader::open(file)?)),
|
||||
b"FRM8" => Ok(Box::new(DffReader::open(file)?)),
|
||||
_ => Err(DsdError::Corrupt("not a DSF or DFF file".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_u32_le(f: &mut File) -> Result<u32, DsdError> {
|
||||
let mut b = [0u8; 4];
|
||||
f.read_exact(&mut b)?;
|
||||
Ok(u32::from_le_bytes(b))
|
||||
}
|
||||
fn read_u64_le(f: &mut File) -> Result<u64, DsdError> {
|
||||
let mut b = [0u8; 8];
|
||||
f.read_exact(&mut b)?;
|
||||
Ok(u64::from_le_bytes(b))
|
||||
}
|
||||
fn read_u64_be(f: &mut File) -> Result<u64, DsdError> {
|
||||
let mut b = [0u8; 8];
|
||||
f.read_exact(&mut b)?;
|
||||
Ok(u64::from_be_bytes(b))
|
||||
}
|
||||
fn read_id(f: &mut File) -> Result<[u8; 4], DsdError> {
|
||||
let mut b = [0u8; 4];
|
||||
f.read_exact(&mut b)?;
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
fn validate_rate(rate: u32) -> Result<(), DsdError> {
|
||||
if VALID_RATES.contains(&rate) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DsdError::UnsupportedRate(rate))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_id3_tags(file: &mut File, offset: u64) -> DsdTags {
|
||||
let mut tags = DsdTags::default();
|
||||
if file.seek(SeekFrom::Start(offset)).is_err() {
|
||||
return tags;
|
||||
}
|
||||
match id3::Tag::read_from2(&mut *file) {
|
||||
Ok(tag) => {
|
||||
use id3::TagLike;
|
||||
tags.title = tag.title().map(str::to_string);
|
||||
tags.artist = tag.artist().map(str::to_string);
|
||||
tags.album = tag.album().map(str::to_string);
|
||||
tags.album_artist = tag.album_artist().map(str::to_string);
|
||||
tags.genre = tag.genre_parsed().map(|g| g.into_owned());
|
||||
tags.track_number = tag.track();
|
||||
tags.disc_number = tag.disc();
|
||||
tags.year = tag.year().or_else(|| tag.date_recorded().map(|d| d.year));
|
||||
tags.artwork = tag
|
||||
.pictures()
|
||||
.find(|p| p.picture_type == id3::frame::PictureType::CoverFront)
|
||||
.or_else(|| tag.pictures().next())
|
||||
.map(|p| p.data.clone());
|
||||
}
|
||||
Err(e) => log::debug!("[qbz-dsd] ID3 read failed (non-fatal): {e}"),
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DSF
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct DsfReader {
|
||||
file: File,
|
||||
info: DsdStreamInfo,
|
||||
block_size: usize,
|
||||
/// Valid (non-padding) DSD bytes remaining per channel.
|
||||
remaining_per_ch: u64,
|
||||
}
|
||||
|
||||
impl DsfReader {
|
||||
fn open(mut file: File) -> Result<Self, DsdError> {
|
||||
// "DSD " chunk: magic + size(28) + total file size + metadata ptr.
|
||||
let id = read_id(&mut file)?;
|
||||
if &id != b"DSD " {
|
||||
return Err(DsdError::Corrupt("missing DSD chunk".into()));
|
||||
}
|
||||
let dsd_chunk_size = read_u64_le(&mut file)?;
|
||||
if dsd_chunk_size != 28 {
|
||||
return Err(DsdError::Corrupt(format!(
|
||||
"bad DSD chunk size {dsd_chunk_size}"
|
||||
)));
|
||||
}
|
||||
let _total_size = read_u64_le(&mut file)?;
|
||||
let metadata_ptr = read_u64_le(&mut file)?;
|
||||
|
||||
// "fmt " chunk.
|
||||
let id = read_id(&mut file)?;
|
||||
if &id != b"fmt " {
|
||||
return Err(DsdError::Corrupt("missing fmt chunk".into()));
|
||||
}
|
||||
let fmt_size = read_u64_le(&mut file)?;
|
||||
if fmt_size < 52 {
|
||||
return Err(DsdError::Corrupt(format!("bad fmt chunk size {fmt_size}")));
|
||||
}
|
||||
let format_version = read_u32_le(&mut file)?;
|
||||
let format_id = read_u32_le(&mut file)?;
|
||||
let _channel_type = read_u32_le(&mut file)?;
|
||||
let channel_num = read_u32_le(&mut file)?;
|
||||
let sampling_frequency = read_u32_le(&mut file)?;
|
||||
let bits_per_sample = read_u32_le(&mut file)?;
|
||||
let sample_count = read_u64_le(&mut file)?;
|
||||
let block_size = read_u32_le(&mut file)?;
|
||||
let _reserved = read_u32_le(&mut file)?;
|
||||
|
||||
if format_version != 1 || format_id != 0 {
|
||||
return Err(DsdError::Corrupt(format!(
|
||||
"unsupported DSF format version {format_version} / id {format_id}"
|
||||
)));
|
||||
}
|
||||
if !(1..=6).contains(&channel_num) {
|
||||
return Err(DsdError::UnsupportedChannels(channel_num as u16));
|
||||
}
|
||||
validate_rate(sampling_frequency)?;
|
||||
let lsb_first = match bits_per_sample {
|
||||
1 => true,
|
||||
8 => false,
|
||||
other => {
|
||||
return Err(DsdError::Corrupt(format!(
|
||||
"bad DSF bits_per_sample {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
if block_size == 0 || block_size > (1 << 20) {
|
||||
return Err(DsdError::Corrupt(format!("bad DSF block size {block_size}")));
|
||||
}
|
||||
|
||||
// "data" chunk header; sample data starts right after.
|
||||
let id = read_id(&mut file)?;
|
||||
if &id != b"data" {
|
||||
return Err(DsdError::Corrupt("missing data chunk".into()));
|
||||
}
|
||||
let _data_chunk_size = read_u64_le(&mut file)?;
|
||||
let data_start = file.stream_position()?;
|
||||
|
||||
let tags = if metadata_ptr != 0 {
|
||||
let t = read_id3_tags(&mut file, metadata_ptr);
|
||||
file.seek(SeekFrom::Start(data_start))?;
|
||||
t
|
||||
} else {
|
||||
DsdTags::default()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
file,
|
||||
block_size: block_size as usize,
|
||||
remaining_per_ch: sample_count.div_ceil(8),
|
||||
info: DsdStreamInfo {
|
||||
dsd_rate: sampling_frequency,
|
||||
channels: channel_num as u16,
|
||||
sample_count,
|
||||
lsb_first,
|
||||
tags,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl DsdDemuxer for DsfReader {
|
||||
fn info(&self) -> &DsdStreamInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
fn read_planar(
|
||||
&mut self,
|
||||
out: &mut [Vec<u8>],
|
||||
max_bytes_per_ch: usize,
|
||||
) -> Result<usize, DsdError> {
|
||||
debug_assert_eq!(out.len(), self.info.channels as usize);
|
||||
if self.remaining_per_ch == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut appended = 0usize;
|
||||
let mut block = vec![0u8; self.block_size];
|
||||
while appended < max_bytes_per_ch && self.remaining_per_ch > 0 {
|
||||
// One block group: block_size bytes for each channel in order.
|
||||
let valid = (self.remaining_per_ch as usize).min(self.block_size);
|
||||
for ch in 0..self.info.channels as usize {
|
||||
match self.file.read_exact(&mut block) {
|
||||
Ok(()) => out[ch].extend_from_slice(&block[..valid]),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
// Truncated file: stop at what we got.
|
||||
self.remaining_per_ch = 0;
|
||||
return Ok(appended);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
self.remaining_per_ch -= valid as u64;
|
||||
appended += valid;
|
||||
}
|
||||
Ok(appended)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DFF (DSDIFF)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct DffReader {
|
||||
file: File,
|
||||
info: DsdStreamInfo,
|
||||
/// Bytes (all channels interleaved) remaining in the DSD data chunk.
|
||||
remaining_total: u64,
|
||||
}
|
||||
|
||||
impl DffReader {
|
||||
fn open(mut file: File) -> Result<Self, DsdError> {
|
||||
let id = read_id(&mut file)?;
|
||||
if &id != b"FRM8" {
|
||||
return Err(DsdError::Corrupt("missing FRM8 chunk".into()));
|
||||
}
|
||||
let _form_size = read_u64_be(&mut file)?;
|
||||
let form_type = read_id(&mut file)?;
|
||||
if &form_type != b"DSD " {
|
||||
return Err(DsdError::Corrupt("FRM8 form type is not DSD".into()));
|
||||
}
|
||||
|
||||
let mut dsd_rate: Option<u32> = None;
|
||||
let mut channels: Option<u16> = None;
|
||||
let mut data: Option<(u64, u64)> = None; // (offset, size)
|
||||
let mut id3_offset: Option<u64> = None;
|
||||
|
||||
// Top-level chunk scan (seek past payloads; chunks are even-padded).
|
||||
loop {
|
||||
let mut idbuf = [0u8; 4];
|
||||
match file.read_exact(&mut idbuf) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let size = read_u64_be(&mut file)?;
|
||||
let payload_start = file.stream_position()?;
|
||||
match &idbuf {
|
||||
b"PROP" => {
|
||||
// Property container: "SND " + subchunks.
|
||||
let prop_type = read_id(&mut file)?;
|
||||
if &prop_type != b"SND " {
|
||||
return Err(DsdError::Corrupt("PROP type is not SND".into()));
|
||||
}
|
||||
let prop_end = payload_start + size;
|
||||
while file.stream_position()? + 12 <= prop_end {
|
||||
let sub_id = read_id(&mut file)?;
|
||||
let sub_size = read_u64_be(&mut file)?;
|
||||
let sub_start = file.stream_position()?;
|
||||
match &sub_id {
|
||||
b"FS " => {
|
||||
let mut b = [0u8; 4];
|
||||
file.read_exact(&mut b)?;
|
||||
dsd_rate = Some(u32::from_be_bytes(b));
|
||||
}
|
||||
b"CHNL" => {
|
||||
let mut b = [0u8; 2];
|
||||
file.read_exact(&mut b)?;
|
||||
channels = Some(u16::from_be_bytes(b));
|
||||
}
|
||||
b"CMPR" => {
|
||||
let cmpr = read_id(&mut file)?;
|
||||
if &cmpr == b"DST " {
|
||||
return Err(DsdError::UnsupportedDst);
|
||||
}
|
||||
if &cmpr != b"DSD " {
|
||||
return Err(DsdError::Corrupt(format!(
|
||||
"unknown DFF compression {:?}",
|
||||
String::from_utf8_lossy(&cmpr)
|
||||
)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Subchunks are even-padded too.
|
||||
let padded = sub_size + (sub_size & 1);
|
||||
file.seek(SeekFrom::Start(sub_start + padded))?;
|
||||
}
|
||||
}
|
||||
b"DSD " => {
|
||||
data = Some((payload_start, size));
|
||||
}
|
||||
b"DST " => return Err(DsdError::UnsupportedDst),
|
||||
b"ID3 " => {
|
||||
id3_offset = Some(payload_start);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let padded = size + (size & 1);
|
||||
file.seek(SeekFrom::Start(payload_start + padded))?;
|
||||
}
|
||||
|
||||
let dsd_rate = dsd_rate.ok_or_else(|| DsdError::Corrupt("DFF missing FS".into()))?;
|
||||
let channels = channels.ok_or_else(|| DsdError::Corrupt("DFF missing CHNL".into()))?;
|
||||
let (data_offset, data_size) =
|
||||
data.ok_or_else(|| DsdError::Corrupt("DFF missing DSD data".into()))?;
|
||||
validate_rate(dsd_rate)?;
|
||||
if !(1..=6).contains(&channels) {
|
||||
return Err(DsdError::UnsupportedChannels(channels));
|
||||
}
|
||||
|
||||
let tags = match id3_offset {
|
||||
Some(off) => read_id3_tags(&mut file, off),
|
||||
None => DsdTags::default(),
|
||||
};
|
||||
|
||||
file.seek(SeekFrom::Start(data_offset))?;
|
||||
let sample_count = data_size / channels as u64 * 8;
|
||||
|
||||
Ok(Self {
|
||||
file,
|
||||
remaining_total: data_size,
|
||||
info: DsdStreamInfo {
|
||||
dsd_rate,
|
||||
channels,
|
||||
sample_count,
|
||||
lsb_first: false,
|
||||
tags,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl DsdDemuxer for DffReader {
|
||||
fn info(&self) -> &DsdStreamInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
fn read_planar(
|
||||
&mut self,
|
||||
out: &mut [Vec<u8>],
|
||||
max_bytes_per_ch: usize,
|
||||
) -> Result<usize, DsdError> {
|
||||
let ch = self.info.channels as usize;
|
||||
debug_assert_eq!(out.len(), ch);
|
||||
if self.remaining_total == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
// Whole frames only (one byte per channel).
|
||||
let want_total = (max_bytes_per_ch * ch).min(self.remaining_total as usize);
|
||||
let want_total = want_total - (want_total % ch);
|
||||
if want_total == 0 {
|
||||
self.remaining_total = 0;
|
||||
return Ok(0);
|
||||
}
|
||||
let mut buf = vec![0u8; want_total];
|
||||
let mut filled = 0usize;
|
||||
while filled < want_total {
|
||||
match self.file.read(&mut buf[filled..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => filled += n,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
let frames = filled / ch;
|
||||
for f in 0..frames {
|
||||
for c in 0..ch {
|
||||
out[c].push(buf[f * ch + c]);
|
||||
}
|
||||
}
|
||||
self.remaining_total = if filled < want_total {
|
||||
0
|
||||
} else {
|
||||
self.remaining_total - filled as u64
|
||||
};
|
||||
Ok(frames)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! DoP (DSD over PCM) framing per the DoP Open Standard v1.1 (dCS).
|
||||
//!
|
||||
//! Each 24-bit PCM sample carries 16 DSD bits (MSB-first, temporally
|
||||
//! first byte in the high bits) under a marker byte that alternates
|
||||
//! 0x05 / 0xFA on successive frames. Both channels of one frame carry the
|
||||
//! SAME marker. A DoP-aware DAC detects the alternation and switches to
|
||||
//! DSD mode; anything that alters even one sample breaks the sequence and
|
||||
//! the DAC falls back to interpreting the stream as PCM (loud noise) —
|
||||
//! which is why the packed words must travel a bit-exact integer path
|
||||
//! (no f32, no gain, no resampling).
|
||||
//!
|
||||
//! Output words are FINAL S32 samples: the 24-bit DoP word left-justified
|
||||
//! (`<< 8`), ready for `snd_pcm_writei` on an S32_LE stream.
|
||||
|
||||
use crate::demux::{DsdDemuxer, DsdError};
|
||||
use crate::dsd2pcm::bit_reverse;
|
||||
|
||||
/// PCM carrier rate for a DSD bit rate: 16 DSD bits per frame per channel.
|
||||
/// DSD64 → 176 400 Hz, DSD128 → 352 800 Hz.
|
||||
pub const fn dop_carrier_rate(dsd_rate: u32) -> u32 {
|
||||
dsd_rate / 16
|
||||
}
|
||||
|
||||
/// Stateful DoP frame packer (keeps marker phase across calls).
|
||||
pub struct DopPacker {
|
||||
marker_fa: bool,
|
||||
}
|
||||
|
||||
impl DopPacker {
|
||||
pub fn new() -> Self {
|
||||
Self { marker_fa: false }
|
||||
}
|
||||
|
||||
/// Pack planar MSB-first DSD bytes (2 bytes per channel per frame) into
|
||||
/// interleaved S32 DoP samples, appending to `out`. Consumes
|
||||
/// `min(len)/2` frames worth of every channel.
|
||||
pub fn pack(&mut self, planar: &[Vec<u8>], out: &mut Vec<i32>) {
|
||||
let ch = planar.len();
|
||||
let frames = planar.iter().map(|c| c.len()).min().unwrap_or(0) / 2;
|
||||
out.reserve(frames * ch);
|
||||
for f in 0..frames {
|
||||
let marker: i32 = if self.marker_fa { 0xFA } else { 0x05 };
|
||||
for c in planar.iter() {
|
||||
let b0 = c[f * 2] as i32;
|
||||
let b1 = c[f * 2 + 1] as i32;
|
||||
out.push(((marker << 16) | (b0 << 8) | b1) << 8);
|
||||
}
|
||||
self.marker_fa = !self.marker_fa;
|
||||
}
|
||||
}
|
||||
|
||||
/// DSD silence (0x69 payload) with valid alternating markers — REQUIRED
|
||||
/// for pause/stop/tail padding: PCM zeros would break the marker
|
||||
/// sequence and pop the DAC out of DSD mode.
|
||||
pub fn silence(&mut self, n_frames: usize, channels: u16, out: &mut Vec<i32>) {
|
||||
out.reserve(n_frames * channels as usize);
|
||||
for _ in 0..n_frames {
|
||||
let marker: i32 = if self.marker_fa { 0xFA } else { 0x05 };
|
||||
for _ in 0..channels {
|
||||
out.push(((marker << 16) | 0x6969) << 8);
|
||||
}
|
||||
self.marker_fa = !self.marker_fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DopPacker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole-file streaming DoP word source: demuxer → (bit reversal when the
|
||||
/// container is LSB-first) → packer. Yields interleaved S32 samples at the
|
||||
/// carrier rate. Stereo only — DoP receivers are 2-channel devices.
|
||||
pub struct DopStream {
|
||||
demux: Box<dyn DsdDemuxer>,
|
||||
packer: DopPacker,
|
||||
lsb_first: bool,
|
||||
dsd_rate: u32,
|
||||
total_frames: u64,
|
||||
buf: Vec<i32>,
|
||||
idx: usize,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
/// DSD bytes pulled from the demuxer per refill, per channel.
|
||||
const REFILL_BYTES_PER_CH: usize = 32 * 1024;
|
||||
|
||||
impl DopStream {
|
||||
pub fn new(demux: Box<dyn DsdDemuxer>) -> Result<Self, DsdError> {
|
||||
let info = demux.info().clone();
|
||||
if info.channels != 2 {
|
||||
return Err(DsdError::UnsupportedChannels(info.channels));
|
||||
}
|
||||
Ok(Self {
|
||||
demux,
|
||||
packer: DopPacker::new(),
|
||||
lsb_first: info.lsb_first,
|
||||
dsd_rate: info.dsd_rate,
|
||||
total_frames: info.sample_count / 16,
|
||||
buf: Vec::new(),
|
||||
idx: 0,
|
||||
done: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn carrier_rate(&self) -> u32 {
|
||||
dop_carrier_rate(self.dsd_rate)
|
||||
}
|
||||
pub fn dsd_rate(&self) -> u32 {
|
||||
self.dsd_rate
|
||||
}
|
||||
/// Total DoP frames (per channel) this stream will yield.
|
||||
pub fn total_frames(&self) -> u64 {
|
||||
self.total_frames
|
||||
}
|
||||
|
||||
fn refill(&mut self) -> bool {
|
||||
let mut planar: Vec<Vec<u8>> = vec![Vec::new(), Vec::new()];
|
||||
match self.demux.read_planar(&mut planar, REFILL_BYTES_PER_CH) {
|
||||
Ok(0) | Err(_) => {
|
||||
self.done = true;
|
||||
false
|
||||
}
|
||||
Ok(_) => {
|
||||
if self.lsb_first {
|
||||
for chan in planar.iter_mut() {
|
||||
for b in chan.iter_mut() {
|
||||
*b = bit_reverse(*b);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.buf.clear();
|
||||
self.idx = 0;
|
||||
self.packer.pack(&planar, &mut self.buf);
|
||||
!self.buf.is_empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for DopStream {
|
||||
type Item = i32;
|
||||
fn next(&mut self) -> Option<i32> {
|
||||
loop {
|
||||
if self.idx < self.buf.len() {
|
||||
let v = self.buf[self.idx];
|
||||
self.idx += 1;
|
||||
return Some(v);
|
||||
}
|
||||
if self.done || !self.refill() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn packer_layout_and_marker_alternation() {
|
||||
let mut p = DopPacker::new();
|
||||
let mut out = Vec::new();
|
||||
// One channel pair, two frames.
|
||||
let l = vec![0xAB, 0xCD, 0x12, 0x34];
|
||||
let r = vec![0x55, 0xAA, 0x9C, 0x63];
|
||||
p.pack(&[l, r], &mut out);
|
||||
assert_eq!(out.len(), 4);
|
||||
assert_eq!(out[0], ((0x05 << 16) | 0xABCD) << 8);
|
||||
assert_eq!(out[1], ((0x05 << 16) | 0x55AA) << 8);
|
||||
assert_eq!(out[2], ((0xFA << 16) | 0x1234) << 8);
|
||||
assert_eq!(out[3], ((0xFA << 16) | 0x9C63) << 8);
|
||||
// Phase continues across calls.
|
||||
let mut out2 = Vec::new();
|
||||
p.pack(&[vec![0, 0], vec![0, 0]], &mut out2);
|
||||
assert_eq!(out2[0], (0x05 << 16) << 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_is_0x69_payload_with_markers() {
|
||||
let mut p = DopPacker::new();
|
||||
let mut out = Vec::new();
|
||||
p.silence(2, 2, &mut out);
|
||||
assert_eq!(out[0], ((0x05 << 16) | 0x6969) << 8);
|
||||
assert_eq!(out[2], ((0xFA << 16) | 0x6969) << 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Faithful Rust port of Sebastian Gesemann's dsd2pcm (BSD-2-Clause).
|
||||
//!
|
||||
//! Original: "DSD2PCM conversion engine", (c) 2009 Sebastian Gesemann,
|
||||
//! distributed under the BSD 2-Clause license; the `HTAPS` coefficient table
|
||||
//! and the FIFO/table-fold algorithm below are ported verbatim from
|
||||
//! dsd2pcm.c. A symmetric 96-tap FIR low-pass runs at the DSD rate,
|
||||
//! evaluated bytewise through 6×256 precomputed partial-sum tables, emitting
|
||||
//! one float sample per input byte (8:1 decimation).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const HTAPS_LEN: usize = 48;
|
||||
const FIFO_SIZE: usize = 16;
|
||||
const FIFO_MASK: usize = FIFO_SIZE - 1;
|
||||
const CTABLES: usize = 6;
|
||||
|
||||
/// Half of the symmetric 96-tap filter (dsd2pcm.c `htaps`, verbatim).
|
||||
#[rustfmt::skip]
|
||||
const HTAPS: [f64; HTAPS_LEN] = [
|
||||
0.09950731974056658, 0.09562845727714668, 0.08819647126516944,
|
||||
0.07782552527068175, 0.06534876523171299, 0.05172629311427257,
|
||||
0.0379429484910187, 0.02490921351762261, 0.0133774746265897,
|
||||
0.003883043418804416, -0.003284703416210726, -0.008080250212687497,
|
||||
-0.01067241812471033, -0.01139427235000863, -0.0106813877974587,
|
||||
-0.009007905078766049, -0.006828859761015335, -0.004535184322001496,
|
||||
-0.002425035959059578, -0.0006922187080790708, 0.0005700762133516592,
|
||||
0.001353838005269448, 0.001713709169690937, 0.001742046839472948,
|
||||
0.001545601648013235, 0.001226696225277855, 0.0008704322683580222,
|
||||
0.0005381636200535649, 0.000266446345425276, 7.002968738383528e-05,
|
||||
-5.279407053811266e-05, -0.0001140625650874684, -0.0001304796361231895,
|
||||
-0.0001189970287491285, -9.396247155265073e-05, -6.577634378272832e-05,
|
||||
-4.07492895872535e-05, -2.17407957554587e-05, -9.163058931391722e-06,
|
||||
-2.017460145032201e-06, 1.249721855219005e-06, 2.166655190537392e-06,
|
||||
1.930520892991082e-06, 1.319400334374195e-06, 7.410039764949091e-07,
|
||||
3.423230509967409e-07, 1.244182214744588e-07, 3.130441005359396e-08,
|
||||
];
|
||||
|
||||
struct Tables {
|
||||
bitreverse: [u8; 256],
|
||||
ctables: [[f32; 256]; CTABLES],
|
||||
}
|
||||
|
||||
fn tables() -> &'static Tables {
|
||||
static TABLES: OnceLock<Tables> = OnceLock::new();
|
||||
TABLES.get_or_init(|| {
|
||||
// Bit-reverse table (dsd2pcm.c precalc, verbatim logic).
|
||||
let mut bitreverse = [0u8; 256];
|
||||
let mut e: u32 = 0;
|
||||
for t in 0..256usize {
|
||||
bitreverse[t] = e as u8;
|
||||
let mut m: u32 = 128;
|
||||
while m != 0 {
|
||||
e ^= m;
|
||||
if e & m != 0 {
|
||||
break;
|
||||
}
|
||||
m >>= 1;
|
||||
}
|
||||
}
|
||||
// Partial-sum tables: ctables[CTABLES-1-t][e] = Σ ±htaps[t*8+m].
|
||||
let mut ctables = [[0.0f32; 256]; CTABLES];
|
||||
for t in 0..CTABLES {
|
||||
let k = (HTAPS_LEN - t * 8).min(8);
|
||||
for e in 0..256usize {
|
||||
let mut acc = 0.0f64;
|
||||
for m in 0..k {
|
||||
let sign = (((e >> (7 - m)) & 1) as i32 * 2 - 1) as f64;
|
||||
acc += sign * HTAPS[t * 8 + m];
|
||||
}
|
||||
ctables[CTABLES - 1 - t][e] = acc as f32;
|
||||
}
|
||||
}
|
||||
Tables { bitreverse, ctables }
|
||||
})
|
||||
}
|
||||
|
||||
/// Reverse the bit order of a DSD byte (LSB-first ↔ MSB-first).
|
||||
pub(crate) fn bit_reverse(b: u8) -> u8 {
|
||||
tables().bitreverse[b as usize]
|
||||
}
|
||||
|
||||
/// Streaming DSD→PCM decimator, one instance per channel. Emits one f32
|
||||
/// sample (at dsd_rate/8) per input DSD byte.
|
||||
pub struct Dsd2Pcm {
|
||||
fifo: [u8; FIFO_SIZE],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Dsd2Pcm {
|
||||
pub fn new() -> Self {
|
||||
// 0x69 is DSD silence; priming the FIFO with it avoids a start click.
|
||||
Self { fifo: [0x69; FIFO_SIZE], pos: 0 }
|
||||
}
|
||||
|
||||
/// Translate DSD bytes to float samples, appending to `dst`.
|
||||
/// `lsb_first` = bit order within each source byte.
|
||||
pub fn translate(&mut self, src: &[u8], lsb_first: bool, dst: &mut Vec<f32>) {
|
||||
let tb = tables();
|
||||
let mut ffp = self.pos;
|
||||
dst.reserve(src.len());
|
||||
for &byte in src {
|
||||
let bite1 = if lsb_first { tb.bitreverse[byte as usize] } else { byte };
|
||||
self.fifo[ffp] = bite1;
|
||||
// Pre-reverse the byte that just crossed the filter midpoint so
|
||||
// the mirrored half reads straight from the table (dsd2pcm.c).
|
||||
let p = (ffp.wrapping_sub(CTABLES)) & FIFO_MASK;
|
||||
self.fifo[p] = tb.bitreverse[self.fifo[p] as usize];
|
||||
let mut acc = 0.0f64;
|
||||
for i in 0..CTABLES {
|
||||
let a = self.fifo[(ffp.wrapping_sub(i)) & FIFO_MASK] as usize;
|
||||
let b = self.fifo[(ffp.wrapping_sub(CTABLES * 2 - 1).wrapping_add(i)) & FIFO_MASK]
|
||||
as usize;
|
||||
acc += (tb.ctables[i][a] + tb.ctables[i][b]) as f64;
|
||||
}
|
||||
dst.push(acc as f32);
|
||||
ffp = (ffp + 1) & FIFO_MASK;
|
||||
}
|
||||
self.pos = ffp;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Dsd2Pcm {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn silence_in_near_zero_out() {
|
||||
let mut d = Dsd2Pcm::new();
|
||||
let mut out = Vec::new();
|
||||
// 0x69 = 01101001: the classic DSD silence pattern (DC-balanced).
|
||||
d.translate(&[0x69; 4096], false, &mut out);
|
||||
assert_eq!(out.len(), 4096);
|
||||
// Skip the filter warm-up, then expect (near) silence.
|
||||
for &s in &out[64..] {
|
||||
assert!(s.abs() < 1e-3, "sample {s} not near zero");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_ones_approaches_full_scale_positive() {
|
||||
let mut d = Dsd2Pcm::new();
|
||||
let mut out = Vec::new();
|
||||
d.translate(&[0xFF; 4096], false, &mut out);
|
||||
let tail = &out[512..];
|
||||
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
|
||||
// DC gain of the filter is ~1.0 for the all-ones (+1) stream.
|
||||
assert!(avg > 0.8, "avg {avg} too low for all-ones input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsb_msb_orders_differ_only_by_bit_reversal() {
|
||||
let mut a = Dsd2Pcm::new();
|
||||
let mut b = Dsd2Pcm::new();
|
||||
let (mut oa, mut ob) = (Vec::new(), Vec::new());
|
||||
let pattern: Vec<u8> = (0..=255u8).cycle().take(2048).collect();
|
||||
let reversed: Vec<u8> = pattern.iter().map(|&x| tables().bitreverse[x as usize]).collect();
|
||||
a.translate(&pattern, false, &mut oa);
|
||||
b.translate(&reversed, true, &mut ob);
|
||||
assert_eq!(oa, ob);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! DSD (Direct Stream Digital) support: DSF/DFF demuxing and streaming
|
||||
//! DSD-to-PCM conversion.
|
||||
//!
|
||||
//! Phase 1 scope (see qbz-nix-docs/dsd-support/): local .dsf/.dff files are
|
||||
//! demuxed here and converted on the fly to 176.4 kHz / 24-bit PCM, which the
|
||||
//! player delivers through its existing (bit-perfect-capable) PCM pipeline.
|
||||
//! DoP and native-DSD delivery are later phases and intentionally absent.
|
||||
//!
|
||||
//! Frontend-agnostic (ADR-006): no UI, no player, no Tauri types.
|
||||
|
||||
mod convert;
|
||||
mod demux;
|
||||
mod dop;
|
||||
mod dsd2pcm;
|
||||
mod native;
|
||||
mod wav;
|
||||
|
||||
pub use convert::{DsdPcmConverter, DEFAULT_GAIN_DB, OUTPUT_RATE};
|
||||
pub use demux::{open_dsd, DsdDemuxer, DsdError, DsdStreamInfo, DsdTags};
|
||||
pub use dop::{dop_carrier_rate, DopPacker, DopStream};
|
||||
pub use native::{native_u32_rate, NativeDsdStream, NATIVE_DSD_SILENCE_U32};
|
||||
pub use wav::{frames_to_pcm24, wav_header, wav_total_size};
|
||||
|
||||
/// DSD64 base rate (bits per second per channel). Multiples of this are the
|
||||
/// only valid DSD rates: DSD64/128/256/512.
|
||||
pub const DSD64_RATE: u32 = 2_822_400;
|
||||
|
||||
/// True when the path has a DSD container extension (.dsf / .dff).
|
||||
pub fn is_dsd_path(path: &std::path::Path) -> bool {
|
||||
matches!(
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.as_deref(),
|
||||
Some("dsf") | Some("dff")
|
||||
)
|
||||
}
|
||||
|
||||
/// "DSD64" / "DSD128" / … label for a DSD bit rate. Falls back to "DSD" for
|
||||
/// non-standard rates.
|
||||
pub fn dsd_label(dsd_rate: u32) -> String {
|
||||
if dsd_rate >= DSD64_RATE && dsd_rate % DSD64_RATE == 0 {
|
||||
format!("DSD{}", 64 * (dsd_rate / DSD64_RATE))
|
||||
} else {
|
||||
"DSD".to_string()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Native DSD packing for ALSA's DSD_U32 formats (DSD plan Phase 3).
|
||||
//!
|
||||
//! One 32-bit sample per channel per frame carries 4 consecutive DSD bytes
|
||||
//! (MSB-first bit order within each byte — same normalization the DoP path
|
||||
//! uses). ALSA's frame rate is therefore dsd_rate / 32 (DSD64 → 88 200).
|
||||
//!
|
||||
//! Endianness (values below are what an i32 must hold on a little-endian
|
||||
//! machine so the bytes land in the right memory order):
|
||||
//! - `DSD_U32_BE` (what the kernel's generic DSD quirk grants USB DACs):
|
||||
//! temporally-first byte first in memory → `i32::from_le_bytes([b0..b3])`.
|
||||
//! - `DSD_U32_LE`: first byte in the value's MSB, little-endian storage →
|
||||
//! `i32::from_be_bytes([b0..b3])`.
|
||||
//!
|
||||
//! Native DSD silence is the byte 0x69 in every lane — conveniently the
|
||||
//! same i32 (0x69696969) in both layouts.
|
||||
|
||||
use crate::demux::{DsdDemuxer, DsdError};
|
||||
use crate::dsd2pcm::bit_reverse;
|
||||
|
||||
/// ALSA frame rate for a DSD bit rate in U32 containers.
|
||||
pub const fn native_u32_rate(dsd_rate: u32) -> u32 {
|
||||
dsd_rate / 32
|
||||
}
|
||||
|
||||
/// Native DSD silence sample (0x69 in all four byte lanes).
|
||||
pub const NATIVE_DSD_SILENCE_U32: i32 = 0x6969_6969;
|
||||
|
||||
/// Whole-file streaming native-DSD word source: demuxer → (bit reversal for
|
||||
/// LSB-first containers) → 4-byte packing. Yields interleaved i32 samples at
|
||||
/// [`native_u32_rate`]. Stereo only, mirroring the DoP path.
|
||||
pub struct NativeDsdStream {
|
||||
demux: Box<dyn DsdDemuxer>,
|
||||
lsb_first: bool,
|
||||
little_endian: bool,
|
||||
dsd_rate: u32,
|
||||
total_frames: u64,
|
||||
buf: Vec<i32>,
|
||||
idx: usize,
|
||||
/// Carry-over bytes per channel when a demux read isn't a multiple of 4.
|
||||
carry: [Vec<u8>; 2],
|
||||
done: bool,
|
||||
}
|
||||
|
||||
const REFILL_BYTES_PER_CH: usize = 32 * 1024;
|
||||
|
||||
impl NativeDsdStream {
|
||||
/// `little_endian` selects DSD_U32_LE packing; false = DSD_U32_BE.
|
||||
pub fn new(demux: Box<dyn DsdDemuxer>, little_endian: bool) -> Result<Self, DsdError> {
|
||||
let info = demux.info().clone();
|
||||
if info.channels != 2 {
|
||||
return Err(DsdError::UnsupportedChannels(info.channels));
|
||||
}
|
||||
Ok(Self {
|
||||
demux,
|
||||
lsb_first: info.lsb_first,
|
||||
little_endian,
|
||||
dsd_rate: info.dsd_rate,
|
||||
total_frames: info.sample_count / 32,
|
||||
buf: Vec::new(),
|
||||
idx: 0,
|
||||
carry: [Vec::new(), Vec::new()],
|
||||
done: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn rate(&self) -> u32 {
|
||||
native_u32_rate(self.dsd_rate)
|
||||
}
|
||||
pub fn dsd_rate(&self) -> u32 {
|
||||
self.dsd_rate
|
||||
}
|
||||
pub fn total_frames(&self) -> u64 {
|
||||
self.total_frames
|
||||
}
|
||||
|
||||
fn pack_word(&self, b: [u8; 4]) -> i32 {
|
||||
if self.little_endian {
|
||||
i32::from_be_bytes(b)
|
||||
} else {
|
||||
i32::from_le_bytes(b)
|
||||
}
|
||||
}
|
||||
|
||||
fn refill(&mut self) -> bool {
|
||||
// Carry bytes are ALREADY bit-normalized (reversed on the refill that
|
||||
// produced them) — only the newly-read span gets reversed below.
|
||||
let mut planar: Vec<Vec<u8>> = vec![
|
||||
std::mem::take(&mut self.carry[0]),
|
||||
std::mem::take(&mut self.carry[1]),
|
||||
];
|
||||
let pre = [planar[0].len(), planar[1].len()];
|
||||
let got = match self.demux.read_planar(&mut planar, REFILL_BYTES_PER_CH) {
|
||||
Ok(n) => n,
|
||||
Err(_) => 0,
|
||||
};
|
||||
if self.lsb_first {
|
||||
for (i, chan) in planar.iter_mut().enumerate() {
|
||||
for b in chan[pre[i]..].iter_mut() {
|
||||
*b = bit_reverse(*b);
|
||||
}
|
||||
}
|
||||
}
|
||||
if got == 0 {
|
||||
// EOF: pad the final partial word (if any) with DSD silence
|
||||
// (0x69 — planar is MSB-first-normalized at this point).
|
||||
let leftover = planar[0].len().min(planar[1].len());
|
||||
if leftover == 0 {
|
||||
self.done = true;
|
||||
return false;
|
||||
}
|
||||
for chan in planar.iter_mut() {
|
||||
while chan.len() % 4 != 0 {
|
||||
chan.push(0x69);
|
||||
}
|
||||
}
|
||||
self.done = true;
|
||||
}
|
||||
let words = planar.iter().map(|c| c.len()).min().unwrap_or(0) / 4;
|
||||
self.buf.clear();
|
||||
self.idx = 0;
|
||||
self.buf.reserve(words * 2);
|
||||
for w in 0..words {
|
||||
for chan in planar.iter() {
|
||||
let b = [
|
||||
chan[w * 4],
|
||||
chan[w * 4 + 1],
|
||||
chan[w * 4 + 2],
|
||||
chan[w * 4 + 3],
|
||||
];
|
||||
self.buf.push(self.pack_word(b));
|
||||
}
|
||||
}
|
||||
// Keep whatever didn't fill a whole word for the next refill.
|
||||
if !self.done {
|
||||
for (i, chan) in planar.iter().enumerate() {
|
||||
self.carry[i] = chan[words * 4..].to_vec();
|
||||
}
|
||||
}
|
||||
!self.buf.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for NativeDsdStream {
|
||||
type Item = i32;
|
||||
fn next(&mut self) -> Option<i32> {
|
||||
loop {
|
||||
if self.idx < self.buf.len() {
|
||||
let v = self.buf[self.idx];
|
||||
self.idx += 1;
|
||||
return Some(v);
|
||||
}
|
||||
if self.done || !self.refill() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn word_packing_endianness() {
|
||||
// Direct math checks (pack_word needs an instance; test the formulas).
|
||||
let b = [0xAAu8, 0xBB, 0xCC, 0xDD];
|
||||
assert_eq!(i32::from_le_bytes(b), 0xDDCCBBAAu32 as i32); // BE layout
|
||||
assert_eq!(i32::from_be_bytes(b), 0xAABBCCDDu32 as i32); // LE layout
|
||||
assert_eq!(NATIVE_DSD_SILENCE_U32, i32::from_le_bytes([0x69; 4]));
|
||||
assert_eq!(NATIVE_DSD_SILENCE_U32, i32::from_be_bytes([0x69; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_rate_math() {
|
||||
assert_eq!(native_u32_rate(2_822_400), 88_200);
|
||||
assert_eq!(native_u32_rate(11_289_600), 352_800);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Minimal 24-bit PCM WAV encoding for the converted-DSD stream.
|
||||
//!
|
||||
//! The converter knows its exact final frame count up front (DSF/DFF headers
|
||||
//! carry the sample count), so the WAV header sizes are exact — the player's
|
||||
//! streaming decoder sees an ordinary finite WAV file arriving over a
|
||||
//! buffered source.
|
||||
|
||||
const HEADER_LEN: u64 = 44;
|
||||
const BYTES_PER_SAMPLE: u64 = 3; // 24-bit
|
||||
|
||||
pub fn wav_total_size(total_frames: u64, channels: u16) -> u64 {
|
||||
HEADER_LEN + total_frames * channels as u64 * BYTES_PER_SAMPLE
|
||||
}
|
||||
|
||||
/// Standard 44-byte RIFF/WAVE header for 24-bit integer PCM.
|
||||
pub fn wav_header(total_frames: u64, channels: u16, sample_rate: u32) -> Vec<u8> {
|
||||
let data_len = (total_frames * channels as u64 * BYTES_PER_SAMPLE) as u32;
|
||||
let byte_rate = sample_rate * channels as u32 * BYTES_PER_SAMPLE as u32;
|
||||
let block_align = channels * BYTES_PER_SAMPLE as u16;
|
||||
let mut h = Vec::with_capacity(HEADER_LEN as usize);
|
||||
h.extend_from_slice(b"RIFF");
|
||||
h.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
h.extend_from_slice(b"WAVE");
|
||||
h.extend_from_slice(b"fmt ");
|
||||
h.extend_from_slice(&16u32.to_le_bytes());
|
||||
h.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||
h.extend_from_slice(&channels.to_le_bytes());
|
||||
h.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
h.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
h.extend_from_slice(&block_align.to_le_bytes());
|
||||
h.extend_from_slice(&24u16.to_le_bytes()); // bits per sample
|
||||
h.extend_from_slice(b"data");
|
||||
h.extend_from_slice(&data_len.to_le_bytes());
|
||||
debug_assert_eq!(h.len() as u64, HEADER_LEN);
|
||||
h
|
||||
}
|
||||
|
||||
/// Encode interleaved f32 frames to little-endian 24-bit PCM, appending to
|
||||
/// `out`. Values are hard-clamped; no dither (inaudible at 24-bit).
|
||||
pub fn frames_to_pcm24(frames: &[f32], out: &mut Vec<u8>) {
|
||||
out.reserve(frames.len() * 3);
|
||||
for &s in frames {
|
||||
let v = (s.clamp(-1.0, 1.0) * 8_388_607.0).round() as i32;
|
||||
let b = v.to_le_bytes();
|
||||
out.extend_from_slice(&b[0..3]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn header_and_size_math_agree() {
|
||||
let frames = 176_400u64 * 3; // 3 seconds
|
||||
let h = wav_header(frames, 2, 176_400);
|
||||
assert_eq!(h.len(), 44);
|
||||
assert_eq!(wav_total_size(frames, 2), 44 + frames * 2 * 3);
|
||||
// RIFF size field = total - 8.
|
||||
let riff = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as u64;
|
||||
assert_eq!(riff, wav_total_size(frames, 2) - 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcm24_encoding_clamps_and_scales() {
|
||||
let mut out = Vec::new();
|
||||
frames_to_pcm24(&[0.0, 1.0, -1.0, 2.0], &mut out);
|
||||
assert_eq!(out.len(), 12);
|
||||
assert_eq!(&out[0..3], &[0, 0, 0]);
|
||||
let max = i32::from_le_bytes([out[3], out[4], out[5], 0]);
|
||||
assert_eq!(max, 8_388_607);
|
||||
// -1.0 → -8388607 in 24-bit two's complement (low 3 bytes).
|
||||
let neg = i32::from_le_bytes([out[6], out[7], out[8], 0xFF]);
|
||||
assert_eq!(neg, -8_388_607);
|
||||
assert_eq!(&out[9..12], &out[3..6]); // 2.0 clamps to full scale
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Integration tests over synthesized DSF/DFF files.
|
||||
|
||||
use qbz_dsd::{open_dsd, DsdError, DsdPcmConverter};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(name)
|
||||
}
|
||||
|
||||
/// Minimal valid DSF: `groups` block-groups of 0x69 silence.
|
||||
fn write_dsf(name: &str, channels: u32, rate: u32, groups: usize, metadata: Option<&[u8]>) -> PathBuf {
|
||||
let block_size = 4096u32;
|
||||
let bytes_per_ch = groups * block_size as usize;
|
||||
let sample_count = (bytes_per_ch as u64) * 8;
|
||||
let data_len = (bytes_per_ch * channels as usize) as u64;
|
||||
// DSD chunk (28) + fmt chunk (52, header included) + data header (12).
|
||||
let file_len_without_meta = 28 + 52 + 12 + data_len;
|
||||
let metadata_ptr = if metadata.is_some() { file_len_without_meta } else { 0 };
|
||||
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(b"DSD ");
|
||||
f.extend_from_slice(&28u64.to_le_bytes());
|
||||
f.extend_from_slice(&(file_len_without_meta + metadata.map_or(0, |m| m.len() as u64)).to_le_bytes());
|
||||
f.extend_from_slice(&metadata_ptr.to_le_bytes());
|
||||
f.extend_from_slice(b"fmt ");
|
||||
f.extend_from_slice(&52u64.to_le_bytes());
|
||||
f.extend_from_slice(&1u32.to_le_bytes()); // version
|
||||
f.extend_from_slice(&0u32.to_le_bytes()); // format id = DSD raw
|
||||
f.extend_from_slice(&2u32.to_le_bytes()); // channel type (stereo)
|
||||
f.extend_from_slice(&channels.to_le_bytes());
|
||||
f.extend_from_slice(&rate.to_le_bytes());
|
||||
f.extend_from_slice(&1u32.to_le_bytes()); // bits per sample: 1 = LSB first
|
||||
f.extend_from_slice(&sample_count.to_le_bytes());
|
||||
f.extend_from_slice(&block_size.to_le_bytes());
|
||||
f.extend_from_slice(&0u32.to_le_bytes()); // reserved
|
||||
f.extend_from_slice(b"data");
|
||||
f.extend_from_slice(&(12 + data_len).to_le_bytes());
|
||||
for _ in 0..groups {
|
||||
for _ in 0..channels {
|
||||
f.extend_from_slice(&vec![0x69u8; block_size as usize]);
|
||||
}
|
||||
}
|
||||
if let Some(m) = metadata {
|
||||
f.extend_from_slice(m);
|
||||
}
|
||||
let path = tmp(name);
|
||||
std::fs::File::create(&path).unwrap().write_all(&f).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// Minimal DFF: FRM8 { FVER, PROP(SND){ FS, CHNL, CMPR }, DSD data }.
|
||||
fn write_dff(name: &str, channels: u16, rate: u32, data_bytes_total: usize, cmpr: &[u8; 4]) -> PathBuf {
|
||||
let mut prop = Vec::new();
|
||||
prop.extend_from_slice(b"SND ");
|
||||
prop.extend_from_slice(b"FS ");
|
||||
prop.extend_from_slice(&4u64.to_be_bytes());
|
||||
prop.extend_from_slice(&rate.to_be_bytes());
|
||||
prop.extend_from_slice(b"CHNL");
|
||||
let chnl_len = 2 + 4 * channels as u64;
|
||||
prop.extend_from_slice(&chnl_len.to_be_bytes());
|
||||
prop.extend_from_slice(&channels.to_be_bytes());
|
||||
for _ in 0..channels {
|
||||
prop.extend_from_slice(b"SLFT");
|
||||
}
|
||||
if chnl_len % 2 == 1 {
|
||||
prop.push(0);
|
||||
}
|
||||
prop.extend_from_slice(b"CMPR");
|
||||
// Compression subchunk: 4-byte ID + pascal-ish name (we write just the ID
|
||||
// + a 1-byte count 0, padded).
|
||||
prop.extend_from_slice(&5u64.to_be_bytes());
|
||||
prop.extend_from_slice(cmpr);
|
||||
prop.push(0);
|
||||
prop.push(0); // even padding
|
||||
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(b"FRM8");
|
||||
f.extend_from_slice(&0u64.to_be_bytes()); // form size (unused by reader)
|
||||
f.extend_from_slice(b"DSD ");
|
||||
f.extend_from_slice(b"FVER");
|
||||
f.extend_from_slice(&4u64.to_be_bytes());
|
||||
f.extend_from_slice(&[1, 5, 0, 0]);
|
||||
f.extend_from_slice(b"PROP");
|
||||
f.extend_from_slice(&(prop.len() as u64).to_be_bytes());
|
||||
f.extend_from_slice(&prop);
|
||||
f.extend_from_slice(b"DSD ");
|
||||
f.extend_from_slice(&(data_bytes_total as u64).to_be_bytes());
|
||||
f.extend_from_slice(&vec![0x69u8; data_bytes_total]);
|
||||
let path = tmp(name);
|
||||
std::fs::File::create(&path).unwrap().write_all(&f).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsf_parses_and_converts_to_exact_frame_count() {
|
||||
let path = write_dsf("silence64.dsf", 2, 2_822_400, 2, None);
|
||||
let demux = open_dsd(&path).unwrap();
|
||||
let info = demux.info().clone();
|
||||
assert_eq!(info.dsd_rate, 2_822_400);
|
||||
assert_eq!(info.channels, 2);
|
||||
assert_eq!(info.sample_count, 2 * 4096 * 8);
|
||||
assert!(info.lsb_first);
|
||||
|
||||
let mut conv = DsdPcmConverter::new(demux, -6.0).unwrap();
|
||||
let expected_frames = info.sample_count / 32; // DSD64 → 88.2k
|
||||
assert_eq!(conv.total_frames(), expected_frames);
|
||||
let mut frames = 0u64;
|
||||
let mut peak = 0f32;
|
||||
while let Some(block) = conv.next_block().unwrap() {
|
||||
frames += (block.len() / 2) as u64;
|
||||
for s in block {
|
||||
peak = peak.max(s.abs());
|
||||
}
|
||||
}
|
||||
assert_eq!(frames, expected_frames);
|
||||
assert!(peak < 0.01, "silence converted loud: peak {peak}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsf_reads_embedded_id3() {
|
||||
let mut tag = id3::Tag::new();
|
||||
use id3::TagLike;
|
||||
tag.set_title("Karma Police");
|
||||
tag.set_artist("Radiohead");
|
||||
tag.set_album("OK Computer");
|
||||
tag.set_track(6);
|
||||
let mut blob = Vec::new();
|
||||
tag.write_to(&mut blob, id3::Version::Id3v24).unwrap();
|
||||
|
||||
let path = write_dsf("tagged.dsf", 2, 2_822_400, 1, Some(&blob));
|
||||
let demux = open_dsd(&path).unwrap();
|
||||
let tags = &demux.info().tags;
|
||||
assert_eq!(tags.title.as_deref(), Some("Karma Police"));
|
||||
assert_eq!(tags.artist.as_deref(), Some("Radiohead"));
|
||||
assert_eq!(tags.album.as_deref(), Some("OK Computer"));
|
||||
assert_eq!(tags.track_number, Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsf_dsd128_uses_three_stages() {
|
||||
let path = write_dsf("silence128.dsf", 2, 5_644_800, 2, None);
|
||||
let demux = open_dsd(&path).unwrap();
|
||||
let mut conv = DsdPcmConverter::new(demux, -6.0).unwrap();
|
||||
assert_eq!(conv.total_frames(), (2 * 4096 * 8) / 64);
|
||||
let mut frames = 0u64;
|
||||
while let Some(block) = conv.next_block().unwrap() {
|
||||
frames += (block.len() / 2) as u64;
|
||||
}
|
||||
assert_eq!(frames, (2 * 4096 * 8) / 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsf_eight_channels_rejected() {
|
||||
let path = write_dsf("multi8.dsf", 8, 2_822_400, 1, None);
|
||||
match open_dsd(&path) {
|
||||
Err(DsdError::UnsupportedChannels(8)) => {}
|
||||
Err(other) => panic!("expected UnsupportedChannels, got {other:?}"),
|
||||
Ok(_) => panic!("expected UnsupportedChannels, got Ok"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsf_5_1_downmixes_to_stereo() {
|
||||
let path = write_dsf("multi51.dsf", 6, 2_822_400, 1, None);
|
||||
let demux = open_dsd(&path).unwrap();
|
||||
assert_eq!(demux.info().channels, 6);
|
||||
let mut conv = DsdPcmConverter::new(demux, -6.0).unwrap();
|
||||
assert_eq!(conv.channels(), 2);
|
||||
let expected_frames = demux_total_frames(4096 * 8);
|
||||
assert_eq!(conv.total_frames(), expected_frames);
|
||||
let mut frames = 0u64;
|
||||
let mut peak = 0f32;
|
||||
while let Some(block) = conv.next_block().unwrap() {
|
||||
frames += (block.len() / 2) as u64;
|
||||
for s in block {
|
||||
peak = peak.max(s.abs());
|
||||
}
|
||||
}
|
||||
assert_eq!(frames, expected_frames);
|
||||
assert!(peak < 0.01, "5.1 silence downmix not silent: peak {peak}");
|
||||
}
|
||||
|
||||
fn demux_total_frames(sample_count: u64) -> u64 {
|
||||
sample_count / 32 // DSD64 → 88.2 kHz
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dop_stream_frames_and_markers() {
|
||||
use qbz_dsd::DopStream;
|
||||
let path = write_dsf("dop64.dsf", 2, 2_822_400, 1, None);
|
||||
let demux = open_dsd(&path).unwrap();
|
||||
let mut dop = DopStream::new(demux).unwrap();
|
||||
assert_eq!(dop.carrier_rate(), 176_400);
|
||||
assert_eq!(dop.total_frames(), 4096 * 8 / 16);
|
||||
let words: Vec<i32> = dop.by_ref().collect();
|
||||
assert_eq!(words.len() as u64, dop.total_frames() * 2);
|
||||
// DSF silence is 0x69 LSB-first → bit-reversed payload is 0x96, and the
|
||||
// markers alternate per frame across both channels.
|
||||
assert_eq!(words[0], ((0x05 << 16) | 0x9696) << 8);
|
||||
assert_eq!(words[1], ((0x05 << 16) | 0x9696) << 8);
|
||||
assert_eq!(words[2], ((0xFA << 16) | 0x9696) << 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dff_parses_stereo() {
|
||||
let path = write_dff("plain.dff", 2, 2_822_400, 8192, b"DSD ");
|
||||
let demux = open_dsd(&path).unwrap();
|
||||
let info = demux.info();
|
||||
assert_eq!(info.dsd_rate, 2_822_400);
|
||||
assert_eq!(info.channels, 2);
|
||||
assert_eq!(info.sample_count, 8192 / 2 * 8);
|
||||
assert!(!info.lsb_first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dff_dst_rejected() {
|
||||
let path = write_dff("dst.dff", 2, 2_822_400, 128, b"DST ");
|
||||
match open_dsd(&path) {
|
||||
Err(DsdError::UnsupportedDst) => {}
|
||||
Err(other) => panic!("expected UnsupportedDst, got {other:?}"),
|
||||
Ok(_) => panic!("expected UnsupportedDst, got Ok"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_rejected() {
|
||||
let path = tmp("garbage.bin");
|
||||
std::fs::write(&path, b"definitely not dsd").unwrap();
|
||||
assert!(matches!(open_dsd(&path), Err(DsdError::Corrupt(_))));
|
||||
}
|
||||
Reference in new issue
Block a user