From 7de4522e8c45f7df43dbe79240e5bffe140334e0 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Sun, 12 Jul 2026 18:44:40 -0400 Subject: [PATCH 1/8] feat: port AudioSectorReader file backing onto the 1.0 API Reconstructs the pluggable file/image backing from the pre-1.0 `file-based-backend` branch on top of main's unified read API, since a literal rebase would collide with main's independently-built data-track support. - backend.rs: AudioSectorReader trait, `read_track` free fn, and TrackReadError, with the CdReader impl now reading via `read_sector_range(.., &ReadOptions::default())`. - Expose the support surface the seam needs: free `create_wav` and a public `lba_to_msf` (for building a Toc from image metadata). - examples: file_backend (dependency-free backing) and save_data_track (detect -> read cooked -> save .iso -> mount). - docs/consuming-cd-da-reader.md: downstream-consumer guide covering the options model, sector formats, detection, the data-track workflow, Mode 1 vs Mode 2, file backing, and a pre-1.0 -> 1.0 migration table. - README: data-track and file-image sections. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 45 ++++++ docs/consuming-cd-da-reader.md | 281 +++++++++++++++++++++++++++++++++ examples/file_backend.rs | 85 ++++++++++ examples/save_data_track.rs | 94 +++++++++++ src/backend.rs | 203 ++++++++++++++++++++++++ src/lib.rs | 20 ++- src/parse_toc.rs | 8 +- 7 files changed, 732 insertions(+), 4 deletions(-) create mode 100644 docs/consuming-cd-da-reader.md create mode 100644 examples/file_backend.rs create mode 100644 examples/save_data_track.rs create mode 100644 src/backend.rs 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/consuming-cd-da-reader.md b/docs/consuming-cd-da-reader.md new file mode 100644 index 0000000..7aec030 --- /dev/null +++ b/docs/consuming-cd-da-reader.md @@ -0,0 +1,281 @@ +# Consuming `cd-da-reader` (1.0) + +A guide for downstream projects. It covers the unified read/stream model, sector +formats and auto-detection, the data-track workflow (save → mount → explore), +what is and isn't automatic for Mode 1 vs Mode 2, reading from file/image +backings, and a migration cheat-sheet from the pre-1.0 API. + +Everything here is exercised by the crate's `examples/`; each section links the +runnable one. + +--- + +## 1. The shape of the API + +Three steps, always in this order: + +```rust +use cd_da_reader::CdReader; + +let reader = CdReader::open_default()?; // 1. get a drive +let toc = reader.read_toc()?; // 2. read the table of contents +let pcm = reader.read_track(&toc, 1)?; // 3. read a track (audio, by default) +``` + +Opening a drive: + +| Call | Use when | +| --- | --- | +| `CdReader::open_default()` | Grab the first drive that has an audio CD. Usually what you want. | +| `CdReader::list_drives()` → `CdReader::open(&drive)` | Let the user choose; each `DriveInfo` has `has_audio_cd`. | +| `CdReader::open_path("disk6" \| "/dev/sr0" \| r"\\.\E:")` | You already know the platform device path. | + +The reader owns the open handle and closes it on `Drop`. Use one reader at a +time — CD drives are physical, sequential devices. + +--- + +## 2. One options struct for every read + +Blocking reads and streaming reads run over the **same** machinery; the only +difference is which options struct you hand in. + +- **Blocking:** [`ReadOptions`] → `read_track_with_options` / `read_sector_range` +- **Streaming:** [`TrackStreamOptions`] → `open_track_stream_with_options` + +Both are builders whose defaults read **audio** with the default retry policy, so +you override only what you need: + +```rust +use cd_da_reader::{ReadOptions, SectorReadFormat, RetryConfig}; + +let options = ReadOptions::default() + .with_format(SectorReadFormat::Mode1Cooked) // default: Audio + .with_retry(RetryConfig::default().with_max_attempts(6)); + +// NOTE: read_track_with_options takes the options by reference. +let data = reader.read_track_with_options(&toc, 3, &options)?; +``` + +The convenience methods are just defaults over this: + +| Convenience | Equivalent | +| --- | --- | +| `read_track(&toc, n)` | `read_track_with_options(&toc, n, &ReadOptions::default())` | +| `open_track_stream(&toc, n)` | `open_track_stream_with_options(&toc, n, TrackStreamOptions::default())` | + +`read_track_with_options` validates the requested format against the track type +from the TOC and returns `CdReaderError::TrackFormatMismatch` if, say, you ask for +`Audio` on a data track. `read_sector_range(start_lba, sectors, &options)` is the +low-level escape hatch: it does **no** validation and expects you to supply valid +bounds and a compatible format. + +--- + +## 3. Sector formats + +`SectorReadFormat` selects what the drive returns per sector: + +| Format | Bytes/sector | What it is | +| --- | --- | --- | +| `Audio` | 2352 | CD-DA PCM (16-bit signed LE, stereo, 44100 Hz). | +| `Mode1Cooked` | 2048 | Mode 1 **user data only** — sync/header/EDC/ECC stripped. This is the filesystem image. | +| `Mode1Raw` | 2352 | Complete Mode 1 sector: sync + header + 2048 user + EDC/ECC. | +| `Mode2Raw` | 2352 | Complete Mode 2 sector. The Mode 2 *form* is per-sector; the payload lives behind an XA subheader. | + +`format.sector_size()` returns these numbers, so +`bytes == sectors * format.sector_size()`. + +### Auto-detecting a track's format + +You rarely need to hard-code the format — ask the drive: + +```rust +for track in &toc.tracks { + let format = reader.detect_track_format(track)?; + println!("Track #{}: {format:?}", track.number); +} +``` + +See `examples/detect_track_formats.rs`. + +`detect_track_format` resolves to: + +- `Audio` for audio tracks (straight from the TOC), +- `Mode1Cooked` for Mode 1 data tracks, +- `Mode2Raw` for Mode 2 data tracks. + +It uses MMC `READ TRACK INFORMATION`, and falls back to inspecting one raw sector +if the drive's Data Mode field is inconclusive. If it still can't tell, you get +`CdReaderError::CannotDetectTrackFormat`. + +> There is **no** `find_data_track(&toc)` helper — a "data track" is just +> `!track.is_audio`, so the idiom is a plain filter: +> `toc.tracks.iter().find(|t| !t.is_audio)`. + +--- + +## 4. Reading a data track: save → mount → explore + +The common case is a mixed-mode / enhanced ("CD-Extra") disc: audio tracks plus a +data track holding an ISO 9660 filesystem with extra files (artwork, videos, +liner notes). + +Because `detect_track_format` returns `Mode1Cooked` for such a track, and cooked +Mode 1 is *exactly* the 2048-byte user data per sector, reading the whole track +cooked gives you a byte-for-byte ISO 9660 image you can write to `.iso` and mount: + +```rust +use cd_da_reader::{ReadOptions, SectorReadFormat}; + +let data_track = toc.tracks.iter().find(|t| !t.is_audio) + .ok_or("no data track on this disc")?; + +let format = reader.detect_track_format(data_track)?; // Mode1Cooked here +assert_eq!(format, SectorReadFormat::Mode1Cooked); + +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)?; +``` + +Then mount and explore: + +| OS | Mount | Unmount | +| --- | --- | --- | +| macOS | `hdiutil attach disc.iso` | `hdiutil detach /Volumes/` | +| Linux | `sudo mount -o loop,ro disc.iso /mnt/cd` | `sudo umount /mnt/cd` | +| Windows | `Mount-DiskImage -ImagePath disc.iso` | `Dismount-DiskImage -ImagePath disc.iso` | + +The full runnable version — including the Mode 2 branch — is +`examples/save_data_track.rs`. To verify a data read against the on-disc ISO +structure (sync pattern, `CD001` signature, cooked-equals-raw), see +`examples/read_data_track.rs`. + +--- + +## 5. Mode 1 vs Mode 2 — what's automatic + +**Mode 1 is fully handled.** You can read it either way and both are reliable: + +- `Mode1Cooked` (2048 B) — the ready-to-mount user data. +- `Mode1Raw` (2352 B) — the complete sector if you want the framing/ECC. The + user data is `raw[16..16+2048]` (sync 12 + header 4). `detect_track_format` + returns `Mode1Cooked`, but you can request raw instead: + + ```rust + let options = ReadOptions::default().with_format(SectorReadFormat::Mode1Raw); + ``` + +**Mode 2 is detected but not cooked for you.** Mode 2 mixes Form 1 (2048-byte +payload) and Form 2 (2324-byte payload) sectors, and the form is encoded in an +8-byte **XA subheader** at the front of each sector's user area — it is a +*per-sector* property, so there is no single "cooked" size for the track. The +crate therefore exposes Mode 2 only as `Mode2Raw` (complete 2352-byte sectors) +and leaves payload extraction to you: read raw, and for each sector inspect the +subheader to decide Form 1 vs Form 2 and slice the payload accordingly. This is +intentionally the consumer's responsibility. + +--- + +## 6. Streaming + +Same formats and retry config, pulled incrementally — for live playback or +progress reporting instead of one big blocking read: + +```rust +use cd_da_reader::TrackStreamOptions; + +let options = TrackStreamOptions::default() + .with_sectors_per_chunk(27); // ~64 KB of audio per chunk +let mut stream = reader.open_track_stream_with_options(&toc, 1, options)?; + +while let Some(chunk) = stream.next_chunk()? { + // chunk length == sectors_this_chunk * format.sector_size() +} +``` + +`TrackStream` also exposes `total_sectors()`, `current_sector()`, +`current_seconds()`, `total_seconds()`, `seek_to_sector()`, and +`seek_to_seconds()`. See `examples/stream_with_progress.rs` and +`examples/stream_last_track.rs`. + +--- + +## 7. Reading from a file/image instead of a drive + +Everything above a raw sector read — the `Toc`/`Track` types, track-bounds math +(including the CD-Extra trailing-gap rule), and WAV wrapping — is +hardware-independent. Implement [`AudioSectorReader`] for any backing that can +yield raw CD-DA sectors (a CHD image, a BIN/CUE dump, an in-memory buffer, a +network stream) and reuse the same machinery — **the crate takes on no +image-format dependencies**: + +```rust +use cd_da_reader::{AudioSectorReader, Toc, Track, create_wav, lba_to_msf, read_track}; + +struct MyImage { /* ... */ } + +impl AudioSectorReader for MyImage { + type Error = std::io::Error; + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + // decode from your image and return exactly count * 2352 bytes + // (16-bit signed LE, stereo) + todo!() + } +} + +// Build a Toc from the image's own metadata (lba_to_msf fills start_msf), +// then read tracks with the free `read_track`: +let pcm = read_track(&image, &toc, 1)?; // Result, TrackReadError> +let wav = create_wav(pcm); // free fn; also CdReader::create_wav +``` + +`CdReader` itself implements `AudioSectorReader`, so drive-backed and file-backed +code can share the generic `read_track` path. `read_track` returns +`TrackReadError`, which keeps a bad track request (`Toc`) separate from a +failure inside your backing (`Backend(E)`), preserving your error type. Full +dependency-free example: `examples/file_backend.rs`. + +--- + +## 8. Errors + +`CdReaderError` is the one error type for drive operations: + +| Variant | Meaning | +| --- | --- | +| `Io(std::io::Error)` | OS/transport failure (open, ioctl, FFI). | +| `Scsi(ScsiError)` | Device reported a SCSI failure; carries status + sense. | +| `Parse(String)` | Couldn't parse a command response. | +| `TrackFormatMismatch { .. }` | Requested a format incompatible with the track type. | +| `CannotDetectTrackFormat { .. }` | `detect_track_format` couldn't determine the format. | +| `NoUsableDrive` | Enumeration found no drive with an audio CD. | + +The file-backing `read_track` uses its own `TrackReadError` (see §7) so your +backing's error type isn't flattened into the SCSI-oriented `CdReaderError`. + +--- + +## 9. Migrating from the pre-1.0 API + +1.0 renamed a lot for clarity. The mechanical substitutions: + +| Before (0.x) | Now (1.0) | +| --- | --- | +| `CdReader::open("disk6")` | `CdReader::open_path("disk6")`, or `CdReader::open(&drive_info)` | +| `SectorReadMode` | `SectorReadFormat` | +| `SectorReadMode::DataCooked` | `SectorReadFormat::Mode1Cooked` | +| `SectorReadMode::DataRaw` | `SectorReadFormat::Mode1Raw` (plus new `Mode2Raw`) | +| `read_data_sectors(lba, n, mode, &cfg)` | `read_sector_range(lba, n, &ReadOptions::default().with_format(fmt).with_retry(cfg))` | +| `read_track_with_retry(&toc, n, &cfg)` | `read_track_with_options(&toc, n, &ReadOptions::default().with_retry(cfg))` | +| `TrackStreamConfig { sectors_per_chunk, retry }` | `TrackStreamOptions::default().with_sectors_per_chunk(..).with_retry(..)` | +| `open_track_stream(&toc, n, cfg)` | `open_track_stream(&toc, n)` or `open_track_stream_with_options(&toc, n, options)` | + +New in 1.0: `detect_track_format`, `Mode1Raw` / `Mode2Raw`, per-read +`ReadOptions`, format validation on `read_track_with_options`, and the +`AudioSectorReader` file-backing seam. + +[`ReadOptions`]: https://docs.rs/cd-da-reader/latest/cd_da_reader/struct.ReadOptions.html +[`TrackStreamOptions`]: https://docs.rs/cd-da-reader/latest/cd_da_reader/struct.TrackStreamOptions.html +[`AudioSectorReader`]: https://docs.rs/cd-da-reader/latest/cd_da_reader/trait.AudioSectorReader.html diff --git a/examples/file_backend.rs b/examples/file_backend.rs new file mode 100644 index 0000000..e3fa1d5 --- /dev/null +++ b/examples/file_backend.rs @@ -0,0 +1,85 @@ +//! 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, 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()); + } + + Ok(()) +} diff --git a/examples/save_data_track.rs b/examples/save_data_track.rs new file mode 100644 index 0000000..382aa5a --- /dev/null +++ b/examples/save_data_track.rs @@ -0,0 +1,94 @@ +//! 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, read it **cooked** (2048 B/sector) — that is exactly the +//! ISO 9660 filesystem image, so it writes straight to a `.iso` you can +//! mount and explore, +//! 4. Mode 2 is detected but not auto-cooked here (see the note it prints and +//! `docs/consuming-cd-da-reader.md`). +//! +//! Run with: `cargo run --example save_data_track` +mod common; + +use cd_da_reader::{CdReader, ReadOptions, SectorReadFormat}; + +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 options = ReadOptions::default().with_format(SectorReadFormat::Mode1Cooked); + let image = reader.read_track_with_options(&toc, data_track.number, &options)?; + + let iso_path = output_dir.join(format!("track{:02}.iso", data_track.number)); + std::fs::write(&iso_path, &image)?; + println!( + "Wrote {} ({} bytes, {} sectors)\n", + iso_path.display(), + image.len(), + image.len() / 2048 + ); + 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 options = ReadOptions::default().with_format(SectorReadFormat::Mode2Raw); + let raw = reader.read_track_with_options(&toc, data_track.number, &options)?; + + let bin_path = output_dir.join(format!("track{:02}.mode2.bin", data_track.number)); + std::fs::write(&bin_path, &raw)?; + println!( + "This is a Mode 2 track. Saved complete raw sectors to {} \ + ({} bytes, {} sectors).", + bin_path.display(), + raw.len(), + raw.len() / 2352 + ); + 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(()) +} + +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..ddc78f8 --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,203 @@ +//! 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 call [`read_track`] to +//! get PCM in the exact same little-endian, 2352-byte/sector format the physical +//! reader produces — ready for [`create_wav`](crate::create_wav). +//! +//! See `examples/file_backend.rs` for a complete, dependency-free example. + +use std::fmt; + +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. +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>; +} + +/// Error returned by [`read_track`]. +/// +/// Separates a bad track request (not in the TOC, or invalid TOC bounds) from a +/// failure inside the backing [`AudioSectorReader`], whose error type is +/// preserved as `E` rather than flattened into this crate's SCSI-oriented +/// [`CdReaderError`](crate::CdReaderError). +#[derive(Debug)] +pub enum TrackReadError { + /// The requested track number was not found in the TOC, or the resolved + /// sector bounds were invalid. + Toc(std::io::Error), + /// The backing [`AudioSectorReader`] failed while reading sectors. + Backend(E), +} + +impl fmt::Display for TrackReadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Toc(err) => write!(f, "TOC error: {err}"), + Self::Backend(err) => write!(f, "backend error: {err}"), + } + } +} + +impl std::error::Error for TrackReadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Toc(err) => Some(err), + Self::Backend(err) => Some(err), + } + } +} + +/// Read raw PCM for one track from any [`AudioSectorReader`] backing. +/// +/// 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. +pub fn read_track( + src: &R, + toc: &Toc, + track_no: u8, +) -> Result, TrackReadError> { + let (start_lba, sectors) = + utils::get_track_bounds(toc, track_no).map_err(TrackReadError::Toc)?; + src.read_audio_sectors(start_lba, sectors) + .map_err(TrackReadError::Backend) +} + +#[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_a_toc_error() { + let disc = MemDisc { + pcm: vec![0u8; 2352], + }; + let toc = toc_two_tracks(1, 0); + match read_track(&disc, &toc, 99) { + Err(TrackReadError::Toc(_)) => {} + other => panic!("expected TOC error, got {other:?}"), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index c142ca1..f10b008 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -145,6 +145,9 @@ mod read_loop; mod retry; mod stream; mod utils; + +mod backend; +pub use backend::{AudioSectorReader, TrackReadError, read_track}; pub use data_reader::{ReadOptions, SectorReadFormat}; pub use discovery::DriveInfo; pub use errors::{CdReaderError, ScsiError, ScsiOp}; @@ -152,6 +155,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 +188,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 +240,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; From 205c919bcd7ae1c4423f853b2ea8eccf4ab5e84a Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Sun, 12 Jul 2026 19:42:51 -0400 Subject: [PATCH 2/8] perf(example): stream save_data_track to disk instead of buffering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data-track example read the entire track into a Vec before writing — a full data track can be hundreds of MB. Switch it to the streaming API (open_track_stream_with_options) so cooked/raw chunks are written as they arrive and peak memory is one ~64 KB chunk regardless of track size. Both the ISO (Mode1Cooked) and Mode 2 branches now share one stream_track_to_file helper with a progress line, which also showcases that reading and streaming run over the same options/read path. Docs updated to recommend streaming for large images. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/consuming-cd-da-reader.md | 14 +++++-- examples/save_data_track.rs | 72 +++++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/docs/consuming-cd-da-reader.md b/docs/consuming-cd-da-reader.md index 7aec030..1dca5d9 100644 --- a/docs/consuming-cd-da-reader.md +++ b/docs/consuming-cd-da-reader.md @@ -139,6 +139,12 @@ let image = reader.read_track_with_options(&toc, data_track.number, &options)?; std::fs::write("disc.iso", &image)?; ``` +The blocking read above buffers the whole image in memory. A data track can be +hundreds of MB, so for anything non-trivial prefer the streaming path with the +same `Mode1Cooked` format — pull chunks and write them straight to the file, so +peak memory stays at one chunk. That is exactly what `examples/save_data_track.rs` +does. + Then mount and explore: | OS | Mount | Unmount | @@ -147,10 +153,10 @@ Then mount and explore: | Linux | `sudo mount -o loop,ro disc.iso /mnt/cd` | `sudo umount /mnt/cd` | | Windows | `Mount-DiskImage -ImagePath disc.iso` | `Dismount-DiskImage -ImagePath disc.iso` | -The full runnable version — including the Mode 2 branch — is -`examples/save_data_track.rs`. To verify a data read against the on-disc ISO -structure (sync pattern, `CD001` signature, cooked-equals-raw), see -`examples/read_data_track.rs`. +The full runnable version — streaming to disk with a progress line, including the +Mode 2 branch — is `examples/save_data_track.rs`. To verify a data read against +the on-disc ISO structure (sync pattern, `CD001` signature, cooked-equals-raw), +see `examples/read_data_track.rs`. --- diff --git a/examples/save_data_track.rs b/examples/save_data_track.rs index 382aa5a..591a127 100644 --- a/examples/save_data_track.rs +++ b/examples/save_data_track.rs @@ -4,16 +4,25 @@ //! 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, read it **cooked** (2048 B/sector) — that is exactly the -//! ISO 9660 filesystem image, so it writes straight to a `.iso` you can -//! mount and explore, +//! 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 cd_da_reader::{CdReader, ReadOptions, SectorReadFormat}; +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")?; @@ -35,16 +44,13 @@ fn main() -> Result<(), Box> { 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 options = ReadOptions::default().with_format(SectorReadFormat::Mode1Cooked); - let image = reader.read_track_with_options(&toc, data_track.number, &options)?; - let iso_path = output_dir.join(format!("track{:02}.iso", data_track.number)); - std::fs::write(&iso_path, &image)?; + let bytes = stream_track_to_file(&reader, &toc, data_track.number, format, &iso_path)?; + println!( - "Wrote {} ({} bytes, {} sectors)\n", + "Wrote {} ({bytes} bytes, {} sectors)\n", iso_path.display(), - image.len(), - image.len() / 2048 + bytes / format.sector_size() as u64 ); print_mount_hint(&iso_path.display().to_string()); } @@ -53,17 +59,14 @@ fn main() -> Result<(), Box> { // 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 options = ReadOptions::default().with_format(SectorReadFormat::Mode2Raw); - let raw = reader.read_track_with_options(&toc, data_track.number, &options)?; - let bin_path = output_dir.join(format!("track{:02}.mode2.bin", data_track.number)); - std::fs::write(&bin_path, &raw)?; + 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, {} sectors).", + ({bytes} bytes, {} sectors).", bin_path.display(), - raw.len(), - raw.len() / 2352 + bytes / format.sector_size() as u64 ); println!( "Extracting a mountable filesystem from Mode 2 is consumer territory — \ @@ -82,6 +85,39 @@ fn main() -> Result<(), Box> { 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") { From 885335edd699d5fa5a25b7117084895fbb95a0b7 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Sun, 12 Jul 2026 19:43:42 -0400 Subject: [PATCH 3/8] chore: bump version to 1.0.0 First release carrying the breaking 1.0 API (SectorReadFormat, per-read ReadOptions, detect_track_format) plus the AudioSectorReader file backing. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0cd4280..7ae1b93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "cd-da-reader" -version = "0.4.1" +version = "1.0.0" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index ea04f27..b603d52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cd-da-reader" -version = "0.4.1" +version = "1.0.0" edition = "2024" description = "CD-DA (audio CD) reading library" repository = "https://github.com/Bloomca/rust-cd-da-reader" From 8c25645a47b0fb4e26a3764e82377725eb296787 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 17 Jul 2026 19:08:44 -0400 Subject: [PATCH 4/8] Rollback version bump --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b603d52..ea04f27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cd-da-reader" -version = "1.0.0" +version = "0.4.1" edition = "2024" description = "CD-DA (audio CD) reading library" repository = "https://github.com/Bloomca/rust-cd-da-reader" From dace3b45572ea4d2c691cb22b330b2142239fa0c Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 17 Jul 2026 19:09:42 -0400 Subject: [PATCH 5/8] missed Cargo.lock in version bump exclusion --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 7ae1b93..0cd4280 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "cd-da-reader" -version = "1.0.0" +version = "0.4.1" dependencies = [ "cc", "libc", From 16a91083e5b68eb4661196383e988d870c4de52e Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Mon, 20 Jul 2026 17:33:57 -0400 Subject: [PATCH 6/8] feat(backend): unify errors, add streaming and gapless track bounds Address PR #58 review feedback: - Fold the backing error into CdReaderError::Backend(Box); read_track now returns CdReaderError and the generic TrackReadError is gone, so file- and drive-backed reads share one error type. The backing's own error is preserved as the boxed source(). - Add TrackBounds { PhysicalDisc, GaplessImage }. The CD-Extra trailing-gap rule is correct for a physical disc but wrong for a gapless CHD/BIN extract, where it would drop ~2.5 min off the last audio track before a data session. Both the buffered path (read_track_with_bounds) and the stream take the geometry; PhysicalDisc stays the default, so existing behaviour is unchanged. - Add AudioTrackStream, the file/image counterpart to TrackStream (next_chunk, seek_to_sector/seconds, current/total_sectors/seconds, with_sectors_per_chunk). Open it with open_track_stream (TOC + physical), open_track_stream_with_bounds (TOC + explicit geometry), or open_track_stream_at (raw absolute sector range, for backings that compute their own layout). "Listen to" can now stream instead of buffering a whole track. - Drop docs/consuming-cd-da-reader.md; the file-backing story lives in the backend module docs. - Demonstrate streaming in examples/file_backend.rs. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/consuming-cd-da-reader.md | 287 ---------------------------- examples/file_backend.rs | 20 +- src/backend.rs | 340 +++++++++++++++++++++++++++++---- src/errors.rs | 7 + src/lib.rs | 5 +- src/utils.rs | 63 +++++- 6 files changed, 388 insertions(+), 334 deletions(-) delete mode 100644 docs/consuming-cd-da-reader.md diff --git a/docs/consuming-cd-da-reader.md b/docs/consuming-cd-da-reader.md deleted file mode 100644 index 1dca5d9..0000000 --- a/docs/consuming-cd-da-reader.md +++ /dev/null @@ -1,287 +0,0 @@ -# Consuming `cd-da-reader` (1.0) - -A guide for downstream projects. It covers the unified read/stream model, sector -formats and auto-detection, the data-track workflow (save → mount → explore), -what is and isn't automatic for Mode 1 vs Mode 2, reading from file/image -backings, and a migration cheat-sheet from the pre-1.0 API. - -Everything here is exercised by the crate's `examples/`; each section links the -runnable one. - ---- - -## 1. The shape of the API - -Three steps, always in this order: - -```rust -use cd_da_reader::CdReader; - -let reader = CdReader::open_default()?; // 1. get a drive -let toc = reader.read_toc()?; // 2. read the table of contents -let pcm = reader.read_track(&toc, 1)?; // 3. read a track (audio, by default) -``` - -Opening a drive: - -| Call | Use when | -| --- | --- | -| `CdReader::open_default()` | Grab the first drive that has an audio CD. Usually what you want. | -| `CdReader::list_drives()` → `CdReader::open(&drive)` | Let the user choose; each `DriveInfo` has `has_audio_cd`. | -| `CdReader::open_path("disk6" \| "/dev/sr0" \| r"\\.\E:")` | You already know the platform device path. | - -The reader owns the open handle and closes it on `Drop`. Use one reader at a -time — CD drives are physical, sequential devices. - ---- - -## 2. One options struct for every read - -Blocking reads and streaming reads run over the **same** machinery; the only -difference is which options struct you hand in. - -- **Blocking:** [`ReadOptions`] → `read_track_with_options` / `read_sector_range` -- **Streaming:** [`TrackStreamOptions`] → `open_track_stream_with_options` - -Both are builders whose defaults read **audio** with the default retry policy, so -you override only what you need: - -```rust -use cd_da_reader::{ReadOptions, SectorReadFormat, RetryConfig}; - -let options = ReadOptions::default() - .with_format(SectorReadFormat::Mode1Cooked) // default: Audio - .with_retry(RetryConfig::default().with_max_attempts(6)); - -// NOTE: read_track_with_options takes the options by reference. -let data = reader.read_track_with_options(&toc, 3, &options)?; -``` - -The convenience methods are just defaults over this: - -| Convenience | Equivalent | -| --- | --- | -| `read_track(&toc, n)` | `read_track_with_options(&toc, n, &ReadOptions::default())` | -| `open_track_stream(&toc, n)` | `open_track_stream_with_options(&toc, n, TrackStreamOptions::default())` | - -`read_track_with_options` validates the requested format against the track type -from the TOC and returns `CdReaderError::TrackFormatMismatch` if, say, you ask for -`Audio` on a data track. `read_sector_range(start_lba, sectors, &options)` is the -low-level escape hatch: it does **no** validation and expects you to supply valid -bounds and a compatible format. - ---- - -## 3. Sector formats - -`SectorReadFormat` selects what the drive returns per sector: - -| Format | Bytes/sector | What it is | -| --- | --- | --- | -| `Audio` | 2352 | CD-DA PCM (16-bit signed LE, stereo, 44100 Hz). | -| `Mode1Cooked` | 2048 | Mode 1 **user data only** — sync/header/EDC/ECC stripped. This is the filesystem image. | -| `Mode1Raw` | 2352 | Complete Mode 1 sector: sync + header + 2048 user + EDC/ECC. | -| `Mode2Raw` | 2352 | Complete Mode 2 sector. The Mode 2 *form* is per-sector; the payload lives behind an XA subheader. | - -`format.sector_size()` returns these numbers, so -`bytes == sectors * format.sector_size()`. - -### Auto-detecting a track's format - -You rarely need to hard-code the format — ask the drive: - -```rust -for track in &toc.tracks { - let format = reader.detect_track_format(track)?; - println!("Track #{}: {format:?}", track.number); -} -``` - -See `examples/detect_track_formats.rs`. - -`detect_track_format` resolves to: - -- `Audio` for audio tracks (straight from the TOC), -- `Mode1Cooked` for Mode 1 data tracks, -- `Mode2Raw` for Mode 2 data tracks. - -It uses MMC `READ TRACK INFORMATION`, and falls back to inspecting one raw sector -if the drive's Data Mode field is inconclusive. If it still can't tell, you get -`CdReaderError::CannotDetectTrackFormat`. - -> There is **no** `find_data_track(&toc)` helper — a "data track" is just -> `!track.is_audio`, so the idiom is a plain filter: -> `toc.tracks.iter().find(|t| !t.is_audio)`. - ---- - -## 4. Reading a data track: save → mount → explore - -The common case is a mixed-mode / enhanced ("CD-Extra") disc: audio tracks plus a -data track holding an ISO 9660 filesystem with extra files (artwork, videos, -liner notes). - -Because `detect_track_format` returns `Mode1Cooked` for such a track, and cooked -Mode 1 is *exactly* the 2048-byte user data per sector, reading the whole track -cooked gives you a byte-for-byte ISO 9660 image you can write to `.iso` and mount: - -```rust -use cd_da_reader::{ReadOptions, SectorReadFormat}; - -let data_track = toc.tracks.iter().find(|t| !t.is_audio) - .ok_or("no data track on this disc")?; - -let format = reader.detect_track_format(data_track)?; // Mode1Cooked here -assert_eq!(format, SectorReadFormat::Mode1Cooked); - -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)?; -``` - -The blocking read above buffers the whole image in memory. A data track can be -hundreds of MB, so for anything non-trivial prefer the streaming path with the -same `Mode1Cooked` format — pull chunks and write them straight to the file, so -peak memory stays at one chunk. That is exactly what `examples/save_data_track.rs` -does. - -Then mount and explore: - -| OS | Mount | Unmount | -| --- | --- | --- | -| macOS | `hdiutil attach disc.iso` | `hdiutil detach /Volumes/` | -| Linux | `sudo mount -o loop,ro disc.iso /mnt/cd` | `sudo umount /mnt/cd` | -| Windows | `Mount-DiskImage -ImagePath disc.iso` | `Dismount-DiskImage -ImagePath disc.iso` | - -The full runnable version — streaming to disk with a progress line, including the -Mode 2 branch — is `examples/save_data_track.rs`. To verify a data read against -the on-disc ISO structure (sync pattern, `CD001` signature, cooked-equals-raw), -see `examples/read_data_track.rs`. - ---- - -## 5. Mode 1 vs Mode 2 — what's automatic - -**Mode 1 is fully handled.** You can read it either way and both are reliable: - -- `Mode1Cooked` (2048 B) — the ready-to-mount user data. -- `Mode1Raw` (2352 B) — the complete sector if you want the framing/ECC. The - user data is `raw[16..16+2048]` (sync 12 + header 4). `detect_track_format` - returns `Mode1Cooked`, but you can request raw instead: - - ```rust - let options = ReadOptions::default().with_format(SectorReadFormat::Mode1Raw); - ``` - -**Mode 2 is detected but not cooked for you.** Mode 2 mixes Form 1 (2048-byte -payload) and Form 2 (2324-byte payload) sectors, and the form is encoded in an -8-byte **XA subheader** at the front of each sector's user area — it is a -*per-sector* property, so there is no single "cooked" size for the track. The -crate therefore exposes Mode 2 only as `Mode2Raw` (complete 2352-byte sectors) -and leaves payload extraction to you: read raw, and for each sector inspect the -subheader to decide Form 1 vs Form 2 and slice the payload accordingly. This is -intentionally the consumer's responsibility. - ---- - -## 6. Streaming - -Same formats and retry config, pulled incrementally — for live playback or -progress reporting instead of one big blocking read: - -```rust -use cd_da_reader::TrackStreamOptions; - -let options = TrackStreamOptions::default() - .with_sectors_per_chunk(27); // ~64 KB of audio per chunk -let mut stream = reader.open_track_stream_with_options(&toc, 1, options)?; - -while let Some(chunk) = stream.next_chunk()? { - // chunk length == sectors_this_chunk * format.sector_size() -} -``` - -`TrackStream` also exposes `total_sectors()`, `current_sector()`, -`current_seconds()`, `total_seconds()`, `seek_to_sector()`, and -`seek_to_seconds()`. See `examples/stream_with_progress.rs` and -`examples/stream_last_track.rs`. - ---- - -## 7. Reading from a file/image instead of a drive - -Everything above a raw sector read — the `Toc`/`Track` types, track-bounds math -(including the CD-Extra trailing-gap rule), and WAV wrapping — is -hardware-independent. Implement [`AudioSectorReader`] for any backing that can -yield raw CD-DA sectors (a CHD image, a BIN/CUE dump, an in-memory buffer, a -network stream) and reuse the same machinery — **the crate takes on no -image-format dependencies**: - -```rust -use cd_da_reader::{AudioSectorReader, Toc, Track, create_wav, lba_to_msf, read_track}; - -struct MyImage { /* ... */ } - -impl AudioSectorReader for MyImage { - type Error = std::io::Error; - fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { - // decode from your image and return exactly count * 2352 bytes - // (16-bit signed LE, stereo) - todo!() - } -} - -// Build a Toc from the image's own metadata (lba_to_msf fills start_msf), -// then read tracks with the free `read_track`: -let pcm = read_track(&image, &toc, 1)?; // Result, TrackReadError> -let wav = create_wav(pcm); // free fn; also CdReader::create_wav -``` - -`CdReader` itself implements `AudioSectorReader`, so drive-backed and file-backed -code can share the generic `read_track` path. `read_track` returns -`TrackReadError`, which keeps a bad track request (`Toc`) separate from a -failure inside your backing (`Backend(E)`), preserving your error type. Full -dependency-free example: `examples/file_backend.rs`. - ---- - -## 8. Errors - -`CdReaderError` is the one error type for drive operations: - -| Variant | Meaning | -| --- | --- | -| `Io(std::io::Error)` | OS/transport failure (open, ioctl, FFI). | -| `Scsi(ScsiError)` | Device reported a SCSI failure; carries status + sense. | -| `Parse(String)` | Couldn't parse a command response. | -| `TrackFormatMismatch { .. }` | Requested a format incompatible with the track type. | -| `CannotDetectTrackFormat { .. }` | `detect_track_format` couldn't determine the format. | -| `NoUsableDrive` | Enumeration found no drive with an audio CD. | - -The file-backing `read_track` uses its own `TrackReadError` (see §7) so your -backing's error type isn't flattened into the SCSI-oriented `CdReaderError`. - ---- - -## 9. Migrating from the pre-1.0 API - -1.0 renamed a lot for clarity. The mechanical substitutions: - -| Before (0.x) | Now (1.0) | -| --- | --- | -| `CdReader::open("disk6")` | `CdReader::open_path("disk6")`, or `CdReader::open(&drive_info)` | -| `SectorReadMode` | `SectorReadFormat` | -| `SectorReadMode::DataCooked` | `SectorReadFormat::Mode1Cooked` | -| `SectorReadMode::DataRaw` | `SectorReadFormat::Mode1Raw` (plus new `Mode2Raw`) | -| `read_data_sectors(lba, n, mode, &cfg)` | `read_sector_range(lba, n, &ReadOptions::default().with_format(fmt).with_retry(cfg))` | -| `read_track_with_retry(&toc, n, &cfg)` | `read_track_with_options(&toc, n, &ReadOptions::default().with_retry(cfg))` | -| `TrackStreamConfig { sectors_per_chunk, retry }` | `TrackStreamOptions::default().with_sectors_per_chunk(..).with_retry(..)` | -| `open_track_stream(&toc, n, cfg)` | `open_track_stream(&toc, n)` or `open_track_stream_with_options(&toc, n, options)` | - -New in 1.0: `detect_track_format`, `Mode1Raw` / `Mode2Raw`, per-read -`ReadOptions`, format validation on `read_track_with_options`, and the -`AudioSectorReader` file-backing seam. - -[`ReadOptions`]: https://docs.rs/cd-da-reader/latest/cd_da_reader/struct.ReadOptions.html -[`TrackStreamOptions`]: https://docs.rs/cd-da-reader/latest/cd_da_reader/struct.TrackStreamOptions.html -[`AudioSectorReader`]: https://docs.rs/cd-da-reader/latest/cd_da_reader/trait.AudioSectorReader.html diff --git a/examples/file_backend.rs b/examples/file_backend.rs index e3fa1d5..f95a7f5 100644 --- a/examples/file_backend.rs +++ b/examples/file_backend.rs @@ -12,7 +12,9 @@ //! Run with: `cargo run --example file_backend` mod common; -use cd_da_reader::{AudioSectorReader, Toc, Track, create_wav, lba_to_msf, read_track}; +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 { @@ -81,5 +83,21 @@ fn main() -> Result<(), Box> { 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 gapless + // CHD/BIN image would open with `TrackBounds::GaplessImage`, 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/src/backend.rs b/src/backend.rs index ddc78f8..0c076c6 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -10,14 +10,27 @@ //! 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 call [`read_track`] to -//! get PCM in the exact same little-endian, 2352-byte/sector format the physical -//! reader produces — ready for [`create_wav`](crate::create_wav). +//! 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)). +//! +//! ## Physical vs. gapless track bounds +//! +//! Resolving a track's sector range from a [`Toc`] differs between a physical +//! disc and an extracted image on exactly one track: the last audio track before +//! a trailing data session on a CD-Extra disc. A physical disc has a real +//! inter-session gap there; an extracted CHD/BIN image is laid out gapless. +//! [`read_track`] / [`open_track_stream`] default to +//! [`TrackBounds::PhysicalDisc`]; image backings must pass +//! [`TrackBounds::GaplessImage`] (or supply explicit bounds via +//! [`open_track_stream_at`]) or that track loses ~2.5 min of audio. //! //! See `examples/file_backend.rs` for a complete, dependency-free example. -use std::fmt; +use std::cmp::min; use crate::{CdReader, CdReaderError, ReadOptions, Toc, utils}; @@ -44,6 +57,11 @@ impl AudioSectorReader for CdReader { /// 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; @@ -53,40 +71,38 @@ pub trait AudioSectorReader { fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error>; } -/// Error returned by [`read_track`]. +/// How a track's sector range is resolved from a [`Toc`]. /// -/// Separates a bad track request (not in the TOC, or invalid TOC bounds) from a -/// failure inside the backing [`AudioSectorReader`], whose error type is -/// preserved as `E` rather than flattened into this crate's SCSI-oriented -/// [`CdReaderError`](crate::CdReaderError). -#[derive(Debug)] -pub enum TrackReadError { - /// The requested track number was not found in the TOC, or the resolved - /// sector bounds were invalid. - Toc(std::io::Error), - /// The backing [`AudioSectorReader`] failed while reading sectors. - Backend(E), -} - -impl fmt::Display for TrackReadError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Toc(err) => write!(f, "TOC error: {err}"), - Self::Backend(err) => write!(f, "backend error: {err}"), - } - } +/// 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. +/// +/// - [`PhysicalDisc`](Self::PhysicalDisc) subtracts the inter-session gap +/// (matching [`CdReader::read_track`](crate::CdReader::read_track)) — correct +/// when reading a real disc, where that gap is present. +/// - [`GaplessImage`](Self::GaplessImage) does not: an extracted CHD/BIN image +/// lays tracks back-to-back, so a track spans from its `start_lba` to the next +/// track's start (or the leadout). Subtracting the gap there would drop ~2.5 +/// min of real audio. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrackBounds { + /// Physical-disc geometry: apply the CD-Extra trailing-gap rule. + PhysicalDisc, + /// Gapless extracted-image geometry: no inter-session gap subtraction. + GaplessImage, } -impl std::error::Error for TrackReadError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { +impl TrackBounds { + fn resolve(self, toc: &Toc, track_no: u8) -> std::io::Result<(u32, u32)> { match self { - Self::Toc(err) => Some(err), - Self::Backend(err) => Some(err), + TrackBounds::PhysicalDisc => utils::get_track_bounds(toc, track_no), + TrackBounds::GaplessImage => utils::get_gapless_track_bounds(toc, track_no), } } } -/// Read raw PCM for one track from any [`AudioSectorReader`] backing. +/// Read raw PCM for one track from any [`AudioSectorReader`] backing, using +/// physical-disc track geometry ([`TrackBounds::PhysicalDisc`]). /// /// This is the file/image counterpart to /// [`CdReader::read_track`](crate::CdReader::read_track): it resolves the track's @@ -94,15 +110,187 @@ impl std::error::Error for TrackReadError { /// 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. -pub fn read_track( +/// +/// 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). +/// +/// For a **gapless** extracted CHD/BIN image, use [`read_track_with_bounds`] +/// with [`TrackBounds::GaplessImage`]. +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::PhysicalDisc) +} + +/// Read one track like [`read_track`], but with an explicit [`TrackBounds`] +/// geometry — pass [`TrackBounds::GaplessImage`] for extracted CHD/BIN images. +pub fn read_track_with_bounds( src: &R, toc: &Toc, track_no: u8, -) -> Result, TrackReadError> { - let (start_lba, sectors) = - utils::get_track_bounds(toc, track_no).map_err(TrackReadError::Toc)?; + 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(TrackReadError::Backend) + .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 using physical-disc geometry +/// ([`TrackBounds::PhysicalDisc`]). 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::PhysicalDisc) +} + +/// Open a streaming reader for a track with an explicit [`TrackBounds`] geometry. +/// Use [`TrackBounds::GaplessImage`] for extracted CHD/BIN images. +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. a gapless CHD/BIN +/// image reading `[start_lba(n) .. start_lba(n + 1))` — 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)] @@ -190,14 +378,88 @@ mod tests { } #[test] - fn missing_track_is_a_toc_error() { + 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(TrackReadError::Toc(_)) => {} - other => panic!("expected TOC error, got {other:?}"), + 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 f10b008..077f5f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -147,7 +147,10 @@ mod stream; mod utils; mod backend; -pub use backend::{AudioSectorReader, TrackReadError, read_track}; +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}; diff --git a/src/utils.rs b/src/utils.rs index 69b2e6e..db154d7 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 **gapless** extracted image (CHD/BIN laid out +/// back-to-back): the track spans from its own `start_lba` to the next track's +/// start (or the leadout), with **no** CD-Extra inter-session gap subtracted. +/// +/// Physical-disc reads want [`get_track_bounds`]; extracted-image reads want +/// this. Applying the physical gap rule to a gapless image would drop the +/// inter-session gap (~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(); From 83e13b5452f3655e5f88449802a2588713141198 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Mon, 20 Jul 2026 18:13:32 -0400 Subject: [PATCH 7/8] docs: add CHD/file-image reading task notes Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/add_chd_cd_da_reading.md | 216 ++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/add_chd_cd_da_reading.md 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). From 70f37567f37c05aa4c40578f0a353669f5bd2814 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Mon, 20 Jul 2026 18:31:59 -0400 Subject: [PATCH 8/8] refactor(backend): frame TrackBounds by gap presence, not medium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CD-Extra gap subtraction applies whenever a TOC's addressing includes the inter-session gap — a physical disc OR an image that preserves the disc's real LBAs — and must be skipped only for a gap-stripped contiguous extract. The old PhysicalDisc/GaplessImage names wrongly implied image == gapless. Rename the variants to name that geometry instead of the medium: PhysicalDisc -> SessionGap (addressing includes the gap; default) GaplessImage -> Gapless (tracks contiguous, gap stripped) Reword the module/item docs, the gapless-bounds helper doc, and the example comment to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/file_backend.rs | 9 ++--- src/backend.rs | 77 +++++++++++++++++++++------------------- src/utils.rs | 14 ++++---- 3 files changed, 53 insertions(+), 47 deletions(-) diff --git a/examples/file_backend.rs b/examples/file_backend.rs index f95a7f5..5e6b1ab 100644 --- a/examples/file_backend.rs +++ b/examples/file_backend.rs @@ -84,10 +84,11 @@ fn main() -> Result<(), Box> { } // 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 gapless - // CHD/BIN image would open with `TrackBounds::GaplessImage`, 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.) + // 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()? { diff --git a/src/backend.rs b/src/backend.rs index 0c076c6..4d0a7c2 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -17,16 +17,18 @@ //! [`read_track`], or pull it incrementally with [`open_track_stream`] (the //! file/image counterpart to [`TrackStream`](crate::TrackStream)). //! -//! ## Physical vs. gapless track bounds +//! ## Track bounds and the CD-Extra gap //! -//! Resolving a track's sector range from a [`Toc`] differs between a physical -//! disc and an extracted image on exactly one track: the last audio track before -//! a trailing data session on a CD-Extra disc. A physical disc has a real -//! inter-session gap there; an extracted CHD/BIN image is laid out gapless. -//! [`read_track`] / [`open_track_stream`] default to -//! [`TrackBounds::PhysicalDisc`]; image backings must pass -//! [`TrackBounds::GaplessImage`] (or supply explicit bounds via -//! [`open_track_stream_at`]) or that track loses ~2.5 min of audio. +//! 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. @@ -75,34 +77,37 @@ pub trait AudioSectorReader { /// /// 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. +/// 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. /// -/// - [`PhysicalDisc`](Self::PhysicalDisc) subtracts the inter-session gap -/// (matching [`CdReader::read_track`](crate::CdReader::read_track)) — correct -/// when reading a real disc, where that gap is present. -/// - [`GaplessImage`](Self::GaplessImage) does not: an extracted CHD/BIN image -/// lays tracks back-to-back, so a track spans from its `start_lba` to the next -/// track's start (or the leadout). Subtracting the gap there would drop ~2.5 -/// min of real audio. +/// - [`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 { - /// Physical-disc geometry: apply the CD-Extra trailing-gap rule. - PhysicalDisc, - /// Gapless extracted-image geometry: no inter-session gap subtraction. - GaplessImage, + /// 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::PhysicalDisc => utils::get_track_bounds(toc, track_no), - TrackBounds::GaplessImage => utils::get_gapless_track_bounds(toc, track_no), + 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, using -/// physical-disc track geometry ([`TrackBounds::PhysicalDisc`]). +/// 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 @@ -116,18 +121,18 @@ impl TrackBounds { /// [`CdReaderError::Backend`], which preserves the backing's own error as the /// boxed [`source`](std::error::Error::source). /// -/// For a **gapless** extracted CHD/BIN image, use [`read_track_with_bounds`] -/// with [`TrackBounds::GaplessImage`]. +/// 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::PhysicalDisc) + read_track_with_bounds(src, toc, track_no, TrackBounds::SessionGap) } /// Read one track like [`read_track`], but with an explicit [`TrackBounds`] -/// geometry — pass [`TrackBounds::GaplessImage`] for extracted CHD/BIN images. +/// geometry — pass [`TrackBounds::Gapless`] for a contiguous, gap-stripped layout. pub fn read_track_with_bounds( src: &R, toc: &Toc, @@ -257,18 +262,18 @@ impl<'a, R: AudioSectorReader> AudioTrackStream<'a, R> { } } -/// Open a streaming reader for a track using physical-disc geometry -/// ([`TrackBounds::PhysicalDisc`]). See [`AudioTrackStream`]. +/// 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::PhysicalDisc) + 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::GaplessImage`] for extracted CHD/BIN images. +/// Use [`TrackBounds::Gapless`] for a contiguous, gap-stripped layout. pub fn open_track_stream_with_bounds<'a, R: AudioSectorReader>( src: &'a R, toc: &Toc, @@ -282,9 +287,9 @@ pub fn open_track_stream_with_bounds<'a, R: AudioSectorReader>( /// 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. a gapless CHD/BIN -/// image reading `[start_lba(n) .. start_lba(n + 1))` — this is the zero-policy -/// primitive: no TOC lookup, no CD-Extra rule, and no failure mode. +/// 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, diff --git a/src/utils.rs b/src/utils.rs index db154d7..73f4faf 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -6,14 +6,14 @@ pub(crate) fn get_track_bounds(toc: &Toc, track_no: u8) -> std::io::Result<(u32, track_bounds(toc, track_no, true) } -/// Track bounds for a **gapless** extracted image (CHD/BIN laid out -/// back-to-back): the track spans from its own `start_lba` to the next track's -/// start (or the leadout), with **no** CD-Extra inter-session gap subtracted. +/// 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. /// -/// Physical-disc reads want [`get_track_bounds`]; extracted-image reads want -/// this. Applying the physical gap rule to a gapless image would drop the -/// inter-session gap (~2.5 min) of real audio off the last audio track before a -/// data session. +/// 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) }