feat(disc): read a SACD image's stereo area, measured against two real discs
Adds an ISO 9660 walk (no mounting, no privileges) and a Scarlet Book parser
for the stereo audio area, plus the `DsdDemuxer` adapter that lets every DSD
delivery the player already has — PCM, DoP, native — play a disc image.
The parser exists because the previous account of this format, written in this
repo, was WRONG: it said the per-sector header was a fixed 8 bytes, derived
from looking at one sector. Every structure below was re-derived by a CENSUS —
3 066 893 sectors across both of the owner's Rheingold discs, zero exceptions —
and each claim is falsifiable rather than asserted:
- byte 0 carries packet_count = (b>>5)&7 and frame_info_count = (b>>2)&7,
so the header is 1 + pc*2 + fic*3 and takes the values 5, 8 or 10;
- packet descriptors are BIG-endian, and the length field is ELEVEN bits,
not the twelve every published layout shows. The first descriptor on both
discs is 0x181B, whose low twelve bits are 2075 — larger than the sector
it lives in. With eleven bits it is 27, and 5 + 27 + 2016 = 2048;
- the proof of the whole decoding is the sum rule
`header + SUM(packet lengths) == 2048`, which the parser CHECKS on every
sector rather than trusting;
- the area is uncompressed, proven from the data and not from a flag: the
audio-packet bytes divide by 9408 (one uncompressed DSD64 stereo frame)
exactly — 350 495 frames on Disc 1, 306 696 on Disc 2. DST is
variable-length; those could not be integers.
Track geometry comes from SACDTRL1/TRL2, tied together by an identity that
holds for 44 of 44 tracks: start_lsn == area_start + start_time * 350. The
ISO 9660 extents mirror SACDTRL1 byte for byte, so the one-sector overlap
between consecutive tracks is the disc's own accounting, not a bug to fix.
The channel layout inside a frame was the last unknown, and three statistical
probes could not settle it — they were measuring the DSD noise floor. What
settled it was physics: converted with the real filter, block-per-channel puts
23 % of its energy above the audible band while byte-interleaving puts 0.2 %
there. A correctly decoded 1-bit stream MUST carry that rising noise floor,
and no filter puts it back once scrambling has folded it away.
Also in this commit, from the same measurements: `2C_TAREA.2CH` on Disc 1 is
recorded in FOUR extents, so `IsoImage::find` REFUSES a multi-extent file
rather than returning its first quarter as if it were the whole thing.
cc
This commit is contained in:
12 files changed
+1404
-1
No files matched your search
@@ -6,6 +6,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"
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(" "));
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(¤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<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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(§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<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(§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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user