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}