diff --git a/README.md b/README.md index 87c20f6..4133300 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,58 @@ 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`. + +## 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. + +Two examples cover this, both dependency-free: + +- `examples/file_backend.rs` — the smallest possible backing (whole-disc PCM in memory), to show the shape of the trait. +- `examples/bin_cue_backend.rs` — a real container: it parses a `.cue` sheet into a `Toc` and serves sectors out of the `.bin` with positioned reads. Point it at an image with `cargo run --example bin_cue_backend -- /path/to/disc.cue`, or run it bare and it synthesizes a small mixed-mode image to work against. + +One caveat worth knowing before writing a backing: `read_track` defaults to `TrackBounds::SessionGap`, which subtracts the CD-Extra inter-session gap from the last audio track before a trailing data session. That is right for a physical disc or an image whose TOC preserves the disc's real LBAs, and wrong for an image whose tracks are addressed back-to-back with the gap stripped out (a single-`FILE` BIN/CUE, a `chdman extractcd` extract), where it would drop ~2.5 minutes of real audio. Those backings should pass `TrackBounds::Gapless` to `read_track_with_bounds` / `open_track_stream_with_bounds`. + ## 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/examples/bin_cue_backend.rs b/examples/bin_cue_backend.rs new file mode 100644 index 0000000..50ad076 --- /dev/null +++ b/examples/bin_cue_backend.rs @@ -0,0 +1,408 @@ +//! Read audio tracks out of a BIN/CUE disc image, with no image-format crate. +//! +//! BIN/CUE is the simplest real backing there is: the `.bin` holds the disc's +//! sectors end to end, and the `.cue` says where each track starts. That makes +//! it a good worked example of the whole [`AudioSectorReader`] contract — +//! parse the container's own metadata into a [`Toc`], serve raw 2352-byte +//! sectors, and let the crate do the track math and WAV wrapping. +//! +//! Three things here are worth copying into a real backing: +//! +//! 1. **`&self` reads via positioned I/O.** `read_audio_sectors` takes `&self`, +//! so the backing uses `read_at`/`seek_read` (an offset per call) rather than +//! `seek` + `read`, which would need `&mut self`. +//! 2. **`TrackBounds::Gapless`.** A single-`FILE` cue is contiguous by +//! construction — every `INDEX 01` is an offset into the same `.bin`, so +//! track N+1 begins exactly where track N ends. There is no inter-session gap +//! in the addressing, so the CD-Extra trailing-gap rule must not be applied. +//! See the note this example prints for a mixed-mode disc. +//! 3. **`type Error = io::Error`.** Any `std::error::Error + Send + Sync` works; +//! the crate reports it as [`CdReaderError::Backend`] with the original error +//! kept as its `source()`. +//! +//! Run against a real image: +//! +//! ```text +//! cargo run --example bin_cue_backend -- /path/to/disc.cue +//! ``` +//! +//! Run with no arguments and it writes a small mixed-mode BIN/CUE into the +//! output directory first, so the example works without an image on hand: +//! +//! ```text +//! cargo run --example bin_cue_backend +//! ``` +mod common; + +use std::error::Error; +use std::fs::File; +use std::io; +use std::path::{Path, PathBuf}; + +use cd_da_reader::{ + AudioSectorReader, Toc, Track, TrackBounds, create_wav, lba_to_msf, + open_track_stream_with_bounds, read_track_with_bounds, +}; + +/// Every sector in a raw disc image is 2352 bytes, audio or data alike. +const SECTOR_SIZE: usize = 2352; +const SECTORS_PER_SECOND: u32 = 75; + +/// A single-`FILE` BIN/CUE image: the `.bin` addressed by sector. +/// +/// Sector *n* of the image lives at byte `n * 2352`, and because the cue's +/// offsets are offsets into this same file, that sector index *is* the LBA the +/// [`Toc`] carries — no translation needed. +struct BinImage { + bin: File, +} + +impl AudioSectorReader for BinImage { + type Error = io::Error; + + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + let mut buffer = vec![0u8; count as usize * SECTOR_SIZE]; + read_exact_at( + &self.bin, + &mut buffer, + start_lba as u64 * SECTOR_SIZE as u64, + )?; + Ok(buffer) + } +} + +/// Positioned read: fill `buffer` from `offset` without moving a shared cursor, +/// which is what lets `read_audio_sectors` take `&self`. +fn read_exact_at(file: &File, buffer: &mut [u8], offset: u64) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + file.read_exact_at(buffer, offset) + } + + #[cfg(windows)] + { + use std::os::windows::fs::FileExt; + + let mut filled = 0; + while filled < buffer.len() { + match file.seek_read(&mut buffer[filled..], offset + filled as u64) { + Ok(0) => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "read past the end of the .bin", + )); + } + Ok(read) => filled += read, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(()) + } +} + +fn main() -> Result<(), Box> { + let output_dir = common::fresh_output_dir("bin_cue_backend")?; + + let cue_path = match std::env::args().nth(1) { + Some(path) => PathBuf::from(path), + None => { + println!("No .cue argument given — writing a demo image to work against.\n"); + write_demo_image(&output_dir)? + } + }; + + let (bin_path, toc) = parse_cue(&cue_path)?; + println!("Cue: {}", cue_path.display()); + println!("Bin: {}", bin_path.display()); + println!( + "Toc: {} tracks, leadout at LBA {}\n", + toc.tracks.len(), + toc.leadout_lba + ); + + let image = BinImage { + bin: File::open(&bin_path)?, + }; + + // A single-FILE cue is a contiguous run of sectors, so the CD-Extra + // inter-session gap is not part of the addressing. See `explain_bounds`. + let bounds = TrackBounds::Gapless; + explain_bounds(&image, &toc)?; + + for track in toc.tracks.iter().filter(|track| track.is_audio) { + let pcm = read_track_with_bounds(&image, &toc, track.number, bounds)?; + let wav_path = output_dir.join(format!("track{:02}.wav", track.number)); + std::fs::write(&wav_path, create_wav(pcm))?; + + println!("track {:02}: wrote {}", track.number, wav_path.display()); + } + + // The same backing streams instead of buffering — for a 74-minute image + // that is the difference between one chunk and ~650 MB resident. + if let Some(first_audio) = toc.tracks.iter().find(|track| track.is_audio) { + let mut stream = open_track_stream_with_bounds(&image, &toc, first_audio.number, bounds)?; + let (mut chunks, mut bytes) = (0u32, 0usize); + while let Some(chunk) = stream.next_chunk()? { + chunks += 1; + bytes += chunk.len(); + } + println!( + "\nstreamed track {:02}: {bytes} bytes in {chunks} chunks ({:.1}s)", + first_audio.number, + stream.total_seconds() + ); + } + + Ok(()) +} + +/// Show what the two [`TrackBounds`] policies do on this disc. +/// +/// They differ on exactly one track — the last audio track before a trailing +/// data session — so on a plain audio disc this prints nothing. +fn explain_bounds(image: &BinImage, toc: &Toc) -> Result<(), Box> { + let Some(track) = last_audio_before_data(toc) else { + return Ok(()); + }; + + let gapless = + open_track_stream_with_bounds(image, toc, track, TrackBounds::Gapless)?.total_sectors(); + let physical = match open_track_stream_with_bounds(image, toc, track, TrackBounds::SessionGap) { + Ok(stream) => format!("{} sectors", stream.total_sectors()), + // The gap is larger than the track, so subtracting it underflows. + Err(e) => format!("fails ({e})"), + }; + + println!( + "Track {track:02} is the last audio track before a data track, the one track \ + the two bounds policies disagree on:\n \ + Gapless (used here): {gapless} sectors\n \ + SessionGap: {physical}\n" + ); + Ok(()) +} + +fn last_audio_before_data(toc: &Toc) -> Option { + let last_audio = toc.tracks.iter().rposition(|track| track.is_audio)?; + let has_trailing_data = last_audio + 1 < toc.tracks.len(); + + has_trailing_data.then(|| toc.tracks[last_audio].number) +} + +/// Parse a single-`FILE` cue sheet into the `.bin` path and a [`Toc`]. +/// +/// Only the four lines that matter for reading sectors are interpreted — `FILE`, +/// `TRACK`, `INDEX 01`, and the track mode — which is all a cue needs to carry +/// for this purpose. `REM`, `TITLE`, `PERFORMER`, and friends are metadata and +/// are skipped. +/// +/// Two cue features are deliberately not modelled, because neither changes a +/// byte offset in the `.bin`: +/// +/// - `INDEX 00` marks a pregap that *is* stored in the file. Tracks start at +/// `INDEX 01`, so those sectors fall at the end of the preceding track — the +/// usual "gap appended to the previous track" layout. +/// - `PREGAP` declares silence that is *not* stored in the file. It shifts a +/// disc's addressing but not this file's, and we address by file offset. +fn parse_cue(cue_path: &Path) -> Result<(PathBuf, Toc), Box> { + let text = std::fs::read_to_string(cue_path)?; + let cue_dir = cue_path.parent().unwrap_or(Path::new(".")); + + let mut bin_path: Option = None; + let mut pending: Option<(u8, bool)> = None; + let mut tracks: Vec = Vec::new(); + + for line in text.lines() { + let line = line.trim(); + let mut fields = line.split_whitespace(); + let Some(keyword) = fields.next() else { + continue; + }; + + match keyword.to_ascii_uppercase().as_str() { + "FILE" => { + if bin_path.is_some() { + return Err( + "multi-FILE cue sheets (one file per track) are not handled by \ + this example; it assumes a single .bin addressed by sector" + .into(), + ); + } + bin_path = Some(cue_dir.join(quoted_or_first_field(line)?)); + } + + "TRACK" => { + // e.g. `TRACK 03 MODE1/2352` + let number: u8 = fields.next().ok_or("TRACK line has no number")?.parse()?; + let mode = fields.next().ok_or("TRACK line has no mode")?; + + // The whole image is addressed as a uniform grid of 2352-byte + // sectors, so a cooked data track (MODE1/2048) would desync + // every offset after it. Refuse rather than read garbage. + let sector_size = mode_sector_size(mode); + if sector_size != Some(SECTOR_SIZE) { + return Err(format!( + "track {number} is `{mode}`, which is not stored as 2352-byte sectors; \ + this example needs a fully raw image" + ) + .into()); + } + + pending = Some((number, mode.eq_ignore_ascii_case("AUDIO"))); + } + + // `INDEX 01 MM:SS:FF` is where the track proper begins. + "INDEX" => { + let index = fields.next().ok_or("INDEX line has no number")?; + let msf = fields.next().ok_or("INDEX line has no timestamp")?; + if index != "01" { + continue; + } + + let (number, is_audio) = pending + .take() + .ok_or("INDEX 01 appeared before any TRACK line")?; + let start_lba = msf_to_frames(msf)?; + + tracks.push(Track { + number, + start_lba, + start_msf: lba_to_msf(start_lba), + is_audio, + }); + } + + _ => {} + } + } + + let bin_path = bin_path.ok_or("cue sheet has no FILE line")?; + if tracks.is_empty() { + return Err("cue sheet declares no tracks".into()); + } + tracks.sort_by_key(|track| track.start_lba); + + // The cue has no leadout; the image's own length is the end of the disc. + let bin_bytes = std::fs::metadata(&bin_path) + .map_err(|e| format!("cannot open {}: {e}", bin_path.display()))? + .len(); + let leadout_lba = (bin_bytes / SECTOR_SIZE as u64) as u32; + + let toc = Toc { + first_track: tracks.first().map_or(1, |track| track.number), + last_track: tracks.last().map_or(1, |track| track.number), + tracks, + leadout_lba, + }; + + Ok((bin_path, toc)) +} + +/// Bytes per sector for a cue track mode, or `None` if the mode is unknown. +/// `AUDIO` is always raw; the rest carry their size after a slash +/// (`MODE1/2048`, `MODE1/2352`, `MODE2/2352`, ...). +fn mode_sector_size(mode: &str) -> Option { + if mode.eq_ignore_ascii_case("AUDIO") { + return Some(SECTOR_SIZE); + } + + mode.split_once('/').and_then(|(_, size)| size.parse().ok()) +} + +/// `MM:SS:FF` to a sector count. These are offsets into the `.bin`, so unlike a +/// disc MSF address there is no 150-frame lead-in to subtract. +fn msf_to_frames(msf: &str) -> Result> { + let parts: Vec<&str> = msf.split(':').collect(); + let [minutes, seconds, frames] = parts[..] else { + return Err(format!("expected a MM:SS:FF timestamp, got `{msf}`").into()); + }; + + let minutes: u32 = minutes.parse()?; + let seconds: u32 = seconds.parse()?; + let frames: u32 = frames.parse()?; + + Ok(((minutes * 60) + seconds) * SECTORS_PER_SECOND + frames) +} + +/// The filename from a `FILE "name with spaces.bin" BINARY` line, falling back +/// to the bare second field when it is unquoted. +fn quoted_or_first_field(line: &str) -> Result> { + if let Some((_, rest)) = line.split_once('"') { + return rest + .rsplit_once('"') + .map(|(name, _)| name.to_string()) + .ok_or_else(|| "FILE line has an unterminated quote".into()); + } + + line.split_whitespace() + .nth(1) + .map(str::to_string) + .ok_or_else(|| "FILE line has no filename".into()) +} + +/// Write a small mixed-mode BIN/CUE (two audio tracks then a data track) so the +/// example runs without an image on hand, and returns the `.cue` path. +/// +/// The trailing data track is the point: it makes this disc one where the two +/// [`TrackBounds`] policies actually disagree. +fn write_demo_image(output_dir: &Path) -> Result> { + let layout = [ + (1u8, "AUDIO", 2 * SECTORS_PER_SECOND, Some(440.0)), + (2, "AUDIO", 2 * SECTORS_PER_SECOND, Some(523.25)), + (3, "MODE1/2352", SECTORS_PER_SECOND, None), + ]; + + let mut bin = Vec::new(); + let mut cue = String::from("FILE \"demo.bin\" BINARY\n"); + let mut start_lba = 0; + + for (number, mode, sectors, tone_hz) in layout { + cue += &format!( + " TRACK {number:02} {mode}\n INDEX 01 {}\n", + frames_to_msf(start_lba) + ); + + match tone_hz { + Some(hz) => bin.extend_from_slice(&tone(sectors, hz)), + // Not a real ISO 9660 filesystem — just something that is clearly + // not audio, since the example never reads data tracks. + None => bin.resize(bin.len() + sectors as usize * SECTOR_SIZE, 0xAA), + } + start_lba += sectors; + } + + let cue_path = output_dir.join("demo.cue"); + std::fs::write(output_dir.join("demo.bin"), &bin)?; + std::fs::write(&cue_path, cue)?; + + Ok(cue_path) +} + +/// `sectors` worth of a stereo sine at `hz`, as CD-DA PCM. +fn tone(sectors: u32, hz: f32) -> Vec { + // 2352 bytes per sector / 4 bytes per stereo frame. + let frames = sectors as usize * 588; + let mut pcm = Vec::with_capacity(frames * 4); + + for frame in 0..frames { + let t = frame as f32 / 44_100.0; + let sample = ((t * hz * std::f32::consts::TAU).sin() * 8_000.0) as i16; + pcm.extend_from_slice(&sample.to_le_bytes()); // left + pcm.extend_from_slice(&sample.to_le_bytes()); // right + } + + pcm +} + +/// Sector count to the `MM:SS:FF` a cue sheet expects (file-relative, no lead-in). +fn frames_to_msf(frames: u32) -> String { + let seconds = frames / SECTORS_PER_SECOND; + format!( + "{:02}:{:02}:{:02}", + seconds / 60, + seconds % 60, + frames % SECTORS_PER_SECOND + ) +} diff --git a/examples/file_backend.rs b/examples/file_backend.rs new file mode 100644 index 0000000..5e6b1ab --- /dev/null +++ b/examples/file_backend.rs @@ -0,0 +1,104 @@ +//! Read a track from a file/image backing instead of a physical drive. +//! +//! `cd-da-reader` doesn't bake in any image format (CHD, BIN/CUE, ...). Instead +//! you implement [`AudioSectorReader`] for your backing — supplying raw CD-DA +//! sectors (2352 bytes/sector, 16-bit signed little-endian stereo) — and reuse +//! the crate's TOC/track machinery via [`read_track`] and [`create_wav`]. +//! +//! This example uses a tiny in-memory backing so it stays dependency-free. A +//! real backing (e.g. CHD via `libchdman-rs`) would decode sectors in +//! `read_audio_sectors` and build its TOC from the image's track metadata. +//! +//! Run with: `cargo run --example file_backend` +mod common; + +use cd_da_reader::{ + AudioSectorReader, Toc, Track, create_wav, lba_to_msf, open_track_stream, read_track, +}; + +/// A backing that holds whole-disc PCM in memory and slices it per sector. +struct InMemoryDisc { + /// Whole-disc PCM, laid out as 2352 bytes per sector. + pcm: Vec, +} + +impl AudioSectorReader for InMemoryDisc { + type Error = std::io::Error; + + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + let start = start_lba as usize * 2352; + let end = start + count as usize * 2352; + self.pcm.get(start..end).map(<[u8]>::to_vec).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of disc") + }) + } +} + +fn main() -> Result<(), Box> { + let output_dir = common::fresh_output_dir("file_backend")?; + + // A real backing derives this TOC from the image's own track metadata. + // Here we fabricate a 2-track disc: 2 seconds + 3 seconds of audio. + let track1_sectors = 75 * 2; + let track2_sectors = 75 * 3; + let total_sectors = track1_sectors + track2_sectors; + + let toc = Toc { + first_track: 1, + last_track: 2, + tracks: vec![ + Track { + number: 1, + start_lba: 0, + start_msf: lba_to_msf(0), + is_audio: true, + }, + Track { + number: 2, + start_lba: track1_sectors, + start_msf: lba_to_msf(track1_sectors), + is_audio: true, + }, + ], + leadout_lba: total_sectors, + }; + + // Silence, just for the demo — a real backing decodes actual audio here. + let disc = InMemoryDisc { + pcm: vec![0u8; total_sectors as usize * 2352], + }; + + for track in &toc.tracks { + let pcm = read_track(&disc, &toc, track.number)?; + println!( + "track {}: {} bytes ({} sectors)", + track.number, + pcm.len(), + pcm.len() / 2352 + ); + + let wav = create_wav(pcm); + let output_path = output_dir.join(format!("track{:02}.wav", track.number)); + std::fs::write(&output_path, wav)?; + println!(" wrote {}", output_path.display()); + } + + // The same backing can be streamed instead of buffered: pull sector-aligned + // chunks so a player never holds a whole track in memory at once. (A backing + // whose tracks are addressed contiguously — a gap-stripped extract — would + // open with `TrackBounds::Gapless`, or supply its own bounds via + // `open_track_stream_at`; this demo TOC has no trailing data track, so plain + // `open_track_stream` is equivalent.) + let mut stream = open_track_stream(&disc, &toc, 1)?; + let (mut chunks, mut bytes) = (0u32, 0usize); + while let Some(chunk) = stream.next_chunk()? { + chunks += 1; + bytes += chunk.len(); + } + println!( + "streamed track 1: {bytes} bytes in {chunks} chunks ({:.1}s of audio)", + stream.total_seconds() + ); + + Ok(()) +} diff --git a/examples/save_data_track.rs b/examples/save_data_track.rs new file mode 100644 index 0000000..1936683 --- /dev/null +++ b/examples/save_data_track.rs @@ -0,0 +1,129 @@ +//! Save the data track of a mixed-mode / enhanced ("CD-Extra") disc as a +//! mountable image, then print how to mount it. +//! +//! This is the end-to-end data-track workflow: +//! 1. read the TOC and pick the (first) data track, +//! 2. auto-detect its sector format with `detect_track_format`, +//! 3. for Mode 1, stream it **cooked** (2048 B/sector) straight to a `.iso` — +//! cooked Mode 1 is exactly the ISO 9660 filesystem image, so it mounts as +//! is. Streaming keeps memory flat regardless of track size (a full data +//! track can be hundreds of MB), +//! 4. Mode 2 is detected but not auto-cooked here (see the note it prints). +//! +//! Reads and streams run over the same options and read path, so the only +//! difference from a blocking `read_track_with_options` is that we pull chunks +//! and write them as they arrive. +//! +//! Run with: `cargo run --example save_data_track` +mod common; + +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::Path; + +use cd_da_reader::{CdReader, SectorReadFormat, Toc, TrackStreamOptions}; + +fn main() -> Result<(), Box> { + let output_dir = common::fresh_output_dir("save_data_track")?; + let reader = CdReader::open_default()?; + let toc = reader.read_toc()?; + + // There is no `find_data_track` helper in the crate — the idiom is a plain + // filter on the TOC, since "data track" is simply `!is_audio`. + let data_track = toc + .tracks + .iter() + .find(|track| !track.is_audio) + .ok_or("no data track on this disc (need a mixed-mode / enhanced CD)")?; + + let format = reader.detect_track_format(data_track)?; + println!("Data track #{} detected as {format:?}\n", data_track.number); + + match format { + SectorReadFormat::Mode1Cooked => { + // Cooked Mode 1 strips sync/header/EDC/ECC, leaving exactly the + // 2048-byte user data per sector — i.e. the raw ISO 9660 image. + let iso_path = output_dir.join(format!("track{:02}.iso", data_track.number)); + let bytes = stream_track_to_file(&reader, &toc, data_track.number, format, &iso_path)?; + + println!( + "Wrote {} ({bytes} bytes, {} sectors)\n", + iso_path.display(), + bytes / format.sector_size() as u64 + ); + print_mount_hint(&iso_path.display().to_string()); + } + SectorReadFormat::Mode2Raw => { + // Mode 2 forms are a per-sector property; producing a clean cooked + // payload requires inspecting each sector's XA subheader, which is + // left to the consumer. We save the complete raw sectors so nothing + // is lost. + let bin_path = output_dir.join(format!("track{:02}.mode2.bin", data_track.number)); + let bytes = stream_track_to_file(&reader, &toc, data_track.number, format, &bin_path)?; + + println!( + "This is a Mode 2 track. Saved complete raw sectors to {} \ + ({bytes} bytes, {} sectors).", + bin_path.display(), + bytes / format.sector_size() as u64 + ); + println!( + "Extracting a mountable filesystem from Mode 2 is consumer territory: \ + each sector's XA subheader decides which bytes are user data." + ); + } + other => { + return Err(format!( + "data track #{} detected as {other:?}, which is unexpected for a data track", + data_track.number + ) + .into()); + } + } + + Ok(()) +} + +/// Stream one track straight to a file in `format`, without ever holding the +/// whole track in memory. Returns the number of bytes written. +/// +/// Uses the streaming API so peak memory is one chunk (~64 KB) instead of the +/// entire track, which matters for large data images. +fn stream_track_to_file( + reader: &CdReader, + toc: &Toc, + track_no: u8, + format: SectorReadFormat, + path: &Path, +) -> Result> { + let options = TrackStreamOptions::default().with_format(format); + let mut stream = reader.open_track_stream_with_options(toc, track_no, options)?; + + let total_sectors = stream.total_sectors(); + let mut writer = BufWriter::new(File::create(path)?); + let mut written = 0u64; + + while let Some(chunk) = stream.next_chunk()? { + writer.write_all(&chunk)?; + written += chunk.len() as u64; + + let done = stream.current_sector(); + let pct = done as f32 / total_sectors as f32 * 100.0; + eprint!("\r {done}/{total_sectors} sectors ({pct:5.1}%)"); + } + eprintln!("\r {total_sectors}/{total_sectors} sectors (100.0%)"); + + writer.flush()?; + Ok(written) +} + +fn print_mount_hint(path: &str) { + println!("Mount it and explore the files:"); + if cfg!(target_os = "macos") { + println!(" hdiutil attach \"{path}\""); + } else if cfg!(target_os = "linux") { + println!(" sudo mount -o loop,ro \"{path}\" /mnt/cd"); + } else if cfg!(target_os = "windows") { + println!(" PowerShell: Mount-DiskImage -ImagePath \"{path}\""); + } +} diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..ea9c73d --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,470 @@ +//! Pluggable audio-sector backings. +//! +//! [`CdReader`](crate::CdReader) reads CD-DA sectors from a physical drive over +//! SCSI/ioctl, but everything *above* the raw sector read — the +//! [`Track`](crate::Track)/[`Toc`](crate::Toc) types, the track-bounds math +//! (including the CD-Extra trailing-gap rule), and WAV wrapping — is +//! hardware-independent. [`AudioSectorReader`] exposes that seam so any backing +//! that can produce raw CD-DA sectors (a CHD image, a BIN/CUE dump, an in-memory +//! buffer, a network stream, ...) reuses the same machinery **without this crate +//! taking on any image-format dependencies**. +//! +//! The image format lives in the caller: implement [`AudioSectorReader`] for +//! your backing, build a [`Toc`](crate::Toc) from the image's own track metadata +//! (see [`lba_to_msf`](crate::lba_to_msf)), then read PCM in the exact same +//! little-endian, 2352-byte/sector format the physical reader produces — ready +//! for [`create_wav`](crate::create_wav). Read a whole track at once with +//! [`read_track`], or pull it incrementally with [`open_track_stream`] (the +//! file/image counterpart to [`TrackStream`](crate::TrackStream)). +//! +//! ## Track bounds and the CD-Extra gap +//! +//! Resolving a track's sector range from a [`Toc`] differs on exactly one track: +//! the last audio track before a trailing data session on a CD-Extra disc. There +//! the crate subtracts the inter-session gap — correct whenever that gap is part +//! of the addressing (a physical disc, or an image whose TOC preserves the real +//! LBAs), but wrong when the tracks are addressed back-to-back with the gap +//! stripped out (a `chdman extractcd`-style contiguous extract), where it would +//! drop ~2.5 min of real audio. Only the backing knows its own layout, so +//! [`read_track`] / [`open_track_stream`] default to [`TrackBounds::SessionGap`]; +//! a contiguous backing must pass [`TrackBounds::Gapless`] (or supply explicit +//! bounds via [`open_track_stream_at`]). +//! +//! See `examples/file_backend.rs` for a complete, dependency-free example. + +use std::cmp::min; + +use crate::{CdReader, CdReaderError, ReadOptions, Toc, utils}; + +/// The physical drive is itself an [`AudioSectorReader`], so drive-backed and +/// file-backed code can share the generic [`read_track`] path. This uses the +/// default read options (audio sectors, default retry policy); for explicit +/// control, prefer the inherent [`CdReader::read_track_with_options`]. +impl AudioSectorReader for CdReader { + type Error = CdReaderError; + + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + self.read_sector_range(start_lba, count, &ReadOptions::default()) + } +} + +/// A source of raw CD-DA audio sectors. +/// +/// Implement this for any backing that can yield audio in the crate's canonical +/// format: **2352 bytes per sector, 16-bit signed little-endian, stereo, 44100 +/// Hz** — byte-for-byte identical to what +/// [`CdReader::read_track`](crate::CdReader::read_track) returns. +/// +/// `start_lba` is an absolute Logical Block Address (a sector index; LBA 0 is +/// the first sector after the lead-in), matching +/// [`Track::start_lba`](crate::Track::start_lba). A successful read of `count` +/// sectors must return exactly `count * 2352` bytes. +/// +/// The read takes `&self`, matching [`CdReader`]. A backing that needs a mutable +/// handle (an open `File`, a decoder) should use positioned reads +/// (`read_at`/`seek_read`) or interior mutability so shared-borrow reads stay +/// possible. +pub trait AudioSectorReader { + /// Error type produced by this backing. + /// + /// Bounded here rather than at each call site so an unusable error type + /// (`String`, say) is rejected at the `impl` — where the fix is — instead of + /// compiling happily and then failing at every [`read_track`] call. The + /// bound is what lets a failure be reported as + /// [`CdReaderError::Backend`], which keeps this error as its boxed + /// [`source`](std::error::Error::source). + type Error: std::error::Error + Send + Sync + 'static; + + /// Read `count` sectors starting at absolute `start_lba`, returning exactly + /// `count * 2352` bytes of little-endian PCM. + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error>; +} + +/// How a track's sector range is resolved from a [`Toc`]. +/// +/// The two policies differ on exactly one track: the **last audio track before a +/// trailing data session** on a CD-Extra disc. Every other track resolves +/// identically. Which one to use depends on whether the TOC's addressing includes +/// the inter-session gap — a property of how the source is laid out, not of +/// physical-vs-image. +/// +/// - [`SessionGap`](Self::SessionGap) subtracts the inter-session gap (matching +/// [`CdReader::read_track`](crate::CdReader::read_track)) — for a physical disc, +/// or any image whose TOC preserves the disc's real LBAs. +/// - [`Gapless`](Self::Gapless) does not: when tracks are addressed back-to-back +/// (a gap-stripped extract), a track spans from its `start_lba` to the next +/// track's start (or the leadout). Subtracting a gap that isn't there would drop +/// ~2.5 min of real audio. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrackBounds { + /// Addressing includes the CD-Extra inter-session gap: apply the trailing-gap + /// rule. Correct for a physical disc or a geometry-preserving image. + SessionGap, + /// Tracks are addressed contiguously (gap stripped): no gap subtraction. + Gapless, +} + +impl TrackBounds { + fn resolve(self, toc: &Toc, track_no: u8) -> std::io::Result<(u32, u32)> { + match self { + TrackBounds::SessionGap => utils::get_track_bounds(toc, track_no), + TrackBounds::Gapless => utils::get_gapless_track_bounds(toc, track_no), + } + } +} + +/// Read raw PCM for one track from any [`AudioSectorReader`] backing, assuming +/// the TOC includes the CD-Extra inter-session gap ([`TrackBounds::SessionGap`]). +/// +/// This is the file/image counterpart to +/// [`CdReader::read_track`](crate::CdReader::read_track): it resolves the track's +/// sector range from `toc` (honouring the CD-Extra trailing-gap rule) and pulls +/// those sectors from `src`. The returned bytes are the same little-endian, +/// 2352-B/sector PCM, so [`create_wav`](crate::create_wav) wraps them into a +/// playable file unchanged. +/// +/// A bad track request (not in the TOC, or invalid bounds) is +/// [`CdReaderError::Io`]; a failure inside the backing is +/// [`CdReaderError::Backend`], which preserves the backing's own error as the +/// boxed [`source`](std::error::Error::source). +/// +/// If the backing addresses tracks contiguously (a gap-stripped extract), use +/// [`read_track_with_bounds`] with [`TrackBounds::Gapless`]. +pub fn read_track( + src: &R, + toc: &Toc, + track_no: u8, +) -> Result, CdReaderError> { + read_track_with_bounds(src, toc, track_no, TrackBounds::SessionGap) +} + +/// Read one track like [`read_track`], but with an explicit [`TrackBounds`] +/// geometry — pass [`TrackBounds::Gapless`] for a contiguous, gap-stripped layout. +pub fn read_track_with_bounds( + src: &R, + toc: &Toc, + track_no: u8, + bounds: TrackBounds, +) -> Result, CdReaderError> { + let (start_lba, sectors) = bounds.resolve(toc, track_no).map_err(CdReaderError::Io)?; + src.read_audio_sectors(start_lba, sectors) + .map_err(|e| CdReaderError::Backend(Box::new(e))) +} + +/// Streaming reader over an [`AudioSectorReader`] backing — the file/image +/// counterpart to [`TrackStream`](crate::TrackStream). +/// +/// Pulls a track's PCM in sector-aligned chunks with +/// [`next_chunk`](Self::next_chunk) instead of buffering the whole track, so a +/// player can start immediately and hold only one chunk at a time. Open one +/// with [`open_track_stream`] (TOC + physical geometry), +/// [`open_track_stream_with_bounds`] (TOC + explicit [`TrackBounds`]), or +/// [`open_track_stream_at`] (an explicit absolute sector range, for backings +/// that compute their own bounds). +pub struct AudioTrackStream<'a, R: AudioSectorReader> { + src: &'a R, + start_lba: u32, + next_lba: u32, + remaining_sectors: u32, + total_sectors: u32, + sectors_per_chunk: u32, +} + +impl<'a, R: AudioSectorReader> AudioTrackStream<'a, R> { + const DEFAULT_SECTORS_PER_CHUNK: u32 = 27; + const SECTORS_PER_SECOND: f32 = 75.0; + + fn new(src: &'a R, start_lba: u32, sectors: u32) -> Self { + Self { + src, + start_lba, + next_lba: start_lba, + remaining_sectors: sectors, + total_sectors: sectors, + sectors_per_chunk: Self::DEFAULT_SECTORS_PER_CHUNK, + } + } + + /// Set the target chunk size in sectors (default 27; a full chunk is + /// `sectors_per_chunk * 2352` bytes). Zero is normalized to one. + pub fn with_sectors_per_chunk(mut self, sectors: u32) -> Self { + self.sectors_per_chunk = sectors.max(1); + self + } + + /// Read the next chunk of PCM, or `Ok(None)` at end-of-track. + /// + /// Each chunk is `sectors_per_chunk * 2352` bytes except possibly the last. + /// A backing failure is [`CdReaderError::Backend`]; the position does not + /// advance on error, so a retry re-reads the same chunk. + pub fn next_chunk(&mut self) -> Result>, CdReaderError> { + if self.remaining_sectors == 0 { + return Ok(None); + } + + let sectors = min(self.remaining_sectors, self.sectors_per_chunk); + let chunk = self + .src + .read_audio_sectors(self.next_lba, sectors) + .map_err(|e| CdReaderError::Backend(Box::new(e)))?; + + self.next_lba += sectors; + self.remaining_sectors -= sectors; + + Ok(Some(chunk)) + } + + /// Total number of sectors in this track. + pub fn total_sectors(&self) -> u32 { + self.total_sectors + } + + /// Current position as a track-relative sector index. + pub fn current_sector(&self) -> u32 { + self.total_sectors - self.remaining_sectors + } + + /// Seek to a track-relative sector position (valid range `0..=total_sectors()`). + pub fn seek_to_sector(&mut self, sector: u32) -> Result<(), CdReaderError> { + if sector > self.total_sectors { + return Err(CdReaderError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "seek sector is out of track bounds", + ))); + } + + self.next_lba = self.start_lba + sector; + self.remaining_sectors = self.total_sectors - sector; + Ok(()) + } + + /// Current position in seconds (75 sectors = 1 second). + pub fn current_seconds(&self) -> f32 { + self.current_sector() as f32 / Self::SECTORS_PER_SECOND + } + + /// Total track duration in seconds (75 sectors = 1 second). + pub fn total_seconds(&self) -> f32 { + self.total_sectors as f32 / Self::SECTORS_PER_SECOND + } + + /// Seek to a track-relative time in seconds, clamped to the track length. + pub fn seek_to_seconds(&mut self, seconds: f32) -> Result<(), CdReaderError> { + if !seconds.is_finite() || seconds < 0.0 { + return Err(CdReaderError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "seek seconds must be a finite non-negative number", + ))); + } + + let target_sector = (seconds * Self::SECTORS_PER_SECOND).round() as u32; + self.seek_to_sector(target_sector.min(self.total_sectors)) + } +} + +/// Open a streaming reader for a track assuming the TOC includes the inter-session +/// gap ([`TrackBounds::SessionGap`]). See [`AudioTrackStream`]. +pub fn open_track_stream<'a, R: AudioSectorReader>( + src: &'a R, + toc: &Toc, + track_no: u8, +) -> Result, CdReaderError> { + open_track_stream_with_bounds(src, toc, track_no, TrackBounds::SessionGap) +} + +/// Open a streaming reader for a track with an explicit [`TrackBounds`] geometry. +/// Use [`TrackBounds::Gapless`] for a contiguous, gap-stripped layout. +pub fn open_track_stream_with_bounds<'a, R: AudioSectorReader>( + src: &'a R, + toc: &Toc, + track_no: u8, + bounds: TrackBounds, +) -> Result, CdReaderError> { + let (start_lba, sectors) = bounds.resolve(toc, track_no).map_err(CdReaderError::Io)?; + Ok(AudioTrackStream::new(src, start_lba, sectors)) +} + +/// Open a streaming reader over an explicit absolute sector range +/// (`start_lba .. start_lba + sectors`), bypassing TOC bounds resolution. +/// +/// For backings that compute their own track layout — e.g. reading +/// `[start_lba(n) .. start_lba(n + 1))` from a contiguous extract — this is the +/// zero-policy primitive: no TOC lookup, no CD-Extra rule, and no failure mode. +pub fn open_track_stream_at( + src: &R, + start_lba: u32, + sectors: u32, +) -> AudioTrackStream<'_, R> { + AudioTrackStream::new(src, start_lba, sectors) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Track, create_wav, lba_to_msf}; + + /// Minimal in-memory backing: whole-disc PCM sliced by sector. + struct MemDisc { + pcm: Vec, + } + + impl AudioSectorReader for MemDisc { + type Error = std::io::Error; + + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + let start = start_lba as usize * 2352; + let end = start + count as usize * 2352; + self.pcm.get(start..end).map(<[u8]>::to_vec).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of disc") + }) + } + } + + fn toc_two_tracks(t1_sectors: u32, t2_sectors: u32) -> Toc { + Toc { + first_track: 1, + last_track: 2, + tracks: vec![ + Track { + number: 1, + start_lba: 0, + start_msf: lba_to_msf(0), + is_audio: true, + }, + Track { + number: 2, + start_lba: t1_sectors, + start_msf: lba_to_msf(t1_sectors), + is_audio: true, + }, + ], + leadout_lba: t1_sectors + t2_sectors, + } + } + + #[test] + fn reads_track_bytes_for_the_right_range() { + let (t1, t2) = (100u32, 50u32); + let disc = MemDisc { + pcm: vec![0u8; (t1 + t2) as usize * 2352], + }; + let toc = toc_two_tracks(t1, t2); + + let track1 = read_track(&disc, &toc, 1).unwrap(); + let track2 = read_track(&disc, &toc, 2).unwrap(); + + assert_eq!(track1.len(), t1 as usize * 2352); + assert_eq!(track2.len(), t2 as usize * 2352); + } + + #[test] + fn create_wav_wraps_backend_pcm() { + let disc = MemDisc { + pcm: vec![0u8; 10 * 2352], + }; + // Single-track disc: no next track, so the leadout bounds the read. + let toc = Toc { + first_track: 1, + last_track: 1, + tracks: vec![Track { + number: 1, + start_lba: 0, + start_msf: lba_to_msf(0), + is_audio: true, + }], + leadout_lba: 10, + }; + + let pcm = read_track(&disc, &toc, 1).unwrap(); + let wav = create_wav(pcm); + assert_eq!(&wav[0..4], b"RIFF"); + assert_eq!(&wav[8..12], b"WAVE"); + assert_eq!(wav.len(), 44 + 10 * 2352); + } + + #[test] + fn missing_track_is_an_io_error() { + let disc = MemDisc { + pcm: vec![0u8; 2352], + }; + let toc = toc_two_tracks(1, 0); + match read_track(&disc, &toc, 99) { + Err(CdReaderError::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound), + other => panic!("expected Io(NotFound), got {other:?}"), + } + } + + #[test] + fn backend_failure_is_a_backend_error() { + // Disc holds one sector but the TOC claims track 1 is five, so the read + // runs past the end — a backing failure, not a TOC error. + let disc = MemDisc { + pcm: vec![0u8; 2352], + }; + let toc = toc_two_tracks(5, 10); + match read_track(&disc, &toc, 1) { + Err(CdReaderError::Backend(e)) => { + let io = e + .downcast_ref::() + .expect("backend error preserves the io::Error"); + assert_eq!(io.kind(), std::io::ErrorKind::UnexpectedEof); + } + other => panic!("expected Backend error, got {other:?}"), + } + } + + #[test] + fn stream_pulls_sector_aligned_chunks() { + let sectors = 100u32; + let disc = MemDisc { + pcm: vec![0u8; sectors as usize * 2352], + }; + + let mut stream = open_track_stream_at(&disc, 0, sectors).with_sectors_per_chunk(27); + assert_eq!(stream.total_sectors(), sectors); + + let mut total = 0usize; + let mut chunks = 0usize; + while let Some(chunk) = stream.next_chunk().unwrap() { + assert_eq!(chunk.len() % 2352, 0); + total += chunk.len(); + chunks += 1; + } + + assert_eq!(total, sectors as usize * 2352); + assert_eq!(chunks, 4); // 27 + 27 + 27 + 19 + assert!(stream.next_chunk().unwrap().is_none()); + } + + #[test] + fn stream_seek_repositions() { + // Stream starts at absolute LBA 10 for 300 sectors, so the backing must + // cover absolute sectors 10..310. + let disc = MemDisc { + pcm: vec![0u8; 310 * 2352], + }; + let mut stream = open_track_stream_at(&disc, 10, 300).with_sectors_per_chunk(1000); + + stream.seek_to_sector(250).unwrap(); + assert_eq!(stream.current_sector(), 250); + assert!((stream.current_seconds() - 250.0 / 75.0).abs() < f32::EPSILON); + + let chunk = stream.next_chunk().unwrap().unwrap(); + assert_eq!(chunk.len(), 50 * 2352); // 300 - 250 sectors, one big chunk + assert!(stream.next_chunk().unwrap().is_none()); + + assert!(stream.seek_to_sector(301).is_err()); + } + + #[test] + fn open_track_stream_resolves_toc_bounds() { + let (t1, t2) = (40u32, 60u32); + let disc = MemDisc { + pcm: vec![0u8; (t1 + t2) as usize * 2352], + }; + let toc = toc_two_tracks(t1, t2); + + let stream = open_track_stream(&disc, &toc, 2).unwrap(); + assert_eq!(stream.total_sectors(), t2); + } +} diff --git a/src/errors.rs b/src/errors.rs index c9ba35e..eadaeed 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -62,6 +62,11 @@ pub enum CdReaderError { }, /// Drive enumeration completed without finding a usable audio CD. NoUsableDrive, + /// A pluggable [`AudioSectorReader`](crate::AudioSectorReader) backing failed + /// while reading sectors. The backing's own error is boxed and preserved as + /// this error's [`source`](std::error::Error::source), so it can be displayed + /// or downcast without being flattened into this SCSI-oriented enum. + Backend(Box), } impl fmt::Display for CdReaderError { @@ -97,6 +102,7 @@ impl fmt::Display for CdReaderError { data_mode: None, } => write!(f, "could not detect sector format for track {track_number}"), Self::NoUsableDrive => write!(f, "no usable audio CD drive found"), + Self::Backend(err) => write!(f, "backend reader error: {err}"), } } } @@ -105,6 +111,7 @@ impl std::error::Error for CdReaderError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(error) => Some(error), + Self::Backend(error) => Some(&**error), Self::Scsi(_) | Self::Parse(_) | Self::TrackFormatMismatch { .. } diff --git a/src/lib.rs b/src/lib.rs index c142ca1..ddc4671 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -145,6 +145,12 @@ mod read_loop; mod retry; mod stream; mod utils; + +mod backend; +pub use backend::{ + AudioSectorReader, AudioTrackStream, TrackBounds, open_track_stream, open_track_stream_at, + open_track_stream_with_bounds, read_track, read_track_with_bounds, +}; pub use data_reader::{ReadOptions, SectorReadFormat}; pub use discovery::DriveInfo; pub use errors::{CdReaderError, ScsiError, ScsiOp}; @@ -152,6 +158,7 @@ pub use retry::RetryConfig; pub use stream::{TrackStream, TrackStreamOptions}; mod parse_toc; +pub use parse_toc::lba_to_msf; /// Representation of the track from TOC, purely in terms of data location on the CD. #[derive(Debug)] @@ -184,6 +191,18 @@ pub struct Toc { pub leadout_lba: u32, } +/// Wrap raw CD-DA PCM in a 44-byte WAV/RIFF header (44100 Hz, 2 channels, +/// 16-bit) so the bytes become a playable file. +/// +/// This is the free-function form of [`CdReader::create_wav`], usable without +/// naming the physical-drive type — for example on PCM obtained from a file or +/// image backing via [`read_track`]. +pub fn create_wav(data: Vec) -> Vec { + let mut header = utils::create_wav_header(data.len() as u32); + header.extend_from_slice(&data); + header +} + /// Helper struct to interact with the audio CD. Internally it holds a platform-specific /// handle to the open CD drive to read from it and it is correctly closed when CDReader /// is dropped. @@ -209,6 +228,32 @@ impl CdReader { }) } + /// Builds a reader from an already-open handle to the drive's device node, + /// instead of opening the path ourselves. + /// + /// This exists for privileged access. Reading a raw optical device can + /// require more rights than the calling process has, and there is no way to + /// gain them after the fact — the descriptor has to come from somewhere + /// else. A caller that hits `EPERM` / `EACCES` from [`CdReader::open_path`] + /// can obtain one through a privilege-escalation helper (macOS + /// `/usr/libexec/authopen`, a setuid helper, a launchd service) and hand it + /// over here. + /// + /// The handle must refer to the drive's device node — `/dev/rdiskN` on + /// macOS, `/dev/srN` on Linux — and the reader takes ownership of it, + /// closing it on drop. + /// + /// On Linux the handle must have been opened `O_RDWR`: the SG_IO ioctls + /// this crate issues are rejected on a read-only descriptor. On macOS + /// `O_RDONLY` is correct and preferred, since a write-capable open of an + /// optical device demands exclusivity. + #[cfg(unix)] + pub fn from_file(file: std::fs::File) -> Self { + Self { + drive: platform::Drive::from_file(file), + } + } + #[cfg(test)] pub(crate) fn test_reader() -> Self { Self { @@ -224,9 +269,7 @@ impl CdReader { /// /// * `data` - vector of bytes received from `read_track` function pub fn create_wav(data: Vec) -> Vec { - let mut header = utils::create_wav_header(data.len() as u32); - header.extend_from_slice(&data); - header + crate::create_wav(data) } /// Read Table of Contents for the opened drive. You'll likely only need to access diff --git a/src/parse_toc.rs b/src/parse_toc.rs index ded881b..1525e14 100644 --- a/src/parse_toc.rs +++ b/src/parse_toc.rs @@ -66,7 +66,13 @@ pub(crate) fn parse_toc(data: Vec) -> std::io::Result { } } -fn lba_to_msf(lba: u32) -> (u8, u8, u8) { +/// Convert a Logical Block Address to its Minutes/Seconds/Frames address. +/// +/// MSF addresses include the fixed 2-second (150-frame) lead-in offset, so +/// `lba_to_msf(0)` is `(0, 2, 0)`. This is handy when building a [`Toc`] for a +/// file/image backing (see [`AudioSectorReader`](crate::AudioSectorReader)), +/// where you have sector indices but need to populate [`Track::start_msf`]. +pub fn lba_to_msf(lba: u32) -> (u8, u8, u8) { let total_frames = lba + 150; // MSF addresses are offset by 150 let minutes = (total_frames / 75 / 60) as u8; let seconds = ((total_frames / 75) % 60) as u8; diff --git a/src/platform/linux/device.rs b/src/platform/linux/device.rs index 1f2dad1..a8cb612 100644 --- a/src/platform/linux/device.rs +++ b/src/platform/linux/device.rs @@ -28,6 +28,18 @@ impl Drive { }) } + /// Adopt an already-open handle to the drive's device node. + /// + /// Used when a direct `open` was denied and the caller obtained the + /// descriptor through a privileged channel instead. The `Drive` takes + /// ownership and closes it on drop, exactly as if it had opened the node + /// itself. Note that [`Drive::open`] uses `O_RDWR` because the SG_IO ioctls + /// this crate issues need it — a handle opened read-only will be rejected + /// by the kernel for those commands. + pub(crate) fn from_file(file: File) -> Self { + Self { file } + } + pub(super) fn fd(&self) -> RawFd { self.file.as_raw_fd() } diff --git a/src/platform/macos/device.rs b/src/platform/macos/device.rs index 63eca8a..f4cc7c1 100644 --- a/src/platform/macos/device.rs +++ b/src/platform/macos/device.rs @@ -29,6 +29,16 @@ impl Drive { }) } + /// Adopt an already-open handle to the drive's raw device node. + /// + /// Used when a direct `open` was denied and the caller obtained the + /// descriptor through a privileged channel instead (macOS `authopen`). + /// The `Drive` takes ownership and closes it on drop, exactly as if it had + /// opened the node itself. + pub(crate) fn from_file(file: File) -> Self { + Self { file } + } + pub(super) fn fd(&self) -> RawFd { self.file.as_raw_fd() } diff --git a/src/utils.rs b/src/utils.rs index 69b2e6e..73f4faf 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,6 +3,22 @@ use crate::Toc; const CD_EXTRA_TRAILING_DATA_GAP_SECTORS: u32 = 11_400; pub(crate) fn get_track_bounds(toc: &Toc, track_no: u8) -> std::io::Result<(u32, u32)> { + track_bounds(toc, track_no, true) +} + +/// Track bounds for a **contiguous** layout (tracks addressed back-to-back, the +/// CD-Extra inter-session gap stripped): the track spans from its own `start_lba` +/// to the next track's start (or the leadout), with **no** gap subtracted. +/// +/// Use [`get_track_bounds`] when the addressing includes the gap (a physical disc +/// or a geometry-preserving image); use this for a gap-stripped extract, where +/// subtracting a gap that isn't there would drop ~2.5 min of real audio off the +/// last audio track before a data session. +pub(crate) fn get_gapless_track_bounds(toc: &Toc, track_no: u8) -> std::io::Result<(u32, u32)> { + track_bounds(toc, track_no, false) +} + +fn track_bounds(toc: &Toc, track_no: u8, apply_cd_extra: bool) -> std::io::Result<(u32, u32)> { let idx = toc .tracks .iter() @@ -10,15 +26,17 @@ pub(crate) fn get_track_bounds(toc: &Toc, track_no: u8) -> std::io::Result<(u32, .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "track not in TOC"))?; let start_lba = toc.tracks[idx].start_lba; - let end_lba = get_track_end_lba(toc, idx)?; + let end_lba = if apply_cd_extra { + get_track_end_lba(toc, idx)? + } else { + plain_track_end_lba(toc, idx) + }; if end_lba <= start_lba { return Err(bad_toc_bounds()); } - let sectors = end_lba - start_lba; - - Ok((start_lba, sectors)) + Ok((start_lba, end_lba - start_lba)) } fn get_track_end_lba(toc: &Toc, idx: usize) -> std::io::Result { @@ -29,10 +47,14 @@ fn get_track_end_lba(toc: &Toc, idx: usize) -> std::io::Result { .ok_or_else(bad_toc_bounds); } + Ok(plain_track_end_lba(toc, idx)) +} + +fn plain_track_end_lba(toc: &Toc, idx: usize) -> u32 { if (idx + 1) < toc.tracks.len() { - Ok(toc.tracks[idx + 1].start_lba) + toc.tracks[idx + 1].start_lba } else { - Ok(toc.leadout_lba) + toc.leadout_lba } } @@ -231,6 +253,35 @@ mod test { assert_eq!(sectors, 40_000 - 10_000); } + #[test] + fn gapless_bounds_keep_full_last_audio_track_before_data() { + // Track 2 is the last audio track before a trailing data session. + let toc = Toc { + first_track: 1, + last_track: 4, + tracks: vec![ + track(1, 0, true), + track(2, 10_000, true), + track(3, 40_000, false), + track(4, 80_000, false), + ], + leadout_lba: 120_000, + }; + + // Physical geometry shortens track 2 by the CD-Extra gap... + let (start_p, sectors_p) = get_track_bounds(&toc, 2).unwrap(); + assert_eq!(start_p, 10_000); + assert_eq!( + sectors_p, + (40_000 - CD_EXTRA_TRAILING_DATA_GAP_SECTORS) - 10_000 + ); + + // ...gapless geometry keeps it spanning straight to the data track. + let (start_g, sectors_g) = get_gapless_track_bounds(&toc, 2).unwrap(); + assert_eq!(start_g, 10_000); + assert_eq!(sectors_g, 40_000 - 10_000); + } + #[test] fn returns_error_for_invalid_track() { let toc = get_toc();