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::{self, AssetExt, AssetHandle},
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::SliceRandom, rngs::ThreadRng, thread_rng};
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    /// Time until the next track should be played after a track ends
153    gap_length: f32,
154    /// Time remaining for gap
155    gap_time: f64,
156    /// The title of the last track played. Used to prevent a track
157    /// being played twice in a row
158    last_track: String,
159    last_combat_track: String,
160    /// Time of the last interrupt (to avoid rapid switching)
161    last_interrupt_attempt: Option<ClockTime>,
162    /// The previous track's activity kind, for transitions
163    last_activity: MusicState,
164    // For debug menu
165    current_track: String,
166    current_artist: String,
167    track_length: f32,
168    loop_points: Option<(f32, f32)>,
169}
170
171#[derive(Deserialize)]
172pub struct MusicTransitionManifest {
173    /// Within what radius do enemies count towards combat music?
174    combat_nearby_radius: f32,
175    /// Each multiple of this factor that an enemy has health counts as an extra
176    /// enemy
177    combat_health_factor: f32,
178    /// How many nearby enemies trigger High combat music
179    combat_nearby_high_thresh: u32,
180    /// How many nearby enemies trigger Low combat music
181    combat_nearby_low_thresh: u32,
182    /// Fade in and fade out timings for transitions between channels
183    pub fade_timings: HashMap<(MusicChannelTag, MusicChannelTag), (f32, f32)>,
184    /// How many seconds between interrupt checks
185    pub interrupt_delay: f32,
186}
187
188impl Default for MusicTransitionManifest {
189    fn default() -> MusicTransitionManifest {
190        MusicTransitionManifest {
191            combat_nearby_radius: 40.0,
192            combat_health_factor: 100.0,
193            combat_nearby_high_thresh: 3,
194            combat_nearby_low_thresh: 1,
195            fade_timings: HashMap::new(),
196            interrupt_delay: 5.0,
197        }
198    }
199}
200
201impl assets::Asset for MusicTransitionManifest {
202    type Loader = assets::RonLoader;
203
204    const EXTENSION: &'static str = "ron";
205}
206
207fn time_f64(clock_time: ClockTime) -> f64 { clock_time.ticks as f64 + clock_time.fraction }
208
209impl MusicMgr {
210    pub fn new(calendar: &Calendar) -> Self {
211        Self {
212            soundtrack: Self::load_soundtrack_items(calendar),
213            began_playing: None,
214            song_end: None,
215            gap_length: 0.0,
216            gap_time: -1.0,
217            last_track: String::from("None"),
218            last_combat_track: String::from("None"),
219            last_interrupt_attempt: None,
220            last_activity: MusicState::Activity(MusicActivity::Explore),
221            current_track: String::from("None"),
222            current_artist: String::from("None"),
223            track_length: 0.0,
224            loop_points: None,
225        }
226    }
227
228    /// Checks whether the previous track has completed. If so, sends a
229    /// request to play the next (random) track
230    pub fn maintain(&mut self, audio: &mut AudioFrontend, state: &State, client: &Client) {
231        use common::comp::{Group, Health, Pos, group::ENEMY};
232        use specs::{Join, WorldExt};
233
234        if !audio.music_enabled() || audio.get_clock().is_none() || audio.get_clock_time().is_none()
235        {
236            return;
237        }
238
239        let mut activity_state = MusicActivity::Explore;
240
241        let player = client.entity();
242        let ecs = state.ecs();
243        let entities = ecs.entities();
244        let positions = ecs.read_component::<Pos>();
245        let healths = ecs.read_component::<Health>();
246        let groups = ecs.read_component::<Group>();
247        let mtm = audio.mtm.read();
248        let mut rng = thread_rng();
249
250        if audio.combat_music_enabled {
251            if let Some(player_pos) = positions.get(player) {
252                // TODO: `group::ENEMY` will eventually be moved server-side with an
253                // alignment/faction rework, so this will need an alternative way to measure
254                // "in-combat-ness"
255                let num_nearby_entities: u32 = (&entities, &positions, &healths, &groups)
256                    .join()
257                    .map(|(entity, pos, health, group)| {
258                        if entity != player
259                            && group == &ENEMY
260                            && (player_pos.0 - pos.0).magnitude_squared()
261                                < mtm.combat_nearby_radius.powf(2.0)
262                        {
263                            (health.maximum() / mtm.combat_health_factor).ceil() as u32
264                        } else {
265                            0
266                        }
267                    })
268                    .sum();
269
270                if num_nearby_entities >= mtm.combat_nearby_high_thresh {
271                    activity_state = MusicActivity::Combat(CombatIntensity::High);
272                } else if num_nearby_entities >= mtm.combat_nearby_low_thresh {
273                    activity_state = MusicActivity::Combat(CombatIntensity::Low);
274                }
275            }
276        }
277
278        // Override combat music with explore music if the player is dead
279        if let Some(health) = healths.get(player) {
280            if health.is_dead {
281                activity_state = MusicActivity::Explore;
282            }
283        }
284
285        let mut music_state = match self.last_activity {
286            MusicState::Activity(prev) => {
287                if prev != activity_state {
288                    MusicState::Transition(prev, activity_state)
289                } else {
290                    MusicState::Activity(activity_state)
291                }
292            },
293            MusicState::Transition(_, next) => {
294                warn!("Transitioning: {:?}", self.last_activity);
295                MusicState::Activity(next)
296            },
297        };
298
299        let now = audio.get_clock_time().unwrap();
300
301        let began_playing = *self.began_playing.get_or_insert(now);
302        let last_interrupt_attempt = *self.last_interrupt_attempt.get_or_insert(now);
303        let song_end = *self.song_end.get_or_insert(now);
304        let mut time_since_began_playing = time_f64(now) - time_f64(began_playing);
305
306        // TODO: Instead of a constant tick, make this a timer that starts only when
307        // combat might end, providing a proper "buffer".
308        // interrupt_delay dictates the time between attempted interrupts
309        let interrupt = matches!(music_state, MusicState::Transition(_, _))
310            && time_f64(now) - time_f64(last_interrupt_attempt) > mtm.interrupt_delay as f64;
311
312        // Hack to end combat music since there is currently nothing that detects
313        // transitions away
314        if matches!(
315            music_state,
316            MusicState::Transition(
317                MusicActivity::Combat(CombatIntensity::High),
318                MusicActivity::Explore
319            )
320        ) {
321            music_state = MusicState::Activity(MusicActivity::Explore)
322        }
323
324        if audio.music_enabled()
325            && !self.soundtrack.tracks.is_empty()
326            && (time_since_began_playing
327                > time_f64(song_end) - time_f64(began_playing) // Amount of time between when the song ends and when it began playing
328                || interrupt)
329        {
330            time_since_began_playing = time_f64(now) - time_f64(began_playing);
331            if time_since_began_playing > self.track_length as f64
332                && self.last_activity
333                    != MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
334            {
335                self.current_track = String::from("None");
336                self.current_artist = String::from("None");
337            }
338
339            if interrupt {
340                self.last_interrupt_attempt = Some(now);
341                if let Ok(next_activity) =
342                    self.play_random_track(audio, state, client, &music_state, &mut rng)
343                {
344                    trace!(
345                        "pre-play_random_track: {:?} {:?}",
346                        self.last_activity, music_state
347                    );
348                    self.last_activity = next_activity;
349                }
350            } else if music_state == MusicState::Activity(MusicActivity::Explore)
351                || music_state
352                    == MusicState::Transition(
353                        MusicActivity::Explore,
354                        MusicActivity::Combat(CombatIntensity::High),
355                    )
356            {
357                // If current state is Explore, insert a gap now.
358                if self.gap_time == 0.0 {
359                    self.gap_length = self.generate_silence_between_tracks(
360                        audio.music_spacing,
361                        client,
362                        &music_state,
363                        &mut rng,
364                    );
365                    self.gap_time = self.gap_length as f64;
366                    self.song_end = audio.get_clock_time();
367                } else if self.gap_time < 0.0 {
368                    // Gap time is up, play a track
369                    // Hack to make combat situations not cancel explore music
370                    if music_state
371                        == MusicState::Transition(
372                            MusicActivity::Explore,
373                            MusicActivity::Combat(CombatIntensity::High),
374                        )
375                    {
376                        music_state = MusicState::Activity(MusicActivity::Explore)
377                    }
378                    if let Ok(next_activity) =
379                        self.play_random_track(audio, state, client, &music_state, &mut rng)
380                    {
381                        self.last_activity = next_activity;
382                        self.gap_time = 0.0;
383                        self.gap_length = 0.0;
384                    }
385                }
386            } else if music_state
387                == MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
388            {
389                // Keep playing! The track should loop automatically.
390                self.began_playing = Some(now);
391                self.song_end = Some(ClockTime::from_ticks_f64(
392                    audio.get_clock().unwrap().id(),
393                    time_f64(now) + self.loop_points.unwrap_or((0.0, 0.0)).1 as f64
394                        - self.loop_points.unwrap_or((0.0, 0.0)).0 as f64,
395                ));
396            } else {
397                trace!(
398                    "pre-play_random_track: {:?} {:?}",
399                    self.last_activity, music_state
400                );
401            }
402        } else {
403            if self.began_playing.is_none() {
404                self.began_playing = Some(now)
405            }
406            if self.soundtrack.tracks.is_empty() {
407                warn!("No tracks available to play")
408            }
409        }
410
411        if time_since_began_playing > self.track_length as f64 {
412            // Time remaining = Max time - (current time - time song ended)
413            self.gap_time = (self.gap_length as f64) - (time_f64(now) - time_f64(song_end));
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            // println!("Now playing {:?}", track.title);
517            self.last_track = String::from(&track.title);
518            self.began_playing = Some(now);
519            self.song_end = Some(ClockTime::from_ticks_f64(
520                audio.get_clock().unwrap().id(),
521                time_f64(now) + track.length as f64,
522            ));
523            self.track_length = track.length;
524            self.gap_length = 0.0;
525            if audio.music_enabled() {
526                self.current_track = String::from(&track.title);
527                self.current_artist = String::from(&track.artist.0);
528            } else {
529                self.current_track = String::from("None");
530                self.current_artist = String::from("None");
531            }
532
533            let tag = if matches!(music_state, MusicState::Activity(MusicActivity::Explore)) {
534                MusicChannelTag::Exploration
535            } else {
536                self.last_combat_track = String::from(&track.title);
537                MusicChannelTag::Combat
538            };
539            audio.play_music(&track.path, tag, track.length);
540            if tag == MusicChannelTag::Combat {
541                audio.set_loop_points(
542                    tag,
543                    track.loop_points.unwrap_or((0.0, 0.0)).0,
544                    track.loop_points.unwrap_or((0.0, 0.0)).1,
545                );
546                self.loop_points = track.loop_points
547            } else {
548                self.loop_points = None
549            };
550
551            if let Some(state) = track.activity_override {
552                Ok(MusicState::Activity(state))
553            } else {
554                Ok(*music_state)
555            }
556        } else {
557            Err(format!("{:?}", new_maybe_track))
558        }
559    }
560
561    fn generate_silence_between_tracks(
562        &self,
563        spacing_multiplier: f32,
564        client: &Client,
565        music_state: &MusicState,
566        rng: &mut ThreadRng,
567    ) -> f32 {
568        let mut silence_between_tracks_seconds: f32 = 0.0;
569        if spacing_multiplier > f32::EPSILON {
570            silence_between_tracks_seconds =
571                if matches!(
572                    music_state,
573                    MusicState::Activity(MusicActivity::Explore)
574                        | MusicState::Transition(
575                            MusicActivity::Explore,
576                            MusicActivity::Combat(CombatIntensity::High)
577                        )
578                ) && matches!(client.current_site(), SiteKindMeta::Settlement(_))
579                {
580                    rng.gen_range(120.0 * spacing_multiplier..180.0 * spacing_multiplier)
581                } else if matches!(
582                    music_state,
583                    MusicState::Activity(MusicActivity::Explore)
584                        | MusicState::Transition(
585                            MusicActivity::Explore,
586                            MusicActivity::Combat(CombatIntensity::High)
587                        )
588                ) && matches!(client.current_site(), SiteKindMeta::Dungeon(_))
589                {
590                    rng.gen_range(10.0 * spacing_multiplier..20.0 * spacing_multiplier)
591                } else if matches!(
592                    music_state,
593                    MusicState::Activity(MusicActivity::Explore)
594                        | MusicState::Transition(
595                            MusicActivity::Explore,
596                            MusicActivity::Combat(CombatIntensity::High)
597                        )
598                ) && matches!(client.current_site(), SiteKindMeta::Cave)
599                {
600                    rng.gen_range(20.0 * spacing_multiplier..40.0 * spacing_multiplier)
601                } else if matches!(
602                    music_state,
603                    MusicState::Activity(MusicActivity::Explore)
604                        | MusicState::Transition(
605                            MusicActivity::Explore,
606                            MusicActivity::Combat(CombatIntensity::High)
607                        )
608                ) {
609                    rng.gen_range(120.0 * spacing_multiplier..240.0 * spacing_multiplier)
610                } else if matches!(
611                    music_state,
612                    MusicState::Activity(MusicActivity::Combat(_)) | MusicState::Transition(_, _)
613                ) {
614                    0.0
615                } else {
616                    rng.gen_range(30.0 * spacing_multiplier..60.0 * spacing_multiplier)
617                };
618        }
619        silence_between_tracks_seconds
620    }
621
622    fn get_current_day_period(is_dark: bool) -> DayPeriod {
623        if is_dark {
624            DayPeriod::Night
625        } else {
626            DayPeriod::Day
627        }
628    }
629
630    pub fn current_track(&self) -> String { self.current_track.clone() }
631
632    pub fn current_artist(&self) -> String { self.current_artist.clone() }
633
634    pub fn reset_track(&mut self) {
635        self.current_artist = String::from("None");
636        self.current_track = String::from("None");
637    }
638
639    /// Loads default soundtrack if no events are active. Otherwise, attempts to
640    /// compile and load all active event soundtracks, falling back to default
641    /// if they are empty.
642    fn load_soundtrack_items(calendar: &Calendar) -> SoundtrackCollection<SoundtrackItem> {
643        let mut soundtrack = SoundtrackCollection::default();
644        // Loads default soundtrack if no events are active
645        if calendar.events().len() == 0 {
646            for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
647                .read()
648                .tracks
649                .clone()
650            {
651                soundtrack.tracks.push(track)
652            }
653        } else {
654            // Compiles event-specific soundtracks if any are active
655            for event in calendar.events() {
656                match event {
657                    CalendarEvent::Halloween => {
658                        for track in SoundtrackCollection::load_expect(
659                            "voxygen.audio.calendar.halloween.soundtrack",
660                        )
661                        .read()
662                        .tracks
663                        .clone()
664                        {
665                            soundtrack.tracks.push(track)
666                        }
667                    },
668                    CalendarEvent::Christmas => {
669                        for track in SoundtrackCollection::load_expect(
670                            "voxygen.audio.calendar.christmas.soundtrack",
671                        )
672                        .read()
673                        .tracks
674                        .clone()
675                        {
676                            soundtrack.tracks.push(track)
677                        }
678                    },
679                    _ => {
680                        for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
681                            .read()
682                            .tracks
683                            .clone()
684                        {
685                            soundtrack.tracks.push(track)
686                        }
687                    },
688                }
689            }
690        }
691        // Fallback if events are active but give an empty tracklist
692        if soundtrack.tracks.is_empty() {
693            for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
694                .read()
695                .tracks
696                .clone()
697            {
698                soundtrack.tracks.push(track)
699            }
700            soundtrack
701        } else {
702            soundtrack
703        }
704    }
705}
706impl assets::Asset for SoundtrackCollection<RawSoundtrackItem> {
707    type Loader = assets::RonLoader;
708
709    const EXTENSION: &'static str = "ron";
710}
711
712impl assets::Compound for SoundtrackCollection<SoundtrackItem> {
713    fn load(_: assets::AnyCache, id: &assets::SharedString) -> Result<Self, assets::BoxedError> {
714        let manifest: AssetHandle<SoundtrackCollection<RawSoundtrackItem>> = AssetExt::load(id)?;
715        let mut soundtrack = SoundtrackCollection::default();
716        for item in manifest.read().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            SoundtrackCollection::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}