Skip to main content

bin_cue_backend/
bin_cue_backend.rs

1//! Read audio tracks out of a BIN/CUE disc image, with no image-format crate.
2//!
3//! BIN/CUE is the simplest real backing there is: the `.bin` holds the disc's
4//! sectors end to end, and the `.cue` says where each track starts. That makes
5//! it a good worked example of the whole [`AudioSectorReader`] contract —
6//! parse the container's own metadata into a [`Toc`], serve raw 2352-byte
7//! sectors, and let the crate do the track math and WAV wrapping.
8//!
9//! Three things here are worth copying into a real backing:
10//!
11//! 1. **`&self` reads via positioned I/O.** `read_audio_sectors` takes `&self`,
12//!    so the backing uses `read_at`/`seek_read` (an offset per call) rather than
13//!    `seek` + `read`, which would need `&mut self`.
14//! 2. **`TrackBounds::Gapless`.** A single-`FILE` cue is contiguous by
15//!    construction — every `INDEX 01` is an offset into the same `.bin`, so
16//!    track N+1 begins exactly where track N ends. There is no inter-session gap
17//!    in the addressing, so the CD-Extra trailing-gap rule must not be applied.
18//!    See the note this example prints for a mixed-mode disc.
19//! 3. **`type Error = io::Error`.** Any `std::error::Error + Send + Sync` works;
20//!    the crate reports it as [`CdReaderError::Backend`] with the original error
21//!    kept as its `source()`.
22//!
23//! Run against a real image:
24//!
25//! ```text
26//! cargo run --example bin_cue_backend -- /path/to/disc.cue
27//! ```
28//!
29//! Run with no arguments and it writes a small mixed-mode BIN/CUE into the
30//! output directory first, so the example works without an image on hand:
31//!
32//! ```text
33//! cargo run --example bin_cue_backend
34//! ```
35mod common;
36
37use std::error::Error;
38use std::fs::File;
39use std::io;
40use std::path::{Path, PathBuf};
41
42use cd_da_reader::{
43    AudioSectorReader, Toc, Track, TrackBounds, create_wav, lba_to_msf,
44    open_track_stream_with_bounds, read_track_with_bounds,
45};
46
47/// Every sector in a raw disc image is 2352 bytes, audio or data alike.
48const SECTOR_SIZE: usize = 2352;
49const SECTORS_PER_SECOND: u32 = 75;
50
51/// A single-`FILE` BIN/CUE image: the `.bin` addressed by sector.
52///
53/// Sector *n* of the image lives at byte `n * 2352`, and because the cue's
54/// offsets are offsets into this same file, that sector index *is* the LBA the
55/// [`Toc`] carries — no translation needed.
56struct BinImage {
57    bin: File,
58}
59
60impl AudioSectorReader for BinImage {
61    type Error = io::Error;
62
63    fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result<Vec<u8>, Self::Error> {
64        let mut buffer = vec![0u8; count as usize * SECTOR_SIZE];
65        read_exact_at(
66            &self.bin,
67            &mut buffer,
68            start_lba as u64 * SECTOR_SIZE as u64,
69        )?;
70        Ok(buffer)
71    }
72}
73
74/// Positioned read: fill `buffer` from `offset` without moving a shared cursor,
75/// which is what lets `read_audio_sectors` take `&self`.
76fn read_exact_at(file: &File, buffer: &mut [u8], offset: u64) -> io::Result<()> {
77    #[cfg(unix)]
78    {
79        use std::os::unix::fs::FileExt;
80        file.read_exact_at(buffer, offset)
81    }
82
83    #[cfg(windows)]
84    {
85        use std::os::windows::fs::FileExt;
86
87        let mut filled = 0;
88        while filled < buffer.len() {
89            match file.seek_read(&mut buffer[filled..], offset + filled as u64) {
90                Ok(0) => {
91                    return Err(io::Error::new(
92                        io::ErrorKind::UnexpectedEof,
93                        "read past the end of the .bin",
94                    ));
95                }
96                Ok(read) => filled += read,
97                Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
98                Err(e) => return Err(e),
99            }
100        }
101        Ok(())
102    }
103}
104
105fn main() -> Result<(), Box<dyn Error>> {
106    let output_dir = common::fresh_output_dir("bin_cue_backend")?;
107
108    let cue_path = match std::env::args().nth(1) {
109        Some(path) => PathBuf::from(path),
110        None => {
111            println!("No .cue argument given — writing a demo image to work against.\n");
112            write_demo_image(&output_dir)?
113        }
114    };
115
116    let (bin_path, toc) = parse_cue(&cue_path)?;
117    println!("Cue:   {}", cue_path.display());
118    println!("Bin:   {}", bin_path.display());
119    println!(
120        "Toc:   {} tracks, leadout at LBA {}\n",
121        toc.tracks.len(),
122        toc.leadout_lba
123    );
124
125    let image = BinImage {
126        bin: File::open(&bin_path)?,
127    };
128
129    // A single-FILE cue is a contiguous run of sectors, so the CD-Extra
130    // inter-session gap is not part of the addressing. See `explain_bounds`.
131    let bounds = TrackBounds::Gapless;
132    explain_bounds(&image, &toc)?;
133
134    for track in toc.tracks.iter().filter(|track| track.is_audio) {
135        let pcm = read_track_with_bounds(&image, &toc, track.number, bounds)?;
136        let wav_path = output_dir.join(format!("track{:02}.wav", track.number));
137        std::fs::write(&wav_path, create_wav(pcm))?;
138
139        println!("track {:02}: wrote {}", track.number, wav_path.display());
140    }
141
142    // The same backing streams instead of buffering — for a 74-minute image
143    // that is the difference between one chunk and ~650 MB resident.
144    if let Some(first_audio) = toc.tracks.iter().find(|track| track.is_audio) {
145        let mut stream = open_track_stream_with_bounds(&image, &toc, first_audio.number, bounds)?;
146        let (mut chunks, mut bytes) = (0u32, 0usize);
147        while let Some(chunk) = stream.next_chunk()? {
148            chunks += 1;
149            bytes += chunk.len();
150        }
151        println!(
152            "\nstreamed track {:02}: {bytes} bytes in {chunks} chunks ({:.1}s)",
153            first_audio.number,
154            stream.total_seconds()
155        );
156    }
157
158    Ok(())
159}
160
161/// Show what the two [`TrackBounds`] policies do on this disc.
162///
163/// They differ on exactly one track — the last audio track before a trailing
164/// data session — so on a plain audio disc this prints nothing.
165fn explain_bounds(image: &BinImage, toc: &Toc) -> Result<(), Box<dyn Error>> {
166    let Some(track) = last_audio_before_data(toc) else {
167        return Ok(());
168    };
169
170    let gapless =
171        open_track_stream_with_bounds(image, toc, track, TrackBounds::Gapless)?.total_sectors();
172    let physical = match open_track_stream_with_bounds(image, toc, track, TrackBounds::SessionGap) {
173        Ok(stream) => format!("{} sectors", stream.total_sectors()),
174        // The gap is larger than the track, so subtracting it underflows.
175        Err(e) => format!("fails ({e})"),
176    };
177
178    println!(
179        "Track {track:02} is the last audio track before a data track, the one track \
180         the two bounds policies disagree on:\n  \
181         Gapless (used here): {gapless} sectors\n  \
182         SessionGap:          {physical}\n"
183    );
184    Ok(())
185}
186
187fn last_audio_before_data(toc: &Toc) -> Option<u8> {
188    let last_audio = toc.tracks.iter().rposition(|track| track.is_audio)?;
189    let has_trailing_data = last_audio + 1 < toc.tracks.len();
190
191    has_trailing_data.then(|| toc.tracks[last_audio].number)
192}
193
194/// Parse a single-`FILE` cue sheet into the `.bin` path and a [`Toc`].
195///
196/// Only the four lines that matter for reading sectors are interpreted — `FILE`,
197/// `TRACK`, `INDEX 01`, and the track mode — which is all a cue needs to carry
198/// for this purpose. `REM`, `TITLE`, `PERFORMER`, and friends are metadata and
199/// are skipped.
200///
201/// Two cue features are deliberately not modelled, because neither changes a
202/// byte offset in the `.bin`:
203///
204/// - `INDEX 00` marks a pregap that *is* stored in the file. Tracks start at
205///   `INDEX 01`, so those sectors fall at the end of the preceding track — the
206///   usual "gap appended to the previous track" layout.
207/// - `PREGAP` declares silence that is *not* stored in the file. It shifts a
208///   disc's addressing but not this file's, and we address by file offset.
209fn parse_cue(cue_path: &Path) -> Result<(PathBuf, Toc), Box<dyn Error>> {
210    let text = std::fs::read_to_string(cue_path)?;
211    let cue_dir = cue_path.parent().unwrap_or(Path::new("."));
212
213    let mut bin_path: Option<PathBuf> = None;
214    let mut pending: Option<(u8, bool)> = None;
215    let mut tracks: Vec<Track> = Vec::new();
216
217    for line in text.lines() {
218        let line = line.trim();
219        let mut fields = line.split_whitespace();
220        let Some(keyword) = fields.next() else {
221            continue;
222        };
223
224        match keyword.to_ascii_uppercase().as_str() {
225            "FILE" => {
226                if bin_path.is_some() {
227                    return Err(
228                        "multi-FILE cue sheets (one file per track) are not handled by \
229                                this example; it assumes a single .bin addressed by sector"
230                            .into(),
231                    );
232                }
233                bin_path = Some(cue_dir.join(quoted_or_first_field(line)?));
234            }
235
236            "TRACK" => {
237                // e.g. `TRACK 03 MODE1/2352`
238                let number: u8 = fields.next().ok_or("TRACK line has no number")?.parse()?;
239                let mode = fields.next().ok_or("TRACK line has no mode")?;
240
241                // The whole image is addressed as a uniform grid of 2352-byte
242                // sectors, so a cooked data track (MODE1/2048) would desync
243                // every offset after it. Refuse rather than read garbage.
244                let sector_size = mode_sector_size(mode);
245                if sector_size != Some(SECTOR_SIZE) {
246                    return Err(format!(
247                        "track {number} is `{mode}`, which is not stored as 2352-byte sectors; \
248                         this example needs a fully raw image"
249                    )
250                    .into());
251                }
252
253                pending = Some((number, mode.eq_ignore_ascii_case("AUDIO")));
254            }
255
256            // `INDEX 01 MM:SS:FF` is where the track proper begins.
257            "INDEX" => {
258                let index = fields.next().ok_or("INDEX line has no number")?;
259                let msf = fields.next().ok_or("INDEX line has no timestamp")?;
260                if index != "01" {
261                    continue;
262                }
263
264                let (number, is_audio) = pending
265                    .take()
266                    .ok_or("INDEX 01 appeared before any TRACK line")?;
267                let start_lba = msf_to_frames(msf)?;
268
269                tracks.push(Track {
270                    number,
271                    start_lba,
272                    start_msf: lba_to_msf(start_lba),
273                    is_audio,
274                });
275            }
276
277            _ => {}
278        }
279    }
280
281    let bin_path = bin_path.ok_or("cue sheet has no FILE line")?;
282    if tracks.is_empty() {
283        return Err("cue sheet declares no tracks".into());
284    }
285    tracks.sort_by_key(|track| track.start_lba);
286
287    // The cue has no leadout; the image's own length is the end of the disc.
288    let bin_bytes = std::fs::metadata(&bin_path)
289        .map_err(|e| format!("cannot open {}: {e}", bin_path.display()))?
290        .len();
291    let leadout_lba = (bin_bytes / SECTOR_SIZE as u64) as u32;
292
293    let toc = Toc {
294        first_track: tracks.first().map_or(1, |track| track.number),
295        last_track: tracks.last().map_or(1, |track| track.number),
296        tracks,
297        leadout_lba,
298    };
299
300    Ok((bin_path, toc))
301}
302
303/// Bytes per sector for a cue track mode, or `None` if the mode is unknown.
304/// `AUDIO` is always raw; the rest carry their size after a slash
305/// (`MODE1/2048`, `MODE1/2352`, `MODE2/2352`, ...).
306fn mode_sector_size(mode: &str) -> Option<usize> {
307    if mode.eq_ignore_ascii_case("AUDIO") {
308        return Some(SECTOR_SIZE);
309    }
310
311    mode.split_once('/').and_then(|(_, size)| size.parse().ok())
312}
313
314/// `MM:SS:FF` to a sector count. These are offsets into the `.bin`, so unlike a
315/// disc MSF address there is no 150-frame lead-in to subtract.
316fn msf_to_frames(msf: &str) -> Result<u32, Box<dyn Error>> {
317    let parts: Vec<&str> = msf.split(':').collect();
318    let [minutes, seconds, frames] = parts[..] else {
319        return Err(format!("expected a MM:SS:FF timestamp, got `{msf}`").into());
320    };
321
322    let minutes: u32 = minutes.parse()?;
323    let seconds: u32 = seconds.parse()?;
324    let frames: u32 = frames.parse()?;
325
326    Ok(((minutes * 60) + seconds) * SECTORS_PER_SECOND + frames)
327}
328
329/// The filename from a `FILE "name with spaces.bin" BINARY` line, falling back
330/// to the bare second field when it is unquoted.
331fn quoted_or_first_field(line: &str) -> Result<String, Box<dyn Error>> {
332    if let Some((_, rest)) = line.split_once('"') {
333        return rest
334            .rsplit_once('"')
335            .map(|(name, _)| name.to_string())
336            .ok_or_else(|| "FILE line has an unterminated quote".into());
337    }
338
339    line.split_whitespace()
340        .nth(1)
341        .map(str::to_string)
342        .ok_or_else(|| "FILE line has no filename".into())
343}
344
345/// Write a small mixed-mode BIN/CUE (two audio tracks then a data track) so the
346/// example runs without an image on hand, and returns the `.cue` path.
347///
348/// The trailing data track is the point: it makes this disc one where the two
349/// [`TrackBounds`] policies actually disagree.
350fn write_demo_image(output_dir: &Path) -> Result<PathBuf, Box<dyn Error>> {
351    let layout = [
352        (1u8, "AUDIO", 2 * SECTORS_PER_SECOND, Some(440.0)),
353        (2, "AUDIO", 2 * SECTORS_PER_SECOND, Some(523.25)),
354        (3, "MODE1/2352", SECTORS_PER_SECOND, None),
355    ];
356
357    let mut bin = Vec::new();
358    let mut cue = String::from("FILE \"demo.bin\" BINARY\n");
359    let mut start_lba = 0;
360
361    for (number, mode, sectors, tone_hz) in layout {
362        cue += &format!(
363            "  TRACK {number:02} {mode}\n    INDEX 01 {}\n",
364            frames_to_msf(start_lba)
365        );
366
367        match tone_hz {
368            Some(hz) => bin.extend_from_slice(&tone(sectors, hz)),
369            // Not a real ISO 9660 filesystem — just something that is clearly
370            // not audio, since the example never reads data tracks.
371            None => bin.resize(bin.len() + sectors as usize * SECTOR_SIZE, 0xAA),
372        }
373        start_lba += sectors;
374    }
375
376    let cue_path = output_dir.join("demo.cue");
377    std::fs::write(output_dir.join("demo.bin"), &bin)?;
378    std::fs::write(&cue_path, cue)?;
379
380    Ok(cue_path)
381}
382
383/// `sectors` worth of a stereo sine at `hz`, as CD-DA PCM.
384fn tone(sectors: u32, hz: f32) -> Vec<u8> {
385    // 2352 bytes per sector / 4 bytes per stereo frame.
386    let frames = sectors as usize * 588;
387    let mut pcm = Vec::with_capacity(frames * 4);
388
389    for frame in 0..frames {
390        let t = frame as f32 / 44_100.0;
391        let sample = ((t * hz * std::f32::consts::TAU).sin() * 8_000.0) as i16;
392        pcm.extend_from_slice(&sample.to_le_bytes()); // left
393        pcm.extend_from_slice(&sample.to_le_bytes()); // right
394    }
395
396    pcm
397}
398
399/// Sector count to the `MM:SS:FF` a cue sheet expects (file-relative, no lead-in).
400fn frames_to_msf(frames: u32) -> String {
401    let seconds = frames / SECTORS_PER_SECOND;
402    format!(
403        "{:02}:{:02}:{:02}",
404        seconds / 60,
405        seconds % 60,
406        frames % SECTORS_PER_SECOND
407    )
408}