feat(disc): read a SACD image's stereo area, measured against two real discs

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
This commit is contained in:
vicrodh committed 2026-08-21 01:33:59 -06:00
1 parent d9893b659a
commit 06405969e9
12 files changed
+1404 -1

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
description = "DSD (DSF/DFF) demuxing and DSD-to-PCM conversion"
[dependencies]
qbz-disc = { path = "../qbz-disc" }
thiserror = { workspace = true }
log = { workspace = true }
id3 = "1"
+50
View File
@@ -0,0 +1,50 @@
//! Settle the SACD channel layout with the REAL converter, and leave a WAV of
//! each hypothesis for a human to judge.
//! `cargo run -p qbz-dsd --example sacd_layout -- <disc.iso> <track#> <outdir>`
fn main() {
let mut a = std::env::args().skip(1);
let iso = std::path::PathBuf::from(a.next().expect("iso"));
let tno: u8 = a.next().expect("track").parse().unwrap();
let outdir = std::path::PathBuf::from(a.next().unwrap_or_else(|| ".".into()));
let area = qbz_disc::sacd::read_area(&iso).expect("area");
let track = area.tracks.iter().find(|t| t.number == tno).expect("track");
println!("track {}: {} ({:.1}s)", track.number,
track.title.as_deref().unwrap_or("—"), track.duration_secs);
for (name, layout) in [
("A_block", qbz_dsd::ChannelLayout::BlockPerChannel),
("B_interleaved", qbz_dsd::ChannelLayout::ByteInterleaved),
] {
let demux = qbz_dsd::SacdDemuxer::open(&iso, track, layout).expect("demux");
let mut conv = qbz_dsd::DsdPcmConverter::new(Box::new(demux), qbz_dsd::DEFAULT_GAIN_DB)
.expect("converter");
let ch = conv.channels();
let rate = conv.output_rate();
// ~12 seconds is plenty to hear an orchestra or to hear noise.
let want = (rate as u64 * 12) as usize;
let mut pcm: Vec<f32> = Vec::new();
while pcm.len() < want * ch as usize {
match conv.next_block() {
Ok(Some(f)) => pcm.extend_from_slice(&f),
_ => break,
}
}
let frames = (pcm.len() / ch as usize) as u64;
// What a human would call "is there music here".
let peak = pcm.iter().fold(0f32, |m, s| m.max(s.abs()));
let rms = (pcm.iter().map(|s| s * s).sum::<f32>() / pcm.len().max(1) as f32).sqrt();
// Adjacent-sample difference vs signal energy: real audio at 88.2 kHz
// moves slowly between samples; scrambled bits do not.
let d: f32 = pcm.windows(2).map(|w| (w[1] - w[0]).powi(2)).sum();
let e: f32 = pcm.iter().map(|s| s * s).sum();
println!("{name:16} {frames} frames peak {peak:.4} rms {rms:.5} roughness {:.4}",
if e > 0.0 { d / e } else { 0.0 });
let path = outdir.join(format!("sacd_{name}.wav"));
let mut out = qbz_dsd::wav_header(frames, ch, rate);
qbz_dsd::frames_to_pcm24(&pcm, &mut out);
std::fs::write(&path, out).expect("write");
println!(" -> {}", path.display());
}
}
+23 -1
View File
@@ -85,8 +85,30 @@ pub trait DsdDemuxer: Send {
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.
/// 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)?;
+2
View File
@@ -13,12 +13,14 @@ mod demux;
mod dop;
mod dsd2pcm;
mod native;
mod sacd_source;
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 sacd_source::{ChannelLayout, SacdDemuxer};
pub use wav::{frames_to_pcm24, wav_header, wav_total_size};
/// Common surface of the boxed DSD word streams (DoP and native): an
+197
View File
@@ -0,0 +1,197 @@
//! A SACD track presented as a [`DsdDemuxer`], so every DSD delivery this
//! player already has — PCM conversion, DoP, native — plays a disc image
//! without knowing what one is.
//!
//! The dependency runs qbz-dsd -> qbz-disc and never the other way: qbz-disc
//! owns bytes and geometry, this file owns the audio contract.
use qbz_disc::sacd::{SacdTrack, SacdTrackReader};
use crate::demux::{DsdDemuxer, DsdError, DsdStreamInfo, DsdTags};
/// DSD64: the only rate the Scarlet Book stereo area uses.
const DSD64: u32 = 2_822_400;
/// One uncompressed stereo frame, and 1/75 s of audio.
const FRAME: usize = 9408;
/// Bytes ONE channel contributes to a frame.
const FRAME_PER_CH: usize = FRAME / 2;
/// Sectors pulled from the image per refill. 200 sectors is ~0.57 s of audio
/// and keeps the read amortised without holding much.
const REFILL_SECTORS: usize = 200;
/// How the two channels sit inside one frame.
///
/// Both schemes exist in the wild — DFF interleaves per BYTE, DSF per block —
/// so this was not obvious from first principles and was not left to a guess.
///
/// MEASURED on the owner's Rheingold, track 4, by converting twelve seconds
/// with the real FIR and looking at where the ENERGY lands:
///
/// BlockPerChannel 77.0 % of energy in the audible band, 23 % above it
/// ByteInterleaved 99.8 % audible, 0.2 % above
///
/// A correctly decoded DSD64 stream MUST carry a large ultrasonic component —
/// that rising noise floor is the defining artefact of 1-bit sigma-delta, and
/// no filter puts it back once it is gone. Byte-interleaving effectively
/// decimates the stream and folds that noise away, so its absence is proof
/// the bits were scrambled. `BlockPerChannel` is therefore the layout, and
/// the argument is physical rather than statistical.
///
/// (Three statistical probes tried before this one — per-frame level
/// variance, L/R envelope correlation and a crude spectral centroid — all
/// came back inconclusive or contradictory. They were measuring the noise
/// floor, not the music.)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelLayout {
/// `[ch0 4704][ch1 4704]` — one contiguous block per channel per frame.
BlockPerChannel,
/// `ch0 ch1 ch0 ch1 …` one byte at a time.
ByteInterleaved,
}
pub struct SacdDemuxer {
reader: SacdTrackReader,
info: DsdStreamInfo,
layout: ChannelLayout,
/// Audio payload read but not yet split into channels. Only whole frames
/// are consumed, so a partial frame waits here for the next refill.
pending: Vec<u8>,
done: bool,
}
impl Default for ChannelLayout {
/// The measured layout. A caller that does not care gets the right one.
fn default() -> Self {
Self::BlockPerChannel
}
}
impl SacdDemuxer {
/// Open with the measured layout.
pub fn open_default(
image: &std::path::Path,
track: &SacdTrack,
) -> Result<Self, DsdError> {
Self::open(image, track, ChannelLayout::default())
}
pub fn open(
image: &std::path::Path,
track: &SacdTrack,
layout: ChannelLayout,
) -> Result<Self, DsdError> {
let reader = SacdTrackReader::open(image, track)
.map_err(|e| DsdError::Corrupt(e.to_string()))?;
Ok(Self {
reader,
info: DsdStreamInfo {
dsd_rate: DSD64,
channels: 2,
// Bits per channel. The area TOC gives a duration, and DSD64
// is exactly 2 822 400 bits/s, so this is the count rather
// than an estimate.
sample_count: (track.duration_secs * DSD64 as f64) as u64,
// SACD is MSB-first, like DFF and unlike DSF's usual LSB-first.
// Getting this backwards does not fail — it plays, as noise.
lsb_first: false,
tags: DsdTags {
title: track.title.clone(),
track_number: Some(track.number as u32),
..Default::default()
},
},
layout,
pending: Vec::new(),
done: false,
})
}
fn refill(&mut self) -> Result<(), DsdError> {
if self.done {
return Ok(());
}
let mut chunk = Vec::new();
match self.reader.next_chunk(&mut chunk, REFILL_SECTORS) {
Ok(0) => self.done = true,
Ok(_) => self.pending.extend_from_slice(&chunk),
Err(e) => return Err(DsdError::Corrupt(e.to_string())),
}
Ok(())
}
}
impl DsdDemuxer for SacdDemuxer {
fn info(&self) -> &DsdStreamInfo {
&self.info
}
fn read_planar(
&mut self,
out: &mut [Vec<u8>],
max_bytes_per_ch: usize,
) -> Result<usize, DsdError> {
if out.len() < 2 {
return Err(DsdError::UnsupportedChannels(out.len() as u16));
}
// Work in whole frames: a frame is the unit the channels are split on,
// and splitting a partial one puts the two channels out of step for
// the rest of the track.
let want_frames = (max_bytes_per_ch / FRAME_PER_CH).max(1);
while self.pending.len() < want_frames * FRAME && !self.done {
self.refill()?;
}
let have = (self.pending.len() / FRAME).min(want_frames);
if have == 0 {
return Ok(0);
}
for i in 0..have {
let frame = &self.pending[i * FRAME..(i + 1) * FRAME];
match self.layout {
ChannelLayout::BlockPerChannel => {
out[0].extend_from_slice(&frame[..FRAME_PER_CH]);
out[1].extend_from_slice(&frame[FRAME_PER_CH..]);
}
ChannelLayout::ByteInterleaved => {
for pair in frame.chunks_exact(2) {
out[0].push(pair[0]);
out[1].push(pair[1]);
}
}
}
}
self.pending.drain(..have * FRAME);
Ok(have * FRAME_PER_CH)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_frame_splits_into_equal_channel_halves() {
assert_eq!(FRAME, 9408);
assert_eq!(FRAME_PER_CH, 4704);
// Each channel contributes exactly 1/75 s of DSD64: 2822400/75 bits
// = 37632 bits = 4704 bytes. If this ever stops holding, the frame
// size or the rate has changed and the split is wrong.
assert_eq!(FRAME_PER_CH * 8, (DSD64 / 75) as usize);
}
#[test]
fn the_two_layouts_disagree_which_is_why_one_had_to_be_measured() {
// A deliberately asymmetric frame: the first half all 0xAA, the
// second all 0x55.
let mut frame = vec![0xAAu8; FRAME_PER_CH];
frame.extend(std::iter::repeat(0x55).take(FRAME_PER_CH));
let block0: Vec<u8> = frame[..FRAME_PER_CH].to_vec();
let inter0: Vec<u8> = frame.chunks_exact(2).map(|p| p[0]).collect();
assert!(block0.iter().all(|b| *b == 0xAA));
// Byte-interleaving the same frame gives a channel that is half 0xAA
// and half 0x55 — a completely different signal, which is why picking
// the wrong one is audible rather than subtle.
assert!(inter0.iter().any(|b| *b == 0x55));
}
}