diff --git a/crates/qbz-disc/Cargo.toml b/crates/qbz-disc/Cargo.toml index 8afe8e801..a941bc15a 100644 --- a/crates/qbz-disc/Cargo.toml +++ b/crates/qbz-disc/Cargo.toml @@ -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" diff --git a/crates/qbz-disc/examples/iso_probe.rs b/crates/qbz-disc/examples/iso_probe.rs new file mode 100644 index 000000000..a92edca67 --- /dev/null +++ b/crates/qbz-disc/examples/iso_probe.rs @@ -0,0 +1,32 @@ +//! Walk a real ISO without mounting it. +//! `cargo run -p qbz-disc --example iso_probe -- ` +fn main() { + let path = std::env::args().nth(1).expect("usage: iso_probe "); + 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}"), + } + } +} diff --git a/crates/qbz-disc/examples/sacd_probe.rs b/crates/qbz-disc/examples/sacd_probe.rs new file mode 100644 index 000000000..f4b14a502 --- /dev/null +++ b/crates/qbz-disc/examples/sacd_probe.rs @@ -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 -- ` +fn main() { + let path = std::env::args().nth(1).expect("usage: sacd_probe "); + 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::>().join(" ")); +} diff --git a/crates/qbz-disc/src/discid.rs b/crates/qbz-disc/src/discid.rs new file mode 100644 index 000000000..94b8f1aa2 --- /dev/null +++ b/crates/qbz-disc/src/discid.rs @@ -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: + +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 { + 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 = 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 = (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")); + } +} diff --git a/crates/qbz-disc/src/iso9660.rs b/crates/qbz-disc/src/iso9660.rs new file mode 100644 index 000000000..a6126efce --- /dev/null +++ b/crates/qbz-disc/src/iso9660.rs @@ -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 { + 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, 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, IsoError> { + let mut current = self.root.clone(); + for part in path.split('/').filter(|p| !p.is_empty()) { + let entries = self.read_dir(¤t)?; + 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, 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, 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 { + 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()); + } +} diff --git a/crates/qbz-disc/src/lib.rs b/crates/qbz-disc/src/lib.rs index d38deec66..5f7057332 100644 --- a/crates/qbz-disc/src/lib.rs +++ b/crates/qbz-disc/src/lib.rs @@ -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 { + 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"); + } + } } diff --git a/crates/qbz-disc/src/sacd.rs b/crates/qbz-disc/src/sacd.rs new file mode 100644 index 000000000..57f14f973 --- /dev/null +++ b/crates/qbz-disc/src/sacd.rs @@ -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, +} + +#[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, + /// Album text out of the Master TOC, when it carries any. + pub album: Option, + /// Album artist, same source. + pub artist: Option, +} + +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 { + 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, Option)> { + // 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 { + 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 { + 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> { + 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 { + 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, 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 { + 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, sectors: usize) -> Result { + 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(§or[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 { + 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(§or(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(§or(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(§or(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); + } +} diff --git a/crates/qbz-dsd/Cargo.toml b/crates/qbz-dsd/Cargo.toml index 65b9a4b8c..4253d8599 100644 --- a/crates/qbz-dsd/Cargo.toml +++ b/crates/qbz-dsd/Cargo.toml @@ -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" diff --git a/crates/qbz-dsd/examples/sacd_layout.rs b/crates/qbz-dsd/examples/sacd_layout.rs new file mode 100644 index 000000000..2d4872463 --- /dev/null +++ b/crates/qbz-dsd/examples/sacd_layout.rs @@ -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 -- ` +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 = 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::() / 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()); + } +} diff --git a/crates/qbz-dsd/src/demux.rs b/crates/qbz-dsd/src/demux.rs index d0f036c3f..955716a17 100644 --- a/crates/qbz-dsd/src/demux.rs +++ b/crates/qbz-dsd/src/demux.rs @@ -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, 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)?; diff --git a/crates/qbz-dsd/src/lib.rs b/crates/qbz-dsd/src/lib.rs index 089dc6acf..8ca2ec5ae 100644 --- a/crates/qbz-dsd/src/lib.rs +++ b/crates/qbz-dsd/src/lib.rs @@ -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 diff --git a/crates/qbz-dsd/src/sacd_source.rs b/crates/qbz-dsd/src/sacd_source.rs new file mode 100644 index 000000000..22cd6575e --- /dev/null +++ b/crates/qbz-dsd/src/sacd_source.rs @@ -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, + 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::open(image, track, ChannelLayout::default()) + } + + pub fn open( + image: &std::path::Path, + track: &SacdTrack, + layout: ChannelLayout, + ) -> Result { + 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], + max_bytes_per_ch: usize, + ) -> Result { + 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 = frame[..FRAME_PER_CH].to_vec(); + let inter0: Vec = 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)); + } +}