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    /// 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
203impl assets::Asset for MusicTransitionManifest {
204    type Loader = assets::RonLoader;
205
206    const EXTENSION: &'static str = "ron";
207}
208
209fn time_f64(clock_time: ClockTime) -> f64 { clock_time.ticks as f64 + clock_time.fraction }
210
211impl MusicMgr {
212    pub fn new(calendar: &Calendar) -> Self {
213        Self {
214            soundtrack: Self::load_soundtrack_items(calendar),
215            began_playing: None,
216            song_end: None,
217            is_gap: true,
218            gap_length: 0.0,
219            gap_time: -1.0,
220            last_track: String::from("None"),
221            last_combat_track: String::from("None"),
222            last_interrupt_attempt: None,
223            last_activity: MusicState::Activity(MusicActivity::Explore),
224            current_track: String::from("None"),
225            current_artist: String::from("None"),
226            track_length: 0.0,
227            loop_points: None,
228        }
229    }
230
231    /// Checks whether the previous track has completed. If so, sends a
232    /// request to play the next (random) track
233    pub fn maintain(&mut self, audio: &mut AudioFrontend, state: &State, client: &Client) {
234        use common::comp::{Group, Health, Pos, group::ENEMY};
235        use specs::{Join, WorldExt};
236
237        if !audio.music_enabled() || audio.get_clock().is_none() || audio.get_clock_time().is_none()
238        {
239            return;
240        }
241
242        let mut activity_state = MusicActivity::Explore;
243
244        let player = client.entity();
245        let ecs = state.ecs();
246        let entities = ecs.entities();
247        let positions = ecs.read_component::<Pos>();
248        let healths = ecs.read_component::<Health>();
249        let groups = ecs.read_component::<Group>();
250        let mtm = audio.mtm.read();
251        let mut rng = thread_rng();
252
253        if audio.combat_music_enabled {
254            if let Some(player_pos) = positions.get(player) {
255                // TODO: `group::ENEMY` will eventually be moved server-side with an
256                // alignment/faction rework, so this will need an alternative way to measure
257                // "in-combat-ness"
258                let num_nearby_entities: u32 = (&entities, &positions, &healths, &groups)
259                    .join()
260                    .map(|(entity, pos, health, group)| {
261                        if entity != player
262                            && group == &ENEMY
263                            && (player_pos.0 - pos.0).magnitude_squared()
264                                < mtm.combat_nearby_radius.powf(2.0)
265                        {
266                            (health.maximum() / mtm.combat_health_factor).ceil() as u32
267                        } else {
268                            0
269                        }
270                    })
271                    .sum();
272
273                if num_nearby_entities >= mtm.combat_nearby_high_thresh {
274                    activity_state = MusicActivity::Combat(CombatIntensity::High);
275                } else if num_nearby_entities >= mtm.combat_nearby_low_thresh {
276                    activity_state = MusicActivity::Combat(CombatIntensity::Low);
277                }
278            }
279        }
280
281        // Override combat music with explore music if the player is dead
282        if let Some(health) = healths.get(player) {
283            if health.is_dead {
284                activity_state = MusicActivity::Explore;
285            }
286        }
287
288        let mut music_state = match self.last_activity {
289            MusicState::Activity(prev) => {
290                if prev != activity_state {
291                    MusicState::Transition(prev, activity_state)
292                } else {
293                    MusicState::Activity(activity_state)
294                }
295            },
296            MusicState::Transition(_, next) => {
297                warn!("Transitioning: {:?}", self.last_activity);
298                MusicState::Activity(next)
299            },
300        };
301
302        let now = audio.get_clock_time().unwrap();
303
304        let began_playing = *self.began_playing.get_or_insert(now);
305        let last_interrupt_attempt = *self.last_interrupt_attempt.get_or_insert(now);
306        let song_end = *self.song_end.get_or_insert(now);
307        let time_since_began_playing = time_f64(now) - time_f64(began_playing);
308
309        // TODO: Instead of a constant tick, make this a timer that starts only when
310        // combat might end, providing a proper "buffer".
311        // interrupt_delay dictates the time between attempted interrupts
312        let interrupt = matches!(music_state, MusicState::Transition(_, _))
313            && time_f64(now) - time_f64(last_interrupt_attempt) > mtm.interrupt_delay as f64;
314
315        // Hack to end combat music since there is currently nothing that detects
316        // transitions away
317        if matches!(
318            music_state,
319            MusicState::Transition(
320                MusicActivity::Combat(CombatIntensity::High),
321                MusicActivity::Explore
322            )
323        ) {
324            music_state = MusicState::Activity(MusicActivity::Explore)
325        }
326
327        if audio.music_enabled()
328            && !self.soundtrack.tracks.is_empty()
329            && (time_since_began_playing
330                > time_f64(song_end) - time_f64(began_playing) // Amount of time between when the song ends and when it began playing
331                || interrupt)
332        {
333            if time_since_began_playing > self.track_length as f64
334                && self.last_activity
335                    != MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
336            {
337                self.current_track = String::from("None");
338                self.current_artist = String::from("None");
339            }
340
341            if interrupt {
342                self.last_interrupt_attempt = Some(now);
343                if let Ok(next_activity) =
344                    self.play_random_track(audio, state, client, &music_state, &mut rng)
345                {
346                    trace!(
347                        "pre-play_random_track: {:?} {:?}",
348                        self.last_activity, music_state
349                    );
350                    self.last_activity = next_activity;
351                }
352            } else if music_state == MusicState::Activity(MusicActivity::Explore)
353                || music_state
354                    == MusicState::Transition(
355                        MusicActivity::Explore,
356                        MusicActivity::Combat(CombatIntensity::High),
357                    )
358            {
359                // If current state is Explore, insert a gap now.
360                if !self.is_gap {
361                    self.gap_length = self.generate_silence_between_tracks(
362                        audio.music_spacing,
363                        client,
364                        &music_state,
365                        &mut rng,
366                    );
367                    self.gap_time = self.gap_length as f64;
368                    self.song_end = audio.get_clock_time();
369                    self.is_gap = true
370                } else if self.gap_time < 0.0 {
371                    // Gap time is up, play a track
372                    // Hack to make combat situations not cancel explore music for now
373                    if music_state
374                        == MusicState::Transition(
375                            MusicActivity::Explore,
376                            MusicActivity::Combat(CombatIntensity::High),
377                        )
378                    {
379                        music_state = MusicState::Activity(MusicActivity::Explore)
380                    }
381                    if let Ok(next_activity) =
382                        self.play_random_track(audio, state, client, &music_state, &mut rng)
383                    {
384                        self.last_activity = next_activity;
385                        self.gap_time = 0.0;
386                        self.gap_length = 0.0;
387                        self.is_gap = false;
388                    }
389                }
390            } else if music_state
391                == MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
392            {
393                // Keep playing! The track should loop automatically.
394                self.began_playing = Some(now);
395                self.song_end = Some(ClockTime::from_ticks_f64(
396                    audio.get_clock().unwrap().id(),
397                    time_f64(now) + self.loop_points.unwrap_or((0.0, 0.0)).1 as f64
398                        - self.loop_points.unwrap_or((0.0, 0.0)).0 as f64,
399                ));
400            } else {
401                trace!(
402                    "pre-play_random_track: {:?} {:?}",
403                    self.last_activity, music_state
404                );
405            }
406        } else {
407            if self.began_playing.is_none() {
408                self.began_playing = Some(now)
409            }
410            if self.soundtrack.tracks.is_empty() {
411                warn!("No tracks available to play")
412            }
413        }
414
415        if time_since_began_playing > self.track_length as f64 {
416            // Time remaining = Max time - (current time - time song ended)
417            if self.is_gap {
418                self.gap_time = (self.gap_length as f64) - (time_f64(now) - time_f64(song_end));
419            }
420        }
421    }
422
423    fn play_random_track(
424        &mut self,
425        audio: &mut AudioFrontend,
426        state: &State,
427        client: &Client,
428        music_state: &MusicState,
429        rng: &mut ThreadRng,
430    ) -> Result<MusicState, String> {
431        let is_dark = state.get_day_period().is_dark();
432        let current_period_of_day = Self::get_current_day_period(is_dark);
433        let current_weather = client.weather_at_player();
434        let current_biome = client.current_biome();
435        let current_site = client.current_site();
436
437        // Filter the soundtrack in stages, so that we don't overprune it if there are
438        // too many constraints. Returning Err(()) signals that we couldn't find
439        // an appropriate track for the current state, and hence the state
440        // machine for the activity shouldn't be updated.
441        // First, filter out tracks not matching the timing, site, biome, and current
442        // activity
443        let mut maybe_tracks = self
444            .soundtrack
445            .tracks
446            .iter()
447            .filter(|track| {
448                (match &track.timing {
449                    Some(period_of_day) => period_of_day == &current_period_of_day,
450                    None => true,
451                }) && match &track.weather {
452                    Some(weather) => weather == &current_weather.get_kind(),
453                    None => true,
454                }
455            })
456            .filter(|track| track.sites.iter().any(|s| s == &current_site))
457            .filter(|track| {
458                track.biomes.is_empty() || track.biomes.iter().any(|b| b.0 == current_biome)
459            })
460            .filter(|track| &track.music_state == music_state)
461            .collect::<Vec<&SoundtrackItem>>();
462        if maybe_tracks.is_empty() {
463            let error_string = format!(
464                "No tracks for {:?}, {:?}, {:?}, {:?}, {:?}",
465                &current_period_of_day,
466                &current_weather,
467                &current_site,
468                &current_biome,
469                &music_state
470            );
471            return Err(error_string);
472        }
473        // Second, prevent playing the last track (when not in combat, because then it
474        // needs to loop)
475        if matches!(
476            music_state,
477            &MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
478                | &MusicState::Transition(
479                    MusicActivity::Combat(CombatIntensity::High),
480                    MusicActivity::Explore
481                )
482        ) {
483            let filtered_tracks: Vec<_> = maybe_tracks
484                .iter()
485                .filter(|track| track.title.eq(&self.last_track))
486                .copied()
487                .collect();
488            if !filtered_tracks.is_empty() {
489                maybe_tracks = filtered_tracks;
490            }
491        } else {
492            let filtered_tracks: Vec<_> = maybe_tracks
493                .iter()
494                .filter(|track| !track.title.eq(&self.last_track))
495                .filter(|track| !track.title.eq(&self.last_combat_track))
496                .copied()
497                .collect();
498            if !filtered_tracks.is_empty() {
499                maybe_tracks = filtered_tracks;
500            }
501        }
502
503        // Randomly selects a track from the remaining tracks weighted based
504        // on the biome
505        let new_maybe_track = maybe_tracks.choose_weighted(rng, |track| {
506            // If no biome is listed, the song is still added to the
507            // rotation to allow for site specific songs to play
508            // in any biome
509            track
510                .biomes
511                .iter()
512                .find(|b| b.0 == current_biome)
513                .map_or(1.0, |b| (1.0_f32 / (b.1 as f32)))
514        });
515        debug!(
516            "selecting new track for {:?}: {:?}",
517            music_state, new_maybe_track
518        );
519
520        if let Ok(track) = new_maybe_track {
521            let now = audio.get_clock_time().unwrap();
522            self.last_track = String::from(&track.title);
523            self.began_playing = Some(now);
524            self.song_end = Some(ClockTime::from_ticks_f64(
525                audio.get_clock().unwrap().id(),
526                time_f64(now) + track.length as f64,
527            ));
528            self.track_length = track.length;
529            self.gap_length = 0.0;
530            if audio.music_enabled() {
531                self.current_track = String::from(&track.title);
532                self.current_artist = String::from(&track.artist.0);
533            } else {
534                self.current_track = String::from("None");
535                self.current_artist = String::from("None");
536            }
537
538            let tag = if matches!(music_state, MusicState::Activity(MusicActivity::Explore)) {
539                MusicChannelTag::Exploration
540            } else {
541                self.last_combat_track = String::from(&track.title);
542                MusicChannelTag::Combat
543            };
544            audio.play_music(&track.path, tag, track.length);
545            if tag == MusicChannelTag::Combat {
546                audio.set_loop_points(
547                    tag,
548                    track.loop_points.unwrap_or((0.0, 0.0)).0,
549                    track.loop_points.unwrap_or((0.0, 0.0)).1,
550                );
551                self.loop_points = track.loop_points
552            } else {
553                self.loop_points = None
554            };
555
556            if let Some(state) = track.activity_override {
557                Ok(MusicState::Activity(state))
558            } else {
559                Ok(*music_state)
560            }
561        } else {
562            Err(format!("{:?}", new_maybe_track))
563        }
564    }
565
566    fn generate_silence_between_tracks(
567        &self,
568        spacing_multiplier: f32,
569        client: &Client,
570        music_state: &MusicState,
571        rng: &mut ThreadRng,
572    ) -> f32 {
573        let mut silence_between_tracks_seconds: f32 = 0.0;
574        if spacing_multiplier > f32::EPSILON {
575            silence_between_tracks_seconds =
576                if matches!(
577                    music_state,
578                    MusicState::Activity(MusicActivity::Explore)
579                        | MusicState::Transition(
580                            MusicActivity::Explore,
581                            MusicActivity::Combat(CombatIntensity::High)
582                        )
583                ) && matches!(client.current_site(), SiteKindMeta::Settlement(_))
584                {
585                    rng.gen_range(120.0 * spacing_multiplier..180.0 * spacing_multiplier)
586                } else if matches!(
587                    music_state,
588                    MusicState::Activity(MusicActivity::Explore)
589                        | MusicState::Transition(
590                            MusicActivity::Explore,
591                            MusicActivity::Combat(CombatIntensity::High)
592                        )
593                ) && matches!(client.current_site(), SiteKindMeta::Dungeon(_))
594                {
595                    rng.gen_range(10.0 * spacing_multiplier..20.0 * spacing_multiplier)
596                } else if matches!(
597                    music_state,
598                    MusicState::Activity(MusicActivity::Explore)
599                        | MusicState::Transition(
600                            MusicActivity::Explore,
601                            MusicActivity::Combat(CombatIntensity::High)
602                        )
603                ) && matches!(client.current_site(), SiteKindMeta::Cave)
604                {
605                    rng.gen_range(20.0 * spacing_multiplier..40.0 * spacing_multiplier)
606                } else if matches!(
607                    music_state,
608                    MusicState::Activity(MusicActivity::Explore)
609                        | MusicState::Transition(
610                            MusicActivity::Explore,
611                            MusicActivity::Combat(CombatIntensity::High)
612                        )
613                ) {
614                    rng.gen_range(120.0 * spacing_multiplier..240.0 * spacing_multiplier)
615                } else if matches!(
616                    music_state,
617                    MusicState::Activity(MusicActivity::Combat(_)) | MusicState::Transition(_, _)
618                ) {
619                    0.0
620                } else {
621                    rng.gen_range(30.0 * spacing_multiplier..60.0 * spacing_multiplier)
622                };
623        }
624        silence_between_tracks_seconds
625    }
626
627    fn get_current_day_period(is_dark: bool) -> DayPeriod {
628        if is_dark {
629            DayPeriod::Night
630        } else {
631            DayPeriod::Day
632        }
633    }
634
635    pub fn current_track(&self) -> String { self.current_track.clone() }
636
637    pub fn current_artist(&self) -> String { self.current_artist.clone() }
638
639    pub fn reset_track(&mut self, audio: &mut AudioFrontend) {
640        self.current_artist = String::from("None");
641        self.current_track = String::from("None");
642        self.gap_length = 1.0;
643        self.gap_time = 1.0;
644        self.is_gap = true;
645        self.track_length = 0.0;
646        self.began_playing = audio.get_clock_time();
647        self.song_end = audio.get_clock_time();
648    }
649
650    /// Loads default soundtrack if no events are active. Otherwise, attempts to
651    /// compile and load all active event soundtracks, falling back to default
652    /// if they are empty.
653    fn load_soundtrack_items(calendar: &Calendar) -> SoundtrackCollection<SoundtrackItem> {
654        let mut soundtrack = SoundtrackCollection::default();
655        // Loads default soundtrack if no events are active
656        if calendar.events().len() == 0 {
657            for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
658                .read()
659                .tracks
660                .clone()
661            {
662                soundtrack.tracks.push(track)
663            }
664        } else {
665            // Compiles event-specific soundtracks if any are active
666            for event in calendar.events() {
667                match event {
668                    CalendarEvent::Halloween => {
669                        for track in SoundtrackCollection::load_expect(
670                            "voxygen.audio.calendar.halloween.soundtrack",
671                        )
672                        .read()
673                        .tracks
674                        .clone()
675                        {
676                            soundtrack.tracks.push(track)
677                        }
678                    },
679                    CalendarEvent::Christmas => {
680                        for track in SoundtrackCollection::load_expect(
681                            "voxygen.audio.calendar.christmas.soundtrack",
682                        )
683                        .read()
684                        .tracks
685                        .clone()
686                        {
687                            soundtrack.tracks.push(track)
688                        }
689                    },
690                    _ => {
691                        for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
692                            .read()
693                            .tracks
694                            .clone()
695                        {
696                            soundtrack.tracks.push(track)
697                        }
698                    },
699                }
700            }
701        }
702        // Fallback if events are active but give an empty tracklist
703        if soundtrack.tracks.is_empty() {
704            for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
705                .read()
706                .tracks
707                .clone()
708            {
709                soundtrack.tracks.push(track)
710            }
711            soundtrack
712        } else {
713            soundtrack
714        }
715    }
716}
717impl assets::Asset for SoundtrackCollection<RawSoundtrackItem> {
718    type Loader = assets::RonLoader;
719
720    const EXTENSION: &'static str = "ron";
721}
722
723impl assets::Compound for SoundtrackCollection<SoundtrackItem> {
724    fn load(_: assets::AnyCache, id: &assets::SharedString) -> Result<Self, assets::BoxedError> {
725        let manifest: AssetHandle<SoundtrackCollection<RawSoundtrackItem>> = AssetExt::load(id)?;
726        let mut soundtrack = SoundtrackCollection::default();
727        for item in manifest.read().tracks.iter().cloned() {
728            match item {
729                RawSoundtrackItem::Individual(track) => soundtrack.tracks.push(track),
730                RawSoundtrackItem::Segmented {
731                    title,
732                    timing,
733                    weather,
734                    biomes,
735                    sites,
736                    segments,
737                    loop_points,
738                    artist,
739                } => {
740                    for (path, length, music_state, activity_override) in segments.into_iter() {
741                        soundtrack.tracks.push(SoundtrackItem {
742                            title: title.clone(),
743                            path,
744                            length,
745                            loop_points: Some(loop_points),
746                            timing: timing.clone(),
747                            weather,
748                            biomes: biomes.clone(),
749                            sites: sites.clone(),
750                            music_state,
751                            activity_override,
752                            artist: artist.clone(),
753                        });
754                    }
755                },
756            }
757        }
758        Ok(soundtrack)
759    }
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765    use strum::IntoEnumIterator;
766
767    #[test]
768    fn test_load_soundtracks() {
769        let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
770            SoundtrackCollection::load_expect("voxygen.audio.soundtrack");
771        for event in CalendarEvent::iter() {
772            match event {
773                CalendarEvent::Halloween => {
774                    let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
775                        SoundtrackCollection::load_expect(
776                            "voxygen.audio.calendar.halloween.soundtrack",
777                        );
778                },
779                CalendarEvent::Christmas => {
780                    let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
781                        SoundtrackCollection::load_expect(
782                            "voxygen.audio.calendar.christmas.soundtrack",
783                        );
784                },
785                _ => {},
786            }
787        }
788    }
789}