diff --git a/README.md b/README.md index 87c20f6..41d994b 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,51 @@ std::fs::write("myfile.wav", wav)?; This code will read the first track from the CD file and save it as a WAVE file, which will be playable by any music player. +## Reading data tracks + +Blocking reads and streaming reads share the same options struct, so switching from audio to data is just a matter of the format you pass. Every track's format can be auto-detected: + +```rust +use cd_da_reader::{CdReader, ReadOptions, SectorReadFormat}; + +let reader = CdReader::open_default()?; +let toc = reader.read_toc()?; + +// A "data track" is simply `!is_audio` — there is no dedicated helper. +let data_track = toc.tracks.iter().find(|t| !t.is_audio) + .ok_or("no data track on this disc")?; + +// Mode 1 data tracks detect as Mode1Cooked (2048 B user data per sector), +// which is exactly the ISO 9660 image — write it out and mount it. +let format = reader.detect_track_format(data_track)?; +let options = ReadOptions::default().with_format(format); +let image = reader.read_track_with_options(&toc, data_track.number, &options)?; +std::fs::write("disc.iso", &image)?; +``` + +Mode 1 is fully handled (`Mode1Cooked` for the ready-to-mount user data, `Mode1Raw` for the complete 2352-byte sector). Mode 2 is *detected* (`Mode2Raw`) but its per-sector XA payload extraction is left to the consumer. The full workflow — detect, save, and platform-specific mount commands — is in `examples/save_data_track.rs`, and the detailed guide is [docs/consuming-cd-da-reader.md](docs/consuming-cd-da-reader.md). + +## Reading from a file image + +Everything above a raw sector read is hardware-independent, so you can read tracks from an image (CHD, BIN/CUE, an in-memory buffer, ...) instead of a drive. Implement `AudioSectorReader` for your backing — it must return raw sectors in the exact CD-DA format the physical reader produces: 2352 bytes/sector, 16-bit signed little-endian, stereo — and reuse the crate's TOC/track machinery, with no image-format dependencies pulled into this crate: + +```rust +use cd_da_reader::{AudioSectorReader, create_wav, read_track}; + +impl AudioSectorReader for MyImage { + type Error = std::io::Error; + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + // return exactly count * 2352 bytes of little-endian PCM + todo!() + } +} + +let pcm = read_track(&image, &toc, 1)?; // build `toc` from the image's metadata +let wav = create_wav(pcm); // free fn; also CdReader::create_wav +``` + +`CdReader` itself implements `AudioSectorReader`, so drive-backed and file-backed code share the generic `read_track` path. See `examples/file_backend.rs` for a complete, dependency-free example. + ## What about metadata? You might have asked why do we expose LBA/MSF values if the track reading is abstracted behind specific track numbers. The reason for that is metadata. Even though there is a command [CD-TEXT](https://en.wikipedia.org/wiki/CD-Text) for storing data directly, it is not exposed in this library due to it being extremely unreliable. diff --git a/docs/add_chd_cd_da_reading.md b/docs/add_chd_cd_da_reading.md new file mode 100644 index 0000000..f078898 --- /dev/null +++ b/docs/add_chd_cd_da_reading.md @@ -0,0 +1,216 @@ +# Add file-image CD-DA reading (CHD) to `cd-da-reader` + +> **Task prompt** for a Claude Code session working in this repo +> (`rust-cd-da-reader`, crate `cd-da-reader`). Self-contained: follow it top to +> bottom, then run the verification steps before reporting done. Line numbers are +> approximate — `rg` for symbols. + +## Goal + +Teach `cd-da-reader` to read CD-DA audio from a **file image** — starting with +**CHD** (MAME's compressed disc image) — and return raw PCM in the **exact same +format** the existing physical-drive path already produces: 16-bit **signed +little-endian**, stereo, 2352 bytes/sector. Because the format matches, consumers +reuse the crate's existing `Track`, `Toc`, and `create_wav` unchanged. + +Motivating consumer: **ODE-artwork-downloader** wants to play audio tracks +straight from CHD disc images (it already stores games as CHD). Today it has no +way to do this — see "How the consumer will use it" below. + +## Current state (what you're extending) + +`cd-da-reader` is currently **physical-drive only**: + +- `CdReader::open("/dev/sr0" | "disk6" | r"\\.\E:")` → SCSI/ioctl to real hardware. +- `read_toc() -> Toc`, `read_track(&toc, n) -> Vec` (raw PCM), `read_data_sectors(...)`. +- `Track { number: u8, start_lba: u32, start_msf: (u8,u8,u8), is_audio: bool }` +- `Toc { first_track, last_track, tracks: Vec, leadout_lba }` +- `SectorReadMode::{Audio (2352 B), DataCooked (2048 B), DataRaw (2352 B)}` +- `create_wav(Vec)` prepends a 44-byte RIFF header hard-coded to **44100 Hz, + 2 channels, 16-bit** — i.e. it already assumes exactly the CD-DA PCM layout. + +A CHD is a **file**, not a device, so none of the SCSI code paths apply. This is a +new, parallel backend — do **not** try to route it through `CdReader`. + +CD-DA facts you'll rely on: 44100 Hz · 16-bit signed LE · stereo · **2352 +bytes/sector** (= 588 stereo sample-frames) · **75 sectors/sec**. + +## Recommended backend: `libchdman-rs` (feature-gated) + +Use **`libchdman-rs`** (a Rust wrapper over MAME's `chdman` core). It already +parses CHD, enumerates tracks, decompresses, and — critically — handles the +CD-DA **endian swap** and **subcode strip** correctly. It is the *same core the +consumer (ODE) already links*, so behavior matches and there's no second CHD +implementation to keep in sync. + +Keep the default crate lean (today just `libc` / `windows-sys`) by putting all of +this behind a **`chd` cargo feature**: + +```toml +[features] +chd = ["dep:libchdman-rs"] + +[dependencies] +libchdman-rs = { version = "0.288", features = ["prebuilt"], optional = true } +``` + +> `prebuilt` downloads a prebuilt static lib so there's no MAME C++ compile. +> `libchdman-rs` is a `links` crate — the whole dependency graph must resolve to a +> **single** copy. The consumer (ODE) pins `0.288.8`; keep this pin +> semver-compatible (`^0.288`) so a downstream `cargo tree -i libchdman-rs` shows +> exactly one version. Bump both together when a new MAME release lands. + +### The `libchdman-rs` API you need + +```rust +use libchdman_rs::Chd; +use libchdman_rs::cd::{list_tracks, extract_to_cue, TrackType, TrackInfo}; + +let chd = Chd::open(path_str, /*writeable=*/ false, /*parent=*/ None)?; +let tracks: Vec = list_tracks(&chd)?; +// TrackInfo { track_num: u32, track_type: TrackType, frames: u32, pregap: u32, ... } +// TrackType::Audio == the CD-DA tracks you care about. + +// Robust way to get playable PCM: extract the whole CD to a redump-style BIN/CUE. +let mut on_progress = |_written: u64| {}; +extract_to_cue(chd_path, &cue_path, &bin_path, &mut on_progress)?; +``` + +**Why `extract_to_cue` and not the raw reader:** `libchdman-rs::cd::CdCookedReader` +looks tempting but it **explicitly rejects audio tracks** (it only cooks +Mode1/Mode2 *data* sectors to 2048 bytes). `extract_to_cue`, in contrast, writes +each audio track as **2352-byte little-endian sectors** — it strips the 96-byte +subcode and byte-swaps the audio back to LE, exactly matching +`SectorReadMode::Audio`. It handles every CD CHD, **including audio discs that +stored subcode** (the subcode bytes are dropped, the audio track is kept — verify +this yourself in `libchdman-rs`'s `extract_to_cue`, the older doc comment there +saying "tracks with subcode are dropped" is misleading; the code drops only the +subcode bytes). + +### Endian gotcha (do not skip) + +CHD stores CD-DA **big-endian byte-swapped** internally. The output you return +**must be little-endian** (WAV / `SectorReadMode::Audio` order). `extract_to_cue` +does this swap for you. If you later add a streaming path via raw +`Chd::read_bytes` (see "Follow-ups"), *you* own the swap (swap each 16-bit sample) +and the 2448→2352 subcode strip. + +### Lighter alternative (only if the MAME static lib is a dealbreaker) + +The pure-Rust `chd` crate reads CHD without a native lib, but then you implement +track enumeration, per-track frame-offset math, subcode strip, and the endian +swap yourself — more code and more ways to get subtly-wrong audio. Prefer +`libchdman-rs` unless there's a hard reason to avoid the prebuilt static lib. + +## Proposed public API + +Mirror the physical reader so both feel the same and `Track`/`Toc`/`create_wav` +are reused verbatim: + +```rust +#[cfg(feature = "chd")] +pub struct CdImageReader { /* opened image + parsed track metadata (+ temp bin) */ } + +#[cfg(feature = "chd")] +impl CdImageReader { + /// Open a CD image file (CHD for now; BIN/CUE later). Errors if it isn't a + /// recognized CD image. + pub fn open(path: &std::path::Path) -> Result; + + /// Table of contents built from the image's own track metadata. + pub fn read_toc(&self) -> Result; + + /// Raw PCM for one audio track — little-endian 16-bit stereo, 2352 B/sector, + /// byte-for-byte the same format as `CdReader::read_track`. Errors on a + /// non-audio track. Wrap with `create_wav` to get a playable file. + pub fn read_track(&self, toc: &Toc, track_no: u8) -> Result, CdImageError>; +} +``` + +- **Reuse `Track` / `Toc`.** Fill `Track.number` from `track_num`, `is_audio` from + `track_type == TrackType::Audio`, and `start_lba` / `start_msf` from cumulative + frame offsets (75 frames/sec for MSF; there's an `msf`-style helper pattern to + copy). `Toc.leadout_lba` = total frames across all tracks. +- **`create_wav` already fits** (44100/2ch/16-bit) — the returned `Vec` drops + straight into it. Add a `chd`-gated test asserting that. +- Add a `CdImageError` in `errors.rs` (or reuse/extend `CdReaderError`) covering + open failure, non-CD image, track-not-found, and non-audio track. + +## Implementation sketch (extract-based; robust first) + +1. **`open`**: confirm the file is a CHD (magic `MComprHD` at offset 0, or `.chd` + extension). `Chd::open` it, `list_tracks`, and stash the track metadata. You + may lazily `extract_to_cue` into a temp dir on first `read_track` (use + `std::env::temp_dir()`, or add `tempfile` as a `chd`-only dep) and cache the + bin path + each track's `(start_sector, sector_count)`. +2. **`read_toc`**: build `Toc` from the stashed metadata — cumulative LBA/MSF, + `is_audio`, `first_track`/`last_track`, `leadout_lba`. +3. **`read_track`**: for an audio track, read its `frames * 2352` bytes out of the + extracted BIN (already LE) and return them. Error on non-audio (a CD-DA player + only needs audio; supporting cooked data reads is optional). +4. **`Drop`**: delete the temp BIN/CUE. + +Trade-off to note in a comment: this extracts the **whole disc** to a temp file +before the first read. Fine for correctness and simplicity; see Follow-ups for a +streaming path that avoids it. + +## Example + tests + README + +- **Example** `examples/read_chd.rs` (model it on the existing + `read_all_tracks.rs` / `play_audio_track.rs`): open a CHD, print the TOC, and + dump track 1 to `track1.wav` via `create_wav`. Gate it on the `chd` feature. +- **Tests**: MSF conversion, track enumeration, and a `create_wav` + round-trip; if you can commit a tiny fixture CHD, assert its TOC. Put + `chd`-dependent tests behind `#[cfg(feature = "chd")]`. +- **README**: document the `chd` feature and a short usage snippet. + +## How the consumer (ODE) will use it + +ODE's `audio-chd-player` branch already has an adapter `src/disc/chd_audio.rs` +written against a **nonexistent** `rust_cdda` crate (`CdImage::open`, +`img.audio_reader`, `reader.read_samples`). Once this lands, that adapter is +rewritten to the real API: `CdImageReader::open(path)` → `read_toc()` → +`read_track()`, feeding rodio for playback. ODE will depend on this crate via a +**path dependency** with the feature on: + +```toml +cd-da-reader = { path = "../rust-cd-da-reader", features = ["chd"] } +``` + +Because ODE also depends on `libchdman-rs` directly (`0.288.8`), the pins must be +compatible so only one `links` copy resolves — hence the `^0.288` guidance above. + +## Verification (run all; report output) + +```sh +cargo build --features chd +cargo build # default build stays lean — no libchdman pulled in +cargo test --features chd +cargo run --example read_chd --features chd -- /path/to/disc.chd +cargo clippy --features chd -- -D warnings +cargo fmt --check +``` + +- Play the dumped `track1.wav` and confirm it sounds correct (right pitch/speed → + proves the endianness is right; garbled/static → the LE swap is wrong). +- From a consumer that pins `libchdman-rs`, confirm `cargo tree -i libchdman-rs` + shows a **single** version. + +## Done criteria + +- A `chd` cargo feature adds `CdImageReader` (`open` / `read_toc` / `read_track`), + returning audio as **little-endian** PCM identical in format to the physical + `CdReader::read_track`. +- The **default** build is unchanged and pulls in **no** new deps; the `chd` build + is clean (build, test, clippy, fmt). +- `examples/read_chd.rs` dumps a playable WAV from a real CHD. + +## Follow-ups (don't gold-plate; note them, don't build unless asked) + +- **Streaming reader** (avoids the whole-disc temp extract): read raw 2448-byte CD + frames via `Chd::read_bytes`, strip the 96-byte subcode, swap each 16-bit sample + to LE, and expose a `Read`/iterator like the existing `TrackStream`. You own the + per-track frame-offset math (chdman pads track lengths) and the endian swap here + — get it right against the `extract_to_cue` output as a reference. +- **BIN/CUE images** through the same `CdImageReader::open` (parse the cue, read + audio sectors directly — already LE, no CHD needed). diff --git a/examples/file_backend.rs b/examples/file_backend.rs new file mode 100644 index 0000000..5e6b1ab --- /dev/null +++ b/examples/file_backend.rs @@ -0,0 +1,104 @@ +//! Read a track from a file/image backing instead of a physical drive. +//! +//! `cd-da-reader` doesn't bake in any image format (CHD, BIN/CUE, ...). Instead +//! you implement [`AudioSectorReader`] for your backing — supplying raw CD-DA +//! sectors (2352 bytes/sector, 16-bit signed little-endian stereo) — and reuse +//! the crate's TOC/track machinery via [`read_track`] and [`create_wav`]. +//! +//! This example uses a tiny in-memory backing so it stays dependency-free. A +//! real backing (e.g. CHD via `libchdman-rs`) would decode sectors in +//! `read_audio_sectors` and build its TOC from the image's track metadata. +//! +//! Run with: `cargo run --example file_backend` +mod common; + +use cd_da_reader::{ + AudioSectorReader, Toc, Track, create_wav, lba_to_msf, open_track_stream, read_track, +}; + +/// A backing that holds whole-disc PCM in memory and slices it per sector. +struct InMemoryDisc { + /// Whole-disc PCM, laid out as 2352 bytes per sector. + pcm: Vec, +} + +impl AudioSectorReader for InMemoryDisc { + type Error = std::io::Error; + + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + let start = start_lba as usize * 2352; + let end = start + count as usize * 2352; + self.pcm.get(start..end).map(<[u8]>::to_vec).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of disc") + }) + } +} + +fn main() -> Result<(), Box> { + let output_dir = common::fresh_output_dir("file_backend")?; + + // A real backing derives this TOC from the image's own track metadata. + // Here we fabricate a 2-track disc: 2 seconds + 3 seconds of audio. + let track1_sectors = 75 * 2; + let track2_sectors = 75 * 3; + let total_sectors = track1_sectors + track2_sectors; + + let toc = Toc { + first_track: 1, + last_track: 2, + tracks: vec![ + Track { + number: 1, + start_lba: 0, + start_msf: lba_to_msf(0), + is_audio: true, + }, + Track { + number: 2, + start_lba: track1_sectors, + start_msf: lba_to_msf(track1_sectors), + is_audio: true, + }, + ], + leadout_lba: total_sectors, + }; + + // Silence, just for the demo — a real backing decodes actual audio here. + let disc = InMemoryDisc { + pcm: vec![0u8; total_sectors as usize * 2352], + }; + + for track in &toc.tracks { + let pcm = read_track(&disc, &toc, track.number)?; + println!( + "track {}: {} bytes ({} sectors)", + track.number, + pcm.len(), + pcm.len() / 2352 + ); + + let wav = create_wav(pcm); + let output_path = output_dir.join(format!("track{:02}.wav", track.number)); + std::fs::write(&output_path, wav)?; + println!(" wrote {}", output_path.display()); + } + + // The same backing can be streamed instead of buffered: pull sector-aligned + // chunks so a player never holds a whole track in memory at once. (A backing + // whose tracks are addressed contiguously — a gap-stripped extract — would + // open with `TrackBounds::Gapless`, or supply its own bounds via + // `open_track_stream_at`; this demo TOC has no trailing data track, so plain + // `open_track_stream` is equivalent.) + let mut stream = open_track_stream(&disc, &toc, 1)?; + let (mut chunks, mut bytes) = (0u32, 0usize); + while let Some(chunk) = stream.next_chunk()? { + chunks += 1; + bytes += chunk.len(); + } + println!( + "streamed track 1: {bytes} bytes in {chunks} chunks ({:.1}s of audio)", + stream.total_seconds() + ); + + Ok(()) +} diff --git a/examples/save_data_track.rs b/examples/save_data_track.rs new file mode 100644 index 0000000..591a127 --- /dev/null +++ b/examples/save_data_track.rs @@ -0,0 +1,130 @@ +//! Save the data track of a mixed-mode / enhanced ("CD-Extra") disc as a +//! mountable image, then print how to mount it. +//! +//! This is the end-to-end data-track workflow: +//! 1. read the TOC and pick the (first) data track, +//! 2. auto-detect its sector format with `detect_track_format`, +//! 3. for Mode 1, stream it **cooked** (2048 B/sector) straight to a `.iso` — +//! cooked Mode 1 is exactly the ISO 9660 filesystem image, so it mounts as +//! is. Streaming keeps memory flat regardless of track size (a full data +//! track can be hundreds of MB), +//! 4. Mode 2 is detected but not auto-cooked here (see the note it prints and +//! `docs/consuming-cd-da-reader.md`). +//! +//! Reads and streams run over the same options and read path, so the only +//! difference from a blocking `read_track_with_options` is that we pull chunks +//! and write them as they arrive. +//! +//! Run with: `cargo run --example save_data_track` +mod common; + +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::Path; + +use cd_da_reader::{CdReader, SectorReadFormat, Toc, TrackStreamOptions}; + +fn main() -> Result<(), Box> { + let output_dir = common::fresh_output_dir("save_data_track")?; + let reader = CdReader::open_default()?; + let toc = reader.read_toc()?; + + // There is no `find_data_track` helper in the crate — the idiom is a plain + // filter on the TOC, since "data track" is simply `!is_audio`. + let data_track = toc + .tracks + .iter() + .find(|track| !track.is_audio) + .ok_or("no data track on this disc (need a mixed-mode / enhanced CD)")?; + + let format = reader.detect_track_format(data_track)?; + println!("Data track #{} detected as {format:?}\n", data_track.number); + + match format { + SectorReadFormat::Mode1Cooked => { + // Cooked Mode 1 strips sync/header/EDC/ECC, leaving exactly the + // 2048-byte user data per sector — i.e. the raw ISO 9660 image. + let iso_path = output_dir.join(format!("track{:02}.iso", data_track.number)); + let bytes = stream_track_to_file(&reader, &toc, data_track.number, format, &iso_path)?; + + println!( + "Wrote {} ({bytes} bytes, {} sectors)\n", + iso_path.display(), + bytes / format.sector_size() as u64 + ); + print_mount_hint(&iso_path.display().to_string()); + } + SectorReadFormat::Mode2Raw => { + // Mode 2 forms are a per-sector property; producing a clean cooked + // payload requires inspecting each sector's XA subheader, which is + // left to the consumer. We save the complete raw sectors so nothing + // is lost, and point at the docs. + let bin_path = output_dir.join(format!("track{:02}.mode2.bin", data_track.number)); + let bytes = stream_track_to_file(&reader, &toc, data_track.number, format, &bin_path)?; + + println!( + "This is a Mode 2 track. Saved complete raw sectors to {} \ + ({bytes} bytes, {} sectors).", + bin_path.display(), + bytes / format.sector_size() as u64 + ); + println!( + "Extracting a mountable filesystem from Mode 2 is consumer territory — \ + see docs/consuming-cd-da-reader.md (\"Mode 2\")." + ); + } + other => { + return Err(format!( + "data track #{} detected as {other:?}, which is unexpected for a data track", + data_track.number + ) + .into()); + } + } + + Ok(()) +} + +/// Stream one track straight to a file in `format`, without ever holding the +/// whole track in memory. Returns the number of bytes written. +/// +/// Uses the streaming API so peak memory is one chunk (~64 KB) instead of the +/// entire track, which matters for large data images. +fn stream_track_to_file( + reader: &CdReader, + toc: &Toc, + track_no: u8, + format: SectorReadFormat, + path: &Path, +) -> Result> { + let options = TrackStreamOptions::default().with_format(format); + let mut stream = reader.open_track_stream_with_options(toc, track_no, options)?; + + let total_sectors = stream.total_sectors(); + let mut writer = BufWriter::new(File::create(path)?); + let mut written = 0u64; + + while let Some(chunk) = stream.next_chunk()? { + writer.write_all(&chunk)?; + written += chunk.len() as u64; + + let done = stream.current_sector(); + let pct = done as f32 / total_sectors as f32 * 100.0; + eprint!("\r {done}/{total_sectors} sectors ({pct:5.1}%)"); + } + eprintln!("\r {total_sectors}/{total_sectors} sectors (100.0%)"); + + writer.flush()?; + Ok(written) +} + +fn print_mount_hint(path: &str) { + println!("Mount it and explore the files:"); + if cfg!(target_os = "macos") { + println!(" hdiutil attach \"{path}\""); + } else if cfg!(target_os = "linux") { + println!(" sudo mount -o loop,ro \"{path}\" /mnt/cd"); + } else if cfg!(target_os = "windows") { + println!(" PowerShell: Mount-DiskImage -ImagePath \"{path}\""); + } +} diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..4d0a7c2 --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,470 @@ +//! Pluggable audio-sector backings. +//! +//! [`CdReader`](crate::CdReader) reads CD-DA sectors from a physical drive over +//! SCSI/ioctl, but everything *above* the raw sector read — the +//! [`Track`](crate::Track)/[`Toc`](crate::Toc) types, the track-bounds math +//! (including the CD-Extra trailing-gap rule), and WAV wrapping — is +//! hardware-independent. [`AudioSectorReader`] exposes that seam so any backing +//! that can produce raw CD-DA sectors (a CHD image, a BIN/CUE dump, an in-memory +//! buffer, a network stream, ...) reuses the same machinery **without this crate +//! taking on any image-format dependencies**. +//! +//! The image format lives in the caller: implement [`AudioSectorReader`] for +//! your backing, build a [`Toc`](crate::Toc) from the image's own track metadata +//! (see [`lba_to_msf`](crate::lba_to_msf)), then read PCM in the exact same +//! little-endian, 2352-byte/sector format the physical reader produces — ready +//! for [`create_wav`](crate::create_wav). Read a whole track at once with +//! [`read_track`], or pull it incrementally with [`open_track_stream`] (the +//! file/image counterpart to [`TrackStream`](crate::TrackStream)). +//! +//! ## Track bounds and the CD-Extra gap +//! +//! Resolving a track's sector range from a [`Toc`] differs on exactly one track: +//! the last audio track before a trailing data session on a CD-Extra disc. There +//! the crate subtracts the inter-session gap — correct whenever that gap is part +//! of the addressing (a physical disc, or an image whose TOC preserves the real +//! LBAs), but wrong when the tracks are addressed back-to-back with the gap +//! stripped out (a `chdman extractcd`-style contiguous extract), where it would +//! drop ~2.5 min of real audio. Only the backing knows its own layout, so +//! [`read_track`] / [`open_track_stream`] default to [`TrackBounds::SessionGap`]; +//! a contiguous backing must pass [`TrackBounds::Gapless`] (or supply explicit +//! bounds via [`open_track_stream_at`]). +//! +//! See `examples/file_backend.rs` for a complete, dependency-free example. + +use std::cmp::min; + +use crate::{CdReader, CdReaderError, ReadOptions, Toc, utils}; + +/// The physical drive is itself an [`AudioSectorReader`], so drive-backed and +/// file-backed code can share the generic [`read_track`] path. This uses the +/// default read options (audio sectors, default retry policy); for explicit +/// control, prefer the inherent [`CdReader::read_track_with_options`]. +impl AudioSectorReader for CdReader { + type Error = CdReaderError; + + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + self.read_sector_range(start_lba, count, &ReadOptions::default()) + } +} + +/// A source of raw CD-DA audio sectors. +/// +/// Implement this for any backing that can yield audio in the crate's canonical +/// format: **2352 bytes per sector, 16-bit signed little-endian, stereo, 44100 +/// Hz** — byte-for-byte identical to what +/// [`CdReader::read_track`](crate::CdReader::read_track) returns. +/// +/// `start_lba` is an absolute Logical Block Address (a sector index; LBA 0 is +/// the first sector after the lead-in), matching +/// [`Track::start_lba`](crate::Track::start_lba). A successful read of `count` +/// sectors must return exactly `count * 2352` bytes. +/// +/// The read takes `&self`, matching [`CdReader`]. A backing that needs a mutable +/// handle (an open `File`, a decoder) should use positioned reads +/// (`read_at`/`seek_read`) or interior mutability so shared-borrow reads stay +/// possible. +pub trait AudioSectorReader { + /// Error type produced by this backing. + type Error; + + /// Read `count` sectors starting at absolute `start_lba`, returning exactly + /// `count * 2352` bytes of little-endian PCM. + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error>; +} + +/// How a track's sector range is resolved from a [`Toc`]. +/// +/// The two policies differ on exactly one track: the **last audio track before a +/// trailing data session** on a CD-Extra disc. Every other track resolves +/// identically. Which one to use depends on whether the TOC's addressing includes +/// the inter-session gap — a property of how the source is laid out, not of +/// physical-vs-image. +/// +/// - [`SessionGap`](Self::SessionGap) subtracts the inter-session gap (matching +/// [`CdReader::read_track`](crate::CdReader::read_track)) — for a physical disc, +/// or any image whose TOC preserves the disc's real LBAs. +/// - [`Gapless`](Self::Gapless) does not: when tracks are addressed back-to-back +/// (a gap-stripped extract), a track spans from its `start_lba` to the next +/// track's start (or the leadout). Subtracting a gap that isn't there would drop +/// ~2.5 min of real audio. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrackBounds { + /// Addressing includes the CD-Extra inter-session gap: apply the trailing-gap + /// rule. Correct for a physical disc or a geometry-preserving image. + SessionGap, + /// Tracks are addressed contiguously (gap stripped): no gap subtraction. + Gapless, +} + +impl TrackBounds { + fn resolve(self, toc: &Toc, track_no: u8) -> std::io::Result<(u32, u32)> { + match self { + TrackBounds::SessionGap => utils::get_track_bounds(toc, track_no), + TrackBounds::Gapless => utils::get_gapless_track_bounds(toc, track_no), + } + } +} + +/// Read raw PCM for one track from any [`AudioSectorReader`] backing, assuming +/// the TOC includes the CD-Extra inter-session gap ([`TrackBounds::SessionGap`]). +/// +/// This is the file/image counterpart to +/// [`CdReader::read_track`](crate::CdReader::read_track): it resolves the track's +/// sector range from `toc` (honouring the CD-Extra trailing-gap rule) and pulls +/// those sectors from `src`. The returned bytes are the same little-endian, +/// 2352-B/sector PCM, so [`create_wav`](crate::create_wav) wraps them into a +/// playable file unchanged. +/// +/// A bad track request (not in the TOC, or invalid bounds) is +/// [`CdReaderError::Io`]; a failure inside the backing is +/// [`CdReaderError::Backend`], which preserves the backing's own error as the +/// boxed [`source`](std::error::Error::source). +/// +/// If the backing addresses tracks contiguously (a gap-stripped extract), use +/// [`read_track_with_bounds`] with [`TrackBounds::Gapless`]. +pub fn read_track(src: &R, toc: &Toc, track_no: u8) -> Result, CdReaderError> +where + R: AudioSectorReader, + R::Error: std::error::Error + Send + Sync + 'static, +{ + read_track_with_bounds(src, toc, track_no, TrackBounds::SessionGap) +} + +/// Read one track like [`read_track`], but with an explicit [`TrackBounds`] +/// geometry — pass [`TrackBounds::Gapless`] for a contiguous, gap-stripped layout. +pub fn read_track_with_bounds( + src: &R, + toc: &Toc, + track_no: u8, + bounds: TrackBounds, +) -> Result, CdReaderError> +where + R: AudioSectorReader, + R::Error: std::error::Error + Send + Sync + 'static, +{ + let (start_lba, sectors) = bounds.resolve(toc, track_no).map_err(CdReaderError::Io)?; + src.read_audio_sectors(start_lba, sectors) + .map_err(|e| CdReaderError::Backend(Box::new(e))) +} + +/// Streaming reader over an [`AudioSectorReader`] backing — the file/image +/// counterpart to [`TrackStream`](crate::TrackStream). +/// +/// Pulls a track's PCM in sector-aligned chunks with +/// [`next_chunk`](Self::next_chunk) instead of buffering the whole track, so a +/// player can start immediately and hold only one chunk at a time. Open one +/// with [`open_track_stream`] (TOC + physical geometry), +/// [`open_track_stream_with_bounds`] (TOC + explicit [`TrackBounds`]), or +/// [`open_track_stream_at`] (an explicit absolute sector range, for backings +/// that compute their own bounds). +pub struct AudioTrackStream<'a, R: AudioSectorReader> { + src: &'a R, + start_lba: u32, + next_lba: u32, + remaining_sectors: u32, + total_sectors: u32, + sectors_per_chunk: u32, +} + +impl<'a, R: AudioSectorReader> AudioTrackStream<'a, R> { + const DEFAULT_SECTORS_PER_CHUNK: u32 = 27; + const SECTORS_PER_SECOND: f32 = 75.0; + + fn new(src: &'a R, start_lba: u32, sectors: u32) -> Self { + Self { + src, + start_lba, + next_lba: start_lba, + remaining_sectors: sectors, + total_sectors: sectors, + sectors_per_chunk: Self::DEFAULT_SECTORS_PER_CHUNK, + } + } + + /// Set the target chunk size in sectors (default 27; a full chunk is + /// `sectors_per_chunk * 2352` bytes). Zero is normalized to one. + pub fn with_sectors_per_chunk(mut self, sectors: u32) -> Self { + self.sectors_per_chunk = sectors.max(1); + self + } + + /// Read the next chunk of PCM, or `Ok(None)` at end-of-track. + /// + /// Each chunk is `sectors_per_chunk * 2352` bytes except possibly the last. + /// A backing failure is [`CdReaderError::Backend`]; the position does not + /// advance on error, so a retry re-reads the same chunk. + pub fn next_chunk(&mut self) -> Result>, CdReaderError> + where + R::Error: std::error::Error + Send + Sync + 'static, + { + if self.remaining_sectors == 0 { + return Ok(None); + } + + let sectors = min(self.remaining_sectors, self.sectors_per_chunk); + let chunk = self + .src + .read_audio_sectors(self.next_lba, sectors) + .map_err(|e| CdReaderError::Backend(Box::new(e)))?; + + self.next_lba += sectors; + self.remaining_sectors -= sectors; + + Ok(Some(chunk)) + } + + /// Total number of sectors in this track. + pub fn total_sectors(&self) -> u32 { + self.total_sectors + } + + /// Current position as a track-relative sector index. + pub fn current_sector(&self) -> u32 { + self.total_sectors - self.remaining_sectors + } + + /// Seek to a track-relative sector position (valid range `0..=total_sectors()`). + pub fn seek_to_sector(&mut self, sector: u32) -> Result<(), CdReaderError> { + if sector > self.total_sectors { + return Err(CdReaderError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "seek sector is out of track bounds", + ))); + } + + self.next_lba = self.start_lba + sector; + self.remaining_sectors = self.total_sectors - sector; + Ok(()) + } + + /// Current position in seconds (75 sectors = 1 second). + pub fn current_seconds(&self) -> f32 { + self.current_sector() as f32 / Self::SECTORS_PER_SECOND + } + + /// Total track duration in seconds (75 sectors = 1 second). + pub fn total_seconds(&self) -> f32 { + self.total_sectors as f32 / Self::SECTORS_PER_SECOND + } + + /// Seek to a track-relative time in seconds, clamped to the track length. + pub fn seek_to_seconds(&mut self, seconds: f32) -> Result<(), CdReaderError> { + if !seconds.is_finite() || seconds < 0.0 { + return Err(CdReaderError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "seek seconds must be a finite non-negative number", + ))); + } + + let target_sector = (seconds * Self::SECTORS_PER_SECOND).round() as u32; + self.seek_to_sector(target_sector.min(self.total_sectors)) + } +} + +/// Open a streaming reader for a track assuming the TOC includes the inter-session +/// gap ([`TrackBounds::SessionGap`]). See [`AudioTrackStream`]. +pub fn open_track_stream<'a, R: AudioSectorReader>( + src: &'a R, + toc: &Toc, + track_no: u8, +) -> Result, CdReaderError> { + open_track_stream_with_bounds(src, toc, track_no, TrackBounds::SessionGap) +} + +/// Open a streaming reader for a track with an explicit [`TrackBounds`] geometry. +/// Use [`TrackBounds::Gapless`] for a contiguous, gap-stripped layout. +pub fn open_track_stream_with_bounds<'a, R: AudioSectorReader>( + src: &'a R, + toc: &Toc, + track_no: u8, + bounds: TrackBounds, +) -> Result, CdReaderError> { + let (start_lba, sectors) = bounds.resolve(toc, track_no).map_err(CdReaderError::Io)?; + Ok(AudioTrackStream::new(src, start_lba, sectors)) +} + +/// Open a streaming reader over an explicit absolute sector range +/// (`start_lba .. start_lba + sectors`), bypassing TOC bounds resolution. +/// +/// For backings that compute their own track layout — e.g. reading +/// `[start_lba(n) .. start_lba(n + 1))` from a contiguous extract — this is the +/// zero-policy primitive: no TOC lookup, no CD-Extra rule, and no failure mode. +pub fn open_track_stream_at( + src: &R, + start_lba: u32, + sectors: u32, +) -> AudioTrackStream<'_, R> { + AudioTrackStream::new(src, start_lba, sectors) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Track, create_wav, lba_to_msf}; + + /// Minimal in-memory backing: whole-disc PCM sliced by sector. + struct MemDisc { + pcm: Vec, + } + + impl AudioSectorReader for MemDisc { + type Error = std::io::Error; + + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + let start = start_lba as usize * 2352; + let end = start + count as usize * 2352; + self.pcm.get(start..end).map(<[u8]>::to_vec).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of disc") + }) + } + } + + fn toc_two_tracks(t1_sectors: u32, t2_sectors: u32) -> Toc { + Toc { + first_track: 1, + last_track: 2, + tracks: vec![ + Track { + number: 1, + start_lba: 0, + start_msf: lba_to_msf(0), + is_audio: true, + }, + Track { + number: 2, + start_lba: t1_sectors, + start_msf: lba_to_msf(t1_sectors), + is_audio: true, + }, + ], + leadout_lba: t1_sectors + t2_sectors, + } + } + + #[test] + fn reads_track_bytes_for_the_right_range() { + let (t1, t2) = (100u32, 50u32); + let disc = MemDisc { + pcm: vec![0u8; (t1 + t2) as usize * 2352], + }; + let toc = toc_two_tracks(t1, t2); + + let track1 = read_track(&disc, &toc, 1).unwrap(); + let track2 = read_track(&disc, &toc, 2).unwrap(); + + assert_eq!(track1.len(), t1 as usize * 2352); + assert_eq!(track2.len(), t2 as usize * 2352); + } + + #[test] + fn create_wav_wraps_backend_pcm() { + let disc = MemDisc { + pcm: vec![0u8; 10 * 2352], + }; + // Single-track disc: no next track, so the leadout bounds the read. + let toc = Toc { + first_track: 1, + last_track: 1, + tracks: vec![Track { + number: 1, + start_lba: 0, + start_msf: lba_to_msf(0), + is_audio: true, + }], + leadout_lba: 10, + }; + + let pcm = read_track(&disc, &toc, 1).unwrap(); + let wav = create_wav(pcm); + assert_eq!(&wav[0..4], b"RIFF"); + assert_eq!(&wav[8..12], b"WAVE"); + assert_eq!(wav.len(), 44 + 10 * 2352); + } + + #[test] + fn missing_track_is_an_io_error() { + let disc = MemDisc { + pcm: vec![0u8; 2352], + }; + let toc = toc_two_tracks(1, 0); + match read_track(&disc, &toc, 99) { + Err(CdReaderError::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound), + other => panic!("expected Io(NotFound), got {other:?}"), + } + } + + #[test] + fn backend_failure_is_a_backend_error() { + // Disc holds one sector but the TOC claims track 1 is five, so the read + // runs past the end — a backing failure, not a TOC error. + let disc = MemDisc { + pcm: vec![0u8; 2352], + }; + let toc = toc_two_tracks(5, 10); + match read_track(&disc, &toc, 1) { + Err(CdReaderError::Backend(e)) => { + let io = e + .downcast_ref::() + .expect("backend error preserves the io::Error"); + assert_eq!(io.kind(), std::io::ErrorKind::UnexpectedEof); + } + other => panic!("expected Backend error, got {other:?}"), + } + } + + #[test] + fn stream_pulls_sector_aligned_chunks() { + let sectors = 100u32; + let disc = MemDisc { + pcm: vec![0u8; sectors as usize * 2352], + }; + + let mut stream = open_track_stream_at(&disc, 0, sectors).with_sectors_per_chunk(27); + assert_eq!(stream.total_sectors(), sectors); + + let mut total = 0usize; + let mut chunks = 0usize; + while let Some(chunk) = stream.next_chunk().unwrap() { + assert_eq!(chunk.len() % 2352, 0); + total += chunk.len(); + chunks += 1; + } + + assert_eq!(total, sectors as usize * 2352); + assert_eq!(chunks, 4); // 27 + 27 + 27 + 19 + assert!(stream.next_chunk().unwrap().is_none()); + } + + #[test] + fn stream_seek_repositions() { + // Stream starts at absolute LBA 10 for 300 sectors, so the backing must + // cover absolute sectors 10..310. + let disc = MemDisc { + pcm: vec![0u8; 310 * 2352], + }; + let mut stream = open_track_stream_at(&disc, 10, 300).with_sectors_per_chunk(1000); + + stream.seek_to_sector(250).unwrap(); + assert_eq!(stream.current_sector(), 250); + assert!((stream.current_seconds() - 250.0 / 75.0).abs() < f32::EPSILON); + + let chunk = stream.next_chunk().unwrap().unwrap(); + assert_eq!(chunk.len(), 50 * 2352); // 300 - 250 sectors, one big chunk + assert!(stream.next_chunk().unwrap().is_none()); + + assert!(stream.seek_to_sector(301).is_err()); + } + + #[test] + fn open_track_stream_resolves_toc_bounds() { + let (t1, t2) = (40u32, 60u32); + let disc = MemDisc { + pcm: vec![0u8; (t1 + t2) as usize * 2352], + }; + let toc = toc_two_tracks(t1, t2); + + let stream = open_track_stream(&disc, &toc, 2).unwrap(); + assert_eq!(stream.total_sectors(), t2); + } +} diff --git a/src/errors.rs b/src/errors.rs index c9ba35e..eadaeed 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -62,6 +62,11 @@ pub enum CdReaderError { }, /// Drive enumeration completed without finding a usable audio CD. NoUsableDrive, + /// A pluggable [`AudioSectorReader`](crate::AudioSectorReader) backing failed + /// while reading sectors. The backing's own error is boxed and preserved as + /// this error's [`source`](std::error::Error::source), so it can be displayed + /// or downcast without being flattened into this SCSI-oriented enum. + Backend(Box), } impl fmt::Display for CdReaderError { @@ -97,6 +102,7 @@ impl fmt::Display for CdReaderError { data_mode: None, } => write!(f, "could not detect sector format for track {track_number}"), Self::NoUsableDrive => write!(f, "no usable audio CD drive found"), + Self::Backend(err) => write!(f, "backend reader error: {err}"), } } } @@ -105,6 +111,7 @@ impl std::error::Error for CdReaderError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(error) => Some(error), + Self::Backend(error) => Some(&**error), Self::Scsi(_) | Self::Parse(_) | Self::TrackFormatMismatch { .. } diff --git a/src/lib.rs b/src/lib.rs index c142ca1..077f5f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -145,6 +145,12 @@ mod read_loop; mod retry; mod stream; mod utils; + +mod backend; +pub use backend::{ + AudioSectorReader, AudioTrackStream, TrackBounds, open_track_stream, open_track_stream_at, + open_track_stream_with_bounds, read_track, read_track_with_bounds, +}; pub use data_reader::{ReadOptions, SectorReadFormat}; pub use discovery::DriveInfo; pub use errors::{CdReaderError, ScsiError, ScsiOp}; @@ -152,6 +158,7 @@ pub use retry::RetryConfig; pub use stream::{TrackStream, TrackStreamOptions}; mod parse_toc; +pub use parse_toc::lba_to_msf; /// Representation of the track from TOC, purely in terms of data location on the CD. #[derive(Debug)] @@ -184,6 +191,18 @@ pub struct Toc { pub leadout_lba: u32, } +/// Wrap raw CD-DA PCM in a 44-byte WAV/RIFF header (44100 Hz, 2 channels, +/// 16-bit) so the bytes become a playable file. +/// +/// This is the free-function form of [`CdReader::create_wav`], usable without +/// naming the physical-drive type — for example on PCM obtained from a file or +/// image backing via [`read_track`]. +pub fn create_wav(data: Vec) -> Vec { + let mut header = utils::create_wav_header(data.len() as u32); + header.extend_from_slice(&data); + header +} + /// Helper struct to interact with the audio CD. Internally it holds a platform-specific /// handle to the open CD drive to read from it and it is correctly closed when CDReader /// is dropped. @@ -224,9 +243,7 @@ impl CdReader { /// /// * `data` - vector of bytes received from `read_track` function pub fn create_wav(data: Vec) -> Vec { - let mut header = utils::create_wav_header(data.len() as u32); - header.extend_from_slice(&data); - header + crate::create_wav(data) } /// Read Table of Contents for the opened drive. You'll likely only need to access diff --git a/src/parse_toc.rs b/src/parse_toc.rs index ded881b..1525e14 100644 --- a/src/parse_toc.rs +++ b/src/parse_toc.rs @@ -66,7 +66,13 @@ pub(crate) fn parse_toc(data: Vec) -> std::io::Result { } } -fn lba_to_msf(lba: u32) -> (u8, u8, u8) { +/// Convert a Logical Block Address to its Minutes/Seconds/Frames address. +/// +/// MSF addresses include the fixed 2-second (150-frame) lead-in offset, so +/// `lba_to_msf(0)` is `(0, 2, 0)`. This is handy when building a [`Toc`] for a +/// file/image backing (see [`AudioSectorReader`](crate::AudioSectorReader)), +/// where you have sector indices but need to populate [`Track::start_msf`]. +pub fn lba_to_msf(lba: u32) -> (u8, u8, u8) { let total_frames = lba + 150; // MSF addresses are offset by 150 let minutes = (total_frames / 75 / 60) as u8; let seconds = ((total_frames / 75) % 60) as u8; diff --git a/src/utils.rs b/src/utils.rs index 69b2e6e..73f4faf 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,6 +3,22 @@ use crate::Toc; const CD_EXTRA_TRAILING_DATA_GAP_SECTORS: u32 = 11_400; pub(crate) fn get_track_bounds(toc: &Toc, track_no: u8) -> std::io::Result<(u32, u32)> { + track_bounds(toc, track_no, true) +} + +/// Track bounds for a **contiguous** layout (tracks addressed back-to-back, the +/// CD-Extra inter-session gap stripped): the track spans from its own `start_lba` +/// to the next track's start (or the leadout), with **no** gap subtracted. +/// +/// Use [`get_track_bounds`] when the addressing includes the gap (a physical disc +/// or a geometry-preserving image); use this for a gap-stripped extract, where +/// subtracting a gap that isn't there would drop ~2.5 min of real audio off the +/// last audio track before a data session. +pub(crate) fn get_gapless_track_bounds(toc: &Toc, track_no: u8) -> std::io::Result<(u32, u32)> { + track_bounds(toc, track_no, false) +} + +fn track_bounds(toc: &Toc, track_no: u8, apply_cd_extra: bool) -> std::io::Result<(u32, u32)> { let idx = toc .tracks .iter() @@ -10,15 +26,17 @@ pub(crate) fn get_track_bounds(toc: &Toc, track_no: u8) -> std::io::Result<(u32, .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "track not in TOC"))?; let start_lba = toc.tracks[idx].start_lba; - let end_lba = get_track_end_lba(toc, idx)?; + let end_lba = if apply_cd_extra { + get_track_end_lba(toc, idx)? + } else { + plain_track_end_lba(toc, idx) + }; if end_lba <= start_lba { return Err(bad_toc_bounds()); } - let sectors = end_lba - start_lba; - - Ok((start_lba, sectors)) + Ok((start_lba, end_lba - start_lba)) } fn get_track_end_lba(toc: &Toc, idx: usize) -> std::io::Result { @@ -29,10 +47,14 @@ fn get_track_end_lba(toc: &Toc, idx: usize) -> std::io::Result { .ok_or_else(bad_toc_bounds); } + Ok(plain_track_end_lba(toc, idx)) +} + +fn plain_track_end_lba(toc: &Toc, idx: usize) -> u32 { if (idx + 1) < toc.tracks.len() { - Ok(toc.tracks[idx + 1].start_lba) + toc.tracks[idx + 1].start_lba } else { - Ok(toc.leadout_lba) + toc.leadout_lba } } @@ -231,6 +253,35 @@ mod test { assert_eq!(sectors, 40_000 - 10_000); } + #[test] + fn gapless_bounds_keep_full_last_audio_track_before_data() { + // Track 2 is the last audio track before a trailing data session. + let toc = Toc { + first_track: 1, + last_track: 4, + tracks: vec![ + track(1, 0, true), + track(2, 10_000, true), + track(3, 40_000, false), + track(4, 80_000, false), + ], + leadout_lba: 120_000, + }; + + // Physical geometry shortens track 2 by the CD-Extra gap... + let (start_p, sectors_p) = get_track_bounds(&toc, 2).unwrap(); + assert_eq!(start_p, 10_000); + assert_eq!( + sectors_p, + (40_000 - CD_EXTRA_TRAILING_DATA_GAP_SECTORS) - 10_000 + ); + + // ...gapless geometry keeps it spanning straight to the data track. + let (start_g, sectors_g) = get_gapless_track_bounds(&toc, 2).unwrap(); + assert_eq!(start_g, 10_000); + assert_eq!(sectors_g, 40_000 - 10_000); + } + #[test] fn returns_error_for_invalid_track() { let toc = get_toc();