Adds an ISO 9660 walk (no mounting, no privileges) and a Scarlet Book parser
for the stereo audio area, plus the `DsdDemuxer` adapter that lets every DSD
delivery the player already has — PCM, DoP, native — play a disc image.
The parser exists because the previous account of this format, written in this
repo, was WRONG: it said the per-sector header was a fixed 8 bytes, derived
from looking at one sector. Every structure below was re-derived by a CENSUS —
3 066 893 sectors across both of the owner's Rheingold discs, zero exceptions —
and each claim is falsifiable rather than asserted:
- byte 0 carries packet_count = (b>>5)&7 and frame_info_count = (b>>2)&7,
so the header is 1 + pc*2 + fic*3 and takes the values 5, 8 or 10;
- packet descriptors are BIG-endian, and the length field is ELEVEN bits,
not the twelve every published layout shows. The first descriptor on both
discs is 0x181B, whose low twelve bits are 2075 — larger than the sector
it lives in. With eleven bits it is 27, and 5 + 27 + 2016 = 2048;
- the proof of the whole decoding is the sum rule
`header + SUM(packet lengths) == 2048`, which the parser CHECKS on every
sector rather than trusting;
- the area is uncompressed, proven from the data and not from a flag: the
audio-packet bytes divide by 9408 (one uncompressed DSD64 stereo frame)
exactly — 350 495 frames on Disc 1, 306 696 on Disc 2. DST is
variable-length; those could not be integers.
Track geometry comes from SACDTRL1/TRL2, tied together by an identity that
holds for 44 of 44 tracks: start_lsn == area_start + start_time * 350. The
ISO 9660 extents mirror SACDTRL1 byte for byte, so the one-sector overlap
between consecutive tracks is the disc's own accounting, not a bug to fix.
The channel layout inside a frame was the last unknown, and three statistical
probes could not settle it — they were measuring the DSD noise floor. What
settled it was physics: converted with the real filter, block-per-channel puts
23 % of its energy above the audible band while byte-interleaving puts 0.2 %
there. A correctly decoded 1-bit stream MUST carry that rising noise floor,
and no filter puts it back once scrambling has folded it away.
Also in this commit, from the same measurements: `2C_TAREA.2CH` on Disc 1 is
recorded in FOUR extents, so `IsoImage::find` REFUSES a multi-extent file
rather than returning its first quarter as if it were the whole thing.
cc
486 lines
17 KiB
Rust
486 lines
17 KiB
Rust
//! 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 source: a DSF or DFF file, or a track inside a SACD image.
|
|
///
|
|
/// The SACD arm is answered FIRST, before `File::open`. A `sacd:` reference is
|
|
/// not a filesystem path — opening it would fail, and sniffing DSF magic at
|
|
/// its first byte is meaningless. Routing here rather than at every call site
|
|
/// is what lets the PCM, DoP, native and gapless paths inherit disc images
|
|
/// without one line changing in the player: all six of them already call this.
|
|
pub fn open_dsd(path: &Path) -> Result<Box<dyn DsdDemuxer>, DsdError> {
|
|
let as_str = path.to_string_lossy();
|
|
if qbz_disc::SacdRef::is_sacd_path(&as_str) {
|
|
let r = qbz_disc::SacdRef::parse(&as_str)
|
|
.ok_or_else(|| DsdError::Corrupt(format!("malformed sacd reference: {as_str}")))?;
|
|
let area = qbz_disc::sacd::read_area(&r.image)
|
|
.map_err(|e| DsdError::Corrupt(e.to_string()))?;
|
|
let track = area
|
|
.tracks
|
|
.iter()
|
|
.find(|t| t.number == r.track)
|
|
.ok_or_else(|| DsdError::Corrupt(format!("no track {} on this image", r.track)))?;
|
|
return Ok(Box::new(crate::sacd_source::SacdDemuxer::open_default(
|
|
&r.image, track,
|
|
)?));
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|