use crate::error::DiscError;
use crate::types::{CdToc, CdTrack, CdTrackType, DiscType, Msf};
use std::path::Path;
struct StagedTrack {
track_num: u32,
track_type: CdTrackType,
index0: Option<u32>,
index1: u32,
pregap: u32,
postgap: u32,
file_name: String,
}
#[derive(Debug, Default)]
pub struct CueSheet;
impl CueSheet {
pub fn parse(content: &str, parent_dir: Option<&Path>) -> Result<CdToc, DiscError> {
let mut tracks = Vec::new();
let mut current_file: Option<String> = None;
let mut current_track_num: Option<u32> = None;
let mut current_track_type: Option<CdTrackType> = None;
let mut current_pregap: u32 = 0;
let mut current_postgap: u32 = 0;
let mut current_index0: Option<u32> = None;
let mut current_index1: Option<u32> = None;
let mut file_tracks: Vec<StagedTrack> = Vec::new();
let flush_current_track = |file_tracks: &mut Vec<StagedTrack>,
current_file: &Option<String>,
current_track_num: Option<u32>,
current_track_type: Option<CdTrackType>,
current_index0: Option<u32>,
current_index1: Option<u32>,
current_pregap: u32,
current_postgap: u32|
-> Result<(), DiscError> {
if let (Some(track_num), Some(track_type), Some(index1)) =
(current_track_num, current_track_type, current_index1)
{
let file_name = current_file.clone().unwrap_or_default();
file_tracks.push(StagedTrack {
track_num,
track_type,
index0: current_index0,
index1,
pregap: current_pregap,
postgap: current_postgap,
file_name,
});
}
Ok(())
};
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with("REM") {
continue;
}
let tokens = tokenize_line(line);
if tokens.is_empty() {
continue;
}
match tokens[0].to_uppercase().as_str() {
"FILE" => {
flush_current_track(
&mut file_tracks,
¤t_file,
current_track_num,
current_track_type,
current_index0,
current_index1,
current_pregap,
current_postgap,
)?;
current_track_num = None;
current_track_type = None;
current_index0 = None;
current_index1 = None;
current_pregap = 0;
current_postgap = 0;
if tokens.len() < 2 {
return Err(DiscError::InvalidCue("missing FILE path".to_string()));
}
crate::types::validate_descriptor_path(&tokens[1])?;
current_file = Some(tokens[1].clone());
}
"TRACK" => {
flush_current_track(
&mut file_tracks,
¤t_file,
current_track_num,
current_track_type,
current_index0,
current_index1,
current_pregap,
current_postgap,
)?;
current_index0 = None;
current_index1 = None;
current_pregap = 0;
current_postgap = 0;
if tokens.len() < 3 {
return Err(DiscError::InvalidCue("incomplete TRACK line".to_string()));
}
let num = tokens[1].parse::<u32>().map_err(|_| {
DiscError::InvalidCue(format!("invalid track number: {}", tokens[1]))
})?;
let t_type = CdTrackType::parse_cue_or_metadata(&tokens[2])?;
current_track_num = Some(num);
current_track_type = Some(t_type);
}
"INDEX" => {
if tokens.len() < 3 {
return Err(DiscError::InvalidCue("incomplete INDEX line".to_string()));
}
let idx_num = tokens[1].parse::<u32>().map_err(|_| {
DiscError::InvalidCue(format!("invalid INDEX number: {}", tokens[1]))
})?;
let msf = Msf::parse(&tokens[2])?;
let lba = msf.to_lba();
if idx_num == 0 {
current_index0 = Some(lba);
} else if idx_num == 1 {
current_index1 = Some(lba);
}
}
"PREGAP" => {
if tokens.len() < 2 {
return Err(DiscError::InvalidCue("missing PREGAP value".to_string()));
}
let msf = Msf::parse(&tokens[1])?;
current_pregap += msf.to_lba();
}
"POSTGAP" => {
if tokens.len() < 2 {
return Err(DiscError::InvalidCue("missing POSTGAP value".to_string()));
}
let msf = Msf::parse(&tokens[1])?;
current_postgap += msf.to_lba();
}
_ => {}
}
}
flush_current_track(
&mut file_tracks,
¤t_file,
current_track_num,
current_track_type,
current_index0,
current_index1,
current_pregap,
current_postgap,
)?;
if file_tracks.is_empty() {
return Err(DiscError::InvalidCue(
"no tracks found in CUE sheet".to_string(),
));
}
for i in 0..file_tracks.len() {
let staged = &file_tracks[i];
let mut track_pregap = staged.pregap;
if let Some(i0) = staged.index0 {
if staged.index1 > i0 {
track_pregap += staged.index1 - i0;
}
}
let is_same_file_as_prev = i > 0 && file_tracks[i - 1].file_name == staged.file_name;
let is_same_file_as_next =
i + 1 < file_tracks.len() && file_tracks[i + 1].file_name == staged.file_name;
let cur_start = staged.index0.unwrap_or(staged.index1);
let (frames, byte_offset) = if !is_same_file_as_prev && !is_same_file_as_next {
let total_frames = if let Some(parent) = parent_dir {
let path = parent.join(&staged.file_name);
measure_file_frames(&path, staged.track_type)?
} else {
0
};
(total_frames, 0u64)
} else if is_same_file_as_next {
let next_start = file_tracks[i + 1]
.index0
.unwrap_or(file_tracks[i + 1].index1);
if next_start < cur_start {
return Err(DiscError::InvalidCue(format!(
"track {} index overlaps next track in file '{}'",
staged.track_num, staged.file_name
)));
}
let span = next_start - cur_start;
let offset = cur_start as u64 * staged.track_type.sector_size() as u64;
(span, offset)
} else {
let total_frames = if let Some(parent) = parent_dir {
let path = parent.join(&staged.file_name);
measure_file_frames(&path, staged.track_type)?
} else {
0
};
let span = total_frames.saturating_sub(cur_start);
let offset = cur_start as u64 * staged.track_type.sector_size() as u64;
(span, offset)
};
let mut track = CdTrack::new(staged.track_num, staged.track_type, frames);
track.pregap_frames = track_pregap;
track.postgap_frames = staged.postgap;
track.file_name = Some(staged.file_name.clone());
track.file_offset = byte_offset;
tracks.push(track);
}
Ok(CdToc {
disc_type: DiscType::Cd,
tracks,
})
}
pub fn format_single_bin(toc: &CdToc, bin_filename: &str) -> String {
let mut out = String::new();
out.push_str(&format!("FILE \"{}\" BINARY\n", bin_filename));
let mut cumulative_lba: u32 = 0;
for track in &toc.tracks {
out.push_str(&format!(
" TRACK {:02} {}\n",
track.track_num,
track.track_type.cue_mode_str()
));
if track.pregap_frames > 0 {
let idx0 = Msf::from_lba(cumulative_lba);
let idx1 = Msf::from_lba(cumulative_lba + track.pregap_frames);
out.push_str(&format!(" INDEX 00 {}\n", idx0));
out.push_str(&format!(" INDEX 01 {}\n", idx1));
} else {
let idx1 = Msf::from_lba(cumulative_lba);
out.push_str(&format!(" INDEX 01 {}\n", idx1));
}
cumulative_lba += track.frames + track.postgap_frames;
}
out
}
pub fn format_split_bin(toc: &CdToc, base_name: &str) -> String {
let mut out = String::new();
for track in &toc.tracks {
let track_bin = format!("{} (Track {}).bin", base_name, track.track_num);
out.push_str(&format!("FILE \"{}\" BINARY\n", track_bin));
out.push_str(&format!(
" TRACK {:02} {}\n",
track.track_num,
track.track_type.cue_mode_str()
));
if track.pregap_frames > 0 {
let idx0 = Msf::from_lba(0);
let idx1 = Msf::from_lba(track.pregap_frames);
out.push_str(&format!(" INDEX 00 {}\n", idx0));
out.push_str(&format!(" INDEX 01 {}\n", idx1));
} else {
out.push_str(" INDEX 01 00:00:00\n");
}
}
out
}
}
fn tokenize_line(line: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
for ch in line.chars() {
match ch {
'"' => {
in_quotes = !in_quotes;
}
c if c.is_whitespace() && !in_quotes => {
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
}
c => {
current.push(c);
}
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn measure_file_frames(path: &Path, track_type: CdTrackType) -> Result<u32, DiscError> {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if ext.eq_ignore_ascii_case("wav") {
let reader = hound::WavReader::open(path)?;
let spec = reader.spec();
let samples = reader.duration();
let channels = spec.channels as u32;
let sample_rate = spec.sample_rate;
if sample_rate != 44100 || channels != 2 {
return Err(DiscError::InvalidCue(format!(
"WAV file '{}' must be 44100Hz 16-bit stereo (found {}Hz, {} channels)",
path.display(),
sample_rate,
channels
)));
}
return Ok(samples / 588);
}
}
let meta = std::fs::metadata(path)?;
let bytes = meta.len();
let sec_size = track_type.sector_size() as u64;
Ok((bytes / sec_size) as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cue_single_bin_format() {
let mut toc = CdToc::new(DiscType::Cd);
let t1 = CdTrack::new(1, CdTrackType::Mode1Raw, 4);
let mut t2 = CdTrack::new(2, CdTrackType::Audio, 4);
t2.pregap_frames = 150;
toc.tracks.push(t1);
toc.tracks.push(t2);
let cue = CueSheet::format_single_bin(&toc, "out.bin");
assert!(cue.contains("FILE \"out.bin\" BINARY"));
assert!(cue.contains(" TRACK 01 MODE1/2352\n INDEX 01 00:00:00\n"));
assert!(cue.contains(" TRACK 02 AUDIO\n INDEX 00 00:00:04\n INDEX 01 00:02:04\n"));
}
#[test]
fn test_cue_split_bin_format() {
let mut toc = CdToc::new(DiscType::Cd);
let t1 = CdTrack::new(1, CdTrackType::Mode1, 10);
let t2 = CdTrack::new(2, CdTrackType::Audio, 20);
toc.tracks.push(t1);
toc.tracks.push(t2);
let cue = CueSheet::format_split_bin(&toc, "disc");
assert!(cue.contains("FILE \"disc (Track 1).bin\" BINARY"));
assert!(cue.contains(" TRACK 01 MODE1/2048\n INDEX 01 00:00:00\n"));
assert!(cue.contains("FILE \"disc (Track 2).bin\" BINARY"));
assert!(cue.contains(" TRACK 02 AUDIO\n INDEX 01 00:00:00\n"));
}
#[test]
fn test_cue_single_bin_format_multitrack_with_pregaps() {
let mut toc = CdToc::new(DiscType::Cd);
let t1 = CdTrack::new(1, CdTrackType::Audio, 3365);
let mut t2 = CdTrack::new(2, CdTrackType::Mode1Raw, 8634);
t2.pregap_frames = 231;
let mut t3 = CdTrack::new(3, CdTrackType::Audio, 9417);
t3.pregap_frames = 153;
let t4 = CdTrack::new(4, CdTrackType::Audio, 9261);
toc.tracks.push(t1);
toc.tracks.push(t2);
toc.tracks.push(t3);
toc.tracks.push(t4);
let cue = CueSheet::format_single_bin(&toc, "out.bin");
assert!(cue.contains(" TRACK 01 AUDIO\n INDEX 01 00:00:00\n"));
assert!(cue.contains(" TRACK 02 MODE1/2352\n INDEX 00 00:44:65\n INDEX 01 00:47:71\n"));
assert!(cue.contains(" TRACK 03 AUDIO\n INDEX 00 02:39:74\n INDEX 01 02:42:02\n"));
assert!(cue.contains(" TRACK 04 AUDIO\n INDEX 01 04:45:41\n"));
}
}