Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/target
*.wav
104 changes: 104 additions & 0 deletions examples/play_audio_track.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//! Reads a short preview of the first audio track from the default drive and
//! plays it out loud, so you can confirm by ear that audio extraction works —
//! including on a mixed-mode / enhanced CD where data tracks sit alongside the
//! audio ones.
//!
//! It reads only the first N seconds (30 by default) rather than the whole
//! track, so it returns quickly. CD-DA is 75 sectors/second and already raw PCM
//! (44100 Hz, 16-bit signed little-endian, stereo), which is exactly WAV's
//! native format, so `create_wav` just prepends a 44-byte RIFF header and the
//! result is directly playable — no codecs, no extra crates.
//!
//! Usage:
//! cargo run --example play_audio_track # first 30 seconds
//! cargo run --example play_audio_track -- 60 # first 60 seconds
//!
//! The WAV is written to the current directory and then handed to the platform's
//! built-in player (`afplay` on macOS, `Media.SoundPlayer` on Windows). On other
//! platforms it is saved and you are told how to play it yourself.
use cd_da_reader::{CdReader, RetryConfig, SectorReadMode};

/// CD-DA plays 75 sectors (each 2352 bytes) per second.
const SECTORS_PER_SECOND: u32 = 75;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let seconds: u32 = match std::env::args().nth(1) {
Some(a) => a.parse()?,
None => 30,
};

let reader = CdReader::open_default()?;
let toc = reader.read_toc()?;

let track = toc
.tracks
.iter()
.find(|t| t.is_audio)
.ok_or("no audio tracks found on this disc")?;

// Clamp the preview to what the track actually holds: the track ends where
// the next track starts, or at the lead-out if it's the last one.
let track_end = toc
.tracks
.iter()
.map(|t| t.start_lba)
.filter(|&lba| lba > track.start_lba)
.min()
.unwrap_or(toc.leadout_lba);
let track_sectors = track_end - track.start_lba;
let sectors = (seconds * SECTORS_PER_SECOND).min(track_sectors);
let actual_seconds = sectors / SECTORS_PER_SECOND;

println!(
"Reading first {actual_seconds}s ({sectors} sectors) of audio track #{}...",
track.number
);
let pcm = reader.read_data_sectors(
track.start_lba,
sectors,
SectorReadMode::Audio,
&RetryConfig::default(),
)?;
println!(
"Read {} bytes of PCM ({:.1} MiB)",
pcm.len(),
pcm.len() as f64 / (1024.0 * 1024.0)
);

let filename = format!("track{:02}_preview.wav", track.number);
std::fs::write(&filename, CdReader::create_wav(pcm))?;
println!("Saved {filename}");

play(&filename)
}

/// Hand the WAV to the OS's built-in player and block until it finishes.
#[cfg(target_os = "macos")]
fn play(path: &str) -> Result<(), Box<dyn std::error::Error>> {
println!("Playing (Ctrl+C to stop)...");
let status = std::process::Command::new("afplay").arg(path).status()?;
if !status.success() {
return Err("afplay exited with an error".into());
}
Ok(())
}

#[cfg(target_os = "windows")]
fn play(path: &str) -> Result<(), Box<dyn std::error::Error>> {
println!("Playing (this blocks until the preview ends)...");
// SoundPlayer.PlaySync plays a WAV synchronously using the built-in player.
let script = format!("(New-Object Media.SoundPlayer '{path}').PlaySync()");
let status = std::process::Command::new("powershell")
.args(["-NoProfile", "-Command", &script])
.status()?;
if !status.success() {
return Err("powershell SoundPlayer exited with an error".into());
}
Ok(())
}

#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn play(path: &str) -> Result<(), Box<dyn std::error::Error>> {
println!("Saved the WAV — play it with your audio player, e.g. `aplay {path}`.");
Ok(())
}
90 changes: 90 additions & 0 deletions examples/read_data_track.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/// Reads the first data track from a mixed-mode / enhanced CD and verifies the
/// result against the on-disc structure, so it doubles as a correctness check
/// for the cooked (2048 B) and raw (2352 B) data read paths.
///
/// What it checks, using only the data the disc itself carries:
/// 1. Raw sector framing: a 2352 B sector starts with the 12-byte sync
/// pattern `00 FF*10 00`, and byte 15 reports the sector mode.
/// 2. ISO 9660 signature: logical sector 16 of the volume is the Primary
/// Volume Descriptor — type byte `0x01` followed by `"CD001"`.
/// 3. Cooked vs raw: the cooked 2048 B must equal the user-data region of
/// the raw sector (offset 16 for Mode 1).
use cd_da_reader::{CdReader, RetryConfig, SectorReadMode};

