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,
520                &current_weather,
521                &current_site,
522                &current_biome,
523                &music_state
524            );
525            if maybe_tracks.is_empty() {
526                return Err(error_string);
527            } else {
528                error_string.push_str(", using default music for current site.");
529                warn!(error_string);
530            }
531        }
532        // Second, prevent playing the last track (when not in combat, because then it
533        // needs to loop)
534        if matches!(
535            music_state,
536            &MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
537                | &MusicState::Transition(
538                    MusicActivity::Combat(CombatIntensity::High),
539                    MusicActivity::Explore
540                )
541        ) {
542            let filtered_tracks: Vec<_> = maybe_tracks
543                .iter()
544                .filter(|track| track.title.eq(&self.last_track))
545                .copied()
546                .collect();
547            if !filtered_tracks.is_empty() {
548                maybe_tracks = filtered_tracks;
549            }
550        } else {
551            let filtered_tracks: Vec<_> = maybe_tracks
552                .iter()
553                .filter(|track| !track.title.eq(&self.last_track))
554                .filter(|track| !track.title.eq(&self.last_combat_track))
555                .copied()
556                .collect();
557            if !filtered_tracks.is_empty() {
558                maybe_tracks = filtered_tracks;
559            }
560        }
561
562        // Randomly selects a track from the remaining tracks weighted based
563        // on the biome
564        let new_maybe_track = maybe_tracks.choose_weighted(rng, |track| {
565            // If no biome is listed, the song is still added to the
566            // rotation to allow for site specific songs to play
567            // in any biome
568            track
569                .biomes
570                .iter()
571                .find(|b| b.0 == current_biome)
572                .map_or(1.0, |b| 1.0_f32 / (b.1 as f32))
573        });
574        debug!(
575            "selecting new track for {:?}: {:?}",
576            music_state, new_maybe_track
577        );
578
579        if let Ok(track) = new_maybe_track {
580            let now = audio.get_clock_time().unwrap();
581            self.last_track = String::from(&track.title);
582            self.began_playing = Some(now);
583            self.song_end = Some(ClockTime::from_ticks_f64(
584                audio.get_clock().unwrap().id(),
585                time_f64(now) + track.length as f64,
586            ));
587            self.track_length = track.length;
588            self.gap_length = 0.0;
589            if audio.music_enabled() {
590                self.current_track = String::from(&track.title);
591                self.current_artist = String::from(&track.artist.0);
592            } else {
593                self.current_track = String::from("None");
594                self.current_artist = String::from("None");
595            }
596
597            let tag = if matches!(
598                music_state,
599                MusicState::Activity(MusicActivity::Explore)
600                    | MusicState::Transition(MusicActivity::Explore, MusicActivity::Explore)
601            ) {
602                MusicChannelTag::Exploration
603            } else {
604                self.last_combat_track = String::from(&track.title);
605                MusicChannelTag::Combat
606            };
607            audio.play_music(&track.path, tag, track.length);
608            if tag == MusicChannelTag::Combat {
609                audio.set_loop_points(
610                    tag,
611                    track.loop_points.unwrap_or((0.0, 0.0)).0,
612                    track.loop_points.unwrap_or((0.0, 0.0)).1,
613                );
614                self.loop_points = track.loop_points
615            } else {
616                self.loop_points = None
617            };
618
619            if let Some(state) = track.activity_override {
620                Ok(MusicState::Activity(state))
621            } else {
622                Ok(*music_state)
623            }
624        } else {
625            Err(format!("{:?}", new_maybe_track))
626        }
627    }
628
629    fn generate_silence_between_tracks(
630        &self,
631        spacing_multiplier: f32,
632        client: &Client,
633        music_state: &MusicState,
634        rng: &mut ThreadRng,
635    ) -> f32 {
636        let mut silence_between_tracks_seconds: f32 = 0.0;
637        if spacing_multiplier > f32::EPSILON {
638            silence_between_tracks_seconds =
639                if matches!(
640                    music_state,
641                    MusicState::Activity(MusicActivity::Explore)
642                        | MusicState::Transition(
643                            MusicActivity::Explore,
644                            MusicActivity::Combat(CombatIntensity::High)
645                        )
646                ) && matches!(client.current_site(), SiteKindMeta::Settlement(_))
647                {
648                    rng.random_range(120.0 * spacing_multiplier..180.0 * spacing_multiplier)
649                } else if matches!(
650                    music_state,
651                    MusicState::Activity(MusicActivity::Explore)
652                        | MusicState::Transition(
653                            MusicActivity::Explore,
654                            MusicActivity::Combat(CombatIntensity::High)
655                        )
656                ) && matches!(client.current_site(), SiteKindMeta::Dungeon(_))
657                {
658                    rng.random_range(10.0 * spacing_multiplier..20.0 * spacing_multiplier)
659                } else if matches!(
660                    music_state,
661                    MusicState::Activity(MusicActivity::Explore)
662                        | MusicState::Transition(
663                            MusicActivity::Explore,
664                            MusicActivity::Combat(CombatIntensity::High)
665                        )
666                ) && matches!(client.current_site(), SiteKindMeta::Cave)
667                {
668                    rng.random_range(20.0 * spacing_multiplier..40.0 * spacing_multiplier)
669                } else if matches!(
670                    music_state,
671                    MusicState::Activity(MusicActivity::Explore)
672                        | MusicState::Transition(
673                            MusicActivity::Explore,
674                            MusicActivity::Combat(CombatIntensity::High)
675                        )
676                ) {
677                    rng.random_range(120.0 * spacing_multiplier..240.0 * spacing_multiplier)
678                } else if matches!(
679                    music_state,
680                    MusicState::Activity(MusicActivity::Combat(_)) | MusicState::Transition(_, _)
681                ) {
682                    0.0
683                } else {
684                    rng.random_range(30.0 * spacing_multiplier..60.0 * spacing_multiplier)
685                };
686        }
687        silence_between_tracks_seconds
688    }
689
690    fn get_current_day_period(is_dark: bool) -> DayPeriod {
691        if is_dark {
692            DayPeriod::Night
693        } else {
694            DayPeriod::Day
695        }
696    }
697
698    pub fn current_track(&self) -> String { self.current_track.clone() }
699
700    pub fn current_artist(&self) -> String { self.current_artist.clone() }
701
702    pub fn reset_track(&mut self, audio: &mut AudioFrontend) {
703        self.current_artist = String::from("None");
704        self.current_track = String::from("None");
705        self.gap_length = 1.0;
706        self.gap_time = 1.0;
707        self.is_gap = true;
708        self.track_length = 0.0;
709        self.began_playing = audio.get_clock_time();
710        self.song_end = audio.get_clock_time();
711    }
712
713    /// Loads default soundtrack if no events are active. Otherwise, attempts to
714    /// compile and load all active event soundtracks, falling back to default
715    /// if they are empty.
716    fn load_soundtrack_items(calendar: &Calendar) -> SoundtrackCollection<SoundtrackItem> {
717        let mut soundtrack = SoundtrackCollection::default();
718        // Loads default soundtrack if no events are active
719        if calendar.events().len() == 0 {
720            for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
721                .read()
722                .tracks
723                .clone()
724            {
725                soundtrack.tracks.push(track)
726            }
727        } else {
728            // Compiles event-specific soundtracks if any are active
729            for event in calendar.events() {
730                match event {
731                    CalendarEvent::Halloween => {
732                        for track in SoundtrackCollection::load_expect(
733                            "voxygen.audio.calendar.halloween.soundtrack",
734                        )
735                        .read()
736                        .tracks
737                        .clone()
738                        {
739                            soundtrack.tracks.push(track)
740                        }
741                    },
742                    CalendarEvent::Christmas => {
743                        for track in SoundtrackCollection::load_expect(
744                            "voxygen.audio.calendar.christmas.soundtrack",
745                        )
746                        .read()
747                        .tracks
748                        .clone()
749                        {
750                            soundtrack.tracks.push(track)
751                        }
752                    },
753                    _ => {
754                        for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
755                            .read()
756                            .tracks
757                            .clone()
758                        {
759                            soundtrack.tracks.push(track)
760                        }
761                    },
762                }
763            }
764        }
765        // Fallback if events are active but give an empty tracklist
766        if soundtrack.tracks.is_empty() {
767            for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
768                .read()
769                .tracks
770                .clone()
771            {
772                soundtrack.tracks.push(track)
773            }
774            soundtrack
775        } else {
776            soundtrack
777        }
778    }
779}
780impl Asset for SoundtrackCollection<SoundtrackItem> {
781    fn load(_: &AssetCache, id: &SharedString) -> Result<Self, BoxedError> {
782        let manifest: AssetHandle<Ron<SoundtrackCollection<RawSoundtrackItem>>> =
783            AssetExt::load(id)?;
784        let mut soundtrack = SoundtrackCollection::default();
785        for item in manifest.read().0.tracks.iter().cloned() {
786            match item {
787                RawSoundtrackItem::Individual(track) => soundtrack.tracks.push(track),
788                RawSoundtrackItem::Segmented {
789                    title,
790                    timing,
791                    weather,
792                    biomes,
793                    sites,
794                    segments,
795                    loop_points,
796                    artist,
797                } => {
798                    for (path, length, music_state, activity_override) in segments.into_iter() {
799                        soundtrack.tracks.push(SoundtrackItem {
800                            title: title.clone(),
801                            path,
802                            length,
803                            loop_points: Some(loop_points),
804                            timing: timing.clone(),
805                            weather,
806                            biomes: biomes.clone(),
807                            sites: sites.clone(),
808                            music_state,
809                            activity_override,
810                            artist: artist.clone(),
811                        });
812                    }
813                },
814            }
815        }
816        Ok(soundtrack)
817    }
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823    use strum::IntoEnumIterator;
824
825    #[test]
826    fn test_load_soundtracks() {
827        let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
828            AssetExt::load_expect("voxygen.audio.soundtrack");
829        for event in CalendarEvent::iter() {
830            match event {
831                CalendarEvent::Halloween => {
832                    let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
833                        SoundtrackCollection::load_expect(
834                            "voxygen.audio.calendar.halloween.soundtrack",
835                        );
836                },
837                CalendarEvent::Christmas => {
838                    let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
839                        SoundtrackCollection::load_expect(
840                            "voxygen.audio.calendar.christmas.soundtrack",
841                        );
842                },
843                _ => {},
844            }
845        }
846    }
847}