reaper_regions/
lib.rs

1//! # REAPER Regions Library
2//!
3//! This library parses [REAPER DAW](https://www.reaper.fm/) region markers from WAV files.
4//! It extracts markers, regions, and their associated metadata from
5//! WAV files rendered from REAPER with markers or markers + regions included.
6//! These are stored in RIFF `'cue '`, `'labl'`, and `'smpl'` chunks by REAPER.
7//! In order for this to work properly, two conditions must be met:
8//!
9//! 1. The project **must** have at least one marker or region defined in the track view:
10//! <img alt="Track showing a marker and two regions" src="https://raw.githubusercontent.com/dra11y/reaper-regions/main/images/track.png" width="511">
11//!
12//! 2. The WAV file **must** be rendered with Regions or Regions + Markers, and there must be at least one marker or region in the time range of the rendered output.
13//! <img alt="Render with markers or markers + regions" src="https://raw.githubusercontent.com/dra11y/reaper-regions/main/images/render.png" width="610">
14//!    - The "Write BWF ('bext') chunk" checkbox is **optional** and has no effect on the regions/markers:
15//!
16//! This library **might** work with WAV files exported from other DAWs with markers/regions,
17//! but many of them do not support embedding markers or loop regions in exported WAV files.
18//! If you find another DAW whose exports this library can read, please let me know.
19//!
20//! ## Features
21//! - Parses REAPER region markers and cues from WAV files
22//! - Extracts region names, start/end sample offsets, and start/end times and durations (in seconds)
23//! - Supports both markers (single points) and regions (start/end ranges)
24//! - Provides human-readable and machine-readable output formats
25//!
26//! ## Supported WAV Chunks
27//! - `cue ` - Cue points with unique IDs and positions
28//! - `labl` - Labels associated with cue points
29//! - `smpl` - Sampler data including loop points
30//! - `LIST` - List chunks containing additional metadata
31//!
32//! ## Example
33//! ```rust,no_run
34//! use reaper_regions::parse_markers_from_file;
35//!
36//! let data = parse_markers_from_file("path/to/audio.wav").unwrap();
37//! println!("{data:#?}");
38//! ```
39//!
40//! **Output:**
41//! ```rust,ignore
42//! WavData {
43//!     path: "tests/fixtures/3-markers-3-regions-overlapping_stripped.wav",
44//!     sample_rate: 48000,
45//!     markers: [
46//!         Marker {
47//!             id: 1,
48//!             name: "Region 1",
49//!             type: Region,
50//!             start: 290708,
51//!             end: Some(
52//!                 886374,
53//!             ),
54//!             start_time: 6.056416666666666,
55//!             end_time: Some(
56//!                 18.466125,
57//!             ),
58//!             duration: Some(
59//!                 12.409708333333334,
60//!             ),
61//!         },
62//!         Marker {
63//!             id: 2,
64//!             name: "Marker 1",
65//!             type: Marker,
66//!             start: 383050,
67//!             end: None,
68//!             start_time: 7.980208333333334,
69//!             end_time: None,
70//!             duration: None,
71//!         },
72//!         Marker {
73//!             id: 3,
74//!             name: "Region 2",
75//!             type: Region,
76//!             start: 1060229,
77//!             end: Some(
78//!                 1496290,
79//!             ),
80//!             start_time: 22.088104166666668,
81//!             end_time: Some(
82//!                 31.172708333333333,
83//!             ),
84//!             duration: Some(
85//!                 9.084604166666665,
86//!             ),
87//!         },
88//!         ...
89//!     ],
90//!     reason: None,
91//!     reason_text: None,
92//! }
93//! ```
94//!
95//! ## Installation
96//! ```bash
97//! cargo add reaper-regions --no-default-features
98//! ```
99//!
100//! ## Motivation
101//! I was motivated to create this tool because I needed to sync song regions from my
102//! master mixdown created in REAPER with my video projects in [DaVinci Resolve](https://www.blackmagicdesign.com/products/davinciresolve)
103//! for live concert video and audio productions.
104//! Unfortunately, Resolve does not read markers or regions embedded in WAV files.
105//! Also, the metadata exported by REAPER, as inspected with `ffprobe`, reports
106//! incorrect end times for regions (possibly due to metadata spec limitations?),
107//! necessitating this tool.
108//!
109//! ## Acknowledgements / License
110//!
111//! REAPER is a trademark and the copyright property of [Cockos, Incorporated](https://www.cockos.com/).
112//! This library is free, open source, and MIT-licensed.
113//! DaVinci Resolve is a trademark and the copyright property of [Blackmagic Design Pty. Ltd.](https://www.blackmagicdesign.com/)
114
115pub mod wavtag;
116
117use log::{debug, warn};
118use serde::{Deserialize, Serialize};
119use std::{collections::HashMap, error::Error};
120use strum::EnumMessage;
121use wavtag::{ChunkType, RiffFile};
122
123/// Reason for missing or incomplete markers in a WAV file.
124///
125/// These enum variants explain why marker parsing might yield incomplete results,
126/// helping users understand the limitations of the parsed data.
127#[derive(Debug, strum::EnumMessage, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128pub enum Reason {
129    /// No label chunks were found in the file
130    NoLabels,
131    /// No 'smpl' (sampler) chunk was found in the file
132    NoSamplerData,
133    /// Labels and/or sampler data found but no 'cue ' chunk
134    NoCuePoints,
135    /// Metadata exists but couldn't be matched into markers
136    NoMarkersMatched,
137}
138
139/// Error type for parsing operations.
140///
141/// This enum covers all possible errors that can occur during WAV file parsing,
142/// including I/O errors, malformed chunks, and missing required data.
143#[derive(Debug, wherror::Error)]
144#[error(debug)]
145pub enum ParseError {
146    /// I/O error when reading the file
147    Io(#[from] std::io::Error),
148    /// File doesn't contain a WAVE tag
149    #[error("no WAVE tag found")]
150    NoWaveTag,
151    /// File doesn't contain a RIFF tag
152    #[error("no RIFF tag found")]
153    NoRiffTag,
154    /// Format chunk is missing
155    MissingFormatChunk,
156    /// Format chunk has invalid length
157    #[error("Format chunk length: expected >= 8, got {0}")]
158    InvalidFormatChunk(usize),
159    /// Failed to convert bytes to little-endian integer
160    #[error("bytes to little endian at step: {0}")]
161    BytesToLe(String),
162    /// Other parsing errors
163    Other(String),
164}
165
166/// Result type for parsing operations.
167pub type ParseResult = Result<WavData, ParseError>;
168
169/// The complete result of parsing a WAV file for markers.
170///
171/// Contains all parsed markers along with file metadata and any parsing warnings.
172#[derive(Debug, Default, Serialize)]
173pub struct WavData {
174    /// Path to the source WAV file
175    pub path: String,
176    /// Sample rate in Hz
177    pub sample_rate: u32,
178    /// Vector of parsed markers and regions
179    pub markers: Vec<Marker>,
180    /// Reason for incomplete parsing, if any
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub reason: Option<Reason>,
183    /// Human-readable description of the parsing reason
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub reason_text: Option<String>,
186}
187
188impl WavData {
189    /// Sets a reason for incomplete parsing.
190    ///
191    /// # Arguments
192    /// * `reason` - The [`Reason`] variant describing why parsing was incomplete
193    ///
194    /// This also sets `reason_text` to the human-readable documentation from the enum.
195    pub fn set_reason(&mut self, reason: Reason) {
196        self.reason = Some(reason);
197        self.reason_text = reason.get_documentation().map(ToString::to_string);
198    }
199
200    /// Clears any previously set parsing reason.
201    ///
202    /// Used when markers are successfully parsed or when resetting the state.
203    pub fn clear_reason(&mut self) {
204        self.reason = None;
205        self.reason_text = None;
206    }
207}
208
209/// Type of marker in the WAV file.
210///
211/// Distinguishes between simple markers (single points) and regions (ranges).
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213pub enum MarkerType {
214    /// A simple marker representing a single point in time
215    Marker,
216    /// A region with both start and end points defining a range
217    Region,
218}
219
220/// Represents a labeled marker or region in a Reaper WAV file.
221///
222/// Contains all metadata for a single marker or region including timing information
223/// in both samples and seconds, and derived duration for regions.
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225pub struct Marker {
226    /// Unique identifier matching the cue point in the WAV file
227    pub id: u32,
228    /// Name of the marker (from 'labl' chunk)
229    pub name: String,
230    /// Type of marker (Marker or Region)
231    pub r#type: MarkerType,
232    /// Start position in samples
233    pub start: u32,
234    /// End position in samples (None for simple markers)
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub end: Option<u32>,
237    /// DERIVED: Start time in seconds
238    #[serde(serialize_with = "serialize_f64")]
239    pub start_time: f64,
240    /// DERIVED: End time in seconds (None for simple markers)
241    #[serde(
242        serialize_with = "serialize_opt_f64",
243        skip_serializing_if = "Option::is_none"
244    )]
245    pub end_time: Option<f64>,
246    /// DERIVED: Duration in seconds (None for simple markers)
247    #[serde(
248        serialize_with = "serialize_opt_f64",
249        skip_serializing_if = "Option::is_none"
250    )]
251    pub duration: Option<f64>,
252}
253
254/// Rounds a floating-point value to 3 decimal places.
255///
256/// # Arguments
257/// * `value` - The floating-point value to round
258///
259/// # Returns
260/// * `f64` - The rounded value
261///
262/// # Example
263/// ```
264/// use reaper_regions::round3;
265/// let value = 1.234567;
266/// assert_eq!(round3(value), 1.235);
267/// ```
268pub fn round3(value: f64) -> f64 {
269    (value * 1_000.0).round() / 1_000.0
270}
271
272/// Custom serializer for f64 values.
273///
274/// Automatically rounds values to 3 decimal places during serialization.
275fn serialize_f64<S>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
276where
277    S: serde::Serializer,
278{
279    serializer.serialize_f64(round3(*value))
280}
281
282/// Custom serializer for optional f64 values.
283///
284/// Automatically rounds values to 3 decimal places during serialization.
285fn serialize_opt_f64<S>(value: &Option<f64>, serializer: S) -> Result<S::Ok, S::Error>
286where
287    S: serde::Serializer,
288{
289    match value {
290        Some(value) => serializer.serialize_some(&round3(*value)),
291        None => serializer.serialize_none(),
292    }
293}
294
295impl Marker {
296    /// Creates a new marker or region.
297    ///
298    /// # Arguments
299    /// * `id` - Unique identifier for the marker
300    /// * `name` - Name/label of the marker
301    /// * `start` - Start position in samples
302    /// * `end` - End position in samples (None for markers, Some for regions)
303    /// * `sample_rate` - Sample rate of the audio file in Hz
304    ///
305    /// # Returns
306    /// * [`Marker`] - A new Marker instance with derived timing information
307    ///
308    /// # Example
309    /// ```
310    /// use reaper_regions::{Marker, MarkerType};
311    ///
312    /// // Create a marker
313    /// let marker = Marker::new(1, "Verse".to_string(), 44100, None, 44100);
314    /// assert_eq!(marker.r#type, MarkerType::Marker);
315    /// assert_eq!(marker.start_time, 1.0);
316    ///
317    /// // Create a region
318    /// let region = Marker::new(2, "Chorus".to_string(), 44100, Some(88200), 44100);
319    /// assert_eq!(region.r#type, MarkerType::Region);
320    /// assert_eq!(region.duration, Some(1.0));
321    /// ```
322    pub fn new(id: u32, name: String, start: u32, end: Option<u32>, sample_rate: u32) -> Self {
323        let marker_type = if end.is_some() {
324            MarkerType::Region
325        } else {
326            MarkerType::Marker
327        };
328
329        // Calculate derived time values
330        let start_time = start as f64 / sample_rate as f64;
331        let (end_time, duration) = match end {
332            Some(end) => {
333                let end_s = end as f64 / sample_rate as f64;
334                let dur_s = end_s - start_time;
335                (Some(end_s), Some(dur_s))
336            }
337            None => (None, None),
338        };
339
340        Marker {
341            id,
342            name,
343            r#type: marker_type,
344            start,
345            end,
346            start_time,
347            end_time,
348            duration,
349        }
350    }
351
352    /// Formats the marker as a human-readable string.
353    ///
354    /// # Returns
355    /// * `String` - Formatted description of the marker
356    ///
357    /// # Example
358    /// ```
359    /// use reaper_regions::Marker;
360    ///
361    /// let marker = Marker::new(1, "Intro".to_string(), 0, None, 44100);
362    /// println!("{}", marker.format());
363    /// // Output: "Marker (ID: 1): 'Intro'\n  Position: 0.000s (0 samples)"
364    /// ```
365    pub fn format(&self) -> String {
366        match self.r#type {
367            MarkerType::Region => {
368                let end = self.end.unwrap();
369                format!(
370                    "Region (ID: {}): '{}'\n  Start: {:.3}s ({} samples), End: {:.3}s ({} samples), Duration: {:.3}s",
371                    self.id,
372                    self.name,
373                    self.start_time,
374                    self.start,
375                    self.end_time.unwrap(),
376                    end,
377                    self.duration.unwrap()
378                )
379            }
380            MarkerType::Marker => {
381                format!(
382                    "Marker (ID: {}): '{}'\n  Position: {:.3}s ({} samples)",
383                    self.id, self.name, self.start_time, self.start
384                )
385            }
386        }
387    }
388}
389
390/// Parses all markers from a Reaper WAV file.
391///
392/// # Arguments
393/// * `file_path` - Path to the WAV file to parse
394///
395/// # Returns
396/// * [`ParseResult`] - Result containing parsed markers or an error
397///
398/// # Errors
399/// * [`ParseError::Io`] - If the file cannot be read
400/// * [`ParseError::NoRiffTag`] - If the file is not a valid RIFF file
401/// * [`ParseError::NoWaveTag`] - If the file is not a valid WAV file
402/// * [`ParseError::MissingFormatChunk`] - If the format chunk is missing
403/// * [`ParseError::InvalidFormatChunk`] - If the format chunk is malformed
404///
405/// # Example
406/// ```
407/// use reaper_regions::parse_markers_from_file;
408///
409/// match parse_markers_from_file("audio.wav") {
410///     Ok(markers) => {
411///         println!("Found {} markers", markers.markers.len());
412///     }
413///     Err(e) => {
414///         eprintln!("Failed to parse markers: {}", e);
415///     }
416/// }
417/// ```
418pub fn parse_markers_from_file(file_path: &str) -> Result<WavData, ParseError> {
419    let file = std::fs::File::open(file_path)?;
420    let riff_file = RiffFile::read(file, file_path.to_string()).map_err(|err| {
421        let string = err.to_string();
422        if string.contains("no RIFF tag found") {
423            return ParseError::NoRiffTag;
424        }
425        if string.contains("no WAVE tag found") {
426            return ParseError::NoWaveTag;
427        }
428        err.into()
429    })?;
430
431    // Get sample rate from format chunk
432    let sample_rate = get_sample_rate(&riff_file)?;
433    debug!("Sample rate: {} Hz", sample_rate);
434
435    let mut result = WavData {
436        path: file_path.to_string(),
437        sample_rate,
438        ..WavData::default()
439    };
440
441    // Parse labels
442    let labels = parse_labels(&riff_file);
443    debug!("Found {} label(s)", labels.len());
444
445    // Parse sampler loops
446    let sampler_data = parse_sampler_data(&riff_file)?;
447    if sampler_data.is_none() {
448        debug!("No sample loops found.");
449        result.set_reason(Reason::NoSamplerData);
450    }
451
452    // Parse cue points for start positions
453    let Some(cue_points) = parse_cue_points(&riff_file)? else {
454        debug!("No cue points found.");
455        result.set_reason(Reason::NoCuePoints);
456        return Ok(result);
457    };
458
459    // Match everything together
460    result.markers = match_markers(labels, sampler_data, cue_points, sample_rate);
461
462    Ok(result)
463}
464
465/// Internal struct for label data.
466#[derive(Debug, Clone)]
467struct Label {
468    cue_id: u32,
469    name: String,
470}
471
472/// Parses the sample rate from the format chunk.
473///
474/// # Arguments
475/// * `riff_file` - Reference to the parsed RIFF file
476///
477/// # Returns
478/// * `Result<u32, ParseError>` - Sample rate in Hz or an error
479///
480/// # Errors
481/// * [`ParseError::MissingFormatChunk`] - If format chunk is not found
482/// * [`ParseError::InvalidFormatChunk`] - If format chunk is too short (< 8 bytes)
483/// * [`ParseError::BytesToLe`] - If bytes cannot be converted to little-endian
484fn get_sample_rate(riff_file: &RiffFile) -> Result<u32, ParseError> {
485    let format_chunk = riff_file
486        .find_chunk_by_type(ChunkType::Format)
487        .ok_or(ParseError::MissingFormatChunk)?;
488    // Format chunk structure for PCM:
489    // Offset 0-1: Audio format (1 for PCM)
490    // Offset 2-3: Number of channels
491    // Offset 4-7: Sample rate (u32, little-endian)
492    let len = format_chunk.data.len();
493    if len < 8 {
494        warn!("Format chunk too short: expected >= 8, got: {len}");
495        return Err(ParseError::InvalidFormatChunk(len));
496    }
497    let sample_rate_bytes = &format_chunk.data[4..8];
498    let sample_rate = u32::from_le_bytes(
499        sample_rate_bytes
500            .try_into()
501            .map_err(|_| ParseError::BytesToLe("sample rate".into()))?,
502    );
503    Ok(sample_rate)
504}
505
506/// Parses all labels from the file (standalone or LIST chunks).
507///
508/// # Arguments
509/// * `riff_file` - Reference to the parsed RIFF file
510///
511/// # Returns
512/// * `Vec<Label>` - Vector of parsed labels
513///
514/// # Note
515/// This function first looks for standalone 'labl' chunks, then falls back
516/// to parsing labels from LIST-adtl chunks if no standalone labels are found.
517fn parse_labels(riff_file: &RiffFile) -> Vec<Label> {
518    let mut labels = Vec::new();
519    let mut found_standalone_labels = false;
520
521    // Look for standalone 'labl' chunks first
522    debug!("=== LOOKING FOR STANDALONE LABEL CHUNKS ===");
523    for chunk in &riff_file.chunks {
524        if chunk.header == ChunkType::Label {
525            found_standalone_labels = true;
526            if chunk.data.len() >= 4 {
527                // Convert first 4 bytes to u32 (cue_id)
528                let cue_id_bytes: [u8; 4] = match chunk.data[0..4].try_into() {
529                    Ok(bytes) => bytes,
530                    Err(_) => {
531                        warn!("Failed to convert label chunk data to array of 4 bytes, skipping");
532                        continue;
533                    }
534                };
535                let cue_id = u32::from_le_bytes(cue_id_bytes);
536                let name_bytes = &chunk.data[4..];
537                let name = String::from_utf8_lossy(name_bytes)
538                    .trim_end_matches('\0')
539                    .to_string();
540
541                // Use the name for logging before moving it into the Label
542                debug!(
543                    "  Found standalone Label -> Cue ID: {}, Name: '{}'",
544                    cue_id, name
545                );
546
547                // Now create the Label with the name
548                labels.push(Label { cue_id, name });
549            }
550        }
551    }
552
553    // If no standalone labels, parse the LIST-adtl chunk
554    if !found_standalone_labels {
555        debug!("=== PARSING LIST CHUNK ===");
556        if let Some(list_chunk) = riff_file.find_chunk_by_type(ChunkType::List) {
557            debug!("  LIST chunk size: {} bytes", list_chunk.data.len());
558
559            if let Ok(list_labels) = parse_list_chunk_for_labels(list_chunk) {
560                debug!("  Found {} label(s) in LIST chunk", list_labels.len());
561                labels.extend(list_labels);
562            }
563        }
564    }
565
566    labels
567}
568
569/// Parses sampler chunk data to extract sample loops.
570///
571/// # Arguments
572/// * `riff_file` - Reference to the parsed RIFF file
573///
574/// # Returns
575/// * `Result<Option<Vec<wavtag::SampleLoop>>, ParseError>` - Sample loops or None if not found
576///
577/// # Errors
578/// * [`ParseError::BytesToLe`] - If sampler chunk data cannot be parsed
579fn parse_sampler_data(riff_file: &RiffFile) -> Result<Option<Vec<wavtag::SampleLoop>>, ParseError> {
580    if let Some(smpl_chunk) = riff_file.find_chunk_by_type(ChunkType::Sampler) {
581        let sampler_data = wavtag::SamplerChunk::from_chunk(smpl_chunk)?;
582        debug!("Found {} sample loop(s)", sampler_data.sample_loops.len());
583        Ok(Some(sampler_data.sample_loops))
584    } else {
585        warn!("No 'smpl' chunk found!");
586        Ok(None)
587    }
588}
589
590/// Parses 'labl' subchunks from a LIST-adtl chunk.
591///
592/// # Arguments
593/// * `list_chunk` - Reference to the LIST chunk to parse
594///
595/// # Returns
596/// * `Result<Vec<Label>, Box<dyn Error>>` - Vector of labels or an error
597///
598/// # Note
599/// LIST-adtl chunks can contain multiple label subchunks. This function
600/// iterates through the LIST chunk data to extract all 'labl' subchunks.
601fn parse_list_chunk_for_labels(
602    list_chunk: &wavtag::RiffChunk,
603) -> Result<Vec<Label>, Box<dyn Error>> {
604    let mut labels = Vec::new();
605    let data = &list_chunk.data;
606
607    if data.len() < 4 || &data[0..4] != b"adtl" {
608        return Ok(labels);
609    }
610
611    let mut pos = 4;
612    while pos + 8 <= data.len() {
613        let sub_id = std::str::from_utf8(&data[pos..pos + 4]).unwrap_or("<invalid>");
614        let sub_size = u32::from_le_bytes(
615            data[pos + 4..pos + 8]
616                .try_into()
617                .map_err(|_| ParseError::BytesToLe("'labl' chunk".into()))?,
618        ) as usize;
619
620        if pos + 8 + sub_size > data.len() {
621            break;
622        }
623
624        if sub_id == "labl" && sub_size >= 4 {
625            let cue_id = u32::from_le_bytes(
626                data[pos + 8..pos + 12]
627                    .try_into()
628                    .map_err(|_| ParseError::BytesToLe("cue ID".into()))?,
629            );
630            let text_start = pos + 12;
631            let text_end = text_start + (sub_size - 4);
632            let raw_text = &data[text_start..text_end];
633
634            let name = String::from_utf8_lossy(raw_text)
635                .trim_end_matches('\0')
636                .to_string();
637
638            debug!("    Found label: Cue ID={}, Name='{}'", cue_id, name);
639            labels.push(Label { cue_id, name });
640        }
641
642        let padded_size = (sub_size + 1) & !1;
643        pos += 8 + padded_size;
644    }
645
646    Ok(labels)
647}
648
649/// Matches labels with sampler loops to create complete markers/regions.
650///
651/// # Arguments
652/// * `labels` - Vector of parsed labels with cue IDs and names
653/// * `sampler_loops` - Vector of sampler loops containing end positions
654/// * `cue_points` - HashMap of cue IDs to start positions (from 'cue ' chunk)
655/// * `sample_rate` - Sample rate of the audio file
656///
657/// # Returns
658/// * `Vec<Marker>` - Vector of complete markers/regions
659///
660/// # Algorithm
661/// 1. Creates a label map from cue ID to name
662/// 2. Creates a sampler map from cue ID to end position
663/// 3. For each label, looks up its start position and end position (if any)
664/// 4. Creates markers (no end) or regions (with end)
665/// 5. Sorts markers by start time
666fn match_markers(
667    labels: Vec<Label>,
668    sampler_loops: Option<Vec<wavtag::SampleLoop>>,
669    cue_points: HashMap<u32, u32>, // NEW: Start positions from 'cue ' chunk
670    sample_rate: u32,
671) -> Vec<Marker> {
672    let label_map: HashMap<u32, String> = labels
673        .into_iter()
674        .map(|label| (label.cue_id, label.name))
675        .collect();
676
677    let sampler_map: HashMap<u32, u32> = sampler_loops
678        .unwrap_or_default()
679        .into_iter()
680        .map(|sl| (sl.id, sl.end))
681        .collect();
682
683    let mut markers = Vec::new();
684
685    for (cue_id, name) in label_map {
686        let end = sampler_map.get(&cue_id).copied();
687        let start = cue_points.get(&cue_id).copied().unwrap_or(0); // Use real start or 0 if missing
688
689        markers.push(Marker::new(cue_id, name, start, end, sample_rate));
690    }
691
692    // Sort markers by their start time for cleaner output
693    markers.sort_by_key(|m| m.start);
694
695    markers
696}
697
698/// Parses 'cue ' chunk to get cue point positions (start samples).
699///
700/// # Arguments
701/// * `riff_file` - Reference to the parsed RIFF file
702///
703/// # Returns
704/// * `Result<Option<HashMap<u32, u32>>, ParseError>` - Map of cue IDs to start positions, or None if not found
705///
706/// # Errors
707/// * [`ParseError::BytesToLe`] - If cue chunk data cannot be parsed
708///
709/// # Note
710/// Each cue point record is 24 bytes with the following structure:
711/// - dwIdentifier (4 bytes): Cue ID
712/// - dwPosition (4 bytes): Position
713/// - fccChunk (4 bytes): Chunk type
714/// - dwChunkStart (4 bytes): Chunk start
715/// - dwBlockStart (4 bytes): Block start
716/// - dwSampleOffset (4 bytes): Sample offset (used as start position)
717fn parse_cue_points(riff_file: &RiffFile) -> Result<Option<HashMap<u32, u32>>, ParseError> {
718    let mut cue_map = HashMap::new();
719
720    let Some(cue_chunk) = riff_file.find_chunk_by_type(ChunkType::Cue) else {
721        debug!("No 'cue ' chunk found");
722        return Ok(None);
723    };
724
725    let data = &cue_chunk.data;
726    if data.len() < 4 {
727        warn!("expected 'cue ' chunk length >= 4, got {}", data.len());
728        return Ok(None);
729    }
730
731    let num_cues = u32::from_le_bytes(
732        data[0..4]
733            .try_into()
734            .map_err(|_| ParseError::BytesToLe("number of cues".into()))?,
735    );
736    debug!("Found {} cue points in 'cue ' chunk", num_cues);
737
738    // Each cue point record is 24 bytes
739    // Structure: dwIdentifier(4), dwPosition(4), fccChunk(4), dwChunkStart(4), dwBlockStart(4), dwSampleOffset(4)
740    let record_size = 24;
741    for i in 0..num_cues {
742        let start = 4 + (i as usize * record_size);
743        if start + record_size <= data.len() {
744            let cue_id = u32::from_le_bytes(
745                data[start..start + 4]
746                    .try_into()
747                    .map_err(|_| ParseError::BytesToLe("cue id".into()))?,
748            );
749            // The sample position is in dwSampleOffset at offset 20 within the record
750            let sample_offset = u32::from_le_bytes(
751                data[start + 20..start + 24]
752                    .try_into()
753                    .map_err(|_| ParseError::BytesToLe("sample offset".into()))?,
754            );
755            cue_map.insert(cue_id, sample_offset);
756            debug!("  Cue ID {} -> Start sample: {}", cue_id, sample_offset);
757        }
758    }
759
760    Ok(Some(cue_map))
761}