// ISO 9660 places the Primary Volume Descriptor at logical sector 16.
const PVD_SECTOR_OFFSET: u32 = 16;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let reader = CdReader::open_default()?;
let toc = reader.read_toc()?;

let data_track = toc
.tracks
.iter()
.find(|t| !t.is_audio)
.ok_or("no data track on this disc (need a mixed-mode / enhanced CD)")?;

let pvd_lba = data_track.start_lba + PVD_SECTOR_OFFSET;
println!(
"Data track #{} starts at LBA {}; reading PVD at LBA {}\n",
data_track.number, data_track.start_lba, pvd_lba
);

let cfg = RetryConfig::default();

// --- raw read (2352 B) -------------------------------------------------
let raw = reader.read_data_sectors(pvd_lba, 1, SectorReadMode::DataRaw, &cfg)?;
if raw.len() != 2352 {
return Err(format!("raw read returned {} bytes, expected 2352", raw.len()).into());
}

let sync_ok = raw[0] == 0x00 && raw[1..11].iter().all(|&b| b == 0xFF) && raw[11] == 0x00;
let mode = raw[15];
println!("raw sync pattern : {}", pass(sync_ok));
println!("raw sector mode : Mode {mode}");

// User data sits after sync(12) + header(4) for Mode 1, and additionally
// after an 8-byte subheader for Mode 2 Form 1.
let user_offset = match mode {
1 => 16,
2 => 24,
other => return Err(format!("unexpected sector mode {other}").into()),
};
let raw_user = &raw[user_offset..user_offset + 2048];

let iso_ok = raw_user[0] == 0x01 && &raw_user[1..6] == b"CD001";
println!("ISO 9660 'CD001' : {}", pass(iso_ok));

// --- cooked read (2048 B) ---------------------------------------------
// Our cooked mode maps to Mode 1; only meaningful to cross-check there.
if mode == 1 {
let cooked = reader.read_data_sectors(pvd_lba, 1, SectorReadMode::DataCooked, &cfg)?;
if cooked.len() != 2048 {
return Err(
format!("cooked read returned {} bytes, expected 2048", cooked.len()).into(),
);
}
let matches_raw = cooked == raw_user;
println!("cooked == raw[16..]: {}", pass(matches_raw));

if sync_ok && iso_ok && matches_raw {
println!("\nALL CHECKS PASSED — cooked and raw data reads are correct.");
return Ok(());
}
} else {
println!(
"\nData track is Mode {mode} (e.g. CD-Extra / Mode 2 Form 1). The cooked path \
targets Mode 1, so only the raw checks apply here."
);
if sync_ok && iso_ok {
println!("Raw read verified against on-disc ISO structure.");
return Ok(());
}
}

Err("one or more verification checks FAILED — see output above".into())
}

fn pass(ok: bool) -> &'static str {
if ok { "PASS" } else { "FAIL" }
}
95 changes: 95 additions & 0 deletions src/data_reader.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/// Sector read mode for the READ CD (0xBE) command.
///
/// Controls CDB byte 1 (Expected Sector Type) and byte 9 (Main Channel Selection)
/// to read different sector formats from the same READ CD command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SectorReadMode {
/// Audio: 2352 bytes/sector, raw PCM.
/// CDB byte 1 = 0x00 (any type), byte 9 = 0x10 (user data).
Audio,
/// Data cooked: 2048 bytes/sector, user data only (no sync/header/EDC/ECC).
/// CDB byte 1 = 0x08 (Mode 1), byte 9 = 0x10 (user data).
DataCooked,
/// Data raw: 2352 bytes/sector with sync + header + user data + EDC/ECC.
/// CDB byte 1 = 0x08 (Mode 1), byte 9 = 0xF8 (sync + header + user data + EDC/ECC).
DataRaw,
}

