Skip to main content

proteus_lib/container/
prot.rs

1//! Container model and play settings parsing for `.prot`/`.mka`.
2
3use matroska::Matroska;
4use rand::Rng;
5use std::collections::{BTreeSet, HashMap, HashSet};
6
7use log::{error, info, warn};
8
9use crate::container::info::*;
10use crate::container::play_settings::{PlaySettingsFile, PlaySettingsLegacy, SettingsTrack};
11use crate::dsp::effects::convolution_reverb::{
12    parse_impulse_response_spec, parse_impulse_response_tail_db, ImpulseResponseSpec,
13};
14use crate::dsp::effects::AudioEffect;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub(crate) enum ShuffleSource {
18    TrackId(u32),
19    FilePath(String),
20}
21
22#[derive(Debug, Clone)]
23pub(crate) struct ShuffleScheduleEntry {
24    pub at_ms: u64,
25    pub sources: Vec<ShuffleSource>,
26}
27
28#[derive(Debug, Clone)]
29pub(crate) struct ShuffleRuntimePlan {
30    pub current_sources: Vec<ShuffleSource>,
31    pub upcoming_events: Vec<ShuffleScheduleEntry>,
32}
33
34/// Parsed `.prot` container with resolved tracks and playback metadata.
35#[derive(Debug, Clone)]
36pub struct Prot {
37    pub info: Info,
38    file_path: Option<String>,
39    file_paths: Option<Vec<PathsTrack>>,
40    file_paths_dictionary: Option<Vec<String>>,
41    track_ids: Option<Vec<u32>>,
42    track_paths: Option<Vec<String>>,
43    duration: f64,
44    shuffle_schedule: Vec<ShuffleScheduleEntry>,
45    play_settings: Option<PlaySettingsFile>,
46    impulse_response_spec: Option<ImpulseResponseSpec>,
47    impulse_response_tail_db: Option<f32>,
48    effects: Option<Vec<AudioEffect>>,
49}
50
51impl Prot {
52    /// Load a single container file and resolve tracks.
53    pub fn new(file_path: &String) -> Self {
54        let info = Info::new(file_path.clone());
55
56        println!("Info: {:?}", info);
57
58        let mut this = Self {
59            info,
60            file_path: Some(file_path.clone()),
61            file_paths: None,
62            file_paths_dictionary: None,
63            track_ids: None,
64            track_paths: None,
65            duration: 0.0,
66            shuffle_schedule: Vec::new(),
67            play_settings: None,
68            impulse_response_spec: None,
69            impulse_response_tail_db: None,
70            effects: None,
71        };
72
73        this.load_play_settings();
74        this.refresh_tracks();
75
76        this
77    }
78
79    /// Build a container from multiple standalone file path sets.
80    pub fn new_from_file_paths(file_paths: Vec<PathsTrack>) -> Self {
81        let mut file_paths_dictionary = Vec::new();
82        // Add all file paths to file_paths_dictionary
83        // but do not add duplicates
84        for track in file_paths.clone() {
85            for path in &track.file_paths {
86                if !file_paths_dictionary.contains(path) {
87                    file_paths_dictionary.push(path.clone());
88                }
89            }
90        }
91
92        let info = Info::new_from_file_paths(file_paths_dictionary.clone());
93
94        let mut this = Self {
95            info,
96            file_path: None,
97            file_paths: Some(file_paths),
98            file_paths_dictionary: Some(file_paths_dictionary),
99            track_ids: None,
100            track_paths: None,
101            duration: 0.0,
102            shuffle_schedule: Vec::new(),
103            play_settings: None,
104            impulse_response_spec: None,
105            impulse_response_tail_db: None,
106            effects: None,
107        };
108
109        this.refresh_tracks();
110
111        this
112    }
113
114    /// Legacy constructor for backwards compatibility.
115    pub fn new_from_file_paths_legacy(file_paths: &Vec<Vec<String>>) -> Self {
116        let mut paths_track_list = Vec::new();
117        for track in file_paths {
118            paths_track_list.push(PathsTrack::new_from_file_paths(track.clone()));
119        }
120        Self::new_from_file_paths(paths_track_list)
121    }
122
123    // fn get_duration_from_file_path(file_path: &String) -> f64 {
124    //     let file = std::fs::File::open(file_path).unwrap();
125    //     let symphonia: Symphonia = Symphonia::open(file).expect("Could not open file");
126    // }
127
128    /// Rebuild the active track list (e.g., after shuffle).
129    pub fn refresh_tracks(&mut self) {
130        self.track_ids = None;
131        self.track_paths = None;
132        self.shuffle_schedule.clear();
133        self.duration = 0.0;
134
135        if let Some(file_paths) = &self.file_paths {
136            let (schedule, longest_duration) = build_paths_shuffle_schedule(
137                file_paths,
138                &self.info,
139                self.file_paths_dictionary.as_deref().unwrap_or(&[]),
140            );
141            self.shuffle_schedule = schedule;
142            self.duration = longest_duration;
143
144            if let Some(entry) = self.shuffle_schedule.first() {
145                self.track_paths = Some(sources_to_track_paths(&entry.sources));
146            }
147
148            return;
149        }
150
151        if !self.file_path.is_some() {
152            return;
153        }
154
155        match self.play_settings.as_ref() {
156            Some(play_settings) => match play_settings {
157                PlaySettingsFile::Legacy(file) => {
158                    let mut longest_duration = 0.0;
159                    let mut track_index_array: Vec<u32> = Vec::new();
160                    collect_legacy_tracks(
161                        file.settings.inner(),
162                        &mut track_index_array,
163                        &mut longest_duration,
164                        &self.info,
165                        &mut self.duration,
166                    );
167                    self.track_ids = Some(track_index_array.clone());
168                    self.shuffle_schedule = vec![ShuffleScheduleEntry {
169                        at_ms: 0,
170                        sources: track_index_array
171                            .into_iter()
172                            .map(ShuffleSource::TrackId)
173                            .collect(),
174                    }];
175                }
176                PlaySettingsFile::V1(file) => {
177                    let (schedule, longest_duration) =
178                        build_id_shuffle_schedule(&file.settings.inner().tracks, &self.info);
179                    self.shuffle_schedule = schedule;
180                    self.duration = longest_duration;
181                }
182                PlaySettingsFile::V2(file) => {
183                    let (schedule, longest_duration) =
184                        build_id_shuffle_schedule(&file.settings.inner().tracks, &self.info);
185                    self.shuffle_schedule = schedule;
186                    self.duration = longest_duration;
187                }
188                PlaySettingsFile::V3(file) => {
189                    let (schedule, longest_duration) =
190                        build_id_shuffle_schedule(&file.settings.inner().tracks, &self.info);
191                    self.shuffle_schedule = schedule;
192                    self.duration = longest_duration;
193                }
194                PlaySettingsFile::Unknown { .. } => {
195                    error!("Unknown file format");
196                }
197            },
198            None => {
199                warn!("No play_settings.json found; no tracks resolved.");
200            }
201        }
202
203        if let Some(entry) = self.shuffle_schedule.first() {
204            self.track_ids = Some(sources_to_track_ids(&entry.sources));
205        }
206    }
207
208    /// Return effects parsed from play_settings, if any.
209    pub fn get_effects(&self) -> Option<Vec<AudioEffect>> {
210        self.effects.clone()
211    }
212
213    fn load_play_settings(&mut self) {
214        println!("Loading play settings...");
215        let Some(file_path) = self.file_path.as_ref() else {
216            return;
217        };
218
219        let file = std::fs::File::open(file_path).unwrap();
220        let mka: Matroska = Matroska::open(file).expect("Could not open file");
221
222        let mut parsed = None;
223
224        for attachment in &mka.attachments {
225            if attachment.name == "play_settings.json" {
226                match serde_json::from_slice::<PlaySettingsFile>(&attachment.data) {
227                    Ok(play_settings) => {
228                        parsed = Some(play_settings);
229                        break;
230                    }
231                    Err(err) => {
232                        error!("Failed to parse play_settings.json: {}", err);
233                    }
234                }
235            }
236        }
237
238        let Some(play_settings) = parsed else {
239            return;
240        };
241
242        info!("Parsed play_settings.json");
243
244        self.impulse_response_spec = parse_impulse_response_spec(&play_settings);
245        self.impulse_response_tail_db = parse_impulse_response_tail_db(&play_settings);
246
247        match &play_settings {
248            PlaySettingsFile::V1(file) => {
249                self.effects = Some(file.settings.inner().effects.clone());
250            }
251            PlaySettingsFile::V2(file) => {
252                self.effects = Some(file.settings.inner().effects.clone());
253            }
254            PlaySettingsFile::V3(file) => {
255                self.effects = Some(file.settings.inner().effects.clone());
256            }
257            _ => {}
258        }
259
260        if let Some(effects) = self.effects.as_ref() {
261            info!(
262                "Loaded play_settings effects ({}): {:?}",
263                effects.len(),
264                effects
265            );
266        }
267
268        self.play_settings = Some(play_settings);
269    }
270
271    /// Get the convolution impulse response spec, if configured.
272    pub fn get_impulse_response_spec(&self) -> Option<ImpulseResponseSpec> {
273        self.impulse_response_spec.clone()
274    }
275
276    /// Get the configured impulse response tail trim in dB, if any.
277    pub fn get_impulse_response_tail_db(&self) -> Option<f32> {
278        self.impulse_response_tail_db
279    }
280
281    /// Return the container path if this is a `.prot`/`.mka` file.
282    pub fn get_container_path(&self) -> Option<String> {
283        self.file_path.clone()
284    }
285
286    /// Override the impulse response spec at runtime.
287    pub fn set_impulse_response_spec(&mut self, spec: ImpulseResponseSpec) {
288        self.impulse_response_spec = Some(spec);
289    }
290
291    /// Override the impulse response tail trim at runtime.
292    pub fn set_impulse_response_tail_db(&mut self, tail_db: f32) {
293        self.impulse_response_tail_db = Some(tail_db);
294    }
295
296    /// Return per-track keys for UI selection.
297    pub fn get_keys(&self) -> Vec<u32> {
298        // This should just be a range from 0 to the length of the track_paths or track_ids array
299        if let Some(track_paths) = &self.track_paths {
300            return (0..track_paths.len() as u32).collect();
301        }
302
303        if let Some(track_ids) = &self.track_ids {
304            return (0..track_ids.len() as u32).collect();
305        }
306
307        Vec::new()
308    }
309
310    /// Return per-track identifiers or file paths for display.
311    pub fn get_ids(&self) -> Vec<String> {
312        if let Some(track_paths) = &self.track_paths {
313            return track_paths.clone();
314        }
315
316        if let Some(track_ids) = &self.track_ids {
317            return track_ids.into_iter().map(|id| format!("{}", id)).collect();
318        }
319
320        Vec::new()
321    }
322
323    /// Return the full timestamped shuffle schedule for display.
324    ///
325    /// Each entry is `(time_seconds, selected_ids_or_paths)`.
326    pub fn get_shuffle_schedule(&self) -> Vec<(f64, Vec<String>)> {
327        if self.shuffle_schedule.is_empty() {
328            let current = self.get_ids();
329            if current.is_empty() {
330                return Vec::new();
331            }
332            return vec![(0.0, current)];
333        }
334
335        self.shuffle_schedule
336            .iter()
337            .map(|entry| {
338                let ids = entry
339                    .sources
340                    .iter()
341                    .map(|source| match source {
342                        ShuffleSource::TrackId(track_id) => track_id.to_string(),
343                        ShuffleSource::FilePath(path) => path.clone(),
344                    })
345                    .collect();
346                (entry.at_ms as f64 / 1000.0, ids)
347            })
348            .collect()
349    }
350
351    /// Return a list of `(key, path, optional track_id)` for buffering.
352    pub fn enumerated_list(&self) -> Vec<(u16, String, Option<u32>)> {
353        let mut list: Vec<(u16, String, Option<u32>)> = Vec::new();
354        if let Some(track_paths) = &self.track_paths {
355            for (index, file_path) in track_paths.iter().enumerate() {
356                list.push((index as u16, String::from(file_path), None));
357            }
358
359            return list;
360        }
361
362        if let Some(track_ids) = &self.track_ids {
363            for (index, track_id) in track_ids.iter().enumerate() {
364                list.push((
365                    index as u16,
366                    String::from(self.file_path.as_ref().unwrap()),
367                    Some(*track_id),
368                ));
369            }
370
371            return list;
372        }
373
374        list
375    }
376
377    /// Return container track entries for shared container streaming.
378    pub fn container_track_entries(&self) -> Option<(String, Vec<(u16, u32)>)> {
379        let file_path = self.file_path.as_ref()?;
380        let track_ids = self.track_ids.as_ref()?;
381        let mut entries = Vec::new();
382        for (index, track_id) in track_ids.iter().enumerate() {
383            entries.push((index as u16, *track_id));
384        }
385        Some((file_path.clone(), entries))
386    }
387
388    /// Get the longest selected duration (seconds).
389    pub fn get_duration(&self) -> &f64 {
390        &self.duration
391    }
392
393    pub(crate) fn build_runtime_shuffle_plan(&self, start_time: f64) -> ShuffleRuntimePlan {
394        if self.shuffle_schedule.is_empty() {
395            let mut sources = Vec::new();
396            if let Some(track_ids) = &self.track_ids {
397                sources.extend(track_ids.iter().copied().map(ShuffleSource::TrackId));
398            } else if let Some(track_paths) = &self.track_paths {
399                sources.extend(track_paths.iter().cloned().map(ShuffleSource::FilePath));
400            }
401            return ShuffleRuntimePlan {
402                current_sources: sources,
403                upcoming_events: Vec::new(),
404            };
405        }
406
407        let start_ms = seconds_to_ms(start_time);
408        let mut current_index = 0usize;
409        for (index, entry) in self.shuffle_schedule.iter().enumerate() {
410            if entry.at_ms <= start_ms {
411                current_index = index;
412            } else {
413                break;
414            }
415        }
416
417        ShuffleRuntimePlan {
418            current_sources: self.shuffle_schedule[current_index].sources.clone(),
419            upcoming_events: self.shuffle_schedule[(current_index + 1)..].to_vec(),
420        }
421    }
422
423    /// Return per-track `(level, pan)` settings keyed by track key.
424    pub fn get_track_mix_settings(&self) -> HashMap<u16, (f32, f32)> {
425        let mut settings = HashMap::new();
426
427        if let Some(file_paths) = self.file_paths.as_ref() {
428            let mut slot_index: u16 = 0;
429            for track in file_paths {
430                let selections = track.selections_count.max(1);
431                for _ in 0..selections {
432                    settings.insert(slot_index, (track.level, track.pan));
433                    slot_index = slot_index.saturating_add(1);
434                }
435            }
436            return settings;
437        }
438
439        let tracks = match self.play_settings.as_ref() {
440            Some(PlaySettingsFile::V1(file)) => Some(&file.settings.inner().tracks),
441            Some(PlaySettingsFile::V2(file)) => Some(&file.settings.inner().tracks),
442            Some(PlaySettingsFile::V3(file)) => Some(&file.settings.inner().tracks),
443            _ => None,
444        };
445
446        if let Some(tracks) = tracks {
447            let mut slot_index: u16 = 0;
448            for track in tracks {
449                let selections = track.selections_count.max(1);
450                for _ in 0..selections {
451                    settings.insert(slot_index, (track.level, track.pan));
452                    slot_index = slot_index.saturating_add(1);
453                }
454            }
455        }
456
457        settings
458    }
459
460    /// Update the `(level, pan)` mix settings for a selected slot.
461    ///
462    /// Returns `true` when a matching slot was updated.
463    pub fn set_slot_mix_settings(&mut self, slot_index: usize, level: f32, pan: f32) -> bool {
464        let level = sanitize_level(level);
465        let pan = sanitize_pan(pan);
466
467        if let Some(file_paths) = self.file_paths.as_mut() {
468            if let Some(track) = get_paths_track_for_slot_mut(file_paths, slot_index) {
469                track.level = level;
470                track.pan = pan;
471                return true;
472            }
473            return false;
474        }
475
476        match self.play_settings.as_mut() {
477            Some(PlaySettingsFile::V1(file)) => update_settings_track_slot(
478                file.settings.inner_mut().tracks.as_mut_slice(),
479                slot_index,
480                level,
481                pan,
482            ),
483            Some(PlaySettingsFile::V2(file)) => update_settings_track_slot(
484                file.settings.inner_mut().tracks.as_mut_slice(),
485                slot_index,
486                level,
487                pan,
488            ),
489            Some(PlaySettingsFile::V3(file)) => update_settings_track_slot(
490                file.settings.inner_mut().tracks.as_mut_slice(),
491                slot_index,
492                level,
493                pan,
494            ),
495            _ => false,
496        }
497    }
498
499    /// Return all slot indices that share the same track settings as `slot_index`.
500    pub fn linked_slot_indices(&self, slot_index: usize) -> Option<Vec<usize>> {
501        if let Some(file_paths) = self.file_paths.as_ref() {
502            return linked_paths_slots(file_paths, slot_index);
503        }
504
505        let tracks = match self.play_settings.as_ref() {
506            Some(PlaySettingsFile::V1(file)) => Some(&file.settings.inner().tracks),
507            Some(PlaySettingsFile::V2(file)) => Some(&file.settings.inner().tracks),
508            Some(PlaySettingsFile::V3(file)) => Some(&file.settings.inner().tracks),
509            _ => None,
510        }?;
511
512        linked_settings_slots(tracks, slot_index)
513    }
514
515    /// Return the number of selected tracks.
516    pub fn get_length(&self) -> usize {
517        if let Some(track_paths) = &self.track_paths {
518            return track_paths.len();
519        }
520
521        if let Some(file_paths) = &self.file_paths {
522            return file_paths.len();
523        }
524
525        if let Some(track_ids) = &self.track_ids {
526            return track_ids.len();
527        }
528
529        0
530    }
531
532    /// Return the number of possible unique selections based on track settings.
533    pub fn count_possible_combinations(&self) -> Option<u128> {
534        if let Some(file_paths) = &self.file_paths {
535            return count_paths_track_combinations(file_paths);
536        }
537
538        let play_settings = self.play_settings.as_ref()?;
539        match play_settings {
540            PlaySettingsFile::Legacy(file) => {
541                count_legacy_track_combinations(file.settings.inner())
542            }
543            PlaySettingsFile::V1(file) => {
544                count_settings_track_combinations(&file.settings.inner().tracks)
545            }
546            PlaySettingsFile::V2(file) => {
547                count_settings_track_combinations(&file.settings.inner().tracks)
548            }
549            PlaySettingsFile::V3(file) => {
550                count_settings_track_combinations(&file.settings.inner().tracks)
551            }
552            PlaySettingsFile::Unknown { .. } => None,
553        }
554    }
555
556    /// Return the unique file paths used for a multi-file container.
557    pub fn get_file_paths_dictionary(&self) -> Vec<String> {
558        match &self.file_paths_dictionary {
559            Some(dictionary) => dictionary.to_vec(),
560            None => Vec::new(),
561        }
562    }
563}
564
565/// Standalone file-path track configuration.
566#[derive(Debug, Clone)]
567pub struct PathsTrack {
568    /// Candidate file paths for this track.
569    pub file_paths: Vec<String>,
570    /// Track gain scalar.
571    pub level: f32,
572    /// Track pan position.
573    pub pan: f32,
574    /// Number of selections to pick per refresh.
575    pub selections_count: u32,
576    /// Timestamps where this track is reshuffled.
577    pub shuffle_points: Vec<String>,
578}
579
580fn count_settings_track_combinations(tracks: &[SettingsTrack]) -> Option<u128> {
581    let mut total: u128 = 1;
582    for track in tracks {
583        let choices = track.ids.len() as u128;
584        let reshuffle_events = parse_shuffle_points(&track.shuffle_points).len() as u32;
585        let total_draws = track
586            .selections_count
587            .checked_mul(reshuffle_events.checked_add(1)?)?;
588        let count = checked_pow(choices, total_draws)?;
589        total = total.checked_mul(count)?;
590    }
591    Some(total)
592}
593
594fn count_paths_track_combinations(tracks: &[PathsTrack]) -> Option<u128> {
595    let mut total: u128 = 1;
596    for track in tracks {
597        let choices = track.file_paths.len() as u128;
598        let reshuffle_events = parse_shuffle_points(&track.shuffle_points).len() as u32;
599        let total_draws = track
600            .selections_count
601            .checked_mul(reshuffle_events.checked_add(1)?)?;
602        let count = checked_pow(choices, total_draws)?;
603        total = total.checked_mul(count)?;
604    }
605    Some(total)
606}
607
608fn count_legacy_track_combinations(settings: &PlaySettingsLegacy) -> Option<u128> {
609    let mut total: u128 = 1;
610    for track in &settings.tracks {
611        let choices = track.length.unwrap_or(0) as u128;
612        let count = checked_pow(choices, 1)?;
613        total = total.checked_mul(count)?;
614    }
615    Some(total)
616}
617
618fn checked_pow(base: u128, exp: u32) -> Option<u128> {
619    if exp == 0 {
620        return Some(1);
621    }
622    if base == 0 {
623        return Some(1);
624    }
625    let mut result: u128 = 1;
626    for _ in 0..exp {
627        result = result.checked_mul(base)?;
628    }
629    Some(result)
630}
631
632impl PathsTrack {
633    /// Create a new PathsTrack from a vector of file paths.
634    pub fn new_from_file_paths(file_paths: Vec<String>) -> Self {
635        PathsTrack {
636            file_paths,
637            level: 1.0,
638            pan: 0.0,
639            selections_count: 1,
640            shuffle_points: Vec::new(),
641        }
642    }
643}
644
645fn build_id_shuffle_schedule(
646    tracks: &[SettingsTrack],
647    info: &Info,
648) -> (Vec<ShuffleScheduleEntry>, f64) {
649    let mut shuffle_timestamps = BTreeSet::new();
650    let mut slot_candidates: Vec<Vec<u32>> = Vec::new();
651    let mut slot_points: Vec<HashSet<u64>> = Vec::new();
652    let mut current_ids: Vec<u32> = Vec::new();
653    let mut longest_duration = 0.0_f64;
654    shuffle_timestamps.insert(0);
655
656    for track in tracks {
657        if track.ids.is_empty() {
658            continue;
659        }
660        let selections = track.selections_count as usize;
661        if selections == 0 {
662            continue;
663        }
664        let points = parse_shuffle_points(&track.shuffle_points);
665        for point in &points {
666            shuffle_timestamps.insert(*point);
667        }
668        let point_set: HashSet<u64> = points.into_iter().collect();
669        for _ in 0..selections {
670            slot_candidates.push(track.ids.clone());
671            slot_points.push(point_set.clone());
672            let choice = random_id(&track.ids);
673            if let Some(duration) = info.get_duration(choice) {
674                longest_duration = longest_duration.max(duration);
675            }
676            current_ids.push(choice);
677        }
678    }
679
680    let mut schedule = Vec::new();
681    if current_ids.is_empty() {
682        return (schedule, longest_duration);
683    }
684
685    schedule.push(ShuffleScheduleEntry {
686        at_ms: 0,
687        sources: current_ids
688            .iter()
689            .copied()
690            .map(ShuffleSource::TrackId)
691            .collect(),
692    });
693
694    for timestamp in shuffle_timestamps.into_iter().filter(|point| *point > 0) {
695        for slot_index in 0..current_ids.len() {
696            if slot_points[slot_index].contains(&timestamp) {
697                current_ids[slot_index] = random_id(&slot_candidates[slot_index]);
698                if let Some(duration) = info.get_duration(current_ids[slot_index]) {
699                    longest_duration = longest_duration.max(duration);
700                }
701            }
702        }
703        schedule.push(ShuffleScheduleEntry {
704            at_ms: timestamp,
705            sources: current_ids
706                .iter()
707                .copied()
708                .map(ShuffleSource::TrackId)
709                .collect(),
710        });
711    }
712
713    (schedule, longest_duration)
714}
715
716fn build_paths_shuffle_schedule(
717    tracks: &[PathsTrack],
718    info: &Info,
719    dictionary: &[String],
720) -> (Vec<ShuffleScheduleEntry>, f64) {
721    let mut shuffle_timestamps = BTreeSet::new();
722    let mut slot_candidates: Vec<Vec<String>> = Vec::new();
723    let mut slot_points: Vec<HashSet<u64>> = Vec::new();
724    let mut current_paths: Vec<String> = Vec::new();
725    let mut longest_duration = 0.0_f64;
726    let dictionary_lookup: HashMap<&str, u32> = dictionary
727        .iter()
728        .enumerate()
729        .map(|(index, path)| (path.as_str(), index as u32))
730        .collect();
731    shuffle_timestamps.insert(0);
732
733    for track in tracks {
734        if track.file_paths.is_empty() {
735            continue;
736        }
737        let selections = track.selections_count as usize;
738        if selections == 0 {
739            continue;
740        }
741        let points = parse_shuffle_points(&track.shuffle_points);
742        for point in &points {
743            shuffle_timestamps.insert(*point);
744        }
745        let point_set: HashSet<u64> = points.into_iter().collect();
746        for _ in 0..selections {
747            slot_candidates.push(track.file_paths.clone());
748            slot_points.push(point_set.clone());
749            let choice = random_path(&track.file_paths);
750            if let Some(index) = dictionary_lookup.get(choice.as_str()).copied() {
751                if let Some(duration) = info.get_duration(index) {
752                    longest_duration = longest_duration.max(duration);
753                }
754            }
755            current_paths.push(choice);
756        }
757    }
758
759    let mut schedule = Vec::new();
760    if current_paths.is_empty() {
761        return (schedule, longest_duration);
762    }
763
764    schedule.push(ShuffleScheduleEntry {
765        at_ms: 0,
766        sources: current_paths
767            .iter()
768            .cloned()
769            .map(ShuffleSource::FilePath)
770            .collect(),
771    });
772
773    for timestamp in shuffle_timestamps.into_iter().filter(|point| *point > 0) {
774        for slot_index in 0..current_paths.len() {
775            if slot_points[slot_index].contains(&timestamp) {
776                current_paths[slot_index] = random_path(&slot_candidates[slot_index]);
777                if let Some(index) = dictionary_lookup
778                    .get(current_paths[slot_index].as_str())
779                    .copied()
780                {
781                    if let Some(duration) = info.get_duration(index) {
782                        longest_duration = longest_duration.max(duration);
783                    }
784                }
785            }
786        }
787        schedule.push(ShuffleScheduleEntry {
788            at_ms: timestamp,
789            sources: current_paths
790                .iter()
791                .cloned()
792                .map(ShuffleSource::FilePath)
793                .collect(),
794        });
795    }
796
797    (schedule, longest_duration)
798}
799
800fn parse_shuffle_points(points: &[String]) -> Vec<u64> {
801    let mut parsed = Vec::new();
802    for point in points {
803        match parse_timestamp_ms(point) {
804            Some(value) => parsed.push(value),
805            None => warn!("Invalid shuffle point timestamp: {}", point),
806        }
807    }
808    parsed.sort_unstable();
809    parsed.dedup();
810    parsed
811}
812
813fn parse_timestamp_ms(value: &str) -> Option<u64> {
814    let parts: Vec<&str> = value.trim().split(':').collect();
815    if parts.is_empty() || parts.len() > 3 {
816        return None;
817    }
818
819    let seconds_component = parts.last()?.parse::<f64>().ok()?;
820    if seconds_component.is_sign_negative() {
821        return None;
822    }
823
824    let minutes = if parts.len() >= 2 {
825        parts[parts.len() - 2].parse::<u64>().ok()?
826    } else {
827        0
828    };
829    let hours = if parts.len() == 3 {
830        parts[0].parse::<u64>().ok()?
831    } else {
832        0
833    };
834
835    let total_seconds = (hours as f64 * 3600.0) + (minutes as f64 * 60.0) + seconds_component;
836    if total_seconds.is_sign_negative() || !total_seconds.is_finite() {
837        return None;
838    }
839    Some((total_seconds * 1000.0).round() as u64)
840}
841
842fn seconds_to_ms(seconds: f64) -> u64 {
843    if !seconds.is_finite() || seconds <= 0.0 {
844        return 0;
845    }
846    (seconds * 1000.0).round() as u64
847}
848
849fn random_id(ids: &[u32]) -> u32 {
850    let random_index = rand::thread_rng().gen_range(0..ids.len());
851    ids[random_index]
852}
853
854fn random_path(paths: &[String]) -> String {
855    let random_index = rand::thread_rng().gen_range(0..paths.len());
856    paths[random_index].clone()
857}
858
859fn sanitize_level(level: f32) -> f32 {
860    if level.is_finite() {
861        level.max(0.0)
862    } else {
863        1.0
864    }
865}
866
867fn sanitize_pan(pan: f32) -> f32 {
868    if pan.is_finite() {
869        pan.clamp(-1.0, 1.0)
870    } else {
871        0.0
872    }
873}
874
875fn get_paths_track_for_slot_mut(
876    tracks: &mut [PathsTrack],
877    slot_index: usize,
878) -> Option<&mut PathsTrack> {
879    let mut slot_cursor = 0usize;
880    for track in tracks.iter_mut() {
881        let span = track.selections_count.max(1) as usize;
882        if slot_index < slot_cursor + span {
883            return Some(track);
884        }
885        slot_cursor += span;
886    }
887    None
888}
889
890fn update_settings_track_slot(
891    tracks: &mut [SettingsTrack],
892    slot_index: usize,
893    level: f32,
894    pan: f32,
895) -> bool {
896    let mut slot_cursor = 0usize;
897    for track in tracks.iter_mut() {
898        let span = track.selections_count.max(1) as usize;
899        if slot_index < slot_cursor + span {
900            track.level = level;
901            track.pan = pan;
902            return true;
903        }
904        slot_cursor += span;
905    }
906    false
907}
908
909fn linked_paths_slots(tracks: &[PathsTrack], slot_index: usize) -> Option<Vec<usize>> {
910    let mut slot_cursor = 0usize;
911    for track in tracks {
912        let span = track.selections_count.max(1) as usize;
913        if slot_index < slot_cursor + span {
914            return Some((slot_cursor..(slot_cursor + span)).collect());
915        }
916        slot_cursor += span;
917    }
918    None
919}
920
921fn linked_settings_slots(tracks: &[SettingsTrack], slot_index: usize) -> Option<Vec<usize>> {
922    let mut slot_cursor = 0usize;
923    for track in tracks {
924        let span = track.selections_count.max(1) as usize;
925        if slot_index < slot_cursor + span {
926            return Some((slot_cursor..(slot_cursor + span)).collect());
927        }
928        slot_cursor += span;
929    }
930    None
931}
932
933fn sources_to_track_ids(sources: &[ShuffleSource]) -> Vec<u32> {
934    sources
935        .iter()
936        .filter_map(|source| match source {
937            ShuffleSource::TrackId(track_id) => Some(*track_id),
938            ShuffleSource::FilePath(_) => None,
939        })
940        .collect()
941}
942
943fn sources_to_track_paths(sources: &[ShuffleSource]) -> Vec<String> {
944    sources
945        .iter()
946        .filter_map(|source| match source {
947            ShuffleSource::TrackId(_) => None,
948            ShuffleSource::FilePath(path) => Some(path.clone()),
949        })
950        .collect()
951}
952
953fn collect_legacy_tracks(
954    settings: &PlaySettingsLegacy,
955    track_index_array: &mut Vec<u32>,
956    longest_duration: &mut f64,
957    info: &Info,
958    total_duration: &mut f64,
959) {
960    for track in &settings.tracks {
961        let (Some(starting_index), Some(length)) = (track.starting_index, track.length) else {
962            continue;
963        };
964        let starting_index = starting_index + 1;
965        let index = rand::thread_rng().gen_range(starting_index..(starting_index + length));
966        if let Some(track_duration) = info.get_duration(index) {
967            if track_duration > *longest_duration {
968                *longest_duration = track_duration;
969                *total_duration = *longest_duration;
970            }
971        }
972        track_index_array.push(index);
973    }
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979
980    fn test_info() -> Info {
981        Info {
982            file_paths: Vec::new(),
983            duration_map: HashMap::new(),
984            channels: 2,
985            sample_rate: 48_000,
986            bits_per_sample: 16,
987        }
988    }
989
990    fn settings_track(
991        ids: Vec<u32>,
992        selections_count: u32,
993        shuffle_points: Vec<&str>,
994    ) -> SettingsTrack {
995        SettingsTrack {
996            level: 1.0,
997            pan: 0.0,
998            ids,
999            name: "Track".to_string(),
1000            safe_name: "Track".to_string(),
1001            selections_count,
1002            shuffle_points: shuffle_points.into_iter().map(|v| v.to_string()).collect(),
1003        }
1004    }
1005
1006    #[test]
1007    fn count_settings_combinations_without_shuffle_points() {
1008        let tracks = vec![settings_track(vec![1, 2, 3], 2, vec![])];
1009        assert_eq!(count_settings_track_combinations(&tracks), Some(9));
1010    }
1011
1012    #[test]
1013    fn count_settings_combinations_with_shuffle_points() {
1014        let tracks = vec![settings_track(vec![1, 2, 3], 2, vec!["0:30", "1:00"])];
1015        assert_eq!(count_settings_track_combinations(&tracks), Some(729));
1016    }
1017
1018    #[test]
1019    fn count_settings_combinations_uses_unique_valid_shuffle_points() {
1020        let tracks = vec![settings_track(
1021            vec![1, 2, 3, 4],
1022            1,
1023            vec!["1:00", "bad", "1:00"],
1024        )];
1025        assert_eq!(count_settings_track_combinations(&tracks), Some(16));
1026    }
1027
1028    #[test]
1029    fn count_paths_combinations_with_shuffle_points() {
1030        let tracks = vec![PathsTrack {
1031            file_paths: vec!["a.wav".to_string(), "b.wav".to_string()],
1032            level: 1.0,
1033            pan: 0.0,
1034            selections_count: 1,
1035            shuffle_points: vec!["0:15".to_string(), "0:45".to_string()],
1036        }];
1037        assert_eq!(count_paths_track_combinations(&tracks), Some(8));
1038    }
1039
1040    #[test]
1041    fn get_track_mix_settings_repeats_by_selections_count_for_paths_tracks() {
1042        let prot = Prot {
1043            info: test_info(),
1044            file_path: None,
1045            file_paths: Some(vec![PathsTrack {
1046                file_paths: vec!["a.wav".to_string()],
1047                level: 0.7,
1048                pan: -0.3,
1049                selections_count: 2,
1050                shuffle_points: vec![],
1051            }]),
1052            file_paths_dictionary: Some(vec!["a.wav".to_string()]),
1053            track_ids: None,
1054            track_paths: None,
1055            duration: 0.0,
1056            shuffle_schedule: Vec::new(),
1057            play_settings: None,
1058            impulse_response_spec: None,
1059            impulse_response_tail_db: None,
1060            effects: None,
1061        };
1062
1063        let settings = prot.get_track_mix_settings();
1064        assert_eq!(settings.get(&0), Some(&(0.7, -0.3)));
1065        assert_eq!(settings.get(&1), Some(&(0.7, -0.3)));
1066    }
1067
1068    #[test]
1069    fn set_slot_mix_settings_updates_paths_track() {
1070        let mut prot = Prot {
1071            info: test_info(),
1072            file_path: None,
1073            file_paths: Some(vec![PathsTrack {
1074                file_paths: vec!["a.wav".to_string()],
1075                level: 1.0,
1076                pan: 0.0,
1077                selections_count: 2,
1078                shuffle_points: vec![],
1079            }]),
1080            file_paths_dictionary: Some(vec!["a.wav".to_string()]),
1081            track_ids: None,
1082            track_paths: None,
1083            duration: 0.0,
1084            shuffle_schedule: Vec::new(),
1085            play_settings: None,
1086            impulse_response_spec: None,
1087            impulse_response_tail_db: None,
1088            effects: None,
1089        };
1090
1091        assert!(prot.set_slot_mix_settings(1, 0.4, 0.6));
1092        let settings = prot.get_track_mix_settings();
1093        assert_eq!(settings.get(&0), Some(&(0.4, 0.6)));
1094        assert_eq!(settings.get(&1), Some(&(0.4, 0.6)));
1095    }
1096
1097    #[test]
1098    fn get_track_mix_settings_includes_v3_tracks() {
1099        use crate::container::play_settings::{
1100            PlaySettingsContainer, PlaySettingsV3, PlaySettingsV3File,
1101        };
1102
1103        let play_settings = PlaySettingsFile::V3(PlaySettingsV3File {
1104            settings: PlaySettingsContainer::Flat(PlaySettingsV3 {
1105                effects: Vec::new(),
1106                tracks: vec![SettingsTrack {
1107                    level: 0.25,
1108                    pan: 0.2,
1109                    ids: vec![1],
1110                    name: "Track".to_string(),
1111                    safe_name: "track".to_string(),
1112                    selections_count: 2,
1113                    shuffle_points: vec![],
1114                }],
1115            }),
1116        });
1117
1118        let prot = Prot {
1119            info: test_info(),
1120            file_path: Some("dummy.prot".to_string()),
1121            file_paths: None,
1122            file_paths_dictionary: None,
1123            track_ids: Some(vec![1, 1]),
1124            track_paths: None,
1125            duration: 0.0,
1126            shuffle_schedule: Vec::new(),
1127            play_settings: Some(play_settings),
1128            impulse_response_spec: None,
1129            impulse_response_tail_db: None,
1130            effects: None,
1131        };
1132
1133        let settings = prot.get_track_mix_settings();
1134        assert_eq!(settings.get(&0), Some(&(0.25, 0.2)));
1135        assert_eq!(settings.get(&1), Some(&(0.25, 0.2)));
1136    }
1137
1138    #[test]
1139    fn linked_slot_indices_returns_all_slots_for_same_track() {
1140        let prot = Prot {
1141            info: test_info(),
1142            file_path: None,
1143            file_paths: Some(vec![
1144                PathsTrack {
1145                    file_paths: vec!["a.wav".to_string()],
1146                    level: 1.0,
1147                    pan: 0.0,
1148                    selections_count: 2,
1149                    shuffle_points: vec![],
1150                },
1151                PathsTrack {
1152                    file_paths: vec!["b.wav".to_string()],
1153                    level: 1.0,
1154                    pan: 0.0,
1155                    selections_count: 1,
1156                    shuffle_points: vec![],
1157                },
1158            ]),
1159            file_paths_dictionary: Some(vec!["a.wav".to_string(), "b.wav".to_string()]),
1160            track_ids: None,
1161            track_paths: None,
1162            duration: 0.0,
1163            shuffle_schedule: Vec::new(),
1164            play_settings: None,
1165            impulse_response_spec: None,
1166            impulse_response_tail_db: None,
1167            effects: None,
1168        };
1169
1170        assert_eq!(prot.linked_slot_indices(0), Some(vec![0, 1]));
1171        assert_eq!(prot.linked_slot_indices(1), Some(vec![0, 1]));
1172        assert_eq!(prot.linked_slot_indices(2), Some(vec![2]));
1173        assert_eq!(prot.linked_slot_indices(3), None);
1174    }
1175}