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:
12 files changed
+1404
-1
No files matched your search
@@ -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"
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user