feat(sacd): support raw and DST-compressed ISO playback
Parse resilient SACD images, decode DST, scan ISO releases into the local library, and keep explicit opens ephemeral. Ship decoder notices across distribution formats and align DSD album filtering and version selection. cx
This commit is contained in:
59 files changed
+3199
-436
No files matched your search
@@ -10,3 +10,4 @@ qbz-disc = { path = "../qbz-disc" }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
id3 = "1"
|
||||
dst-decoder = "=0.1.2"
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Decode complete SACD tracks, check their exact DSD64 byte count and print
|
||||
//! deterministic per-channel hashes without writing the copyrighted audio.
|
||||
//!
|
||||
//! `cargo run --release -p qbz-dsd --example sacd_verify -- <image.iso> [track]`
|
||||
|
||||
use qbz_dsd::{DsdDemuxer, SacdDemuxer};
|
||||
|
||||
const BYTES_PER_CHANNEL_FRAME: u64 = 4_704;
|
||||
const BITS_PER_CHANNEL_FRAME: u64 = BYTES_PER_CHANNEL_FRAME * 8;
|
||||
const READ_FRAMES: usize = 64;
|
||||
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
|
||||
|
||||
fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 {
|
||||
for byte in bytes {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let image = std::path::PathBuf::from(
|
||||
args.next()
|
||||
.ok_or("usage: sacd_verify <image.iso> [track]")?,
|
||||
);
|
||||
let selected = args.next().map(|value| value.parse::<u8>()).transpose()?;
|
||||
if args.next().is_some() {
|
||||
return Err("usage: sacd_verify <image.iso> [track]".into());
|
||||
}
|
||||
|
||||
let area = qbz_disc::sacd::read_area(&image)?;
|
||||
let tracks = area
|
||||
.tracks
|
||||
.iter()
|
||||
.filter(|track| selected.is_none_or(|number| track.number == number))
|
||||
.collect::<Vec<_>>();
|
||||
if tracks.is_empty() {
|
||||
return Err(format!("track {} does not exist", selected.unwrap_or_default()).into());
|
||||
}
|
||||
|
||||
for track in tracks {
|
||||
let mut demux = SacdDemuxer::open_default(&image, track)?;
|
||||
let expected = u64::from(track.duration_frames) * BYTES_PER_CHANNEL_FRAME;
|
||||
let midpoint_frame = u64::from(track.duration_frames / 2);
|
||||
let suffix_start = midpoint_frame * BYTES_PER_CHANNEL_FRAME;
|
||||
let mut total = 0u64;
|
||||
let mut hashes = [FNV_OFFSET; 2];
|
||||
let mut suffix_hashes = [FNV_OFFSET; 2];
|
||||
let mut first_frame_hashes = None;
|
||||
let mut last_frame_hashes = [FNV_OFFSET; 2];
|
||||
let mut output = vec![Vec::new(), Vec::new()];
|
||||
loop {
|
||||
output.iter_mut().for_each(Vec::clear);
|
||||
let read =
|
||||
demux.read_planar(&mut output, READ_FRAMES * BYTES_PER_CHANNEL_FRAME as usize)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
if output.iter().any(|channel| channel.len() != read) {
|
||||
return Err(
|
||||
format!("track {} returned unequal channel lengths", track.number).into(),
|
||||
);
|
||||
}
|
||||
total = total
|
||||
.checked_add(read as u64)
|
||||
.ok_or("decoded byte count overflow")?;
|
||||
let suffix_offset = suffix_start.saturating_sub(total - read as u64) as usize;
|
||||
for (channel_index, ((hash, suffix_hash), channel)) in hashes
|
||||
.iter_mut()
|
||||
.zip(&mut suffix_hashes)
|
||||
.zip(&output)
|
||||
.enumerate()
|
||||
{
|
||||
*hash = hash_bytes(*hash, channel);
|
||||
if suffix_offset < channel.len() {
|
||||
*suffix_hash = hash_bytes(*suffix_hash, &channel[suffix_offset..]);
|
||||
}
|
||||
last_frame_hashes[channel_index] = hash_bytes(
|
||||
FNV_OFFSET,
|
||||
&channel[channel.len() - BYTES_PER_CHANNEL_FRAME as usize..],
|
||||
);
|
||||
}
|
||||
if first_frame_hashes.is_none() {
|
||||
first_frame_hashes = Some([
|
||||
hash_bytes(FNV_OFFSET, &output[0][..BYTES_PER_CHANNEL_FRAME as usize]),
|
||||
hash_bytes(FNV_OFFSET, &output[1][..BYTES_PER_CHANNEL_FRAME as usize]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
if total != expected {
|
||||
return Err(format!(
|
||||
"track {} decoded {total} bytes/channel, expected {expected}",
|
||||
track.number
|
||||
)
|
||||
.into());
|
||||
}
|
||||
println!(
|
||||
"track {:>3}: {total} bytes/channel fnv1a64={:016x}/{:016x}",
|
||||
track.number, hashes[0], hashes[1]
|
||||
);
|
||||
|
||||
// A selected-track run also proves that the seek estimate reacquires
|
||||
// the exact timecoded frame even when DST compression varies across
|
||||
// the track. Compare the suffix to a fresh decoder, which exercises
|
||||
// decoder reset as well as container positioning.
|
||||
if selected.is_some() {
|
||||
let mut seeked = SacdDemuxer::open_default(&image, track)?;
|
||||
seeked.seek_to_bit(midpoint_frame * BITS_PER_CHANNEL_FRAME)?;
|
||||
let mut seek_total = 0u64;
|
||||
let mut seek_hashes = [FNV_OFFSET; 2];
|
||||
loop {
|
||||
output.iter_mut().for_each(Vec::clear);
|
||||
let read = seeked
|
||||
.read_planar(&mut output, READ_FRAMES * BYTES_PER_CHANNEL_FRAME as usize)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
seek_total += read as u64;
|
||||
for (hash, channel) in seek_hashes.iter_mut().zip(&output) {
|
||||
*hash = hash_bytes(*hash, channel);
|
||||
}
|
||||
}
|
||||
if seek_total != expected - suffix_start || seek_hashes != suffix_hashes {
|
||||
return Err(format!(
|
||||
"track {} midpoint seek disagrees with its linear suffix: bytes={seek_total}/{} hash={:016x}/{:016x} expected={:016x}/{:016x}",
|
||||
track.number,
|
||||
expected - suffix_start,
|
||||
seek_hashes[0],
|
||||
seek_hashes[1],
|
||||
suffix_hashes[0],
|
||||
suffix_hashes[1],
|
||||
)
|
||||
.into());
|
||||
}
|
||||
println!(" midpoint seek verified at frame {midpoint_frame}");
|
||||
|
||||
let last_frame = u64::from(track.duration_frames)
|
||||
.checked_sub(1)
|
||||
.ok_or("zero-duration track")?;
|
||||
seeked.seek_to_bit(last_frame * BITS_PER_CHANNEL_FRAME)?;
|
||||
output.iter_mut().for_each(Vec::clear);
|
||||
let read = seeked.read_planar(&mut output, BYTES_PER_CHANNEL_FRAME as usize)?;
|
||||
let last_seek_hashes = [
|
||||
hash_bytes(FNV_OFFSET, &output[0]),
|
||||
hash_bytes(FNV_OFFSET, &output[1]),
|
||||
];
|
||||
if read as u64 != BYTES_PER_CHANNEL_FRAME || last_seek_hashes != last_frame_hashes {
|
||||
return Err(format!("track {} last-frame seek disagrees", track.number).into());
|
||||
}
|
||||
output.iter_mut().for_each(Vec::clear);
|
||||
if seeked.read_planar(&mut output, BYTES_PER_CHANNEL_FRAME as usize)? != 0 {
|
||||
return Err(format!("track {} emitted data past EOF", track.number).into());
|
||||
}
|
||||
|
||||
seeked.seek_to_bit(expected * 8)?;
|
||||
output.iter_mut().for_each(Vec::clear);
|
||||
if seeked.read_planar(&mut output, BYTES_PER_CHANNEL_FRAME as usize)? != 0 {
|
||||
return Err(format!("track {} EOF seek emitted data", track.number).into());
|
||||
}
|
||||
|
||||
seeked.seek_to_bit(0)?;
|
||||
output.iter_mut().for_each(Vec::clear);
|
||||
let read = seeked.read_planar(&mut output, BYTES_PER_CHANNEL_FRAME as usize)?;
|
||||
let start_seek_hashes = [
|
||||
hash_bytes(FNV_OFFSET, &output[0]),
|
||||
hash_bytes(FNV_OFFSET, &output[1]),
|
||||
];
|
||||
if read as u64 != BYTES_PER_CHANNEL_FRAME
|
||||
|| Some(start_seek_hashes) != first_frame_hashes
|
||||
{
|
||||
return Err(format!("track {} repeated start seek disagrees", track.number).into());
|
||||
}
|
||||
println!(" last-frame, EOF and repeated-start seeks verified");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
//! 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 dst_decoder::decoder::DstDecoder;
|
||||
use qbz_disc::sacd::{SacdFrameKind, SacdFrameReader, SacdTrack, SacdTrackReader};
|
||||
|
||||
use crate::demux::{DsdDemuxer, DsdError, DsdStreamInfo, DsdTags};
|
||||
|
||||
@@ -15,10 +16,36 @@ const DSD64: u32 = 2_822_400;
|
||||
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.
|
||||
/// Sectors pulled from the image per legacy diagnostic refill.
|
||||
const REFILL_SECTORS: usize = 200;
|
||||
|
||||
fn decode_dst_frame(
|
||||
decoder: &mut DstDecoder,
|
||||
scratch: &mut [u8],
|
||||
payload: &[u8],
|
||||
pending: &mut Vec<u8>,
|
||||
track: u8,
|
||||
frame_number: u32,
|
||||
start_lsn: u64,
|
||||
) -> Result<(), DsdError> {
|
||||
scratch.fill(0);
|
||||
let written = decoder.decode_frame(payload, scratch).map_err(|error| {
|
||||
DsdError::Corrupt(format!(
|
||||
"SACD track {track} DST frame {frame_number} at LSN {start_lsn}: {error}"
|
||||
))
|
||||
})?;
|
||||
if written != FRAME {
|
||||
return Err(DsdError::Corrupt(format!(
|
||||
"SACD track {track} DST frame {frame_number} at LSN {start_lsn} decoded {written} bytes, expected {FRAME}"
|
||||
)));
|
||||
}
|
||||
// Upstream writes 0x55 into `scratch` on Err. Publication is deliberately
|
||||
// after both Ok and the exact-size check, so synthetic silence cannot
|
||||
// escape into PCM, DoP or native output.
|
||||
pending.extend_from_slice(&scratch[..written]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How the two channels sit inside one frame.
|
||||
///
|
||||
/// Both schemes exist in the wild — DFF interleaves per BYTE, DSF per block —
|
||||
@@ -71,15 +98,26 @@ pub enum ChannelLayout {
|
||||
}
|
||||
|
||||
pub struct SacdDemuxer {
|
||||
reader: SacdTrackReader,
|
||||
reader: SacdReader,
|
||||
info: DsdStreamInfo,
|
||||
layout: ChannelLayout,
|
||||
/// Audio payload read but not yet split into channels. Only whole frames
|
||||
/// are consumed, so a partial frame waits here for the next refill.
|
||||
pending: Vec<u8>,
|
||||
dst_decoder: Option<DstDecoder>,
|
||||
decode_scratch: Vec<u8>,
|
||||
track_number: u8,
|
||||
frame_number: u32,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
enum SacdReader {
|
||||
/// Playback: exact TRL2-bounded frames for both flat DSD and DST.
|
||||
Framed(SacdFrameReader),
|
||||
/// Kept only for the `sync=false` diagnostic that reproduces the old bug.
|
||||
Legacy(SacdTrackReader),
|
||||
}
|
||||
|
||||
impl Default for ChannelLayout {
|
||||
/// The measured layout. A caller that does not care gets the right one.
|
||||
fn default() -> Self {
|
||||
@@ -89,10 +127,7 @@ impl Default for ChannelLayout {
|
||||
|
||||
impl SacdDemuxer {
|
||||
/// Open with the measured layout.
|
||||
pub fn open_default(
|
||||
image: &std::path::Path,
|
||||
track: &SacdTrack,
|
||||
) -> Result<Self, DsdError> {
|
||||
pub fn open_default(image: &std::path::Path, track: &SacdTrack) -> Result<Self, DsdError> {
|
||||
Self::open(image, track, ChannelLayout::default())
|
||||
}
|
||||
|
||||
@@ -113,16 +148,29 @@ impl SacdDemuxer {
|
||||
layout: ChannelLayout,
|
||||
sync: bool,
|
||||
) -> Result<Self, DsdError> {
|
||||
let reader = SacdTrackReader::open_with(
|
||||
image,
|
||||
track,
|
||||
if sync {
|
||||
qbz_disc::sacd::FrameSync::ToFirstFrame
|
||||
} else {
|
||||
qbz_disc::sacd::FrameSync::FromPayloadStart
|
||||
},
|
||||
)
|
||||
.map_err(|e| DsdError::Corrupt(e.to_string()))?;
|
||||
let reader = if sync {
|
||||
SacdReader::Framed(
|
||||
SacdFrameReader::open(image, track)
|
||||
.map_err(|error| DsdError::Corrupt(error.to_string()))?,
|
||||
)
|
||||
} else {
|
||||
SacdReader::Legacy(
|
||||
SacdTrackReader::open_with(
|
||||
image,
|
||||
track,
|
||||
qbz_disc::sacd::FrameSync::FromPayloadStart,
|
||||
)
|
||||
.map_err(|error| DsdError::Corrupt(error.to_string()))?,
|
||||
)
|
||||
};
|
||||
let dst_decoder = if track.encoding == qbz_disc::sacd::SacdEncoding::Dst {
|
||||
Some(
|
||||
DstDecoder::new(2, DSD64 as usize)
|
||||
.map_err(|error| DsdError::Corrupt(error.to_string()))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Self {
|
||||
reader,
|
||||
info: DsdStreamInfo {
|
||||
@@ -131,7 +179,7 @@ impl SacdDemuxer {
|
||||
// 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,
|
||||
sample_count: u64::from(track.duration_frames) * u64::from(DSD64 / 75),
|
||||
// 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,
|
||||
@@ -143,6 +191,10 @@ impl SacdDemuxer {
|
||||
},
|
||||
layout,
|
||||
pending: Vec::new(),
|
||||
dst_decoder,
|
||||
decode_scratch: vec![0u8; FRAME],
|
||||
track_number: track.number,
|
||||
frame_number: 0,
|
||||
done: false,
|
||||
})
|
||||
}
|
||||
@@ -151,19 +203,54 @@ impl SacdDemuxer {
|
||||
if self.done {
|
||||
return Ok(());
|
||||
}
|
||||
let mut chunk = Vec::new();
|
||||
match self.reader.next_chunk(&mut chunk, REFILL_SECTORS) {
|
||||
// ZERO IS NOT THE END. While the reader is still hunting for the
|
||||
// first frame boundary it drops every audio byte it sees, so an
|
||||
// empty chunk is an ordinary early read. Only the reader knows
|
||||
// whether the track is over, and it is asked.
|
||||
Ok(_) => {
|
||||
match &mut self.reader {
|
||||
SacdReader::Legacy(reader) => {
|
||||
let mut chunk = Vec::new();
|
||||
reader
|
||||
.next_chunk(&mut chunk, REFILL_SECTORS)
|
||||
.map_err(|error| DsdError::Corrupt(error.to_string()))?;
|
||||
self.pending.extend_from_slice(&chunk);
|
||||
if self.reader.finished() {
|
||||
self.done = true;
|
||||
}
|
||||
self.done = reader.finished();
|
||||
}
|
||||
Err(e) => return Err(DsdError::Corrupt(e.to_string())),
|
||||
SacdReader::Framed(reader) => match reader
|
||||
.next_frame()
|
||||
.map_err(|error| DsdError::Corrupt(error.to_string()))?
|
||||
{
|
||||
Some(frame) => {
|
||||
match frame.kind {
|
||||
SacdFrameKind::Dsd => {
|
||||
if frame.payload.len() != FRAME {
|
||||
return Err(DsdError::Corrupt(format!(
|
||||
"SACD track {} frame {} at LSN {} produced {} DSD bytes, expected {FRAME}",
|
||||
self.track_number,
|
||||
self.frame_number,
|
||||
frame.start_lsn,
|
||||
frame.payload.len(),
|
||||
)));
|
||||
}
|
||||
self.pending.extend_from_slice(&frame.payload);
|
||||
}
|
||||
SacdFrameKind::Dst => {
|
||||
let decoder = self.dst_decoder.as_mut().ok_or_else(|| {
|
||||
DsdError::Corrupt(
|
||||
"DST frame reached a stream without a decoder".to_string(),
|
||||
)
|
||||
})?;
|
||||
decode_dst_frame(
|
||||
decoder,
|
||||
&mut self.decode_scratch,
|
||||
&frame.payload,
|
||||
&mut self.pending,
|
||||
self.track_number,
|
||||
self.frame_number,
|
||||
frame.start_lsn,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
self.frame_number = self.frame_number.saturating_add(1);
|
||||
}
|
||||
None => self.done = true,
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -176,9 +263,24 @@ impl DsdDemuxer for SacdDemuxer {
|
||||
|
||||
fn seek_to_bit(&mut self, bit_per_channel: u64) -> Result<(), DsdError> {
|
||||
let target = bit_per_channel.min(self.info.sample_count);
|
||||
self.reader.seek_to_fraction(target, self.info.sample_count);
|
||||
match &mut self.reader {
|
||||
SacdReader::Framed(reader) => reader
|
||||
.seek_to_fraction(target, self.info.sample_count)
|
||||
.map_err(|error| DsdError::Corrupt(error.to_string()))?,
|
||||
SacdReader::Legacy(reader) => reader.seek_to_fraction(target, self.info.sample_count),
|
||||
}
|
||||
if self.dst_decoder.is_some() {
|
||||
self.dst_decoder = Some(
|
||||
DstDecoder::new(2, DSD64 as usize)
|
||||
.map_err(|error| DsdError::Corrupt(error.to_string()))?,
|
||||
);
|
||||
}
|
||||
self.pending.clear();
|
||||
self.done = self.reader.finished();
|
||||
self.frame_number = ((u128::from(target) * 75) / u128::from(DSD64)) as u32;
|
||||
self.done = match &self.reader {
|
||||
SacdReader::Framed(reader) => reader.finished(),
|
||||
SacdReader::Legacy(reader) => reader.finished(),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -281,4 +383,21 @@ mod tests {
|
||||
assert!(r.iter().all(|b| *b == 0x22), "channel 1 is the odd bytes");
|
||||
assert_eq!(l.len() + r.len(), FRAME, "no byte is dropped or doubled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_dst_frame_publishes_none_of_upstreams_synthetic_silence() {
|
||||
let mut decoder = DstDecoder::new(2, DSD64 as usize).unwrap();
|
||||
let mut scratch = vec![0u8; FRAME];
|
||||
let mut pending = vec![0x12, 0x34];
|
||||
let before = pending.clone();
|
||||
|
||||
let result = decode_dst_frame(&mut decoder, &mut scratch, &[], &mut pending, 1, 7, 1234);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(pending, before, "no decoded bytes may be appended on Err");
|
||||
assert!(
|
||||
scratch.iter().all(|byte| *byte == 0x55),
|
||||
"the test must exercise upstream's error-fill behavior"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user