veloren_voxygen/audio/
music.rs

1//! Handles music playback and transitions
2//!
3//! Game music is controlled though a configuration file found in the source at
4//! `/assets/voxygen/audio/soundtrack.ron`. Each track enabled in game has a
5//! configuration corresponding to the
6//! [`SoundtrackItem`](struct.SoundtrackItem.html) format, as well as the
7//! corresponding `.ogg` file in the `/assets/voxygen/audio/soundtrack/`
8//! directory.
9//!
10//! If there are errors while reading or deserialising the configuration file, a
11//! warning is logged and music will be disabled.
12//!
13//! ## Adding new music
14//!
15//! To add a new item, append the details to the audio configuration file, and
16//! add the audio file (in `.ogg` format) to the assets directory.
17//!
18//! The `length` should be provided in seconds. This allows us to know when to
19//! transition to another track, without having to spend time determining track
20//! length programmatically.
21//!
22//! An example of a new night time track:
23//! ```text
24//! (
25//!     title: "Sleepy Song",
26//!     path: "voxygen.audio.soundtrack.sleepy",
27//!     length: 400.0,
28//!     timing: Some(Night),
29//!     biomes: [
30//!         (Forest, 1),
31//!         (Grassland, 2),
32//!     ],
33//!     site: None,
34//!     activity: Explore,
35//!     artist: "Elvis",
36//! ),
37//! ```
38//!
39//! Before sending an MR for your new track item:
40//! - Be conscious of the file size for your new track. Assets contribute to
41//!   download sizes
42//! - Ensure that the track is mastered to a volume proportionate to other music
43//!   tracks
44//! - If you are not the author of the track, ensure that the song's licensing
45//!   permits usage of the track for non-commercial use
46use crate::audio::{AudioFrontend, MusicChannelTag};
47use client::Client;
48use common::{
49    assets::{Asset, AssetCache, AssetExt, AssetHandle, BoxedError, Ron, SharedString},
50    calendar::{Calendar, CalendarEvent},
51    terrain::{BiomeKind, SiteKindMeta},
52    weather::WeatherKind,
53};
54use common_state::State;
55use hashbrown::HashMap;
56use kira::clock::ClockTime;
57use rand::{Rng, prelude::IndexedRandom, rng, rngs::ThreadRng};
58use serde::Deserialize;
59use tracing::{debug, trace, warn};
60
61/// Collection of all the tracks
62#[derive(Debug, Deserialize)]
63struct SoundtrackCollection<T> {
64    /// List of tracks
65    tracks: Vec<T>,
66}
67
68impl<T> Default for SoundtrackCollection<T> {
69    fn default() -> Self { Self { tracks: Vec::new() } }
70}
71
72/// Configuration for a single music track in the soundtrack
73#[derive(Clone, Debug, Deserialize)]
74pub struct SoundtrackItem {
75    /// Song title
76    title: String,
77    /// File path to asset
78    path: String,
79    /// Length of the track in seconds
80    length: f32,
81    loop_points: Option<(f32, f32)>,
82    /// Whether this track should play during day or night
83    timing: Option<DayPeriod>,
84    /// Whether this track should play during a certain weather
85    weather: Option<WeatherKind>,
86    /// What biomes this track should play in with chance of play
87    biomes: Vec<(BiomeKind, u8)>,
88    /// Whether this track should play in a specific site
89    sites: Vec<SiteKindMeta>,
90    /// What the player is doing when the track is played (i.e. exploring,
91    /// combat)
92    music_state: MusicState,
93    /// What activity to override the activity state with, if any (e.g. to make
94    /// a long combat intro also act like the loop for the purposes of outro
95    /// transitions)
96    #[serde(default)]
97    activity_override: Option<MusicActivity>,
98    /// Song artist and website
99    artist: (String, Option<String>),
100}
101
102#[derive(Clone, Debug, Deserialize)]
103enum RawSoundtrackItem {
104    Individual(SoundtrackItem),
105    Segmented {
106        title: String,
107        timing: Option<DayPeriod>,
108        weather: Option<WeatherKind>,
109        biomes: Vec<(BiomeKind, u8)>,
110        sites: Vec<SiteKindMeta>,
111        segments: Vec<(String, f32, MusicState, Option<MusicActivity>)>,
112        loop_points: (f32, f32),
113        artist: (String, Option<String>),
114    },
115}
116
117#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
118enum CombatIntensity {
119    Low,
120    High,
121}
122
123#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
124enum MusicActivity {
125    Explore,
126    Combat(CombatIntensity),
127}
128
129#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
130enum MusicState {
131    Activity(MusicActivity),
132    Transition(MusicActivity, MusicActivity),
133}
134
135/// Allows control over when a track should play based on in-game time of day
136#[derive(Clone, Debug, Deserialize, PartialEq)]
137enum DayPeriod {
138    /// 8:00 AM to 7:30 PM
139    Day,
140    /// 7:31 PM to 6:59 AM
141    Night,
142}
143
144/// Provides methods to control music playback
145pub struct MusicMgr {
146    /// Collection of all the tracks
147    soundtrack: SoundtrackCollection<SoundtrackItem>,
148    /// Instant at which the current track began playing
149    began_playing: Option<ClockTime>,
150    /// Instant at which the current track should stop
151    song_end: Option<ClockTime>,
152    /// Currently staying silent for gap between tracks
153    is_gap: bool,
154    /// Time until the next track should be played after a track ends
155    gap_length: f32,
156    /// Time remaining for gap
157    gap_time: f64,
158    /// The title of the last track played. Used to prevent a track
159    /// being played twice in a row
160    last_track: String,
161    last_combat_track: String,
162    /// Time of the last interrupt (to avoid rapid switching)
163    last_interrupt_attempt: Option<ClockTime>,
164    /// The previous track's activity kind, for transitions
165    last_activity: MusicState,
166    // For debug menu
167    current_track: String,
168    current_artist: String,
169    track_length: f32,
170    loop_points: Option<(f32, f32)>,
171}
172
173#[derive(Deserialize)]
174pub struct MusicTransitionManifest {
175    /// Within what radius do enemies count towards combat music?
176    combat_nearby_radius: f32,
177    /// Each multiple of this factor that an enemy has health counts as an extra
178    /// enemy
179    combat_health_factor: f32,
180    /// How many nearby enemies trigger High combat music
181    combat_nearby_high_thresh: u32,
182    /// How many nearby enemies trigger Low combat music
183    combat_nearby_low_thresh: u32,
184    /// Fade in and fade out timings for transitions between channels
185    pub fade_timings: HashMap<(MusicChannelTag, MusicChannelTag), (f32, f32)>,
186    /// How many seconds between interrupt checks
187    pub interrupt_delay: f32,
188}
189
190impl Default for MusicTransitionManifest {
191    fn default() -> MusicTransitionManifest {
192        MusicTransitionManifest {
193            combat_nearby_radius: 40.0,
194            combat_health_factor: 100.0,
195            combat_nearby_high_thresh: 3,
196            combat_nearby_low_thresh: 1,
197            fade_timings: HashMap::new(),
198            interrupt_delay: 5.0,
199        }
200    }
201}
202
203fn time_f64(clock_time: ClockTime) -> f64 { clock_time.ticks as f64 + clock_time.fraction }
204
205impl MusicMgr {
206    pub fn new(calendar: &Calendar) -> Self {
207        Self {
208            soundtrack: Self::load_soundtrack_items(calendar),
209            began_playing: None,
210            song_end: None,
211            is_gap: true,
212            gap_length: 0.0,
213            gap_time: -1.0,
214            last_track: String::from("None"),
215            last_combat_track: String::from("None"),
216            last_interrupt_attempt: None,
217            last_activity: MusicState::Activity(MusicActivity::Explore),
218            current_track: String::from("None"),
219            current_artist: String::from("None"),
220            track_length: 0.0,
221            loop_points: None,
222        }
223    }
224
225    /// Checks whether the previous track has completed. If so, sends a
226    /// request to play the next (random) track
227    pub fn maintain(&mut self, audio: &mut AudioFrontend, state: &State, client: &Client) {
228        use common::comp::{Group, Health, Pos, group::ENEMY};
229        use specs::{Join, WorldExt};
230
231        if !audio.music_enabled() || audio.get_clock().is_none() || audio.get_clock_time().is_none()
232        {
233            return;
234        }
235
236        let mut activity_state = MusicActivity::Explore;
237
238        let player = client.entity();
239        let ecs = state.ecs();
240        let entities = ecs.entities();
241        let positions = ecs.read_component::<Pos>();
242        let healths = ecs.read_component::<Health>();
243        let groups = ecs.read_component::<Group>();
244        let mtm = audio.mtm.read();
245        let mut rng = rng();
246
247        if audio.combat_music_enabled
248            && let Some(player_pos) = positions.get(player)
249        {
250            // TODO: `group::ENEMY` will eventually be moved server-side with an
251            // alignment/faction rework, so this will need an alternative way to measure
252            // "in-combat-ness"
253            let num_nearby_entities: u32 = (&entities, &positions, &healths, &groups)
254                .join()
255                .map(|(entity, pos, health, group)| {
256                    if entity != player
257                        && group == &ENEMY
258                        && (player_pos.0 - pos.0).magnitude_squared()
259                            < mtm.0.combat_nearby_radius.powf(2.0)
260                    {
261                        (health.maximum() / mtm.0.combat_health_factor).ceil() as u32
262                    } else {
263                        0
264                    }
265                })
266                .sum();
267
268            if num_nearby_entities >= mtm.0.combat_nearby_high_thresh {
269                activity_state = MusicActivity::Combat(CombatIntensity::High);
270            } else if num_nearby_entities >= mtm.0.combat_nearby_low_thresh {
271                activity_state = MusicActivity::Combat(CombatIntensity::Low);
272            }
273        }
274
275        // Override combat music with explore music if the player is dead
276        if let Some(health) = healths.get(player)
277            && health.is_dead
278        {
279            activity_state = MusicActivity::Explore;
280        }
281
282        let mut music_state = match self.last_activity {
283            MusicState::Activity(prev) => {
284                if prev != activity_state {
285                    MusicState::Transition(prev, activity_state)
286                } else {
287                    MusicState::Activity(activity_state)
288                }
289            },
290            MusicState::Transition(_, next) => {
291                warn!("Transitioning: {:?}", self.last_activity);
292                MusicState::Activity(next)
293            },
294        };
295
296        let now = audio.get_clock_time().unwrap();
297
298        let began_playing = *self.began_playing.get_or_insert(now);
299        let last_interrupt_attempt = *self.last_interrupt_attempt.get_or_insert(now);
300        let song_end = *self.song_end.get_or_insert(now);
301        let time_since_began_playing = time_f64(now) - time_f64(began_playing);
302
303        // TODO: Instead of a constant tick, make this a timer that starts only when
304        // combat might end, providing a proper "buffer".
305        // interrupt_delay dictates the time between attempted interrupts
306        let interrupt = matches!(music_state, MusicState::Transition(_, _))
307            && time_f64(now) - time_f64(last_interrupt_attempt) > mtm.0.interrupt_delay as f64;
308
309        // Hack to end combat music since there is currently nothing that detects
310        // transitions away
311        if matches!(
312            music_state,
313            MusicState::Transition(
314                MusicActivity::Combat(CombatIntensity::High),
315                MusicActivity::Explore
316            )
317        ) {
318            music_state = MusicState::Activity(MusicActivity::Explore)
319        }
320
321        if audio.music_enabled()
322            && !self.soundtrack.tracks.is_empty()
323            && (time_since_began_playing
324                > time_f64(song_end) - time_f64(began_playing) // Amount of time between when the song ends and when it began playing
325                || interrupt)
326        {
327            if time_since_began_playing > self.track_length as f64
328                && self.last_activity
329                    != MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
330            {
331                self.current_track = String::from("None");
332                self.current_artist = String::from("None");
333            }
334
335            if interrupt {
336                self.last_interrupt_attempt = Some(now);
337                if let Ok(next_activity) =
338                    self.play_random_track(audio, state, client, &music_state, &mut rng)
339                {
340                    trace!(
341                        "pre-play_random_track: {:?} {:?}",
342                        self.last_activity, music_state
343                    );
344                    self.last_activity = next_activity;
345                }
346            } else if music_state == MusicState::Activity(MusicActivity::Explore)
347                || music_state
348                    == MusicState::Transition(
349                        MusicActivity::Explore,
350                        MusicActivity::Combat(CombatIntensity::High),
351                    )
352            {
353                // If current state is Explore, insert a gap now.
354                if !self.is_gap {
355                    self.gap_length = self.generate_silence_between_tracks(
356                        audio.music_spacing,
357                        client,
358                        &music_state,
359                        &mut rng,
360                    );
361                    self.gap_time = self.gap_length as f64;
362                    self.song_end = audio.get_clock_time();
363                    self.is_gap = true
364                } else if self.gap_time < 0.0 {
365                    // Gap time is up, play a track
366                    // Hack to make combat situations not cancel explore music for now
367                    if music_state
368                        == MusicState::Transition(
369                            MusicActivity::Explore,
370                            MusicActivity::Combat(CombatIntensity::High),
371                        )
372                    {
373                        music_state = MusicState::Activity(MusicActivity::Explore)
374                    }
375                    if let Ok(next_activity) =
376                        self.play_random_track(audio, state, client, &music_state, &mut rng)
377                    {
378                        self.last_activity = next_activity;
379                        self.gap_time = 0.0;
380                        self.gap_length = 0.0;
381                        self.is_gap = false;
382                    }
383                }
384            } else if music_state
385                == MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
386            {
387                // Keep playing! The track should loop automatically.
388                self.began_playing = Some(now);
389                self.song_end = Some(ClockTime::from_ticks_f64(
390                    audio.get_clock().unwrap().id(),
391                    time_f64(now) + self.loop_points.unwrap_or((0.0, 0.0)).1 as f64
392                        - self.loop_points.unwrap_or((0.0, 0.0)).0 as f64,
393                ));
394            } else {
395                trace!(
396                    "pre-play_random_track: {:?} {:?}",
397                    self.last_activity, music_state
398                );
399            }
400        } else {
401            if self.began_playing.is_none() {
402                self.began_playing = Some(now)
403            }
404            if self.soundtrack.tracks.is_empty() {
405                warn!("No tracks available to play")
406            }
407        }
408
409        if time_since_began_playing > self.track_length as f64 {
410            // Time remaining = Max time - (current time - time song ended)
411            if self.is_gap {
412                self.gap_time = (self.gap_length as f64) - (time_f64(now) - time_f64(song_end));
413            }
414        }
415    }
416
417    fn play_random_track(
418        &mut self,
419        audio: &mut AudioFrontend,
420        state: &State,
421        client: &Client,
422        music_state: &MusicState,
423        rng: &mut ThreadRng,
424    ) -> Result<MusicState, String> {
425        let is_dark = state.get_day_period().is_dark();
426        let current_period_of_day = Self::get_current_day_period(is_dark);
427        let current_weather = client.weather_at_player();
428        let current_biome = client.current_biome();
429        let current_site = client.current_site();
430
431        // Filter the soundtrack in stages, so that we don't overprune it if there are
432        // too many constraints. Returning Err(()) signals that we couldn't find
433        // an appropriate track for the current state, and hence the state
434        // machine for the activity shouldn't be updated.
435        // First, filter out tracks not matching the timing, site, biome, and current
436        // activity
437        let mut maybe_tracks = self
438            .soundtrack
439            .tracks
440            .iter()
441            .filter(|track| {
442                (match &track.timing {
443                    Some(period_of_day) => period_of_day == &current_period_of_day,
444                    None => true,
445                }) && match &track.weather {
446                    Some(weather) => weather == &current_weather.get_kind(),
447                    None => true,
448                }
449            })
450            .filter(|track| track.sites.iter().any(|s| s == &current_site))
451            .filter(|track| {
452                track.biomes.is_empty() || track.biomes.iter().any(|b| b.0 == current_biome)
453            })
454            .filter(|track| &track.music_state == music_state)
455            .collect::<Vec<&SoundtrackItem>>();
456        if maybe_tracks.is_empty() {
457            let error_string = format!(
458                "No tracks for {:?}, {:?}, {:?}, {:?}, {:?}",
459                &current_period_of_day,
460                &current_weather,
461                &current_site,
462                &current_biome,
463                &music_state
464            );
465            return Err(error_string);
466        }
467        // Second, prevent playing the last track (when not in combat, because then it
468        // needs to loop)
469        if matches!(
470            music_state,
471            &MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
472                | &MusicState::Transition(
473                    MusicActivity::Combat(CombatIntensity::High),
474                    MusicActivity::Explore
475                )
476        ) {
477            let filtered_tracks: Vec<_> = maybe_tracks
478                .iter()
479                .filter(|track| track.title.eq(&self.last_track))
480                .copied()
481                .collect();
482            if !filtered_tracks.is_empty() {
483                maybe_tracks = filtered_tracks;
484            }
485        } else {
486            let filtered_tracks: Vec<_> = maybe_tracks
487                .iter()
488                .filter(|track| !track.title.eq(&self.last_track))
489                .filter(|track| !track.title.eq(&self.last_combat_track))
490                .copied()
491                .collect();
492            if !filtered_tracks.is_empty() {
493                maybe_tracks = filtered_tracks;
494            }
495        }
496
497        // Randomly selects a track from the remaining tracks weighted based
498        // on the biome
499        let new_maybe_track = maybe_tracks.choose_weighted(rng, |track| {
500            // If no biome is listed, the song is still added to the
501            // rotation to allow for site specific songs to play
502            // in any biome
503            track
504                .biomes
505                .iter()
506                .find(|b| b.0 == current_biome)
507                .map_or(1.0, |b| 1.0_f32 / (b.1 as f32))
508        });
509        debug!(
510            "selecting new track for {:?}: {:?}",
511            music_state, new_maybe_track
512        );
513
514        if let Ok(track) = new_maybe_track {
515            let now = audio.get_clock_time().unwrap();
516            self.last_track = String::from(&track.title);
517            self.began_playing = Some(now);
518            self.song_end = Some(ClockTime::from_ticks_f64(
519                audio.get_clock().unwrap().id(),
520                time_f64(now) + track.length as f64,
521            ));
522            self.track_length = track.length;
523            self.gap_length = 0.0;
524            if audio.music_enabled() {
525                self.current_track = String::from(&track.title);
526                self.current_artist = String::from(&track.artist.0);
527            } else {
528                self.current_track = String::from("None");
529                self.current_artist = String::from("None");
530            }
531
532            let tag = if matches!(music_state, MusicState::Activity(MusicActivity::Explore)) {
533                MusicChannelTag::Exploration
534            } else {
535                self.last_combat_track = String::from(&track.title);
536                MusicChannelTag::Combat
537            };
538            audio.play_music(&track.path, tag, track.length);
539            if tag == MusicChannelTag::Combat {
540                audio.set_loop_points(
541                    tag,
542                    track.loop_points.unwrap_or((0.0, 0.0)).0,
543                    track.loop_points.unwrap_or((0.0, 0.0)).1,
544                );
545                self.loop_points = track.loop_points
546            } else {
547                self.loop_points = None
548            };
549
550            if let Some(state) = track.activity_override {
551                Ok(MusicState::Activity(state))
552            } else {
553                Ok(*music_state)
554            }
555        } else {
556            Err(format!("{:?}", new_maybe_track))
557        }
558    }
559
560    fn generate_silence_between_tracks(
561        &self,
562        spacing_multiplier: f32,
563        client: &Client,
564        music_state: &MusicState,
565        rng: &mut ThreadRng,
566    ) -> f32 {
567        let mut silence_between_tracks_seconds: f32 = 0.0;
568        if spacing_multiplier > f32::EPSILON {
569            silence_between_tracks_seconds =
570                if matches!(
571                    music_state,
572                    MusicState::Activity(MusicActivity::Explore)
573                        | MusicState::Transition(
574                            MusicActivity::Explore,
575                            MusicActivity::Combat(CombatIntensity::High)
576                        )
577                ) && matches!(client.current_site(), SiteKindMeta::Settlement(_))
578                {
579                    rng.random_range(120.0 * spacing_multiplier..180.0 * spacing_multiplier)
580                } else if matches!(
581                    music_state,
582                    MusicState::Activity(MusicActivity::Explore)
583                        | MusicState::Transition(
584                            MusicActivity::Explore,
585                            MusicActivity::Combat(CombatIntensity::High)
586                        )
587                ) && matches!(client.current_site(), SiteKindMeta::Dungeon(_))
588                {
589                    rng.random_range(10.0 * spacing_multiplier..20.0 * spacing_multiplier)
590                } else if matches!(
591                    music_state,
592                    MusicState::Activity(MusicActivity::Explore)
593                        | MusicState::Transition(
594                            MusicActivity::Explore,
595                            MusicActivity::Combat(CombatIntensity::High)
596                        )
597                ) && matches!(client.current_site(), SiteKindMeta::Cave)
598                {
599                    rng.random_range(20.0 * spacing_multiplier..40.0 * spacing_multiplier)
600                } else if matches!(
601                    music_state,
602                    MusicState::Activity(MusicActivity::Explore)
603                        | MusicState::Transition(
604                            MusicActivity::Explore,
605                            MusicActivity::Combat(CombatIntensity::High)
606                        )
607                ) {
608                    rng.random_range(120.0 * spacing_multiplier..240.0 * spacing_multiplier)
609                } else if matches!(
610                    music_state,
611                    MusicState::Activity(MusicActivity::Combat(_)) | MusicState::Transition(_, _)
612                ) {
613                    0.0
614                } else {
615                    rng.random_range(30.0 * spacing_multiplier..60.0 * spacing_multiplier)
616                };
617        }
618        silence_between_tracks_seconds
619    }
620
621    fn get_current_day_period(is_dark: bool) -> DayPeriod {
622        if is_dark {
623            DayPeriod::Night
624        } else {
625            DayPeriod::Day
626        }
627    }
628
629    pub fn current_track(&self) -> String { self.current_track.clone() }
630
631    pub fn current_artist(&self) -> String { self.current_artist.clone() }
632
633    pub fn reset_track(&mut self, audio: &mut AudioFrontend) {
634        self.current_artist = String::from("None");
635        self.current_track = String::from("None");
636        self.gap_length = 1.0;
637        self.gap_time = 1.0;
638        self.is_gap = true;
639        self.track_length = 0.0;
640        self.began_playing = audio.get_clock_time();
641        self.song_end = audio.get_clock_time();
642    }
643
644    /// Loads default soundtrack if no events are active. Otherwise, attempts to
645    /// compile and load all active event soundtracks, falling back to default
646    /// if they are empty.
647    fn load_soundtrack_items(calendar: &Calendar) -> SoundtrackCollection<SoundtrackItem> {
648        let mut soundtrack = SoundtrackCollection::default();
649        // Loads default soundtrack if no events are active
650        if calendar.events().len() == 0 {
651            for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
652                .read()
653                .tracks
654                .clone()
655            {
656                soundtrack.tracks.push(track)
657            }
658        } else {
659            // Compiles event-specific soundtracks if any are active
660            for event in calendar.events() {
661                match event {
662                    CalendarEvent::Halloween => {
663                        for track in SoundtrackCollection::load_expect(
664                            "voxygen.audio.calendar.halloween.soundtrack",
665                        )
666                        .read()
667                        .tracks
668                        .clone()
669                        {
670                            soundtrack.tracks.push(track)
671                        }
672                    },
673                    CalendarEvent::Christmas => {
674                        for track in SoundtrackCollection::load_expect(
675                            "voxygen.audio.calendar.christmas.soundtrack",
676                        )
677                        .read()
678                        .tracks
679                        .clone()
680                        {
681                            soundtrack.tracks.push(track)
682                        }
683                    },
684                    _ => {
685                        for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
686                            .read()
687                            .tracks
688                            .clone()
689                        {
690                            soundtrack.tracks.push(track)
691                        }
692                    },
693                }
694            }
695        }
696        // Fallback if events are active but give an empty tracklist
697        if soundtrack.tracks.is_empty() {
698            for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
699                .read()
700                .tracks
701                .clone()
702            {
703                soundtrack.tracks.push(track)
704            }
705            soundtrack
706        } else {
707            soundtrack
708        }
709    }
710}
711impl Asset for SoundtrackCollection<SoundtrackItem> {
712    fn load(_: &AssetCache, id: &SharedString) -> Result<Self, BoxedError> {
713        let manifest: AssetHandle<Ron<SoundtrackCollection<RawSoundtrackItem>>> =
714            AssetExt::load(id)?;
715        let mut soundtrack = SoundtrackCollection::default();
716        for item in manifest.read().0.tracks.iter().cloned() {
717            match item {
718                RawSoundtrackItem::Individual(track) => soundtrack.tracks.push(track),
719                RawSoundtrackItem::Segmented {
720                    title,
721                    timing,
722                    weather,
723                    biomes,
724                    sites,
725                    segments,
726                    loop_points,
727                    artist,
728                } => {
729                    for (path, length, music_state, activity_override) in segments.into_iter() {
730                        soundtrack.tracks.push(SoundtrackItem {
731                            title: title.clone(),
732                            path,
733                            length,
734                            loop_points: Some(loop_points),
735                            timing: timing.clone(),
736                            weather,
737                            biomes: biomes.clone(),
738                            sites: sites.clone(),
739                            music_state,
740                            activity_override,
741                            artist: artist.clone(),
742                        });
743                    }
744                },
745            }
746        }
747        Ok(soundtrack)
748    }
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use strum::IntoEnumIterator;
755
756    #[test]
757    fn test_load_soundtracks() {
758        let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
759            AssetExt::load_expect("voxygen.audio.soundtrack");
760        for event in CalendarEvent::iter() {
761            match event {
762                CalendarEvent::Halloween => {
763                    let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
764                        SoundtrackCollection::load_expect(
765                            "voxygen.audio.calendar.halloween.soundtrack",
766                        );
767                },
768                CalendarEvent::Christmas => {
769                    let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
770                        SoundtrackCollection::load_expect(
771                            "voxygen.audio.calendar.christmas.soundtrack",
772                        );
773                },
774                _ => {},
775            }
776        }
777    }
778}