Skip to main content

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::{RngExt, 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    /// The last known site the player was in, used to detect site transition
175    last_site: SiteKindMeta,
176}
177
178#[derive(Deserialize)]
179pub struct MusicTransitionManifest {
180    /// Within what radius do enemies count towards combat music?
181    combat_nearby_radius: f32,
182    /// Each multiple of this factor that an enemy has health counts as an extra
183    /// enemy
184    combat_health_factor: f32,
185    /// How many nearby enemies trigger High combat music
186    combat_nearby_high_thresh: u32,
187    /// How many nearby enemies trigger Low combat music
188    combat_nearby_low_thresh: u32,
189    /// Fade in and fade out timings for transitions between channels
190    pub fade_timings: HashMap<(MusicChannelTag, MusicChannelTag), (f32, f32)>,
191    /// How many seconds between interrupt checks
192    pub interrupt_delay: f32,
193}
194
195impl Default for MusicTransitionManifest {
196    fn default() -> MusicTransitionManifest {
197        MusicTransitionManifest {
198            combat_nearby_radius: 40.0,
199            combat_health_factor: 100.0,
200            combat_nearby_high_thresh: 3,
201            combat_nearby_low_thresh: 1,
202            fade_timings: HashMap::new(),
203            interrupt_delay: 5.0,
204        }
205    }
206}
207
208fn time_f64(clock_time: ClockTime) -> f64 { clock_time.ticks as f64 + clock_time.fraction }
209
210impl MusicMgr {
211    pub fn new(calendar: &Calendar) -> Self {
212        Self {
213            soundtrack: Self::load_soundtrack_items(calendar),
214            began_playing: None,
215            song_end: None,
216            is_gap: true,
217            gap_length: 0.0,
218            gap_time: -1.0,
219            last_track: String::from("None"),
220            last_combat_track: String::from("None"),
221            last_interrupt_attempt: None,
222            last_activity: MusicState::Activity(MusicActivity::Explore),
223            current_track: String::from("None"),
224            current_artist: String::from("None"),
225            track_length: 0.0,
226            loop_points: None,
227            last_site: SiteKindMeta::Void,
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 = rng();
252
253        if audio.combat_music_enabled
254            && let Some(player_pos) = positions.get(player)
255        {
256            // TODO: `group::ENEMY` will eventually be moved server-side with an
257            // alignment/faction rework, so this will need an alternative way to measure
258            // "in-combat-ness"
259            let num_nearby_entities: u32 = (&entities, &positions, &healths, &groups)
260                .join()
261                .map(|(entity, pos, health, group)| {
262                    if entity != player
263                        && group == &ENEMY
264                        && (player_pos.0 - pos.0).magnitude_squared()
265                            < mtm.0.combat_nearby_radius.powf(2.0)
266                    {
267                        (health.maximum() / mtm.0.combat_health_factor).ceil() as u32
268                    } else {
269                        0
270                    }
271                })
272                .sum();
273
274            if num_nearby_entities >= mtm.0.combat_nearby_high_thresh {
275                activity_state = MusicActivity::Combat(CombatIntensity::High);
276            } else if num_nearby_entities >= mtm.0.combat_nearby_low_thresh {
277                activity_state = MusicActivity::Combat(CombatIntensity::Low);
278            }
279        }
280
281        // Override combat music with explore music if the player is dead
282        if let Some(health) = healths.get(player)
283            && health.is_dead
284        {
285            activity_state = MusicActivity::Explore;
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        // Detect site transitions
310        let current_site = client.current_site();
311        let site_changed = current_site != self.last_site;
312        self.last_site = current_site;
313
314        // On site transition override the music state to Transition(Explore, Explore)
315        // so the interrupt path triggers an immediate crossfade to a
316        // context-appropriate track.
317        if (site_changed)
318            && !matches!(
319                music_state,
320                MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
321            )
322        {
323            music_state = MusicState::Transition(MusicActivity::Explore, MusicActivity::Explore);
324        }
325
326        // TODO: Instead of a constant tick, make this a timer that starts only when
327        // combat might end, providing a proper "buffer".
328        // interrupt_delay dictates the time between attempted interrupts.
329        // Transition(Explore, Explore) bypasses the delay.
330        // Site changes should always switch immediately regardless of when the last
331        // interrupt was.
332        let interrupt = matches!(music_state, MusicState::Transition(_, _))
333            && (matches!(
334                music_state,
335                MusicState::Transition(MusicActivity::Explore, MusicActivity::Explore)
336            ) || time_f64(now) - time_f64(last_interrupt_attempt)
337                > mtm.0.interrupt_delay as f64);
338
339        // Hack to end combat music since there is currently nothing that detects
340        // transitions away
341        if matches!(
342            music_state,
343            MusicState::Transition(
344                MusicActivity::Combat(CombatIntensity::High),
345                MusicActivity::Explore
346            )
347        ) {
348            music_state = MusicState::Activity(MusicActivity::Explore)
349        }
350
351        if audio.music_enabled()
352            && !self.soundtrack.tracks.is_empty()
353            && (time_since_began_playing
354                > time_f64(song_end) - time_f64(began_playing) // Amount of time between when the song ends and when it began playing
355                || interrupt)
356        {
357            if time_since_began_playing > self.track_length as f64
358                && self.last_activity
359                    != MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
360            {
361                self.current_track = String::from("None");
362                self.current_artist = String::from("None");
363            }
364
365            if interrupt {
366                self.last_interrupt_attempt = Some(now);
367                self.is_gap = false;
368                self.gap_time = 0.0;
369                self.gap_length = 0.0;
370                // Transition(Explore, Explore) is an internal signal only.
371                // Resolve it to Activity(Explore) so the track filter in play_random_track
372                // finds matching soundtrack entries.
373                let track_state = if music_state
374                    == MusicState::Transition(MusicActivity::Explore, MusicActivity::Explore)
375                {
376                    MusicState::Activity(MusicActivity::Explore)
377                } else {
378                    music_state
379                };
380                if let Ok(next_activity) =
381                    self.play_random_track(audio, state, client, &track_state, &mut rng)
382                {
383                    trace!(
384                        "pre-play_random_track: {:?} {:?}",
385                        self.last_activity, music_state
386                    );
387                    self.last_activity = next_activity;
388                }
389            } else if music_state == MusicState::Activity(MusicActivity::Explore)
390                || music_state
391                    == MusicState::Transition(
392                        MusicActivity::Explore,
393                        MusicActivity::Combat(CombatIntensity::High),
394                    )
395            {
396                // If current state is Explore, insert a gap now.
397                if !self.is_gap {
398                    self.gap_length = self.generate_silence_between_tracks(
399                        audio.music_spacing,
400                        client,
401                        &music_state,
402                        &mut rng,
403                    );
404                    self.gap_time = self.gap_length as f64;
405                    self.song_end = audio.get_clock_time();
406                    self.is_gap = true
407                } else if self.gap_time < 0.0 {
408                    // Gap time is up, play a track
409                    // Hack to make combat situations not cancel explore music for now
410                    if music_state
411                        == MusicState::Transition(
412                            MusicActivity::Explore,
413                            MusicActivity::Combat(CombatIntensity::High),
414                        )
415                    {
416                        music_state = MusicState::Activity(MusicActivity::Explore)
417                    }
418                    if let Ok(next_activity) =
419                        self.play_random_track(audio, state, client, &music_state, &mut rng)
420                    {
421                        self.last_activity = next_activity;
422                        self.gap_time = 0.0;
423                        self.gap_length = 0.0;
424                        self.is_gap = false;
425                    }
426                }
427            } else if music_state
428                == MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
429            {
430                // Keep playing! The track should loop automatically.
431                self.began_playing = Some(now);
432                self.song_end = Some(ClockTime::from_ticks_f64(
433                    audio.get_clock().unwrap().id(),
434                    time_f64(now) + self.loop_points.unwrap_or((0.0, 0.0)).1 as f64
435                        - self.loop_points.unwrap_or((0.0, 0.0)).0 as f64,
436                ));
437            } else {
438                trace!(
439                    "pre-play_random_track: {:?} {:?}",
440                    self.last_activity, music_state
441                );
442            }
443        } else {
444            if self.began_playing.is_none() {
445                self.began_playing = Some(now)
446            }
447            if self.soundtrack.tracks.is_empty() {
448                warn!("No tracks available to play")
449            }
450        }
451
452        if time_since_began_playing > self.track_length as f64 {
453            // Time remaining = Max time - (current time - time song ended)
454            if self.is_gap {
455                self.gap_time = (self.gap_length as f64) - (time_f64(now) - time_f64(song_end));
456            }
457        }
458    }
459
460    fn play_random_track(
461        &mut self,
462        audio: &mut AudioFrontend,
463        state: &State,
464        client: &Client,
465        music_state: &MusicState,
466        rng: &mut ThreadRng,
467    ) -> Result<MusicState, String> {
468        let is_dark = state.get_day_period().is_dark();
469        let current_period_of_day = Self::get_current_day_period(is_dark);
470        let current_weather = client.weather_at_player();
471        let current_biome = client.current_biome();
472        let current_site = client.current_site();
473
474        // Filter the soundtrack in stages, so that we don't overprune it if there are
475        // too many constraints. Returning Err(()) signals that we couldn't find
476        // an appropriate track for the current state, and hence the state
477        // machine for the activity shouldn't be updated.
478        // First, filter out tracks not matching the timing, site, biome, and current
479        // activity
480        let mut maybe_tracks = self
481            .soundtrack
482            .tracks
483            .iter()
484            .filter(|track| {
485                (match &track.timing {
486                    Some(period_of_day) => period_of_day == &current_period_of_day,
487                    None => true,
488                }) && match &track.weather {
489                    Some(weather) => weather == &current_weather.get_kind(),
490                    None => true,
491                }
492            })
493            .filter(|track| track.sites.iter().any(|s| s == &current_site))
494            .filter(|track| {
495                track.biomes.is_empty() || track.biomes.iter().any(|b| b.0 == current_biome)
496            })
497            .filter(|track| &track.music_state == music_state)
498            .collect::<Vec<&SoundtrackItem>>();
499        if maybe_tracks.is_empty() {
500            // If tracklist is empty, it may be that the current site kind does not exist
501            // yet. Use sensible defaults in that case.
502            maybe_tracks =
503                self.soundtrack
504                    .tracks
505                    .iter()
506                    .filter(|track| match &current_site {
507                        SiteKindMeta::Settlement(_) => track.sites.iter().any(|site| {
508                            site == &SiteKindMeta::Settlement(SettlementKindMeta::Default)
509                        }),
510                        SiteKindMeta::Dungeon(_) => track
511                            .sites
512                            .iter()
513                            .any(|site| site == &SiteKindMeta::Dungeon(DungeonKindMeta::Cultist)),
514                        _ => false,
515                    })
516                    .collect::<Vec<&SoundtrackItem>>();
517            let mut error_string = format!(
518                "No tracks for {:?}, {:?}, {:?}, {:?}, {:?}",
519                current_period_of_day, current_weather, current_site, current_biome, music_state
520            );
521            if maybe_tracks.is_empty() {
522                return Err(error_string);
523            } else {
524                error_string.push_str(", using default music for current site.");
525                warn!(error_string);
526            }
527        }
528        // Second, prevent playing the last track (when not in combat, because then it
529        // needs to loop)
530        if matches!(
531            music_state,
532            &MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
533                | &MusicState::Transition(
534                    MusicActivity::Combat(CombatIntensity::High),
535                    MusicActivity::Explore
536                )
537        ) {
538            let filtered_tracks: Vec<_> = maybe_tracks
539                .iter()
540                .filter(|track| track.title.eq(&self.last_track))
541                .copied()
542                .collect();
543            if !filtered_tracks.is_empty() {
544                maybe_tracks = filtered_tracks;
545            }
546        } else {
547            let filtered_tracks: Vec<_> = maybe_tracks
548                .iter()
549                .filter(|track| !track.title.eq(&self.last_track))
550                .filter(|track| !track.title.eq(&self.last_combat_track))
551                .copied()
552                .collect();
553            if !filtered_tracks.is_empty() {
554                maybe_tracks = filtered_tracks;
555            }
556        }
557
558        // Randomly selects a track from the remaining tracks weighted based
559        // on the biome
560        let new_maybe_track = maybe_tracks.choose_weighted(rng, |track| {
561            // If no biome is listed, the song is still added to the
562            // rotation to allow for site specific songs to play
563            // in any biome
564            track
565                .biomes
566                .iter()
567                .find(|b| b.0 == current_biome)
568                .map_or(1.0, |b| 1.0_f32 / (b.1 as f32))
569        });
570        debug!(
571            "selecting new track for {:?}: {:?}",
572            music_state, new_maybe_track
573        );
574
575        if let Ok(track) = new_maybe_track {
576            let now = audio.get_clock_time().unwrap();
577            self.last_track = String::from(&track.title);
578            self.began_playing = Some(now);
579            self.song_end = Some(ClockTime::from_ticks_f64(
580                audio.get_clock().unwrap().id(),
581                time_f64(now) + track.length as f64,
582            ));
583            self.track_length = track.length;
584            self.gap_length = 0.0;
585            if audio.music_enabled() {
586                self.current_track = String::from(&track.title);
587                self.current_artist = String::from(&track.artist.0);
588            } else {
589                self.current_track = String::from("None");
590                self.current_artist = String::from("None");
591            }
592
593            let tag = if matches!(
594                music_state,
595                MusicState::Activity(MusicActivity::Explore)
596                    | MusicState::Transition(MusicActivity::Explore, MusicActivity::Explore)
597            ) {
598                MusicChannelTag::Exploration
599            } else {
600                self.last_combat_track = String::from(&track.title);
601                MusicChannelTag::Combat
602            };
603            audio.play_music(&track.path, tag, track.length);
604            if tag == MusicChannelTag::Combat {
605                audio.set_loop_points(
606                    tag,
607                    track.loop_points.unwrap_or((0.0, 0.0)).0,
608                    track.loop_points.unwrap_or((0.0, 0.0)).1,
609                );
610                self.loop_points = track.loop_points
611            } else {
612                self.loop_points = None
613            };
614
615            if let Some(state) = track.activity_override {
616                Ok(MusicState::Activity(state))
617            } else {
618                Ok(*music_state)
619            }
620        } else {
621            Err(format!("{:?}", new_maybe_track))
622        }
623    }
624
625    fn generate_silence_between_tracks(
626        &self,
627        spacing_multiplier: f32,
628        client: &Client,
629        music_state: &MusicState,
630        rng: &mut ThreadRng,
631    ) -> f32 {
632        let mut silence_between_tracks_seconds: f32 = 0.0;
633        if spacing_multiplier > f32::EPSILON {
634            silence_between_tracks_seconds =
635                if matches!(
636                    music_state,
637                    MusicState::Activity(MusicActivity::Explore)
638                        | MusicState::Transition(
639                            MusicActivity::Explore,
640                            MusicActivity::Combat(CombatIntensity::High)
641                        )
642                ) && matches!(client.current_site(), SiteKindMeta::Settlement(_))
643                {
644                    rng.random_range(120.0 * spacing_multiplier..180.0 * spacing_multiplier)
645                } else if matches!(
646                    music_state,
647                    MusicState::Activity(MusicActivity::Explore)
648                        | MusicState::Transition(
649                            MusicActivity::Explore,
650                            MusicActivity::Combat(CombatIntensity::High)
651                        )
652                ) && matches!(client.current_site(), SiteKindMeta::Dungeon(_))
653                {
654                    rng.random_range(10.0 * spacing_multiplier..20.0 * spacing_multiplier)
655                } else if matches!(
656                    music_state,
657                    MusicState::Activity(MusicActivity::Explore)
658                        | MusicState::Transition(
659                            MusicActivity::Explore,
660                            MusicActivity::Combat(CombatIntensity::High)
661                        )
662                ) && matches!(client.current_site(), SiteKindMeta::Cave)
663                {
664                    rng.random_range(20.0 * spacing_multiplier..40.0 * spacing_multiplier)
665                } else if matches!(
666                    music_state,
667                    MusicState::Activity(MusicActivity::Explore)
668                        | MusicState::Transition(
669                            MusicActivity::Explore,
670                            MusicActivity::Combat(CombatIntensity::High)
671                        )
672                ) {
673                    rng.random_range(120.0 * spacing_multiplier..240.0 * spacing_multiplier)
674                } else if matches!(
675                    music_state,
676                    MusicState::Activity(MusicActivity::Combat(_)) | MusicState::Transition(_, _)
677                ) {
678                    0.0
679                } else {
680                    rng.random_range(30.0 * spacing_multiplier..60.0 * spacing_multiplier)
681                };
682        }
683        silence_between_tracks_seconds
684    }
685
686    fn get_current_day_period(is_dark: bool) -> DayPeriod {
687        if is_dark {
688            DayPeriod::Night
689        } else {
690            DayPeriod::Day
691        }
692    }
693
694    pub fn current_track(&self) -> String { self.current_track.clone() }
695
696    pub fn current_artist(&self) -> String { self.current_artist.clone() }
697
698    pub fn reset_track(&mut self, audio: &mut AudioFrontend) {
699        self.current_artist = String::from("None");
700        self.current_track = String::from("None");
701        self.gap_length = 1.0;
702        self.gap_time = 1.0;
703        self.is_gap = true;
704        self.track_length = 0.0;
705        self.began_playing = audio.get_clock_time();
706        self.song_end = audio.get_clock_time();
707    }
708
709    /// Loads default soundtrack if no events are active. Otherwise, attempts to
710    /// compile and load all active event soundtracks, falling back to default
711    /// if they are empty.
712    fn load_soundtrack_items(calendar: &Calendar) -> SoundtrackCollection<SoundtrackItem> {
713        let mut soundtrack = SoundtrackCollection::default();
714        // Loads default soundtrack if no events are active
715        if calendar.events().len() == 0 {
716            for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
717                .read()
718                .tracks
719                .clone()
720            {
721                soundtrack.tracks.push(track)
722            }
723        } else {
724            // Compiles event-specific soundtracks if any are active
725            for event in calendar.events() {
726                match event {
727                    CalendarEvent::Halloween => {
728                        for track in SoundtrackCollection::load_expect(
729                            "voxygen.audio.calendar.halloween.soundtrack",
730                        )
731                        .read()
732                        .tracks
733                        .clone()
734                        {
735                            soundtrack.tracks.push(track)
736                        }
737                    },
738                    CalendarEvent::Christmas => {
739                        for track in SoundtrackCollection::load_expect(
740                            "voxygen.audio.calendar.christmas.soundtrack",
741                        )
742                        .read()
743                        .tracks
744                        .clone()
745                        {
746                            soundtrack.tracks.push(track)
747                        }
748                    },
749                    _ => {
750                        for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
751                            .read()
752                            .tracks
753                            .clone()
754                        {
755                            soundtrack.tracks.push(track)
756                        }
757                    },
758                }
759            }
760        }
761        // Fallback if events are active but give an empty tracklist
762        if soundtrack.tracks.is_empty() {
763            for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
764                .read()
765                .tracks
766                .clone()
767            {
768                soundtrack.tracks.push(track)
769            }
770            soundtrack
771        } else {
772            soundtrack
773        }
774    }
775}
776impl Asset for SoundtrackCollection<SoundtrackItem> {
777    fn load(_: &AssetCache, id: &SharedString) -> Result<Self, BoxedError> {
778        let manifest: AssetHandle<Ron<SoundtrackCollection<RawSoundtrackItem>>> =
779            AssetExt::load(id)?;
780        let mut soundtrack = SoundtrackCollection::default();
781        for item in manifest.read().0.tracks.iter().cloned() {
782            match item {
783                RawSoundtrackItem::Individual(track) => soundtrack.tracks.push(track),
784                RawSoundtrackItem::Segmented {
785                    title,
786                    timing,
787                    weather,
788                    biomes,
789                    sites,
790                    segments,
791                    loop_points,
792                    artist,
793                } => {
794                    for (path, length, music_state, activity_override) in segments.into_iter() {
795                        soundtrack.tracks.push(SoundtrackItem {
796                            title: title.clone(),
797                            path,
798                            length,
799                            loop_points: Some(loop_points),
800                            timing: timing.clone(),
801                            weather,
802                            biomes: biomes.clone(),
803                            sites: sites.clone(),
804                            music_state,
805                            activity_override,
806                            artist: artist.clone(),
807                        });
808                    }
809                },
810            }
811        }
812        Ok(soundtrack)
813    }
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819    use strum::IntoEnumIterator;
820
821    #[test]
822    fn test_load_soundtracks() {
823        let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
824            AssetExt::load_expect("voxygen.audio.soundtrack");
825        for event in CalendarEvent::iter() {
826            match event {
827                CalendarEvent::Halloween => {
828                    let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
829                        SoundtrackCollection::load_expect(
830                            "voxygen.audio.calendar.halloween.soundtrack",
831                        );
832                },
833                CalendarEvent::Christmas => {
834                    let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
835                        SoundtrackCollection::load_expect(
836                            "voxygen.audio.calendar.christmas.soundtrack",
837                        );
838                },
839                _ => {},
840            }
841        }
842    }
843}