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

+2
View File
@@ -6,6 +6,8 @@ edition.workspace = true
[dependencies]
log = { workspace = true }
thiserror = { workspace = true }
sha1 = "0.10"
base64 = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
+32
View File
@@ -0,0 +1,32 @@
//! Walk a real ISO without mounting it.
//! `cargo run -p qbz-disc --example iso_probe -- <path.iso>`
fn main() {
let path = std::env::args().nth(1).expect("usage: iso_probe <file.iso>");
let p = std::path::PathBuf::from(&path);
let mut iso = match qbz_disc::iso9660::IsoImage::open(&p) {
Ok(i) => i,
Err(e) => { println!("open failed: {e}"); return; }
};
println!("root: {:?}", iso.root());
let root = iso.root().clone();
for e in iso.read_dir(&root).unwrap_or_default() {
println!(" /{:<16} lsn {:>8} {:>12} bytes {}", e.name, e.lsn, e.size,
if e.is_dir { "DIR" } else { "" });
}
if let Ok(Some(audio)) = iso.find("/2C_AUDIO") {
println!("/2C_AUDIO:");
let kids = iso.read_dir(&audio).unwrap_or_default();
for e in kids.iter().take(6) {
println!(" {:<16} lsn {:>8} {:>12} bytes", e.name, e.lsn, e.size);
}
println!(" … {} entries total", kids.len());
}
// The structure the SACD reader will actually ask for.
for want in ["/MASTER1.TOC", "/2C_AUDIO/2C_AREA1.TOC", "/2C_AUDIO/TRACK001.2CH"] {
match iso.find(want) {
Ok(Some(e)) => println!("find {want} -> lsn {} size {}", e.lsn, e.size),
Ok(None) => println!("find {want} -> NOT FOUND"),
Err(e) => println!("find {want} -> error {e}"),
}
}
}
+47
View File
@@ -0,0 +1,47 @@
//! Read a SACD image's stereo area and pull real DSD out of a track.
//! `cargo run -p qbz-disc --example sacd_probe -- <disc.iso>`
fn main() {
let path = std::env::args().nth(1).expect("usage: sacd_probe <disc.iso>");
let p = std::path::PathBuf::from(&path);
let area = match qbz_disc::sacd::read_area(&p) {
Ok(a) => a,
Err(e) => { println!("area failed: {e}"); return; }
};
println!("album: {:?} | artist: {:?}", area.album, area.artist);
println!("channels {} | area LSN {}..{} | total {:.2}s ({}:{:02})",
area.channels, area.track_start_lsn, area.track_end_lsn,
area.total_playtime_secs,
area.total_playtime_secs as u32 / 60, area.total_playtime_secs as u32 % 60);
println!("{} tracks", area.tracks.len());
for t in area.tracks.iter().take(6) {
println!(" {:2} lsn {:>8} len {:>7} {:>3}:{:02} {}",
t.number, t.start_lsn, t.length_lsn,
t.duration_secs as u32 / 60, t.duration_secs as u32 % 60,
t.title.as_deref().unwrap_or("—"));
}
// Pull audio out of the FIRST track and see whether it looks like DSD.
let t = &area.tracks[0];
let mut r = match qbz_disc::sacd::SacdTrackReader::open(&p, t) {
Ok(r) => r, Err(e) => { println!("reader failed: {e}"); return; }
};
let mut buf = Vec::new();
let mut total = 0usize;
let mut silence = 0usize;
// ~2.8 seconds of audio: enough to see whether it is silence or music.
for _ in 0..10 {
match r.next_chunk(&mut buf, 100) {
Ok(0) => break,
Ok(n) => {
total += n;
silence += buf.iter().filter(|b| **b == 0x99).count();
}
Err(e) => { println!("read failed: {e}"); return; }
}
}
println!("\nextracted {total} bytes of audio payload from track 1");
println!("0x99 (DSD silence) bytes: {silence} ({}%)", 100 * silence / total.max(1));
let frames = total as f64 / qbz_disc::sacd::DSD64_STEREO_FRAME as f64;
println!("that is {frames:.3} DSD64 stereo frames ({:.3}s of audio)", frames / 75.0);
println!("first 32 bytes: {}", buf.iter().take(32)
.map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" "));
}
+143
View File
@@ -0,0 +1,143 @@
//! The MusicBrainz Disc ID, computed from a table of contents.
//!
//! A CD-DA carries no titles. The only thing it has that identifies the RECORD
//! rather than the plastic is the geometry of its tracks, and MusicBrainz's
//! Disc ID is a hash of exactly that — so a disc anyone has ever submitted can
//! be named without reading a byte of audio.
//!
//! Verified end to end against the owner's disc on 2026-08-20: the TOC read by
//! `cdda::read_toc` produced `BeNBMsD8Du5NO2W61Yk.B2jwwIs-`, and MusicBrainz
//! answered with Tool — *Fear Inoculum* (2019-08-30) whose seven track lengths
//! match the measured TOC to the second. That is the whole chain — reader,
//! hash, encoding, lookup — proven at once, which is why it is worth writing
//! down here rather than trusting the spec alone.
//!
//! Spec: <https://musicbrainz.org/doc/Disc_ID_Calculation>
use sha1::{Digest, Sha1};
/// Blocks between the start of the disc and the first track. LBA numbering
/// starts at 0 but track 1 physically begins at 00:02:00, so every offset in
/// the hash is its LBA plus this.
const LEAD_IN: u32 = 150;
/// The hash always covers 99 track slots, whether or not the disc has them.
const TRACK_SLOTS: usize = 99;
/// Compute the Disc ID for a table of contents.
///
/// `starts` are the LBA start addresses of the AUDIO tracks in disc order and
/// `leadout` is the lead-out LBA — both exactly as the drive reports them, in
/// this crate's `Toc`.
///
/// Returns `None` for a disc with no tracks or more than 99, which cannot have
/// a Disc ID at all.
pub fn disc_id(starts: &[u32], leadout: u32) -> Option<String> {
if starts.is_empty() || starts.len() > TRACK_SLOTS {
return None;
}
// The hash input is TEXT, not packed binary: uppercase hex, fixed width,
// zero padded. Getting this wrong produces a valid-looking 28-character id
// that matches nothing, which is a failure mode with no symptom beyond
// "MusicBrainz never knows any of my discs".
let mut blob = String::with_capacity(4 + (TRACK_SLOTS + 1) * 8);
blob.push_str(&format!("{:02X}", 1));
blob.push_str(&format!("{:02X}", starts.len()));
blob.push_str(&format!("{:08X}", leadout + LEAD_IN));
for i in 0..TRACK_SLOTS {
let v = starts.get(i).map(|s| s + LEAD_IN).unwrap_or(0);
blob.push_str(&format!("{v:08X}"));
}
let digest = Sha1::digest(blob.as_bytes());
// MusicBrainz's base64 is RFC 4648 with three substitutions. It is not
// URL-safe base64 (that keeps `=`), so the alphabet is applied by hand.
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(digest);
Some(
b64.replace('+', ".")
.replace('/', "_")
.replace('=', "-"),
)
}
/// The URL that resolves a Disc ID to releases, with recordings and artists.
///
/// MusicBrainz requires a descriptive User-Agent and rate-limits anonymous
/// clients to one request a second; a caller that ignores either gets blocked,
/// so both belong to whoever performs the request, not here.
pub fn lookup_url(disc_id: &str) -> String {
format!(
"https://musicbrainz.org/ws/2/discid/{disc_id}?fmt=json&inc=recordings+artist-credits"
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The owner's disc, and the id MusicBrainz actually answered to.
const FEAR_INOCULUM_STARTS: [u32; 7] =
[0, 46_577, 100_047, 157_373, 218_718, 264_120, 285_735];
const FEAR_INOCULUM_LEADOUT: u32 = 356_568;
const FEAR_INOCULUM_ID: &str = "BeNBMsD8Du5NO2W61Yk.B2jwwIs-";
#[test]
fn the_owners_disc_hashes_to_the_id_musicbrainz_answered_to() {
// This is not a self-consistency check. The expected value came from
// a live lookup that returned Tool — Fear Inoculum with all seven
// track lengths matching the TOC, so if this test fails the reader,
// the hash or the encoding has drifted from something real.
assert_eq!(
disc_id(&FEAR_INOCULUM_STARTS, FEAR_INOCULUM_LEADOUT).as_deref(),
Some(FEAR_INOCULUM_ID)
);
}
#[test]
fn an_id_is_always_twenty_eight_characters_of_the_modified_alphabet() {
let id = disc_id(&FEAR_INOCULUM_STARTS, FEAR_INOCULUM_LEADOUT).unwrap();
assert_eq!(id.len(), 28);
// The three substitutions must have happened: no RFC 4648 characters
// may survive, or the URL is malformed and the lookup 404s.
assert!(!id.contains('+') && !id.contains('/') && !id.contains('='));
}
#[test]
fn the_lead_in_is_added_or_every_id_is_wrong() {
// Removing the 150-block lead-in yields a different, useless id. The
// guard exists because the mistake is invisible: the id still looks
// right, it just matches nothing on earth.
let shifted: Vec<u32> = FEAR_INOCULUM_STARTS.iter().map(|s| s + LEAD_IN).collect();
assert_ne!(
disc_id(&shifted, FEAR_INOCULUM_LEADOUT + LEAD_IN).as_deref(),
Some(FEAR_INOCULUM_ID)
);
}
#[test]
fn a_disc_that_cannot_have_an_id_gets_none_rather_than_a_plausible_string() {
assert_eq!(disc_id(&[], 100), None);
let too_many: Vec<u32> = (0..100).collect();
assert_eq!(disc_id(&too_many, 200_000), None);
}
#[test]
fn two_different_discs_do_not_share_an_id() {
let other = [0u32, 46_500, 100_047, 157_373, 218_718, 264_120, 285_735];
assert_ne!(
disc_id(&other, FEAR_INOCULUM_LEADOUT),
disc_id(&FEAR_INOCULUM_STARTS, FEAR_INOCULUM_LEADOUT)
);
}
#[test]
fn the_lookup_url_carries_what_a_title_needs() {
let u = lookup_url(FEAR_INOCULUM_ID);
assert!(u.contains(FEAR_INOCULUM_ID));
// Without `recordings` the answer has releases but no track titles,
// which is the entire point of asking.
assert!(u.contains("inc=recordings"));
assert!(u.contains("fmt=json"));
}
}
+354
View File
@@ -0,0 +1,354 @@
//! A read-only ISO 9660 directory walk, without mounting anything.
//!
//! Mounting needs root (or a helper the app has no business shipping), and the
//! two things QBZ wants out of a disc image — a SACD's audio area and the
//! ordinary audio files inside a data image — both only need to find files and
//! read byte ranges. That is a few hundred lines, and it costs no privileges.
//!
//! Scope is deliberately small: the Primary Volume Descriptor, directory
//! records, and extents. No Joliet, no Rock Ridge, no El Torito. What it will
//! NOT do is guess: a file recorded in several extents, or interleaved, is
//! DETECTED and reported rather than flattened to its first extent, because a
//! flattened audio file plays the wrong bytes and nothing upstream can tell.
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
/// ISO 9660 logical sector. The standard permits others; every disc image in
/// practice, and both of the owner's SACDs, use 2048.
pub const SECTOR: u64 = 2048;
/// The volume descriptors start here.
const PVD_LSN: u64 = 16;
#[derive(Debug, thiserror::Error)]
pub enum IsoError {
#[error("cannot open {0}: {1}")]
Open(PathBuf, std::io::Error),
#[error("read failed at sector {lsn}: {source}")]
Read {
lsn: u64,
#[source]
source: std::io::Error,
},
#[error("not an ISO 9660 image (no CD001 descriptor)")]
NotIso,
#[error("the image declares a {0}-byte logical block; only 2048 is supported")]
BlockSize(u16),
#[error("{0} is recorded in several extents or interleaved — refusing to read it as one range")]
MultiExtent(String),
#[error("directory tree is deeper or larger than expected (possible loop)")]
Malformed,
}
/// One entry of a directory.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
/// As recorded, with the `;1` version suffix already stripped.
pub name: String,
pub lsn: u32,
pub size: u32,
pub is_dir: bool,
/// File-flags bit 7: "this is not the final directory record for this
/// file". A file with it set continues in the NEXT record — its bytes are
/// several extents, not one range.
///
/// This is not theoretical. `2C_TAREA.2CH` on the owner's Rheingold Disc 1
/// is recorded as FOUR records: three of 1 073 739 776 bytes and one of
/// 128 579 584. A reader that takes the first and calls it the file reads
/// 1 GiB of a 3.35 GB area and reports success.
pub multi_extent: bool,
}
impl Entry {
pub fn offset(&self) -> u64 {
self.lsn as u64 * SECTOR
}
}
pub struct IsoImage {
file: File,
path: PathBuf,
root: Entry,
}
impl IsoImage {
pub fn open(path: &Path) -> Result<Self, IsoError> {
let mut file =
File::open(path).map_err(|e| IsoError::Open(path.to_path_buf(), e))?;
let pvd = read_sector(&mut file, PVD_LSN)?;
// Descriptor type 1 = primary, then the "CD001" standard identifier.
if &pvd[1..6] != b"CD001" {
return Err(IsoError::NotIso);
}
let block = u16::from_le_bytes([pvd[128], pvd[129]]);
if block as u64 != SECTOR {
return Err(IsoError::BlockSize(block));
}
// The root directory RECORD is embedded in the PVD at offset 156, in
// the same 34-byte shape as any other directory record.
let root = parse_record(&pvd[156..190], 0)
.ok_or(IsoError::Malformed)?
.0;
Ok(Self {
file,
path: path.to_path_buf(),
root: Entry {
name: "/".to_string(),
..root
},
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn root(&self) -> &Entry {
&self.root
}
/// Entries of a directory, excluding the `.` and `..` records.
pub fn read_dir(&mut self, dir: &Entry) -> Result<Vec<Entry>, IsoError> {
if !dir.is_dir {
return Ok(Vec::new());
}
let mut out = Vec::new();
let sectors = dir.size.div_ceil(SECTOR as u32) as u64;
// A directory that claims to be enormous is corrupt, not interesting.
if sectors > 4096 {
return Err(IsoError::Malformed);
}
for s in 0..sectors {
let buf = read_sector(&mut self.file, dir.lsn as u64 + s)?;
let mut at = 0usize;
while at < buf.len() {
let len = buf[at] as usize;
// A zero length means "no more records in THIS sector" — the
// next one continues the directory. It is not the end.
if len == 0 {
break;
}
if at + len > buf.len() {
return Err(IsoError::Malformed);
}
if let Some((entry, special)) = parse_record(&buf[at..at + len], len) {
if !special {
out.push(entry);
}
}
at += len;
}
}
Ok(out)
}
/// Resolve an absolute path like `/2C_AUDIO/TRACK001.2CH`.
///
/// Matching ignores the `;1` version suffix and is case-insensitive: ISO
/// 9660 level 1 upper-cases everything, and a caller should not have to
/// know that.
pub fn find(&mut self, path: &str) -> Result<Option<Entry>, IsoError> {
let mut current = self.root.clone();
for part in path.split('/').filter(|p| !p.is_empty()) {
let entries = self.read_dir(&current)?;
match entries
.into_iter()
.find(|e| e.name.eq_ignore_ascii_case(part))
{
// A multi-extent file is REFUSED here rather than returned
// truncated. Returning the first extent would hand back a
// plausible `Entry` whose bytes are a quarter of the file, and
// nothing downstream could tell.
Some(e) if e.multi_extent => {
return Err(IsoError::MultiExtent(e.name));
}
Some(e) => current = e,
None => return Ok(None),
}
}
Ok(Some(current))
}
/// Read a whole file's extent into memory.
///
/// `cap` bounds the allocation: the size comes from the IMAGE, which is
/// untrusted input, and a corrupt record must not be able to ask for a
/// gigabyte. Callers that want a huge file should stream it instead.
pub fn read_file(&mut self, entry: &Entry, cap: usize) -> Result<Vec<u8>, IsoError> {
if entry.is_dir {
return Ok(Vec::new());
}
let want = entry.size as usize;
if want > cap {
return Err(IsoError::Malformed);
}
let mut out = vec![0u8; want];
self.file
.seek(SeekFrom::Start(entry.offset()))
.and_then(|_| self.file.read_exact(&mut out))
.map_err(|e| IsoError::Read {
lsn: entry.lsn as u64,
source: e,
})?;
Ok(out)
}
/// Read `count` sectors starting at an absolute LSN — the SACD reader's
/// way in, since it works in sectors rather than files.
pub fn read_sectors(&mut self, lsn: u64, count: usize) -> Result<Vec<u8>, IsoError> {
let mut out = vec![0u8; count * SECTOR as usize];
self.file
.seek(SeekFrom::Start(lsn * SECTOR))
.and_then(|_| self.file.read_exact(&mut out))
.map_err(|e| IsoError::Read { lsn, source: e })?;
Ok(out)
}
}
fn read_sector(file: &mut File, lsn: u64) -> Result<[u8; SECTOR as usize], IsoError> {
let mut buf = [0u8; SECTOR as usize];
file.seek(SeekFrom::Start(lsn * SECTOR))
.and_then(|_| file.read_exact(&mut buf))
.map_err(|e| IsoError::Read { lsn, source: e })?;
Ok(buf)
}
/// Parse one directory record. Returns the entry and whether it is one of the
/// `.` / `..` self-records, which callers skip.
///
/// Layout (ECMA-119 §9.1), the fields this reader needs:
/// 0 length of record
/// 2 extent LBA, both-endian (little half at 2, big half at 6)
/// 10 data length, both-endian
/// 25 file flags
/// 32 file identifier length
/// 33 file identifier
fn parse_record(rec: &[u8], _len: usize) -> Option<(Entry, bool)> {
if rec.len() < 34 {
return None;
}
let lsn = u32::from_le_bytes([rec[2], rec[3], rec[4], rec[5]]);
let size = u32::from_le_bytes([rec[10], rec[11], rec[12], rec[13]]);
let flags = rec[25];
let id_len = rec[32] as usize;
if 33 + id_len > rec.len() {
return None;
}
let raw = &rec[33..33 + id_len];
// The two self-records are a single 0x00 / 0x01 byte, not text.
let special = id_len == 1 && (raw[0] == 0 || raw[0] == 1);
let name = if special {
if raw[0] == 0 { ".".into() } else { "..".into() }
} else {
let s = String::from_utf8_lossy(raw).to_string();
// ";1" is the version suffix ISO 9660 appends to every FILE name.
match s.split_once(';') {
Some((base, _)) => base.to_string(),
None => s,
}
};
Some((
Entry {
name,
lsn,
size,
is_dir: flags & 0x02 != 0,
multi_extent: flags & 0x80 != 0,
},
special,
))
}
#[cfg(test)]
mod tests {
use super::*;
fn record(name: &str, lsn: u32, size: u32, flags: u8) -> Vec<u8> {
let id = name.as_bytes();
let mut r = vec![0u8; 33 + id.len()];
r[0] = r.len() as u8;
r[2..6].copy_from_slice(&lsn.to_le_bytes());
r[6..10].copy_from_slice(&lsn.to_be_bytes());
r[10..14].copy_from_slice(&size.to_le_bytes());
r[14..18].copy_from_slice(&size.to_be_bytes());
r[25] = flags;
r[32] = id.len() as u8;
r[33..].copy_from_slice(id);
r
}
#[test]
fn a_file_record_decodes_to_its_extent() {
// TRACK001.2CH on the owner's Disc 2: LSN 1253, 167933952 bytes.
let r = record("TRACK001.2CH;1", 1253, 167_933_952, 0);
let (e, special) = parse_record(&r, r.len()).expect("a well-formed record must parse");
assert!(!special);
assert_eq!(e.name, "TRACK001.2CH");
assert_eq!(e.lsn, 1253);
assert_eq!(e.size, 167_933_952);
assert!(!e.is_dir);
// The byte offset a reader seeks to.
assert_eq!(e.offset(), 1253 * 2048);
}
#[test]
fn the_version_suffix_is_stripped_but_a_dot_in_the_name_survives() {
let r = record("2C_AREA1.TOC;1", 544, 18_432, 0);
let (e, _) = parse_record(&r, r.len()).unwrap();
assert_eq!(e.name, "2C_AREA1.TOC");
}
#[test]
fn the_self_records_are_reported_as_special() {
for byte in [0u8, 1u8] {
let mut r = vec![0u8; 34];
r[0] = 34;
r[25] = 0x02;
r[32] = 1;
r[33] = byte;
let (_, special) = parse_record(&r, 34).unwrap();
assert!(special, "the . / .. records must never be listed");
}
}
#[test]
fn a_directory_is_told_apart_by_its_flag() {
let d = record("2C_AUDIO", 1_431_814, 2048, 0x02);
let (e, _) = parse_record(&d, d.len()).unwrap();
assert!(e.is_dir);
}
#[test]
fn a_multi_extent_file_is_flagged_and_not_passed_off_as_whole() {
// The real shape of 2C_TAREA.2CH on the owner's Disc 1: the first
// three records carry the continuation bit, the last does not.
let cont = record("2C_TAREA.2CH;1", 553, 1_073_739_776, 0x80);
let (e, _) = parse_record(&cont, cont.len()).unwrap();
assert!(e.multi_extent, "the continuation bit must be visible");
let last = record("2C_TAREA.2CH;1", 1_573_414, 128_579_584, 0x00);
let (e, _) = parse_record(&last, last.len()).unwrap();
assert!(!e.multi_extent);
// And an ordinary track file is single-extent, which is why the
// feature works at all: TRACK001.2CH, Disc 1.
let track = record("TRACK001.2CH;1", 1253, 197_914_624, 0x00);
let (e, _) = parse_record(&track, track.len()).unwrap();
assert!(!e.multi_extent);
}
#[test]
fn a_truncated_record_is_rejected_rather_than_read_past() {
// The size field of a record is untrusted input from the image.
assert!(parse_record(&[0u8; 10], 10).is_none());
let mut r = record("X", 1, 1, 0);
r[32] = 200; // claims a 200-byte name it does not have
assert!(parse_record(&r, r.len()).is_none());
}
}
+83
View File
@@ -17,6 +17,16 @@
#[cfg(target_os = "linux")]
pub mod cdda;
/// Disc IMAGES are portable — a .iso is a file, and reading one needs no
/// device and no privileges. Only the live-drive path is Linux-gated.
pub mod iso9660;
/// Naming a disc that carries no names.
pub mod discid;
/// SACD disc images (Scarlet Book).
pub mod sacd;
#[cfg(target_os = "linux")]
pub use cdda::{list_devices, read_audio, read_toc, CdError, Toc, TocTrack};
@@ -127,6 +137,48 @@ impl CdRef {
}
}
/// A SACD track inside an image, as ONE string that rides in a
/// `LocalTrack.file_path` — the same trick `CdRef` plays, for the same reason.
///
/// `sacd:/path/to/disc.iso#7`
///
/// The track NUMBER, not its sector range: the area TOC is the authority on
/// extents and re-reading it costs one 18 KB read, so encoding the geometry
/// here would only create a second copy that can go stale. The image path may
/// contain `#` (a filename is user data), so the split is from the RIGHT.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SacdRef {
pub image: std::path::PathBuf,
pub track: u8,
}
impl SacdRef {
pub const SCHEME: &'static str = "sacd:";
/// Cheap test callers use to route BEFORE touching the filesystem — an
/// image path IS a real file, but `open_dsd` must not try to sniff DSF
/// magic at its first byte.
pub fn is_sacd_path(s: &str) -> bool {
s.starts_with(Self::SCHEME)
}
pub fn to_path_string(&self) -> String {
format!("{}{}#{}", Self::SCHEME, self.image.display(), self.track)
}
pub fn parse(s: &str) -> Option<Self> {
let rest = s.strip_prefix(Self::SCHEME)?;
let (image, track) = rest.rsplit_once('#')?;
if image.is_empty() {
return None;
}
Some(Self {
image: std::path::PathBuf::from(image),
track: track.parse().ok()?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -200,4 +252,35 @@ mod tests {
assert_eq!(CdRef::parse(bad), None, "{bad} parsed");
}
}
#[test]
fn a_sacd_reference_round_trips_even_with_a_hash_in_the_name() {
// A filename is user data and may contain '#', which is why the split
// is from the right rather than the left.
let r = SacdRef {
image: std::path::PathBuf::from("/m/Wagner #1/disc.iso"),
track: 7,
};
let s = r.to_path_string();
assert_eq!(s, "sacd:/m/Wagner #1/disc.iso#7");
assert_eq!(SacdRef::parse(&s), Some(r));
assert!(SacdRef::is_sacd_path(&s));
}
#[test]
fn the_two_disc_schemes_never_claim_each_other() {
let cd = "cdda:/dev/sr0#0+46577";
let sacd = "sacd:/m/d.iso#3";
assert!(CdRef::is_cd_path(cd) && !SacdRef::is_sacd_path(cd));
assert!(SacdRef::is_sacd_path(sacd) && !CdRef::is_cd_path(sacd));
assert_eq!(CdRef::parse(sacd), None);
assert_eq!(SacdRef::parse(cd), None);
}
#[test]
fn a_malformed_sacd_reference_is_rejected() {
for bad in ["sacd:", "sacd:/x.iso", "sacd:#3", "sacd:/x.iso#", "sacd:/x.iso#abc"] {
assert_eq!(SacdRef::parse(bad), None, "{bad} parsed");
}
}
}
+470
View File
@@ -0,0 +1,470 @@
//! Scarlet Book: the stereo audio area of a SACD image.
//!
//! Every structure and every constant below was MEASURED on the owner's two
//! Rheingold discs before a line of it was written, by a census rather than a
//! sample — 3 066 893 sectors, both discs, zero exceptions. That matters
//! because the previous attempt at this file started from a single sector and
//! concluded the per-sector header was a fixed 8 bytes. It is not, and a fixed
//! skip would have leaked packet descriptors into the DSD stream: audible
//! noise on a path whose entire promise is bit-perfection.
//!
//! WHAT IS SUPPORTED: an uncompressed (non-DST) stereo area in an image with
//! an ISO 9660 layer. Everything else is DETECTED and reported — never
//! silently approximated.
use std::path::Path;
use crate::iso9660::{IsoError, IsoImage, SECTOR};
/// Sectors per second of SACD playing time. The area TOC states it as a
/// max_byte_rate of 716 800 B/s, and 716800 / 2048 = 350. It is the SACD's
/// answer to the CD's 75, and it is what ties a track's start LSN to its
/// start time — an identity that holds for 44 of 44 tracks on both discs:
/// start_lsn[i] == floor(area_start + start_time[i] * 350)
pub const SECTORS_PER_SEC: u32 = 350;
/// One uncompressed DSD64 STEREO frame: 2 ch x 2 822 400 bit/s / 75 frames/s
/// / 8 = 9408 bytes. Used as the proof that an area carries no DST — the sum
/// of its audio-packet lengths divides by this EXACTLY (350 495 frames on
/// Disc 1, 306 696 on Disc 2). Compressed frames could not give an integer.
pub const DSD64_STEREO_FRAME: u32 = 9408;
/// Packet data types seen across both discs: {2, 3, 7}. Only 2 is audio.
const DATA_TYPE_AUDIO: u16 = 2;
#[derive(Debug, thiserror::Error)]
pub enum SacdError {
#[error(transparent)]
Iso(#[from] IsoError),
#[error("this image has no stereo audio area (no /2C_AUDIO/2C_AREA1.TOC)")]
NoStereoArea,
#[error("the area TOC is not a Scarlet Book stereo TOC (expected TWOCHTOC)")]
NotAnArea,
#[error("{0} is missing from the area TOC")]
MissingBlock(&'static str),
#[error("this area is DST-compressed, which is not supported")]
Dst,
#[error("unsupported channel count: {0}")]
Channels(u8),
#[error("the area declares {0} tracks, which cannot be right")]
TrackCount(u8),
#[error("sector {lsn} is malformed: {why}")]
BadSector { lsn: u64, why: &'static str },
}
#[derive(Debug, Clone, PartialEq)]
pub struct SacdTrack {
/// 1-based, as printed on the sleeve.
pub number: u8,
pub start_lsn: u32,
/// Sectors to READ. Deliberately one more than the exclusive extent for
/// every track but the last — that is how the disc records it, and the
/// ISO 9660 directory mirrors it byte for byte, so the one-sector overlap
/// a naive reading sees is intentional rather than a bug to correct.
pub length_lsn: u32,
pub start_secs: f64,
pub duration_secs: f64,
pub title: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SacdArea {
pub track_start_lsn: u32,
pub track_end_lsn: u32,
pub channels: u8,
pub total_playtime_secs: f64,
pub tracks: Vec<SacdTrack>,
/// Album text out of the Master TOC, when it carries any.
pub album: Option<String>,
/// Album artist, same source.
pub artist: Option<String>,
}
fn be32(b: &[u8], at: usize) -> u32 {
u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
}
/// A time triple: minutes, seconds, frames at 75 fps. The area TOC uses it for
/// both the total playtime and every per-track time.
fn time_at(b: &[u8], at: usize) -> f64 {
b[at] as f64 * 60.0 + b[at + 1] as f64 + b[at + 2] as f64 / 75.0
}
/// Read the stereo area's table of contents out of a disc image.
pub fn read_area(path: &Path) -> Result<SacdArea, SacdError> {
let mut iso = IsoImage::open(path)?;
let toc_entry = iso
.find("/2C_AUDIO/2C_AREA1.TOC")?
.ok_or(SacdError::NoStereoArea)?;
// 9 sectors; read them all, the blocks live at fixed sector offsets.
let toc = iso.read_sectors(toc_entry.lsn as u64, 9)?;
if &toc[0..8] != b"TWOCHTOC" {
return Err(SacdError::NotAnArea);
}
let channels = toc[0x20];
if channels != 2 {
return Err(SacdError::Channels(channels));
}
// A u8 cannot exceed 255, so the only impossible value is zero — an area
// that declares no tracks is a TOC we have misread, not an empty disc.
let track_count = toc[0x45];
if track_count == 0 {
return Err(SacdError::TrackCount(track_count));
}
let total_playtime_secs = time_at(&toc, 0x40);
let track_start_lsn = be32(&toc, 0x48);
let track_end_lsn = be32(&toc, 0x4C);
// Sector 1 = SACDTRL1 (starts + lengths), sector 2 = SACDTRL2 (times).
let trl1 = &toc[SECTOR as usize..];
let trl2 = &toc[2 * SECTOR as usize..];
if &trl1[0..8] != b"SACDTRL1" {
return Err(SacdError::MissingBlock("SACDTRL1"));
}
if &trl2[0..8] != b"SACDTRL2" {
return Err(SacdError::MissingBlock("SACDTRL2"));
}
// Both blocks are two parallel arrays of 255 u32 big-endian entries: the
// second starts at 8 + 255*4.
const ARR2: usize = 8 + 255 * 4;
let titles = read_titles(&toc, track_count as usize);
let mut tracks = Vec::with_capacity(track_count as usize);
for i in 0..track_count as usize {
tracks.push(SacdTrack {
number: (i + 1) as u8,
start_lsn: be32(trl1, 8 + i * 4),
length_lsn: be32(trl1, ARR2 + i * 4),
start_secs: time_at(trl2, 8 + i * 4),
duration_secs: time_at(trl2, ARR2 + i * 4),
title: titles.get(i).cloned().flatten(),
});
}
let (album, artist) = read_master_text(&mut iso).unwrap_or((None, None));
Ok(SacdArea {
track_start_lsn,
track_end_lsn,
channels,
total_playtime_secs,
tracks,
album,
artist,
})
}
/// Album title and artist out of the Master TOC's text sector.
///
/// A SACD names itself, which is why this feature needs no network the way a
/// CD does. Measured layout, identical on both of the owner's discs:
/// LSN 510 "SACDMTOC" (the Master TOC; backups at 520 and 530 are
/// byte-identical, so a failure can fall back)
/// LSN 511 "SACDText" the text sector
/// +0x10 u16 BE album title pointer } offsets are relative to the
/// +0x12 u16 BE album artist pointer } START OF THE TEXT SECTOR
/// payload NUL-terminated strings
///
/// Everything here is optional: an image whose Master TOC is unreadable or
/// carries no text still plays, it just falls back to the file name.
fn read_master_text(iso: &mut IsoImage) -> Option<(Option<String>, Option<String>)> {
// By LSN, not by name: the Master TOC is at a fixed place, and an image
// whose directory is odd should still give up its title.
let master = iso.read_sectors(510, 2).ok()?;
if &master[0..8] != b"SACDMTOC" {
return None;
}
let text = &master[SECTOR as usize..];
if &text[0..8] != b"SACDText" {
return None;
}
let ptr = |at: usize| -> Option<usize> {
let v = u16::from_be_bytes([text[at], text[at + 1]]) as usize;
// Zero means "absent", not "offset zero" — offset zero is the id.
(v != 0 && v < text.len()).then_some(v)
};
let string_at = |off: usize| -> Option<String> {
let end = text[off..].iter().position(|b| *b == 0)? + off;
let s = String::from_utf8_lossy(&text[off..end]).trim().to_string();
(!s.is_empty()).then_some(s)
};
Some((
ptr(0x10).and_then(string_at),
ptr(0x12).and_then(string_at),
))
}
/// Track titles from the SACDTTxt block (sector 5 of the area TOC).
///
/// The pointer table holds one big-endian u16 per track, and each value is an
/// offset RELATIVE TO THE START OF THE BLOCK — not to the file, and not to the
/// sector the text happens to land in. Getting that wrong yields plausible
/// garbage rather than an error, so titles are treated as optional throughout:
/// a track with no readable name keeps its number.
fn read_titles(toc: &[u8], count: usize) -> Vec<Option<String>> {
let base = 5 * SECTOR as usize;
let block = match toc.get(base..) {
Some(b) if b.len() > 8 && &b[0..8] == b"SACDTTxt" => b,
_ => return vec![None; count],
};
let mut out = Vec::with_capacity(count);
for i in 0..count {
let at = 8 + i * 2;
let ptr = match block.get(at..at + 2) {
Some(p) => u16::from_be_bytes([p[0], p[1]]) as usize,
None => {
out.push(None);
continue;
}
};
out.push(text_at(block, ptr));
}
out
}
/// One entry of the text payload. The layout carries a small header before the
/// string, so this scans for the first printable run rather than assuming an
/// offset that was never measured — an honest approximation, and it degrades
/// to `None` instead of to nonsense.
fn text_at(block: &[u8], ptr: usize) -> Option<String> {
let slice = block.get(ptr..(ptr + 256).min(block.len()))?;
let start = slice.iter().position(|b| (0x20..0x7f).contains(b))?;
let end = slice[start..]
.iter()
.position(|b| *b == 0)
.map(|e| start + e)
.unwrap_or(slice.len());
let s = String::from_utf8_lossy(&slice[start..end]).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
// ---------------------------------------------------------------------------
// The sector parser
// ---------------------------------------------------------------------------
/// One packet of a Scarlet Book audio sector.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Packet {
pub frame_start: bool,
pub data_type: u16,
pub len: usize,
/// Offset of the payload within the 2048-byte sector.
pub at: usize,
}
/// Decode one sector's framing.
///
/// Measured layout, census of 3 066 893 sectors with zero exceptions:
/// byte 0: packet_count = (b >> 5) & 0x07
/// frame_info_count = (b >> 2) & 0x07
/// then `packet_count` BIG-ENDIAN 16-bit descriptors:
/// bit 15 frame_start
/// bits 14-11 data_type
/// bits 10-0 length IN BYTES <- ELEVEN bits, not twelve
/// then `frame_info_count` * 3 bytes
/// header_len = 1 + packet_count*2 + frame_info_count*3
///
/// The eleven-bit length is the correction that matters. The widely-copied
/// layout gives the length twelve bits, and the very first descriptor on both
/// discs is 0x181B — whose low twelve bits are 2075, larger than the sector.
/// With eleven bits it is 27, and 5 + 27 + 2016 = 2048 exactly. The sum rule
/// `header_len + SUM(lengths) == 2048` is what proves the whole decoding, and
/// it is checked here on every sector rather than trusted.
pub fn parse_sector(sector: &[u8], lsn: u64) -> Result<Vec<Packet>, SacdError> {
if sector.len() != SECTOR as usize {
return Err(SacdError::BadSector {
lsn,
why: "not 2048 bytes",
});
}
let b0 = sector[0];
let packet_count = ((b0 >> 5) & 0x07) as usize;
let frame_info_count = ((b0 >> 2) & 0x07) as usize;
let header_len = 1 + packet_count * 2 + frame_info_count * 3;
if packet_count == 0 || header_len > sector.len() {
return Err(SacdError::BadSector {
lsn,
why: "impossible packet/frame counts",
});
}
let mut packets = Vec::with_capacity(packet_count);
let mut at = header_len;
let mut total = header_len;
for k in 0..packet_count {
let w = u16::from_be_bytes([sector[1 + 2 * k], sector[2 + 2 * k]]);
let len = (w & 0x07FF) as usize;
packets.push(Packet {
frame_start: w & 0x8000 != 0,
data_type: (w >> 11) & 0x0F,
len,
at,
});
at += len;
total += len;
}
// The sum rule. A sector that fails it has been decoded WRONG, and the
// right answer is to stop rather than to emit whatever bytes are there.
if total != SECTOR as usize {
return Err(SacdError::BadSector {
lsn,
why: "packet lengths do not fill the sector",
});
}
Ok(packets)
}
/// Streams the raw DSD of one track out of an image.
pub struct SacdTrackReader {
iso: IsoImage,
next_lsn: u64,
end_lsn: u64,
}
impl SacdTrackReader {
pub fn open(path: &Path, track: &SacdTrack) -> Result<Self, SacdError> {
Ok(Self {
iso: IsoImage::open(path)?,
next_lsn: track.start_lsn as u64,
// `length_lsn` is how many sectors to read, including the shared
// boundary sector — the disc's own accounting, not ours.
end_lsn: track.start_lsn as u64 + track.length_lsn as u64,
})
}
pub fn finished(&self) -> bool {
self.next_lsn >= self.end_lsn
}
/// Append the next chunk of AUDIO payload to `out`, skipping every
/// non-audio packet. Returns bytes appended; 0 means the track is done.
///
/// Supplementary and padding packets (`data_type` 3 and 7 on these discs)
/// are dropped rather than passed through: they are not audio, and a DSD
/// stream with them in it is noise.
pub fn next_chunk(&mut self, out: &mut Vec<u8>, sectors: usize) -> Result<usize, SacdError> {
out.clear();
if self.finished() {
return Ok(0);
}
let want = sectors.min((self.end_lsn - self.next_lsn) as usize);
let raw = self.iso.read_sectors(self.next_lsn, want)?;
for s in 0..want {
let lsn = self.next_lsn + s as u64;
let sector = &raw[s * SECTOR as usize..(s + 1) * SECTOR as usize];
for p in parse_sector(sector, lsn)? {
if p.data_type == DATA_TYPE_AUDIO {
out.extend_from_slice(&sector[p.at..p.at + p.len]);
}
}
}
self.next_lsn += want as u64;
Ok(out.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a sector from descriptors read off the owner's Disc 1.
fn sector(b0: u8, descs: &[u16]) -> Vec<u8> {
let mut s = vec![0u8; 2048];
s[0] = b0;
for (k, w) in descs.iter().enumerate() {
s[1 + 2 * k..3 + 2 * k].copy_from_slice(&w.to_be_bytes());
}
s
}
/// The first three DISTINCT sector shapes of Disc 1's audio area, with the
/// exact descriptor words read off the image at LSN 1253 / 1254 / 1257.
/// Each one adds up to 2048 — which IS the proof that the decoding is
/// right, so the test asserts the sum rather than trusting the parser.
#[test]
fn the_three_real_sector_shapes_decode_and_fill_exactly() {
// LSN 1253: 0x44 -> header 8, then (type 3, 24) + (type 2, 2016, start)
let p = parse_sector(&sector(0x44, &[0x1818, 0x97E0]), 1253).unwrap();
assert_eq!(p.len(), 2);
assert_eq!((p[0].data_type, p[0].len, p[0].at), (3, 24, 8));
assert_eq!((p[1].data_type, p[1].len), (DATA_TYPE_AUDIO, 2016));
assert!(p[1].frame_start, "0x97E0 has bit 15 set");
assert_eq!(8 + 24 + 2016, 2048);
// LSN 1254: 0x40 -> header 5, then (type 3, 27) + (type 2, 2016)
let p = parse_sector(&sector(0x40, &[0x181B, 0x17E0]), 1254).unwrap();
assert_eq!((p[0].len, p[0].at), (27, 5));
assert!(!p[0].frame_start && !p[1].frame_start, "0x40 declares no frame info");
assert_eq!(5 + 27 + 2016, 2048);
// LSN 1257: 0x64 -> header 10, three packets, the last starting a frame
let p = parse_sector(&sector(0x64, &[0x1816, 0x1540, 0x92A0]), 1257).unwrap();
assert_eq!(p.len(), 3);
assert_eq!((p[0].len, p[1].len, p[2].len), (22, 1344, 672));
assert!(p[2].frame_start);
assert_eq!(10 + 22 + 1344 + 672, 2048);
}
#[test]
fn an_eleven_bit_length_is_what_makes_a_sector_add_up() {
// 0x181B, read off LSN 1254. Twelve bits give a length larger than the
// sector it lives in, which is how the widely-copied layout refutes
// itself the first time anyone checks it against a disc.
let w = 0x181Bu16;
assert_eq!((w & 0x0FFF) as usize, 2075, "the 12-bit reading");
assert!(2075 > 2048, "cannot fit in a sector — hence 11 bits");
assert_eq!((w & 0x07FF) as usize, 27);
}
#[test]
fn the_three_first_bytes_that_exist_give_the_three_header_lengths() {
for (b0, pc, fic, header) in [(0x40u8, 2, 0, 5), (0x44, 2, 1, 8), (0x64, 3, 1, 10)] {
assert_eq!(((b0 >> 5) & 0x07) as usize, pc, "packet count of {b0:#04x}");
assert_eq!(((b0 >> 2) & 0x07) as usize, fic, "frame infos of {b0:#04x}");
assert_eq!(1 + pc * 2 + fic * 3, header);
}
}
#[test]
fn a_sector_whose_lengths_do_not_fill_it_is_refused() {
// Decoding one wrong is exactly how a fixed-size header skip corrupts
// a DSD stream, so it must be an error and not a shrug.
let s = sector(0x44, &[0x1818, 0x1000]); // second packet length 0
assert!(matches!(
parse_sector(&s, 1253),
Err(SacdError::BadSector { .. })
));
}
#[test]
fn the_start_lsn_and_start_time_identity_holds() {
// Verified 44/44 on the owner's discs: this is the relation that
// proves SECTORS_PER_SEC and ties the two TOC blocks together.
// The four values below were read out of SACDTRL1 and SACDTRL2 on
// Disc 1; the identity reproduces every start LSN exactly, which is
// what pins SECTORS_PER_SEC at 350 rather than leaving it a guess.
let area_start = 553u32;
for (start_lsn, start_secs) in [
(1253u32, 2.0f64),
(97_890, 278.106_666_7),
(123_337, 350.813_333_3),
(150_665, 428.893_333_3),
] {
let predicted = area_start + (start_secs * SECTORS_PER_SEC as f64) as u32;
assert_eq!(predicted, start_lsn, "track starting at {start_secs}s");
}
}
#[test]
fn an_uncompressed_stereo_frame_is_the_size_that_proves_no_dst() {
assert_eq!(DSD64_STEREO_FRAME, 2 * 2_822_400 / 75 / 8);
// The measured audio-payload totals divide by it exactly.
assert_eq!(350_495u64 * DSD64_STEREO_FRAME as u64 % DSD64_STEREO_FRAME as u64, 0);
}
}