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::{
52        BiomeKind, SiteKindMeta,
53        site::{DungeonKindMeta, SettlementKindMeta},
54    },
55    weather::WeatherKind,
56};
57use common_state::State;
58use hashbrown::HashMap;
59use kira::clock::ClockTime;
60use rand::{Rng, prelude::IndexedRandom, rng, rngs::ThreadRng};
61use serde::Deserialize;
62use tracing::{debug, trace, warn};
63
64/// Collection of all the tracks
65#[derive(Debug, Deserialize)]
66struct SoundtrackCollection<T> {
67    /// List of tracks
68    tracks: Vec<T>,
69}
70
71impl<T> Default for SoundtrackCollection<T> {
72    fn default() -> Self { Self { tracks: Vec::new() } }
73}
74
75/// Configuration for a single music track in the soundtrack
76#[derive(Clone, Debug, Deserialize)]
77pub struct SoundtrackItem {
78    /// Song title
79    title: String,
80    /// File path to asset
81    path: String,
82    /// Length of the track in seconds
83    length: f32,
84    loop_points: Option<(f32, f32)>,
85    /// Whether this track should play during day or night
86    timing: Option<DayPeriod>,
87    /// Whether this track should play during a certain weather
88    weather: Option<WeatherKind>,
89    /// What biomes this track should play in with chance of play
90    biomes: Vec<(BiomeKind, u8)>,
91    /// Whether this track should play in a specific site
92    sites: Vec<SiteKindMeta>,
93    /// What the player is doing when the track is played (i.e. exploring,
94    /// combat)
95    music_state: MusicState,
96    /// What activity to override the activity state with, if any (e.g. to make
97    /// a long combat intro also act like the loop for the purposes of outro
98    /// transitions)
99    #[serde(default)]
100    activity_override: Option<MusicActivity>,
101    /// Song artist and website
102    artist: (String, Option<String>),
103}
104
105#[derive(Clone, Debug, Deserialize)]
106enum RawSoundtrackItem {
107    Individual(SoundtrackItem),
108    Segmented {
109        title: String,
110        timing: Option<DayPeriod>,
111        weather: Option<WeatherKind>,
112        biomes: Vec<(BiomeKind, u8)>,
113        sites: Vec<SiteKindMeta>,
114        segments: Vec<(String, f32, MusicState, Option<MusicActivity>)>,
115        loop_points: (f32, f32),
116        artist: (String, Option<String>),
117    },
118}
119
120#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
121enum CombatIntensity {
122    Low,
123    High,
124}
125
126#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
127enum MusicActivity {
128    Explore,
129    Combat(CombatIntensity),
130}
131
132#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
133enum MusicState {
134    Activity(MusicActivity),
135    Transition(MusicActivity, MusicActivity),
136}
137
138/// Allows control over when a track should play based on in-game time of day
139#[derive(Clone, Debug, Deserialize, PartialEq)]
140enum DayPeriod {
141    /// 8:00 AM to 7:30 PM
142    Day,
143    /// 7:31 PM to 6:59 AM
144    Night,
145}
146
147/// Provides methods to control music playback
148pub struct MusicMgr {
149    /// Collection of all the tracks
150    soundtrack: SoundtrackCollection<SoundtrackItem>,
151    /// Instant at which the current track began playing
152    began_playing: Option<ClockTime>,
153    /// Instant at which the current track should stop
154    song_end: Option<ClockTime>,
155    /// Currently staying silent for gap between tracks
156    is_gap: bool,
157    /// Time until the next track should be played after a track ends
158    gap_length: f32,
159    /// Time remaining for gap
160    gap_time: f64,
161    /// The title of the last track played. Used to prevent a track
162    /// being played twice in a row
163    last_track: String,
164    last_combat_track: String,
165    /// Time of the last interrupt (to avoid rapid switching)
166    last_interrupt_attempt: Option<ClockTime>,
167    /// The previous track's activity kind, for transitions
168    last_activity: MusicState,
169    // For debug menu
170    current_track: String,
171    current_artist: String,
172    track_length: f32,
173    loop_points: Option<(f32, f32)>,
174}
175
176#[derive(Deserialize)]
177pub struct MusicTransitionManifest {
178    /// Within what radius do enemies count towards combat music?
179    combat_nearby_radius: f32,
180    /// Each multiple of this factor that an enemy has health counts as an extra
181    /// enemy
182    combat_health_factor: f32,
183    /// How many nearby enemies trigger High combat music
184    combat_nearby_high_thresh: u32,
185    /// How many nearby enemies trigger Low combat music
186    combat_nearby_low_thresh: u32,
187    /// Fade in and fade out timings for transitions between channels
188    pub fade_timings: HashMap<(MusicChannelTag, MusicChannelTag), (f32, f32)>,
189    /// How many seconds between interrupt checks
190    pub interrupt_delay: f32,
191}
192
193impl Default for MusicTransitionManifest {
194    fn default() -> MusicTransitionManifest {
195        MusicTransitionManifest {
196            combat_nearby_radius: 40.0,
197            combat_health_factor: 100.0,
198            combat_nearby_high_thresh: 3,
199            combat_nearby_low_thresh: 1,
200            fade_timings: HashMap::new(),
201            interrupt_delay: 5.0,
202        }
203    }
204}
205
206fn time_f64(clock_time: ClockTime) -> f64 { clock_time.ticks as f64 + clock_time.fraction }
207
208impl MusicMgr {
209    pub fn new(calendar: &Calendar) -> Self {
210        Self {
211            soundtrack: Self::load_soundtrack_items(calendar),
212            began_playing: None,
213            song_end: None,
214            is_gap: true,
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 = rng();
249
250        if audio.combat_music_enabled
251            && let Some(player_pos) = positions.get(player)
252        {
253            // TODO: `group::ENEMY` will eventually be moved server-side with an
254            // alignment/faction rework, so this will need an alternative way to measure
255            // "in-combat-ness"
256            let num_nearby_entities: u32 = (&entities, &positions, &healths, &groups)
257                .join()
258                .map(|(entity, pos, health, group)| {
259                    if entity != player
260                        && group == &ENEMY
261                        && (player_pos.0 - pos.0).magnitude_squared()
262                            < mtm.0.combat_nearby_radius.powf(2.0)
263                    {
264                        (health.maximum() / mtm.0.combat_health_factor).ceil() as u32
265                    } else {
266                        0
267                    }
268                })
269                .sum();
270
271            if num_nearby_entities >= mtm.0.combat_nearby_high_thresh {
272                activity_state = MusicActivity::Combat(CombatIntensity::High);
273            } else if num_nearby_entities >= mtm.0.combat_nearby_low_thresh {
274                activity_state = MusicActivity::Combat(CombatIntensity::Low);
275            }
276        }
277
278        // Override combat music with explore music if the player is dead
279        if let Some(health) = healths.get(player)
280            && health.is_dead
281        {
282            activity_state = MusicActivity::Explore;
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 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.0.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            if time_since_began_playing > self.track_length as f64
331                && self.last_activity
332                    != MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
333            {
334                self.current_track = String::from("None");
335                self.current_artist = String::from("None");
336            }
337
338            if interrupt {
339                self.last_interrupt_attempt = Some(now);
340                if let Ok(next_activity) =
341                    self.play_random_track(audio, state, client, &music_state, &mut rng)
342                {
343                    trace!(
344                        "pre-play_random_track: {:?} {:?}",
345                        self.last_activity, music_state
346                    );
347                    self.last_activity = next_activity;
348                }
349            } else if music_state == MusicState::Activity(MusicActivity::Explore)
350                || music_state
351                    == MusicState::Transition(
352                        MusicActivity::Explore,
353                        MusicActivity::Combat(CombatIntensity::High),
354                    )
355            {
356                // If current state is Explore, insert a gap now.
357                if !self.is_gap {
358                    self.gap_length = self.generate_silence_between_tracks(
359                        audio.music_spacing,
360                        client,
361                        &music_state,
362                        &mut rng,
363                    );
364                    self.gap_time = self.gap_length as f64;
365                    self.song_end = audio.get_clock_time();
366                    self.is_gap = true
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 for now
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                        self.is_gap = false;
385                    }
386                }
387            } else if music_state
388                == MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
389            {
390                // Keep playing! The track should loop automatically.
391                self.began_playing = Some(now);
392                self.song_end = Some(ClockTime::from_ticks_f64(
393                    audio.get_clock().unwrap().id(),
394                    time_f64(now) + self.loop_points.unwrap_or((0.0, 0.0)).1 as f64
395                        - self.loop_points.unwrap_or((0.0, 0.0)).0 as f64,
396                ));
397            } else {
398                trace!(
399                    "pre-play_random_track: {:?} {:?}",
400                    self.last_activity, music_state
401                );
402            }
403        } else {
404            if self.began_playing.is_none() {
405                self.began_playing = Some(now)
406            }
407            if self.soundtrack.tracks.is_empty() {
408                warn!("No tracks available to play")
409            }
410        }
411
412        if time_since_began_playing > self.track_length as f64 {
413            // Time remaining = Max time - (current time - time song ended)
414            if self.is_gap {
415                self.gap_time = (self.gap_length as f64) - (time_f64(now) - time_f64(song_end));
416            }
417        }
418    }
419
420    fn play_random_track(
421        &mut self,
422        audio: &mut AudioFrontend,
423        state: &State,
424        client: &Client,
425        music_state: &MusicState,
426        rng: &mut ThreadRng,
427    ) -> Result<MusicState, String> {
428        let is_dark = state.get_day_period().is_dark();
429        let current_period_of_day = Self::get_current_day_period(is_dark);
430        let current_weather = client.weather_at_player();
431        let current_biome = client.current_biome();
432        let current_site = client.current_site();
433
434        // Filter the soundtrack in stages, so that we don't overprune it if there are
435        // too many constraints. Returning Err(()) signals that we couldn't find
436        // an appropriate track for the current state, and hence the state
437        // machine for the activity shouldn't be updated.
438        // First, filter out tracks not matching the timing, site, biome, and current
439        // activity
440        let mut maybe_tracks = self
441            .soundtrack
442            .tracks
443            .iter()
444            .filter(|track| {
445                (match &track.timing {
446                    Some(period_of_day) => period_of_day == &current_period_of_day,
447                    None => true,
448                }) && match &track.weather {
449                    Some(weather) => weather == &current_weather.get_kind(),
450                    None => true,
451                }
452            })
453            .filter(|track| track.sites.iter().any(|s| s == &current_site))
454            .filter(|track| {
455                track.biomes.is_empty() || track.biomes.iter().any(|b| b.0 == current_biome)
456            })
457            .filter(|track| &track.music_state == music_state)
458            .collect::<Vec<&SoundtrackItem>>();
459        if maybe_tracks.is_empty() {
460            // If tracklist is empty, it may be that the current site kind does not exist
461            // yet. Use sensible defaults in that case.
462            maybe_tracks =
463                self.soundtrack
464                    .tracks
465                    .iter()
466                    .filter(|track| match &current_site {
467                        SiteKindMeta::Settlement(_) => track.sites.iter().any(|site| {
468                            site == &SiteKindMeta::Settlement(SettlementKindMeta::Default)
469                        }),
470                        SiteKindMeta::Dungeon(_) => track
471                            .sites
472                            .iter()
473                            .any(|site| site == &SiteKindMeta::Dungeon(DungeonKindMeta::Cultist)),
474                        _ => false,
475                    })
476                    .collect::<Vec<&SoundtrackItem>>();
477            let mut error_string = format!(
478                "No tracks for {:?}, {:?}, {:?}, {:?}, {:?}",
479                &current_period_of_day,
480                &current_weather,
481                &current_site,
482                &current_biome,
483                &music_state
484            );
485            if maybe_tracks.is_empty() {
486                return Err(error_string);
487            } else {
488                error_string.push_str(", using default music for current site.");
489                warn!(error_string);
490            }
491        }
492        // Second, prevent playing the last track (when not in combat, because then it
493        // needs to loop)
494        if matches!(
495            music_state,
496            &MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
497                | &MusicState::Transition(
498                    MusicActivity::Combat(CombatIntensity::High),
499                    MusicActivity::Explore
500                )
501        ) {
502            let filtered_tracks: Vec<_> = maybe_tracks
503                .iter()
504                .filter(|track| track.title.eq(&self.last_track))
505                .copied()
506                .collect();
507            if !filtered_tracks.is_empty() {
508                maybe_tracks = filtered_tracks;
509            }
510        } else {
511            let filtered_tracks: Vec<_> = maybe_tracks
512                .iter()
513                .filter(|track| !track.title.eq(&self.last_track))
514                .filter(|track| !track.title.eq(&self.last_combat_track))
515                .copied()
516                .collect();
517            if !filtered_tracks.is_empty() {
518                maybe_tracks = filtered_tracks;
519            }
520        }
521
522        // Randomly selects a track from the remaining tracks weighted based
523        // on the biome
524        let new_maybe_track = maybe_tracks.choose_weighted(rng, |track| {
525            // If no biome is listed, the song is still added to the
526            // rotation to allow for site specific songs to play
527            // in any biome
528            track
529                .biomes
530                .iter()
531                .find(|b| b.0 == current_biome)
532                .map_or(1.0, |b| 1.0_f32 / (b.1 as f32))
533        });
534        debug!(
535            "selecting new track for {:?}: {:?}",
536            music_state, new_maybe_track
537        );
538
539        if let Ok(track) = new_maybe_track {
540            let now = audio.get_clock_time().unwrap();
541            self.last_track = String::from(&track.title);
542            self.began_playing = Some(now);
543            self.song_end = Some(ClockTime::from_ticks_f64(
544                audio.get_clock().unwrap().id(),
545                time_f64(now) + track.length as f64,
546            ));
547            self.track_length = track.length;
548            self.gap_length = 0.0;
549            if audio.music_enabled() {
550                self.current_track = String::from(&track.title);
551                self.current_artist = String::from(&track.artist.0);
552            } else {
553                self.current_track = String::from("None");
554                self.current_artist = String::from("None");
555            }
556
557            let tag = if matches!(music_state, MusicState::Activity(MusicActivity::Explore)) {
558                MusicChannelTag::Exploration
559            } else {
560                self.last_combat_track = String::from(&track.title);
561                MusicChannelTag::Combat
562            };
563            audio.play_music(&track.path, tag, track.length);
564            if tag == MusicChannelTag::Combat {
565                audio.set_loop_points(
566                    tag,
567                    track.loop_points.unwrap_or((0.0, 0.0)).0,
568                    track.loop_points.unwrap_or((0.0, 0.0)).1,
569                );
570                self.loop_points = track.loop_points
571            } else {
572                self.loop_points = None
573            };
574
575            if let Some(state) = track.activity_override {
576                Ok(MusicState::Activity(state))
577            } else {
578                Ok(*music_state)
579            }
580        } else {
581            Err(format!("{:?}", new_maybe_track))
582        }
583    }
584
585    fn generate_silence_between_tracks(
586        &self,
587        spacing_multiplier: f32,
588        client: &Client,
589        music_state: &MusicState,
590        rng: &mut ThreadRng,
591    ) -> f32 {
592        let mut silence_between_tracks_seconds: f32 = 0.0;
593        if spacing_multiplier > f32::EPSILON {
594            silence_between_tracks_seconds =
595                if matches!(
596                    music_state,
597                    MusicState::Activity(MusicActivity::Explore)
598                        | MusicState::Transition(
599                            MusicActivity::Explore,
600                            MusicActivity::Combat(CombatIntensity::High)
601                        )
602                ) && matches!(client.current_site(), SiteKindMeta::Settlement(_))
603                {
604                    rng.random_range(120.0 * spacing_multiplier..180.0 * spacing_multiplier)
605                } else if matches!(
606                    music_state,
607                    MusicState::Activity(MusicActivity::Explore)
608                        | MusicState::Transition(
609                            MusicActivity::Explore,
610                            MusicActivity::Combat(CombatIntensity::High)
611                        )
612                ) && matches!(client.current_site(), SiteKindMeta::Dungeon(_))
613                {
614                    rng.random_range(10.0 * spacing_multiplier..20.0 * spacing_multiplier)
615                } else if matches!(
616                    music_state,
617                    MusicState::Activity(MusicActivity::Explore)
618                        | MusicState::Transition(
619                            MusicActivity::Explore,
620                            MusicActivity::Combat(CombatIntensity::High)
621                        )
622                ) && matches!(client.current_site(), SiteKindMeta::Cave)
623                {
624                    rng.random_range(20.0 * spacing_multiplier..40.0 * spacing_multiplier)
625                } else if matches!(
626                    music_state,
627                    MusicState::Activity(MusicActivity::Explore)
628                        | MusicState::Transition(
629                            MusicActivity::Explore,
630                            MusicActivity::Combat(CombatIntensity::High)
631                        )
632                ) {
633                    rng.random_range(120.0 * spacing_multiplier..240.0 * spacing_multiplier)
634                } else if matches!(
635                    music_state,
636                    MusicState::Activity(MusicActivity::Combat(_)) | MusicState::Transition(_, _)
637                ) {
638                    0.0
639                } else {
640                    rng.random_range(30.0 * spacing_multiplier..60.0 * spacing_multiplier)
641                };
642        }
643        silence_between_tracks_seconds
644    }
645
646    fn get_current_day_period(is_dark: bool) -> DayPeriod {
647        if is_dark {
648            DayPeriod::Night
649        } else {
650            DayPeriod::Day
651        }
652    }
653
654    pub fn current_track(&self) -> String { self.current_track.clone() }
655
656    pub fn current_artist(&self) -> String { self.current_artist.clone() }
657
658    pub fn reset_track(&mut self, audio: &mut AudioFrontend) {
659        self.current_artist = String::from("None");
660        self.current_track = String::from("None");
661        self.gap_length = 1.0;
662        self.gap_time = 1.0;
663        self.is_gap = true;
664        self.track_length = 0.0;
665        self.began_playing = audio.get_clock_time();
666        self.song_end = audio.get_clock_time();
667    }
668
669    /// Loads default soundtrack if no events are active. Otherwise, attempts to
670    /// compile and load all active event soundtracks, falling back to default
671    /// if they are empty.
672    fn load_soundtrack_items(calendar: &Calendar) -> SoundtrackCollection<SoundtrackItem> {
673        let mut soundtrack = SoundtrackCollection::default();
674        // Loads default soundtrack if no events are active
675        if calendar.events().len() == 0 {
676            for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
677                .read()
678                .tracks
679                .clone()
680            {
681                soundtrack.tracks.push(track)
682            }
683        } else {
684            // Compiles event-specific soundtracks if any are active
685            for event in calendar.events() {
686                match event {
687                    CalendarEvent::Halloween => {
688                        for track in SoundtrackCollection::load_expect(
689                            "voxygen.audio.calendar.halloween.soundtrack",
690                        )
691                        .read()
692                        .tracks
693                        .clone()
694                        {
695                            soundtrack.tracks.push(track)
696                        }
697                    },
698                    CalendarEvent::Christmas => {
699                        for track in SoundtrackCollection::load_expect(
700                            "voxygen.audio.calendar.christmas.soundtrack",
701                        )
702                        .read()
703                        .tracks
704                        .clone()
705                        {
706                            soundtrack.tracks.push(track)
707                        }
708                    },
709                    _ => {
710                        for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
711                            .read()
712                            .tracks
713                            .clone()
714                        {
715                            soundtrack.tracks.push(track)
716                        }
717                    },
718                }
719            }
720        }
721        // Fallback if events are active but give an empty tracklist
722        if soundtrack.tracks.is_empty() {
723            for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
724                .read()
725                .tracks
726                .clone()
727            {
728                soundtrack.tracks.push(track)
729            }
730            soundtrack
731        } else {
732            soundtrack
733        }
734    }
735}
736impl Asset for SoundtrackCollection<SoundtrackItem> {
737    fn load(_: &AssetCache, id: &SharedString) -> Result<Self, BoxedError> {
738        let manifest: AssetHandle<Ron<SoundtrackCollection<RawSoundtrackItem>>> =
739            AssetExt::load(id)?;
740        let mut soundtrack = SoundtrackCollection::default();
741        for item in manifest.read().0.tracks.iter().cloned() {
742            match item {
743                RawSoundtrackItem::Individual(track) => soundtrack.tracks.push(track),
744                RawSoundtrackItem::Segmented {
745                    title,
746                    timing,
747                    weather,
748                    biomes,
749                    sites,
750                    segments,
751                    loop_points,
752                    artist,
753                } => {
754                    for (path, length, music_state, activity_override) in segments.into_iter() {
755                        soundtrack.tracks.push(SoundtrackItem {
756                            title: title.clone(),
757                            path,
758                            length,
759                            loop_points: Some(loop_points),
760                            timing: timing.clone(),
761                            weather,
762                            biomes: biomes.clone(),
763                            sites: sites.clone(),
764                            music_state,
765                            activity_override,
766                            artist: artist.clone(),
767                        });
768                    }
769                },
770            }
771        }
772        Ok(soundtrack)
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use strum::IntoEnumIterator;
780
781    #[test]
782    fn test_load_soundtracks() {
783        let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
784            AssetExt::load_expect("voxygen.audio.soundtrack");
785        for event in CalendarEvent::iter() {
786            match event {
787                CalendarEvent::Halloween => {
788                    let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
789                        SoundtrackCollection::load_expect(
790                            "voxygen.audio.calendar.halloween.soundtrack",
791                        );
792                },
793                CalendarEvent::Christmas => {
794                    let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
795                        SoundtrackCollection::load_expect(
796                            "voxygen.audio.calendar.christmas.soundtrack",
797                        );
798                },
799                _ => {},
800            }
801        }
802    }
803}