impl SectorReadMode {
/// Bytes per sector for this read mode.
pub fn sector_size(&self) -> usize {
match self {
SectorReadMode::Audio => 2352,
SectorReadMode::DataCooked => 2048,
SectorReadMode::DataRaw => 2352,
}
}

/// CDB byte 1: Expected Sector Type field (bits 4-2).
///
/// Per SCSI MMC, the type value is shifted left by 2: Mode 1 is
/// `010b << 2 = 0x08`. (`0x04` would be `001b << 2`, i.e. CD-DA.)
pub fn cdb_byte1(&self) -> u8 {
match self {
SectorReadMode::Audio => 0x00,
SectorReadMode::DataCooked => 0x08,
SectorReadMode::DataRaw => 0x08,
}
}

/// CDB byte 9: Main Channel Selection bits.
pub fn cdb_byte9(&self) -> u8 {
match self {
SectorReadMode::Audio => 0x10,
SectorReadMode::DataCooked => 0x10,
SectorReadMode::DataRaw => 0xF8,
}
}

/// Maximum sectors per single `READ CD` command.
///
/// This is the sole chunker for the blocking `read_track` /
/// `read_data_sectors` paths, which hand a whole track (tens of thousands
/// of sectors) straight to the read loop; the streaming API already limits
/// itself via `TrackStreamConfig::sectors_per_chunk`. The cap is not about
/// OS pass-through limits (modern SG_IO/SPTI handle far larger transfers)
/// but about optical-drive firmware and USB-bridge reliability: large
/// multi-sector `READ CD` requests are flaky across the zoo of drives. The
/// values keep each transfer around 64 KiB, matching the conventional
/// ~27-sector chunk used by cdparanoia/libcdio.
pub(crate) fn max_sectors_per_xfer(&self) -> u32 {
match self.sector_size() {
2048 => 32, // 32 * 2048 = 64 KiB
_ => 27, // 27 * 2352 ≈ 62 KiB
}
}
}

#[cfg(test)]
mod tests {
use super::SectorReadMode;

#[test]
fn cdb_byte1_encodes_expected_sector_type() {
// Expected Sector Type lives in CDB byte 1, bits 4-2 (value << 2).
// Audio leaves it 0 (any type) and relies on byte 9; data reads must
// select Mode 1 = 010b << 2 = 0x08 (0x04 would wrongly mean CD-DA).
assert_eq!(SectorReadMode::Audio.cdb_byte1(), 0x00);
assert_eq!(SectorReadMode::DataCooked.cdb_byte1(), 0x08);
assert_eq!(SectorReadMode::DataRaw.cdb_byte1(), 0x08);
}

#[test]
fn cdb_byte9_selects_main_channel() {
assert_eq!(SectorReadMode::Audio.cdb_byte9(), 0x10);
assert_eq!(SectorReadMode::DataCooked.cdb_byte9(), 0x10);
assert_eq!(SectorReadMode::DataRaw.cdb_byte9(), 0xF8);
}

#[test]
fn sector_size_matches_mode() {
assert_eq!(SectorReadMode::Audio.sector_size(), 2352);
assert_eq!(SectorReadMode::DataCooked.sector_size(), 2048);
assert_eq!(SectorReadMode::DataRaw.sector_size(), 2352);
}
}
46 changes: 46 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,14 @@ mod macos;
#[cfg(target_os = "windows")]
mod windows;

pub mod data_reader;
mod discovery;
mod errors;
mod read_loop;
mod retry;
mod stream;
mod utils;
pub use data_reader::SectorReadMode;
pub use discovery::DriveInfo;
pub use errors::{CdReaderError, ScsiError, ScsiOp};
pub use retry::RetryConfig;
Expand Down Expand Up @@ -321,6 +324,49 @@ impl CdReader {
}
}

/// Read sectors in a specific mode (audio, data cooked, or data raw).
///
/// This uses the READ CD (0xBE) SCSI command with configurable sector type
/// and main channel flags, supporting audio, cooked data (2048 B/sector),
/// and raw data (2352 B/sector) reads.
pub fn read_data_sectors(
&self,
lba: u32,
count: u32,
mode: SectorReadMode,
cfg: &RetryConfig,
) -> Result<Vec<u8>, CdReaderError> {
self.read_sectors_with_mode(lba, count, &mode, cfg)
}

pub(crate) fn read_sectors_with_mode(
&self,
start_lba: u32,
sectors: u32,
mode: &SectorReadMode,
cfg: &RetryConfig,
) -> Result<Vec<u8>, CdReaderError> {
#[cfg(target_os = "windows")]
{
windows::read_sectors_with_mode(start_lba, sectors, mode, cfg)
}

#[cfg(target_os = "macos")]
{
macos::read_sectors_with_mode(start_lba, sectors, mode, cfg)
}

#[cfg(target_os = "linux")]
{
linux::read_sectors_with_mode(start_lba, sectors, mode, cfg)
}

#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
compile_error!("Unsupported platform")
}
}

pub(crate) fn read_sectors_with_retry(
&self,
start_lba: u32,
Expand Down
Loading