feat(disc): read CD-DA off a real drive, measured before it was written
New crate `qbz-disc`: table of contents and raw audio sectors through the
kernel's own cdrom ioctls. No libcdio — not because libcdio is worse (it is
better at RIPPING than this will ever be) but because this path PLAYS a clean
disc, and for that the kernel interface is enough while a native library would
have to be built into Flatpak, Snap and AppImage. Both need the same --device
permission in a sandbox; only one needs the library shipped.
Every claim below was measured on the owner's USB drive with Tool — Fear
Inoculum BEFORE a line of this crate existed, so the design answers evidence
rather than the other way round:
- the TOC reads back 7 audio tracks, lead-out at LSN 356568, and the track
lengths match the record exactly (10:21, 11:52, 12:44, 13:37, 10:05, 4:48,
15:44 — 79:11 total);
- CDROMREADAUDIO over 20 frames from LSN 4000 returned 47 040 bytes, 99 %
non-zero samples, peak 14559: real audio, not silence;
- the drive hands back LITTLE-endian samples. Neighbouring-sample deltas
average 73.5 read as LE against 20 971 read as BE, a 285x margin, so the
bytes go straight into a little-endian WAV with no swap. That is one
drive, so `looks_byte_swapped` keeps the test available as a diagnostic
instead of an article of faith;
- the disc's MCN is all zeros, which is why identity is a digest over the
track table and not the catalogue number.
What this is NOT is accurate extraction: no overlap synchronisation, no cache
modelling, no jitter verification. On a clean disc that is fine for playback,
and an unreadable sector surfaces as an ERROR rather than a hole full of
zeros — silence that pretends to be audio is the one outcome a bit-perfect
player must never produce.
`/dev/sr0` is nowhere in the code: drives are enumerated, because a box can
have several and /dev/cdrom is a symlink that may point at any of them. Data
tracks of a mixed-mode disc are skipped by their control bits.
`CdRef` is the typed reference (`cdda:/dev/sr0#285735+70833`) with ONE parser,
not prefix tests copied around — the scattered version is a known failure mode
here and cost a whole session on 2026-08-20.
11 tests, several asserting against that real table of contents.
cc
This commit is contained in:
6 files changed
+801
No files matched your search
Generated
+11
@@ -6369,6 +6369,15 @@ dependencies = [
|
||||
"qbz-models",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-disc"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-dsd"
|
||||
version = "2.0.2"
|
||||
@@ -6670,6 +6679,7 @@ dependencies = [
|
||||
"qbz-core",
|
||||
"qbz-credentials",
|
||||
"qbz-dac-wizard-core",
|
||||
"qbz-disc",
|
||||
"qbz-external-reco",
|
||||
"qbz-i18n",
|
||||
"qbz-integrations",
|
||||
@@ -6760,6 +6770,7 @@ version = "2.0.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"log",
|
||||
"qbz-disc",
|
||||
"qbz-jellyfin",
|
||||
"qbz-library",
|
||||
"qbz-media-cache",
|
||||
|
||||
@@ -16,6 +16,10 @@ members = [
|
||||
"qbz-cast",
|
||||
"qbz-cmaf",
|
||||
"qbz-dsd",
|
||||
# Optical media (CD-DA now; SACD/ISO next). Frontend-agnostic, and NOT a
|
||||
# dependency of qbz-dsd's reverse: qbz-dsd will depend on THIS, never the
|
||||
# other way, or the two close a cycle.
|
||||
"qbz-disc",
|
||||
"qbz-secrets",
|
||||
"qbz-offline-cache",
|
||||
"qbz-plex",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "qbz-disc"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Read the disc in the drive and prove the crate agrees with reality.
|
||||
//! `cargo run -p qbz-disc --example cd_probe`
|
||||
fn main() {
|
||||
let devs = qbz_disc::list_devices();
|
||||
println!("devices: {devs:?}");
|
||||
let Some(dev) = devs.first() else {
|
||||
println!("no drive");
|
||||
return;
|
||||
};
|
||||
let toc = match qbz_disc::read_toc(dev) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
println!("toc failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
println!("fingerprint: {}", toc.fingerprint());
|
||||
println!("leadout LSN: {}", toc.leadout_lsn);
|
||||
let mut total = 0u64;
|
||||
for t in &toc.tracks {
|
||||
let d = t.duration_secs();
|
||||
total += d;
|
||||
println!(
|
||||
" track {:2} lsn {:>7} {:>6} sectors {:>2}:{:02} {}",
|
||||
t.number,
|
||||
t.start_lsn,
|
||||
t.sectors,
|
||||
d / 60,
|
||||
d % 60,
|
||||
if t.is_audio { "audio" } else { "DATA (skipped)" }
|
||||
);
|
||||
}
|
||||
println!("total {}:{:02}", total / 60, total % 60);
|
||||
|
||||
// Read the first audio track's opening chunk and prove it is music.
|
||||
let Some(first) = toc.audio_tracks().next() else {
|
||||
return;
|
||||
};
|
||||
let mut r = match qbz_disc::cdda::TrackReader::open(dev, first) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
println!("open failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
// Skip the first second — track 1 often opens on silence.
|
||||
for _ in 0..2 {
|
||||
if let Err(e) = r.next_chunk(&mut buf) {
|
||||
println!("read failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
let samples: Vec<i16> = buf
|
||||
.chunks_exact(2)
|
||||
.map(|b| i16::from_le_bytes([b[0], b[1]]))
|
||||
.collect();
|
||||
let peak = samples.iter().map(|s| s.unsigned_abs()).max().unwrap_or(0);
|
||||
let nz = samples.iter().filter(|s| **s != 0).count();
|
||||
println!(
|
||||
"read {} bytes | {} samples | non-zero {}% | peak {} | byte-swapped? {}",
|
||||
buf.len(),
|
||||
samples.len(),
|
||||
100 * nz / samples.len().max(1),
|
||||
peak,
|
||||
qbz_disc::cdda::looks_byte_swapped(&buf)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
//! CD-DA off a real drive, through the kernel's own ioctls.
|
||||
//!
|
||||
//! No libcdio. Not because libcdio is bad — it is better at RIPPING than this
|
||||
//! will ever be — but because this path PLAYS a clean disc, and for that the
|
||||
//! kernel interface is enough while a native library would have to be built
|
||||
//! into Flatpak, Snap and AppImage. Both approaches need the same `--device`
|
||||
//! permission in a sandbox; only one needs the library shipped.
|
||||
//!
|
||||
//! WHAT THIS IS NOT: accurate extraction. There is no overlap synchronisation,
|
||||
//! no cache modelling, no jitter verification, no scratch reconstruction. On a
|
||||
//! clean disc that is fine for playback; on a marginal one it can click, and
|
||||
//! an unreadable sector surfaces as an ERROR rather than a hole full of zeros.
|
||||
//! Silence that pretends to be audio is the one outcome bit-perfect playback
|
||||
//! must never produce.
|
||||
//!
|
||||
//! MEASURED on the owner's USB drive with Tool — *Fear Inoculum* (2026-08-20),
|
||||
//! before any of this existed:
|
||||
//! - `cd-info` reports access mode IOCTL; the TOC reads back 7 audio tracks,
|
||||
//! lead-out at LSN 356568;
|
||||
//! - `CDROMREADAUDIO` over 20 frames from LSN 4000 returned 47 040 bytes,
|
||||
//! 99 % non-zero samples, peak 14559 — real audio, not silence;
|
||||
//! - the drive hands back LITTLE-endian samples. Neighbouring samples差
|
||||
//! average 73.5 read as LE against 20 971 read as BE, a 285x margin. So
|
||||
//! the bytes go straight into a little-endian WAV with no swap. That is
|
||||
//! measured for THIS drive; `looks_byte_swapped` below keeps the check
|
||||
//! available as a diagnostic rather than as an article of faith.
|
||||
|
||||
use std::os::fd::{AsRawFd, OwnedFd};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// --- ioctl numbers and layouts (uapi/linux/cdrom.h) -------------------------
|
||||
const CDROMREADTOCHDR: libc::c_ulong = 0x5305;
|
||||
const CDROMREADTOCENTRY: libc::c_ulong = 0x5306;
|
||||
const CDROMREADAUDIO: libc::c_ulong = 0x530e;
|
||||
const CDROM_DRIVE_STATUS: libc::c_ulong = 0x5326;
|
||||
|
||||
const CDROM_LBA: u8 = 0x01;
|
||||
/// The lead-out's pseudo track number: its start is the end of the last track.
|
||||
const CDROM_LEADOUT: u8 = 0xAA;
|
||||
const CDS_DISC_OK: libc::c_int = 4;
|
||||
/// `cdte_ctrl` bit 2 marks a DATA track. A mixed-mode disc carries one and it
|
||||
/// must be skipped: handing its bytes to an audio path produces noise.
|
||||
const CTRL_DATA: u8 = 0x04;
|
||||
/// The kernel accepts 1..=75 frames per `CDROMREADAUDIO` call.
|
||||
pub const MAX_FRAMES_PER_READ: u32 = 75;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
struct CdromTocHdr {
|
||||
trk0: u8,
|
||||
trk1: u8,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
struct CdromTocEntry {
|
||||
track: u8,
|
||||
/// adr in the low nibble, ctrl in the high one — a C bitfield, read whole.
|
||||
adr_ctrl: u8,
|
||||
format: u8,
|
||||
_pad: u8,
|
||||
addr_lba: i32,
|
||||
datamode: u8,
|
||||
_pad2: [u8; 3],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct CdromReadAudio {
|
||||
addr_lba: i32,
|
||||
addr_format: u8,
|
||||
_pad: [u8; 3],
|
||||
nframes: libc::c_int,
|
||||
buf: *mut u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CdError {
|
||||
#[error("no optical drive found")]
|
||||
NoDrive,
|
||||
#[error("cannot open {0}: {1}")]
|
||||
Open(PathBuf, std::io::Error),
|
||||
#[error("no disc in the drive")]
|
||||
NoDisc,
|
||||
#[error("the disc has no audio tracks")]
|
||||
NotAudio,
|
||||
#[error("the drive refused to read audio at sector {lsn}: {source}")]
|
||||
Read {
|
||||
lsn: u32,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("reading the table of contents failed: {0}")]
|
||||
Toc(std::io::Error),
|
||||
}
|
||||
|
||||
/// One entry of the disc's table of contents.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TocTrack {
|
||||
pub number: u8,
|
||||
pub start_lsn: u32,
|
||||
/// Sectors up to the next track (or the lead-out for the last one).
|
||||
pub sectors: u32,
|
||||
/// False for the data track of a mixed-mode disc.
|
||||
pub is_audio: bool,
|
||||
}
|
||||
|
||||
impl TocTrack {
|
||||
pub fn duration_secs(&self) -> u64 {
|
||||
crate::sectors_to_secs(self.sectors)
|
||||
}
|
||||
/// PCM frames (sample pairs) this track decodes to.
|
||||
pub fn frames(&self) -> u64 {
|
||||
self.sectors as u64 * 588
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Toc {
|
||||
pub device: PathBuf,
|
||||
pub tracks: Vec<TocTrack>,
|
||||
pub leadout_lsn: u32,
|
||||
}
|
||||
|
||||
impl Toc {
|
||||
/// The AUDIO tracks, in disc order. A caller that wants to play something
|
||||
/// should use this and never `tracks` — the data track of an enhanced CD
|
||||
/// lives in the latter.
|
||||
pub fn audio_tracks(&self) -> impl Iterator<Item = &TocTrack> {
|
||||
self.tracks.iter().filter(|t| t.is_audio)
|
||||
}
|
||||
|
||||
/// A stable identity for the MEDIUM, not the drive.
|
||||
///
|
||||
/// `/dev/sr0` names a piece of hardware; two different discs in it are two
|
||||
/// different sessions, and a persisted queue that resolves against the
|
||||
/// drive would happily play track 3 of whatever is loaded now. The digest
|
||||
/// is over every track's start and length plus the lead-out, which is what
|
||||
/// a disc actually is. (The disc's own MCN would be tidier — the owner's
|
||||
/// Fear Inoculum reports all zeros, so it is not dependable.)
|
||||
pub fn fingerprint(&self) -> String {
|
||||
// FNV-1a: no dependency, and this only has to be stable and
|
||||
// collision-resistant enough to tell two albums apart.
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
let mut eat = |v: u32| {
|
||||
for b in v.to_le_bytes() {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x1000_0000_01b3);
|
||||
}
|
||||
};
|
||||
for t in &self.tracks {
|
||||
eat(t.start_lsn);
|
||||
eat(t.sectors);
|
||||
eat(t.number as u32);
|
||||
}
|
||||
eat(self.leadout_lsn);
|
||||
format!("{h:016x}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Optical devices the kernel is showing, most conventional first.
|
||||
///
|
||||
/// `/dev/sr0` is NOT hardcoded anywhere in this crate: a box can have several
|
||||
/// drives, and `/dev/cdrom` is a udev symlink that may point at any of them.
|
||||
pub fn list_devices() -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for n in 0..8 {
|
||||
let p = PathBuf::from(format!("/dev/sr{n}"));
|
||||
if p.exists() {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
let alt = PathBuf::from("/dev/cdrom");
|
||||
if alt.exists() {
|
||||
out.push(alt);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn open_device(dev: &Path) -> Result<OwnedFd, CdError> {
|
||||
// O_NONBLOCK is required, not optional: without it `open` on an optical
|
||||
// device BLOCKS until a disc is loaded and spun up, which on an empty tray
|
||||
// means hanging forever.
|
||||
let c = std::ffi::CString::new(dev.as_os_str().as_encoded_bytes())
|
||||
.map_err(|_| CdError::Open(dev.to_path_buf(), std::io::Error::other("bad path")))?;
|
||||
let fd = unsafe { libc::open(c.as_ptr(), libc::O_RDONLY | libc::O_NONBLOCK) };
|
||||
if fd < 0 {
|
||||
return Err(CdError::Open(
|
||||
dev.to_path_buf(),
|
||||
std::io::Error::last_os_error(),
|
||||
));
|
||||
}
|
||||
Ok(unsafe { <OwnedFd as std::os::fd::FromRawFd>::from_raw_fd(fd) })
|
||||
}
|
||||
|
||||
/// Read the table of contents, skipping data tracks by their control bits.
|
||||
pub fn read_toc(dev: &Path) -> Result<Toc, CdError> {
|
||||
let fd = open_device(dev)?;
|
||||
let raw = fd.as_raw_fd();
|
||||
|
||||
let status = unsafe { libc::ioctl(raw, CDROM_DRIVE_STATUS, 0) };
|
||||
if status != CDS_DISC_OK {
|
||||
return Err(CdError::NoDisc);
|
||||
}
|
||||
|
||||
let mut hdr = CdromTocHdr::default();
|
||||
if unsafe { libc::ioctl(raw, CDROMREADTOCHDR, &mut hdr) } < 0 {
|
||||
return Err(CdError::Toc(std::io::Error::last_os_error()));
|
||||
}
|
||||
|
||||
let entry_lba = |n: u8| -> Result<(u32, u8), CdError> {
|
||||
let mut e = CdromTocEntry {
|
||||
track: n,
|
||||
format: CDROM_LBA,
|
||||
..Default::default()
|
||||
};
|
||||
if unsafe { libc::ioctl(raw, CDROMREADTOCENTRY, &mut e) } < 0 {
|
||||
return Err(CdError::Toc(std::io::Error::last_os_error()));
|
||||
}
|
||||
Ok((e.addr_lba.max(0) as u32, e.adr_ctrl >> 4))
|
||||
};
|
||||
|
||||
let (leadout_lsn, _) = entry_lba(CDROM_LEADOUT)?;
|
||||
|
||||
let mut raw_tracks: Vec<(u8, u32, bool)> = Vec::new();
|
||||
for n in hdr.trk0..=hdr.trk1 {
|
||||
let (lba, ctrl) = entry_lba(n)?;
|
||||
raw_tracks.push((n, lba, ctrl & CTRL_DATA == 0));
|
||||
}
|
||||
|
||||
// A track's length is the gap to whatever starts next — the following
|
||||
// track, or the lead-out for the last one. The TOC does not carry lengths.
|
||||
let mut tracks = Vec::with_capacity(raw_tracks.len());
|
||||
for (i, &(number, start_lsn, is_audio)) in raw_tracks.iter().enumerate() {
|
||||
let next = raw_tracks
|
||||
.get(i + 1)
|
||||
.map(|t| t.1)
|
||||
.unwrap_or(leadout_lsn);
|
||||
tracks.push(TocTrack {
|
||||
number,
|
||||
start_lsn,
|
||||
sectors: next.saturating_sub(start_lsn),
|
||||
is_audio,
|
||||
});
|
||||
}
|
||||
|
||||
if !tracks.iter().any(|t| t.is_audio) {
|
||||
return Err(CdError::NotAudio);
|
||||
}
|
||||
|
||||
Ok(Toc {
|
||||
device: dev.to_path_buf(),
|
||||
tracks,
|
||||
leadout_lsn,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `frames` sectors starting at `lsn` into `out`, which is resized to fit.
|
||||
///
|
||||
/// Retries a few times before giving up: a marginal sector often reads on a
|
||||
/// second attempt, and the alternative to a retry here is a click. What it
|
||||
/// will NOT do is return zeros — an unrecoverable sector is an error, and the
|
||||
/// caller stops the track rather than playing silence over it.
|
||||
pub fn read_audio(dev: &Path, lsn: u32, frames: u32, out: &mut Vec<u8>) -> Result<(), CdError> {
|
||||
let frames = frames.clamp(1, MAX_FRAMES_PER_READ);
|
||||
out.resize(frames as usize * crate::CDDA_SECTOR_BYTES, 0);
|
||||
let fd = open_device(dev)?;
|
||||
read_audio_fd(fd.as_raw_fd(), dev, lsn, frames, out)
|
||||
}
|
||||
|
||||
fn read_audio_fd(
|
||||
raw: libc::c_int,
|
||||
dev: &Path,
|
||||
lsn: u32,
|
||||
frames: u32,
|
||||
out: &mut [u8],
|
||||
) -> Result<(), CdError> {
|
||||
let mut last = std::io::Error::other("no attempt made");
|
||||
for attempt in 0..3 {
|
||||
let mut arg = CdromReadAudio {
|
||||
addr_lba: lsn as i32,
|
||||
addr_format: CDROM_LBA,
|
||||
_pad: [0; 3],
|
||||
nframes: frames as libc::c_int,
|
||||
buf: out.as_mut_ptr(),
|
||||
};
|
||||
if unsafe { libc::ioctl(raw, CDROMREADAUDIO, &mut arg) } >= 0 {
|
||||
if attempt > 0 {
|
||||
log::info!("[cdda] sector {lsn} read on attempt {}", attempt + 1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
last = std::io::Error::last_os_error();
|
||||
}
|
||||
log::warn!(
|
||||
"[cdda] {} refused sector {lsn} after 3 attempts: {last}",
|
||||
dev.display()
|
||||
);
|
||||
Err(CdError::Read { lsn, source: last })
|
||||
}
|
||||
|
||||
/// An open reader positioned on one track, so a whole track is read through a
|
||||
/// SINGLE file descriptor instead of re-opening the device 4000 times.
|
||||
pub struct TrackReader {
|
||||
fd: OwnedFd,
|
||||
device: PathBuf,
|
||||
next_lsn: u32,
|
||||
end_lsn: u32,
|
||||
}
|
||||
|
||||
impl TrackReader {
|
||||
pub fn open(dev: &Path, track: &TocTrack) -> Result<Self, CdError> {
|
||||
Ok(Self {
|
||||
fd: open_device(dev)?,
|
||||
device: dev.to_path_buf(),
|
||||
next_lsn: track.start_lsn,
|
||||
end_lsn: track.start_lsn + track.sectors,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finished(&self) -> bool {
|
||||
self.next_lsn >= self.end_lsn
|
||||
}
|
||||
|
||||
/// Next chunk of raw CD-DA bytes, up to `MAX_FRAMES_PER_READ` sectors.
|
||||
/// `Ok(0)` means the track is done.
|
||||
pub fn next_chunk(&mut self, out: &mut Vec<u8>) -> Result<usize, CdError> {
|
||||
if self.finished() {
|
||||
out.clear();
|
||||
return Ok(0);
|
||||
}
|
||||
let frames = (self.end_lsn - self.next_lsn).min(MAX_FRAMES_PER_READ);
|
||||
out.resize(frames as usize * crate::CDDA_SECTOR_BYTES, 0);
|
||||
read_audio_fd(
|
||||
self.fd.as_raw_fd(),
|
||||
&self.device,
|
||||
self.next_lsn,
|
||||
frames,
|
||||
out,
|
||||
)?;
|
||||
self.next_lsn += frames;
|
||||
Ok(out.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostic: does this buffer look byte-swapped?
|
||||
///
|
||||
/// Audio is continuous, so neighbouring samples differ by little; a wrong
|
||||
/// endianness turns that into noise. Measured on the owner's drive the ratio
|
||||
/// was 285x in favour of little-endian, so this returns false there. It exists
|
||||
/// because "every drive is little-endian" is a claim about ONE drive, and the
|
||||
/// honest way to hold it is as something checkable rather than assumed.
|
||||
pub fn looks_byte_swapped(pcm: &[u8]) -> bool {
|
||||
let step = |swap: bool| -> f64 {
|
||||
let mut total = 0f64;
|
||||
let mut n = 0u32;
|
||||
let mut prev: Option<i16> = None;
|
||||
// Left channel only: 4 bytes per frame, take the first pair.
|
||||
for f in pcm.chunks_exact(4) {
|
||||
let s = if swap {
|
||||
i16::from_be_bytes([f[0], f[1]])
|
||||
} else {
|
||||
i16::from_le_bytes([f[0], f[1]])
|
||||
};
|
||||
if let Some(p) = prev {
|
||||
total += (s as i32 - p as i32).unsigned_abs() as f64;
|
||||
n += 1;
|
||||
}
|
||||
prev = Some(s);
|
||||
}
|
||||
if n == 0 {
|
||||
0.0
|
||||
} else {
|
||||
total / n as f64
|
||||
}
|
||||
};
|
||||
let le = step(false);
|
||||
let be = step(true);
|
||||
be > 0.0 && le > be
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn toc(entries: &[(u8, u32, bool)], leadout: u32) -> Toc {
|
||||
let mut tracks = Vec::new();
|
||||
for (i, &(number, start_lsn, is_audio)) in entries.iter().enumerate() {
|
||||
let next = entries.get(i + 1).map(|e| e.1).unwrap_or(leadout);
|
||||
tracks.push(TocTrack {
|
||||
number,
|
||||
start_lsn,
|
||||
sectors: next - start_lsn,
|
||||
is_audio,
|
||||
});
|
||||
}
|
||||
Toc {
|
||||
device: PathBuf::from("/dev/null"),
|
||||
tracks,
|
||||
leadout_lsn: leadout,
|
||||
}
|
||||
}
|
||||
|
||||
/// The REAL table of contents of the owner's disc, read 2026-08-20.
|
||||
fn fear_inoculum() -> Toc {
|
||||
toc(
|
||||
&[
|
||||
(1, 0, true),
|
||||
(2, 46_577, true),
|
||||
(3, 100_047, true),
|
||||
(4, 157_373, true),
|
||||
(5, 218_718, true),
|
||||
(6, 264_120, true),
|
||||
(7, 285_735, true),
|
||||
],
|
||||
356_568,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_lengths_come_from_the_gaps_and_the_leadout() {
|
||||
let t = fear_inoculum();
|
||||
// Track 1 = 10:21, and the LAST track has to reach the lead-out or the
|
||||
// longest song on the disc would be cut short.
|
||||
assert_eq!(t.tracks[0].sectors, 46_577);
|
||||
assert_eq!(t.tracks[0].duration_secs(), 621);
|
||||
assert_eq!(t.tracks[6].sectors, 70_833);
|
||||
assert_eq!(t.tracks[6].duration_secs(), 944);
|
||||
assert_eq!(t.tracks.len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_data_track_is_never_offered_as_audio() {
|
||||
// Enhanced CD: three songs then a data track.
|
||||
let t = toc(&[(1, 0, true), (2, 20_000, true), (3, 40_000, false)], 60_000);
|
||||
assert_eq!(t.tracks.len(), 3);
|
||||
assert_eq!(t.audio_tracks().count(), 2);
|
||||
assert!(t.audio_tracks().all(|x| x.number != 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fingerprint_identifies_the_MEDIUM_not_the_drive() {
|
||||
let a = fear_inoculum();
|
||||
let mut b = fear_inoculum();
|
||||
b.device = PathBuf::from("/dev/sr3");
|
||||
// Same disc in a different drive: same identity.
|
||||
assert_eq!(a.fingerprint(), b.fingerprint());
|
||||
|
||||
// A different disc with the same track COUNT must not collide — this
|
||||
// is the case that would resolve a persisted queue onto the wrong
|
||||
// album.
|
||||
let other = toc(
|
||||
&[
|
||||
(1, 0, true),
|
||||
(2, 46_500, true),
|
||||
(3, 100_047, true),
|
||||
(4, 157_373, true),
|
||||
(5, 218_718, true),
|
||||
(6, 264_120, true),
|
||||
(7, 285_735, true),
|
||||
],
|
||||
356_568,
|
||||
);
|
||||
assert_ne!(a.fingerprint(), other.fingerprint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_per_track_match_the_wav_the_player_will_be_promised() {
|
||||
let t = fear_inoculum();
|
||||
let first = &t.tracks[0];
|
||||
assert_eq!(first.frames(), 46_577 * 588);
|
||||
// 10:21 of 44.1k stereo 16-bit — the size the streaming path declares.
|
||||
assert_eq!(
|
||||
crate::wav_total_size_16(first.frames(), 2),
|
||||
44 + 46_577 * 588 * 4
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_is_clamped_to_what_the_kernel_accepts() {
|
||||
// The ioctl takes 1..=75 frames; asking for more is not a silent
|
||||
// truncation somewhere deep, it is clamped here where it is visible.
|
||||
assert_eq!(MAX_FRAMES_PER_READ, 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_endianness_probe_agrees_with_the_measured_drive() {
|
||||
// A smooth ramp is what real audio looks like read the RIGHT way.
|
||||
let mut pcm = Vec::new();
|
||||
for i in 0..2000i16 {
|
||||
let s = (i.wrapping_mul(7)) % 3000;
|
||||
pcm.extend_from_slice(&s.to_le_bytes()); // L
|
||||
pcm.extend_from_slice(&s.to_le_bytes()); // R
|
||||
}
|
||||
assert!(!looks_byte_swapped(&pcm), "little-endian audio flagged as swapped");
|
||||
|
||||
let swapped: Vec<u8> = pcm
|
||||
.chunks_exact(2)
|
||||
.flat_map(|p| [p[1], p[0]])
|
||||
.collect();
|
||||
assert!(looks_byte_swapped(&swapped), "byte-swapped audio not detected");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Optical media QBZ can PLAY without indexing any of it.
|
||||
//!
|
||||
//! Frontend-agnostic (ADR-006): no UI, no player, no Qt. The crate hands back
|
||||
//! a track list and raw bytes; who plays them, and how, is the frontend's
|
||||
//! problem.
|
||||
//!
|
||||
//! Deliberately does NOT depend on `qbz-dsd`. The dependency runs the other
|
||||
//! way — `qbz-dsd` adapts this crate's SACD reader to its `DsdDemuxer` trait —
|
||||
//! because the reverse closes a cycle.
|
||||
//!
|
||||
//! Phase 1 covers CD-DA from a real drive. What is not supported is DETECTED
|
||||
//! and REPORTED, never silently degraded: a data track in a mixed-mode disc is
|
||||
//! skipped by name, an unreadable sector is an error rather than a hole full
|
||||
//! of zeros. Silence that pretends to be audio is the one outcome a
|
||||
//! bit-perfect player must never produce.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod cdda;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use cdda::{list_devices, read_audio, read_toc, CdError, Toc, TocTrack};
|
||||
|
||||
/// Bytes per CD-DA sector (a "frame" in the kernel's vocabulary): 588 stereo
|
||||
/// 16-bit sample pairs.
|
||||
pub const CDDA_SECTOR_BYTES: usize = 2352;
|
||||
/// Sectors per second of CD-DA audio.
|
||||
pub const CDDA_SECTORS_PER_SEC: u32 = 75;
|
||||
/// CD-DA is 44 100 Hz, 16-bit, stereo. These are not configurable and any code
|
||||
/// that treats them as such has misunderstood the format.
|
||||
pub const CDDA_SAMPLE_RATE: u32 = 44_100;
|
||||
pub const CDDA_BITS: u16 = 16;
|
||||
pub const CDDA_CHANNELS: u16 = 2;
|
||||
|
||||
/// Seconds of audio in `sectors`, rounded down.
|
||||
pub fn sectors_to_secs(sectors: u32) -> u64 {
|
||||
(sectors / CDDA_SECTORS_PER_SEC) as u64
|
||||
}
|
||||
|
||||
/// A 44-byte RIFF/WAVE header for 16-BIT PCM.
|
||||
///
|
||||
/// `qbz-dsd` has a `wav_header` already and it is hardcoded to 24-bit — the
|
||||
/// depth its converter emits. Reusing it for a CD would mean either lying in
|
||||
/// the header or promoting 16-bit samples to 24, and while that shift is
|
||||
/// arithmetically lossless it makes the DAC open at 24 bits for a 16-bit
|
||||
/// source. That is exactly the silent conversion a bit-perfect path must not
|
||||
/// contain, so a CD gets its own header.
|
||||
pub fn wav_header_16(total_frames: u64, channels: u16, sample_rate: u32) -> Vec<u8> {
|
||||
const HEADER_LEN: usize = 44;
|
||||
let bytes_per_sample = 2u64;
|
||||
let data_len = (total_frames * channels as u64 * bytes_per_sample) as u32;
|
||||
let byte_rate = sample_rate * channels as u32 * bytes_per_sample as u32;
|
||||
let block_align = channels * bytes_per_sample as u16;
|
||||
let mut h = Vec::with_capacity(HEADER_LEN);
|
||||
h.extend_from_slice(b"RIFF");
|
||||
h.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
h.extend_from_slice(b"WAVE");
|
||||
h.extend_from_slice(b"fmt ");
|
||||
h.extend_from_slice(&16u32.to_le_bytes());
|
||||
h.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||
h.extend_from_slice(&channels.to_le_bytes());
|
||||
h.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
h.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
h.extend_from_slice(&block_align.to_le_bytes());
|
||||
h.extend_from_slice(&CDDA_BITS.to_le_bytes());
|
||||
h.extend_from_slice(b"data");
|
||||
h.extend_from_slice(&data_len.to_le_bytes());
|
||||
debug_assert_eq!(h.len(), HEADER_LEN);
|
||||
h
|
||||
}
|
||||
|
||||
/// Total size of the WAV a track becomes — what the streaming player needs as
|
||||
/// its `content_length`.
|
||||
pub fn wav_total_size_16(total_frames: u64, channels: u16) -> u64 {
|
||||
44 + total_frames * channels as u64 * 2
|
||||
}
|
||||
|
||||
/// A CD-DA track, encoded as ONE string so it can ride in `LocalTrack.file_path`
|
||||
/// and through every store and queue that carries a path today.
|
||||
///
|
||||
/// `cdda:/dev/sr0#0+46577` — device, start sector, sector count.
|
||||
///
|
||||
/// It is a real type with a single `parse`/`to_path_string` pair rather than
|
||||
/// ad-hoc `starts_with` tests scattered around, because the scattered version
|
||||
/// is a known failure mode in this codebase: a second list of prefixes always
|
||||
/// ends up missing one site, and the fold is silent (see the six "a place that
|
||||
/// enumerates sources by hand" bugs of 2026-08-20). One parser, one shape.
|
||||
///
|
||||
/// It is NOT a filesystem path and nothing may stat, open or canonicalise it.
|
||||
/// Callers test with [`CdRef::is_cd_path`] BEFORE reaching for the filesystem.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CdRef {
|
||||
pub device: std::path::PathBuf,
|
||||
pub start_lsn: u32,
|
||||
pub sectors: u32,
|
||||
}
|
||||
|
||||
impl CdRef {
|
||||
pub const SCHEME: &'static str = "cdda:";
|
||||
|
||||
/// Cheap test callers use to route BEFORE touching the filesystem.
|
||||
pub fn is_cd_path(s: &str) -> bool {
|
||||
s.starts_with(Self::SCHEME)
|
||||
}
|
||||
|
||||
pub fn to_path_string(&self) -> String {
|
||||
format!(
|
||||
"{}{}#{}+{}",
|
||||
Self::SCHEME,
|
||||
self.device.display(),
|
||||
self.start_lsn,
|
||||
self.sectors
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let rest = s.strip_prefix(Self::SCHEME)?;
|
||||
let (dev, range) = rest.rsplit_once('#')?;
|
||||
let (lsn, sectors) = range.split_once('+')?;
|
||||
if dev.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
device: std::path::PathBuf::from(dev),
|
||||
start_lsn: lsn.parse().ok()?,
|
||||
sectors: sectors.parse().ok()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_header_describes_a_cd_and_not_a_dsd_conversion() {
|
||||
// 10 seconds of CD-DA.
|
||||
let frames = 44_100u64 * 10;
|
||||
let h = wav_header_16(frames, 2, 44_100);
|
||||
assert_eq!(&h[0..4], b"RIFF");
|
||||
assert_eq!(&h[8..12], b"WAVE");
|
||||
// bits per sample at offset 34 — 16, never 24.
|
||||
assert_eq!(u16::from_le_bytes([h[34], h[35]]), 16);
|
||||
assert_eq!(u32::from_le_bytes([h[24], h[25], h[26], h[27]]), 44_100);
|
||||
// byte rate = 44100 * 2ch * 2 bytes
|
||||
assert_eq!(u32::from_le_bytes([h[28], h[29], h[30], h[31]]), 176_400);
|
||||
// and the sizes agree with the standalone helper
|
||||
assert_eq!(
|
||||
wav_total_size_16(frames, 2),
|
||||
44 + u32::from_le_bytes([h[40], h[41], h[42], h[43]]) as u64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_measured_track_length_converts_to_its_real_duration() {
|
||||
// Tool — Fear Inoculum, measured off the owner's disc 2026-08-20:
|
||||
// track 1 spans 46577 sectors and track 7 spans 70833.
|
||||
assert_eq!(sectors_to_secs(46_577), 621); // 10:21
|
||||
assert_eq!(sectors_to_secs(70_833), 944); // 15:44
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cd_reference_round_trips() {
|
||||
let r = CdRef {
|
||||
device: std::path::PathBuf::from("/dev/sr0"),
|
||||
start_lsn: 285_735,
|
||||
sectors: 70_833,
|
||||
};
|
||||
let s = r.to_path_string();
|
||||
assert_eq!(s, "cdda:/dev/sr0#285735+70833");
|
||||
assert_eq!(CdRef::parse(&s), Some(r));
|
||||
assert!(CdRef::is_cd_path(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_path_is_never_mistaken_for_a_disc() {
|
||||
// The whole point of the scheme: no filesystem path can collide with
|
||||
// it, so a routing test cannot send a real file down the disc path.
|
||||
for p in [
|
||||
"/home/u/Music/a.flac",
|
||||
"/home/u/cdda:weird/x.dsf",
|
||||
"plex:1234",
|
||||
"",
|
||||
] {
|
||||
assert!(!CdRef::is_cd_path(p), "{p} was taken for a disc");
|
||||
assert_eq!(CdRef::parse(p), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_reference_is_rejected_rather_than_guessed() {
|
||||
for bad in [
|
||||
"cdda:",
|
||||
"cdda:/dev/sr0",
|
||||
"cdda:/dev/sr0#",
|
||||
"cdda:/dev/sr0#12",
|
||||
"cdda:/dev/sr0#12+",
|
||||
"cdda:/dev/sr0#a+b",
|
||||
"cdda:#0+1",
|
||||
] {
|
||||
assert_eq!(CdRef::parse(bad), None, "{bad} parsed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user