1use crate::audio::{AudioFrontend, MusicChannelTag};
47use client::Client;
48use common::{
49 assets::{Asset, AssetCache, AssetExt, AssetHandle, BoxedError, Ron, SharedString},
50 calendar::{Calendar, CalendarEvent},
51 terrain::{
52 BiomeKind, SiteKindMeta,
53 site::{DungeonKindMeta, SettlementKindMeta},
54 },
55 weather::WeatherKind,
56};
57use common_state::State;
58use hashbrown::HashMap;
59use kira::clock::ClockTime;
60use rand::{Rng, prelude::IndexedRandom, rng, rngs::ThreadRng};
61use serde::Deserialize;
62use tracing::{debug, trace, warn};
63
64#[derive(Debug, Deserialize)]
66struct SoundtrackCollection<T> {
67 tracks: Vec<T>,
69}
70
71impl<T> Default for SoundtrackCollection<T> {
72 fn default() -> Self { Self { tracks: Vec::new() } }
73}
74
75#[derive(Clone, Debug, Deserialize)]
77pub struct SoundtrackItem {
78 title: String,
80 path: String,
82 length: f32,
84 loop_points: Option<(f32, f32)>,
85 timing: Option<DayPeriod>,
87 weather: Option<WeatherKind>,
89 biomes: Vec<(BiomeKind, u8)>,
91 sites: Vec<SiteKindMeta>,
93 music_state: MusicState,
96 #[serde(default)]
100 activity_override: Option<MusicActivity>,
101 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#[derive(Clone, Debug, Deserialize, PartialEq)]
140enum DayPeriod {
141 Day,
143 Night,
145}
146
147pub struct MusicMgr {
149 soundtrack: SoundtrackCollection<SoundtrackItem>,
151 began_playing: Option<ClockTime>,
153 song_end: Option<ClockTime>,
155 is_gap: bool,
157 gap_length: f32,
159 gap_time: f64,
161 last_track: String,
164 last_combat_track: String,
165 last_interrupt_attempt: Option<ClockTime>,
167 last_activity: MusicState,
169 current_track: String,
171 current_artist: String,
172 track_length: f32,
173 loop_points: Option<(f32, f32)>,
174}
175
176#[derive(Deserialize)]
177pub struct MusicTransitionManifest {
178 combat_nearby_radius: f32,
180 combat_health_factor: f32,
183 combat_nearby_high_thresh: u32,
185 combat_nearby_low_thresh: u32,
187 pub fade_timings: HashMap<(MusicChannelTag, MusicChannelTag), (f32, f32)>,
189 pub interrupt_delay: f32,
191}
192
193impl Default for MusicTransitionManifest {
194 fn default() -> MusicTransitionManifest {
195 MusicTransitionManifest {
196 combat_nearby_radius: 40.0,
197 combat_health_factor: 100.0,
198 combat_nearby_high_thresh: 3,
199 combat_nearby_low_thresh: 1,
200 fade_timings: HashMap::new(),
201 interrupt_delay: 5.0,
202 }
203 }
204}
205
206fn time_f64(clock_time: ClockTime) -> f64 { clock_time.ticks as f64 + clock_time.fraction }
207
208impl MusicMgr {
209 pub fn new(calendar: &Calendar) -> Self {
210 Self {
211 soundtrack: Self::load_soundtrack_items(calendar),
212 began_playing: None,
213 song_end: None,
214 is_gap: true,
215 gap_length: 0.0,
216 gap_time: -1.0,
217 last_track: String::from("None"),
218 last_combat_track: String::from("None"),
219 last_interrupt_attempt: None,
220 last_activity: MusicState::Activity(MusicActivity::Explore),
221 current_track: String::from("None"),
222 current_artist: String::from("None"),
223 track_length: 0.0,
224 loop_points: None,
225 }
226 }
227
228 pub fn maintain(&mut self, audio: &mut AudioFrontend, state: &State, client: &Client) {
231 use common::comp::{Group, Health, Pos, group::ENEMY};
232 use specs::{Join, WorldExt};
233
234 if !audio.music_enabled() || audio.get_clock().is_none() || audio.get_clock_time().is_none()
235 {
236 return;
237 }
238
239 let mut activity_state = MusicActivity::Explore;
240
241 let player = client.entity();
242 let ecs = state.ecs();
243 let entities = ecs.entities();
244 let positions = ecs.read_component::<Pos>();
245 let healths = ecs.read_component::<Health>();
246 let groups = ecs.read_component::<Group>();
247 let mtm = audio.mtm.read();
248 let mut rng = rng();
249
250 if audio.combat_music_enabled
251 && let Some(player_pos) = positions.get(player)
252 {
253 let num_nearby_entities: u32 = (&entities, &positions, &healths, &groups)
257 .join()
258 .map(|(entity, pos, health, group)| {
259 if entity != player
260 && group == &ENEMY
261 && (player_pos.0 - pos.0).magnitude_squared()
262 < mtm.0.combat_nearby_radius.powf(2.0)
263 {
264 (health.maximum() / mtm.0.combat_health_factor).ceil() as u32
265 } else {
266 0
267 }
268 })
269 .sum();
270
271 if num_nearby_entities >= mtm.0.combat_nearby_high_thresh {
272 activity_state = MusicActivity::Combat(CombatIntensity::High);
273 } else if num_nearby_entities >= mtm.0.combat_nearby_low_thresh {
274 activity_state = MusicActivity::Combat(CombatIntensity::Low);
275 }
276 }
277
278 if let Some(health) = healths.get(player)
280 && health.is_dead
281 {
282 activity_state = MusicActivity::Explore;
283 }
284
285 let mut music_state = match self.last_activity {
286 MusicState::Activity(prev) => {
287 if prev != activity_state {
288 MusicState::Transition(prev, activity_state)
289 } else {
290 MusicState::Activity(activity_state)
291 }
292 },
293 MusicState::Transition(_, next) => {
294 warn!("Transitioning: {:?}", self.last_activity);
295 MusicState::Activity(next)
296 },
297 };
298
299 let now = audio.get_clock_time().unwrap();
300
301 let began_playing = *self.began_playing.get_or_insert(now);
302 let last_interrupt_attempt = *self.last_interrupt_attempt.get_or_insert(now);
303 let song_end = *self.song_end.get_or_insert(now);
304 let time_since_began_playing = time_f64(now) - time_f64(began_playing);
305
306 let interrupt = matches!(music_state, MusicState::Transition(_, _))
310 && time_f64(now) - time_f64(last_interrupt_attempt) > mtm.0.interrupt_delay as f64;
311
312 if matches!(
315 music_state,
316 MusicState::Transition(
317 MusicActivity::Combat(CombatIntensity::High),
318 MusicActivity::Explore
319 )
320 ) {
321 music_state = MusicState::Activity(MusicActivity::Explore)
322 }
323
324 if audio.music_enabled()
325 && !self.soundtrack.tracks.is_empty()
326 && (time_since_began_playing
327 > time_f64(song_end) - time_f64(began_playing) || interrupt)
329 {
330 if time_since_began_playing > self.track_length as f64
331 && self.last_activity
332 != MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
333 {
334 self.current_track = String::from("None");
335 self.current_artist = String::from("None");
336 }
337
338 if interrupt {
339 self.last_interrupt_attempt = Some(now);
340 if let Ok(next_activity) =
341 self.play_random_track(audio, state, client, &music_state, &mut rng)
342 {
343 trace!(
344 "pre-play_random_track: {:?} {:?}",
345 self.last_activity, music_state
346 );
347 self.last_activity = next_activity;
348 }
349 } else if music_state == MusicState::Activity(MusicActivity::Explore)
350 || music_state
351 == MusicState::Transition(
352 MusicActivity::Explore,
353 MusicActivity::Combat(CombatIntensity::High),
354 )
355 {
356 if !self.is_gap {
358 self.gap_length = self.generate_silence_between_tracks(
359 audio.music_spacing,
360 client,
361 &music_state,
362 &mut rng,
363 );
364 self.gap_time = self.gap_length as f64;
365 self.song_end = audio.get_clock_time();
366 self.is_gap = true
367 } else if self.gap_time < 0.0 {
368 if music_state
371 == MusicState::Transition(
372 MusicActivity::Explore,
373 MusicActivity::Combat(CombatIntensity::High),
374 )
375 {
376 music_state = MusicState::Activity(MusicActivity::Explore)
377 }
378 if let Ok(next_activity) =
379 self.play_random_track(audio, state, client, &music_state, &mut rng)
380 {
381 self.last_activity = next_activity;
382 self.gap_time = 0.0;
383 self.gap_length = 0.0;
384 self.is_gap = false;
385 }
386 }
387 } else if music_state
388 == MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
389 {
390 self.began_playing = Some(now);
392 self.song_end = Some(ClockTime::from_ticks_f64(
393 audio.get_clock().unwrap().id(),
394 time_f64(now) + self.loop_points.unwrap_or((0.0, 0.0)).1 as f64
395 - self.loop_points.unwrap_or((0.0, 0.0)).0 as f64,
396 ));
397 } else {
398 trace!(
399 "pre-play_random_track: {:?} {:?}",
400 self.last_activity, music_state
401 );
402 }
403 } else {
404 if self.began_playing.is_none() {
405 self.began_playing = Some(now)
406 }
407 if self.soundtrack.tracks.is_empty() {
408 warn!("No tracks available to play")
409 }
410 }
411
412 if time_since_began_playing > self.track_length as f64 {
413 if self.is_gap {
415 self.gap_time = (self.gap_length as f64) - (time_f64(now) - time_f64(song_end));
416 }
417 }
418 }
419
420 fn play_random_track(
421 &mut self,
422 audio: &mut AudioFrontend,
423 state: &State,
424 client: &Client,
425 music_state: &MusicState,
426 rng: &mut ThreadRng,
427 ) -> Result<MusicState, String> {
428 let is_dark = state.get_day_period().is_dark();
429 let current_period_of_day = Self::get_current_day_period(is_dark);
430 let current_weather = client.weather_at_player();
431 let current_biome = client.current_biome();
432 let current_site = client.current_site();
433
434 let mut maybe_tracks = self
441 .soundtrack
442 .tracks
443 .iter()
444 .filter(|track| {
445 (match &track.timing {
446 Some(period_of_day) => period_of_day == ¤t_period_of_day,
447 None => true,
448 }) && match &track.weather {
449 Some(weather) => weather == ¤t_weather.get_kind(),
450 None => true,
451 }
452 })
453 .filter(|track| track.sites.iter().any(|s| s == ¤t_site))
454 .filter(|track| {
455 track.biomes.is_empty() || track.biomes.iter().any(|b| b.0 == current_biome)
456 })
457 .filter(|track| &track.music_state == music_state)
458 .collect::<Vec<&SoundtrackItem>>();
459 if maybe_tracks.is_empty() {
460 maybe_tracks =
463 self.soundtrack
464 .tracks
465 .iter()
466 .filter(|track| match ¤t_site {
467 SiteKindMeta::Settlement(_) => track.sites.iter().any(|site| {
468 site == &SiteKindMeta::Settlement(SettlementKindMeta::Default)
469 }),
470 SiteKindMeta::Dungeon(_) => track
471 .sites
472 .iter()
473 .any(|site| site == &SiteKindMeta::Dungeon(DungeonKindMeta::Cultist)),
474 _ => false,
475 })
476 .collect::<Vec<&SoundtrackItem>>();
477 let mut error_string = format!(
478 "No tracks for {:?}, {:?}, {:?}, {:?}, {:?}",
479 ¤t_period_of_day,
480 ¤t_weather,
481 ¤t_site,
482 ¤t_biome,
483 &music_state
484 );
485 if maybe_tracks.is_empty() {
486 return Err(error_string);
487 } else {
488 error_string.push_str(", using default music for current site.");
489 warn!(error_string);
490 }
491 }
492 if matches!(
495 music_state,
496 &MusicState::Activity(MusicActivity::Combat(CombatIntensity::High))
497 | &MusicState::Transition(
498 MusicActivity::Combat(CombatIntensity::High),
499 MusicActivity::Explore
500 )
501 ) {
502 let filtered_tracks: Vec<_> = maybe_tracks
503 .iter()
504 .filter(|track| track.title.eq(&self.last_track))
505 .copied()
506 .collect();
507 if !filtered_tracks.is_empty() {
508 maybe_tracks = filtered_tracks;
509 }
510 } else {
511 let filtered_tracks: Vec<_> = maybe_tracks
512 .iter()
513 .filter(|track| !track.title.eq(&self.last_track))
514 .filter(|track| !track.title.eq(&self.last_combat_track))
515 .copied()
516 .collect();
517 if !filtered_tracks.is_empty() {
518 maybe_tracks = filtered_tracks;
519 }
520 }
521
522 let new_maybe_track = maybe_tracks.choose_weighted(rng, |track| {
525 track
529 .biomes
530 .iter()
531 .find(|b| b.0 == current_biome)
532 .map_or(1.0, |b| 1.0_f32 / (b.1 as f32))
533 });
534 debug!(
535 "selecting new track for {:?}: {:?}",
536 music_state, new_maybe_track
537 );
538
539 if let Ok(track) = new_maybe_track {
540 let now = audio.get_clock_time().unwrap();
541 self.last_track = String::from(&track.title);
542 self.began_playing = Some(now);
543 self.song_end = Some(ClockTime::from_ticks_f64(
544 audio.get_clock().unwrap().id(),
545 time_f64(now) + track.length as f64,
546 ));
547 self.track_length = track.length;
548 self.gap_length = 0.0;
549 if audio.music_enabled() {
550 self.current_track = String::from(&track.title);
551 self.current_artist = String::from(&track.artist.0);
552 } else {
553 self.current_track = String::from("None");
554 self.current_artist = String::from("None");
555 }
556
557 let tag = if matches!(music_state, MusicState::Activity(MusicActivity::Explore)) {
558 MusicChannelTag::Exploration
559 } else {
560 self.last_combat_track = String::from(&track.title);
561 MusicChannelTag::Combat
562 };
563 audio.play_music(&track.path, tag, track.length);
564 if tag == MusicChannelTag::Combat {
565 audio.set_loop_points(
566 tag,
567 track.loop_points.unwrap_or((0.0, 0.0)).0,
568 track.loop_points.unwrap_or((0.0, 0.0)).1,
569 );
570 self.loop_points = track.loop_points
571 } else {
572 self.loop_points = None
573 };
574
575 if let Some(state) = track.activity_override {
576 Ok(MusicState::Activity(state))
577 } else {
578 Ok(*music_state)
579 }
580 } else {
581 Err(format!("{:?}", new_maybe_track))
582 }
583 }
584
585 fn generate_silence_between_tracks(
586 &self,
587 spacing_multiplier: f32,
588 client: &Client,
589 music_state: &MusicState,
590 rng: &mut ThreadRng,
591 ) -> f32 {
592 let mut silence_between_tracks_seconds: f32 = 0.0;
593 if spacing_multiplier > f32::EPSILON {
594 silence_between_tracks_seconds =
595 if matches!(
596 music_state,
597 MusicState::Activity(MusicActivity::Explore)
598 | MusicState::Transition(
599 MusicActivity::Explore,
600 MusicActivity::Combat(CombatIntensity::High)
601 )
602 ) && matches!(client.current_site(), SiteKindMeta::Settlement(_))
603 {
604 rng.random_range(120.0 * spacing_multiplier..180.0 * spacing_multiplier)
605 } else if matches!(
606 music_state,
607 MusicState::Activity(MusicActivity::Explore)
608 | MusicState::Transition(
609 MusicActivity::Explore,
610 MusicActivity::Combat(CombatIntensity::High)
611 )
612 ) && matches!(client.current_site(), SiteKindMeta::Dungeon(_))
613 {
614 rng.random_range(10.0 * spacing_multiplier..20.0 * spacing_multiplier)
615 } else if matches!(
616 music_state,
617 MusicState::Activity(MusicActivity::Explore)
618 | MusicState::Transition(
619 MusicActivity::Explore,
620 MusicActivity::Combat(CombatIntensity::High)
621 )
622 ) && matches!(client.current_site(), SiteKindMeta::Cave)
623 {
624 rng.random_range(20.0 * spacing_multiplier..40.0 * spacing_multiplier)
625 } else if matches!(
626 music_state,
627 MusicState::Activity(MusicActivity::Explore)
628 | MusicState::Transition(
629 MusicActivity::Explore,
630 MusicActivity::Combat(CombatIntensity::High)
631 )
632 ) {
633 rng.random_range(120.0 * spacing_multiplier..240.0 * spacing_multiplier)
634 } else if matches!(
635 music_state,
636 MusicState::Activity(MusicActivity::Combat(_)) | MusicState::Transition(_, _)
637 ) {
638 0.0
639 } else {
640 rng.random_range(30.0 * spacing_multiplier..60.0 * spacing_multiplier)
641 };
642 }
643 silence_between_tracks_seconds
644 }
645
646 fn get_current_day_period(is_dark: bool) -> DayPeriod {
647 if is_dark {
648 DayPeriod::Night
649 } else {
650 DayPeriod::Day
651 }
652 }
653
654 pub fn current_track(&self) -> String { self.current_track.clone() }
655
656 pub fn current_artist(&self) -> String { self.current_artist.clone() }
657
658 pub fn reset_track(&mut self, audio: &mut AudioFrontend) {
659 self.current_artist = String::from("None");
660 self.current_track = String::from("None");
661 self.gap_length = 1.0;
662 self.gap_time = 1.0;
663 self.is_gap = true;
664 self.track_length = 0.0;
665 self.began_playing = audio.get_clock_time();
666 self.song_end = audio.get_clock_time();
667 }
668
669 fn load_soundtrack_items(calendar: &Calendar) -> SoundtrackCollection<SoundtrackItem> {
673 let mut soundtrack = SoundtrackCollection::default();
674 if calendar.events().len() == 0 {
676 for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
677 .read()
678 .tracks
679 .clone()
680 {
681 soundtrack.tracks.push(track)
682 }
683 } else {
684 for event in calendar.events() {
686 match event {
687 CalendarEvent::Halloween => {
688 for track in SoundtrackCollection::load_expect(
689 "voxygen.audio.calendar.halloween.soundtrack",
690 )
691 .read()
692 .tracks
693 .clone()
694 {
695 soundtrack.tracks.push(track)
696 }
697 },
698 CalendarEvent::Christmas => {
699 for track in SoundtrackCollection::load_expect(
700 "voxygen.audio.calendar.christmas.soundtrack",
701 )
702 .read()
703 .tracks
704 .clone()
705 {
706 soundtrack.tracks.push(track)
707 }
708 },
709 _ => {
710 for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
711 .read()
712 .tracks
713 .clone()
714 {
715 soundtrack.tracks.push(track)
716 }
717 },
718 }
719 }
720 }
721 if soundtrack.tracks.is_empty() {
723 for track in SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
724 .read()
725 .tracks
726 .clone()
727 {
728 soundtrack.tracks.push(track)
729 }
730 soundtrack
731 } else {
732 soundtrack
733 }
734 }
735}
736impl Asset for SoundtrackCollection<SoundtrackItem> {
737 fn load(_: &AssetCache, id: &SharedString) -> Result<Self, BoxedError> {
738 let manifest: AssetHandle<Ron<SoundtrackCollection<RawSoundtrackItem>>> =
739 AssetExt::load(id)?;
740 let mut soundtrack = SoundtrackCollection::default();
741 for item in manifest.read().0.tracks.iter().cloned() {
742 match item {
743 RawSoundtrackItem::Individual(track) => soundtrack.tracks.push(track),
744 RawSoundtrackItem::Segmented {
745 title,
746 timing,
747 weather,
748 biomes,
749 sites,
750 segments,
751 loop_points,
752 artist,
753 } => {
754 for (path, length, music_state, activity_override) in segments.into_iter() {
755 soundtrack.tracks.push(SoundtrackItem {
756 title: title.clone(),
757 path,
758 length,
759 loop_points: Some(loop_points),
760 timing: timing.clone(),
761 weather,
762 biomes: biomes.clone(),
763 sites: sites.clone(),
764 music_state,
765 activity_override,
766 artist: artist.clone(),
767 });
768 }
769 },
770 }
771 }
772 Ok(soundtrack)
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use strum::IntoEnumIterator;
780
781 #[test]
782 fn test_load_soundtracks() {
783 let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
784 AssetExt::load_expect("voxygen.audio.soundtrack");
785 for event in CalendarEvent::iter() {
786 match event {
787 CalendarEvent::Halloween => {
788 let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
789 SoundtrackCollection::load_expect(
790 "voxygen.audio.calendar.halloween.soundtrack",
791 );
792 },
793 CalendarEvent::Christmas => {
794 let _: AssetHandle<SoundtrackCollection<SoundtrackItem>> =
795 SoundtrackCollection::load_expect(
796 "voxygen.audio.calendar.christmas.soundtrack",
797 );
798 },
799 _ => {},
800 }
801 }
802 }
803}