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