diff --git a/assets/voxygen/audio/ambient.ron b/assets/voxygen/audio/ambient.ron new file mode 100644 index 0000000000..48cd6454c2 --- /dev/null +++ b/assets/voxygen/audio/ambient.ron @@ -0,0 +1,22 @@ +// TODO: Add an ambient-soundtrack that runs independently from the musical soundtrack + +( + tracks: [ + ( + title: "Forest Day", // Ambience Track + path: "voxygen.audio.ambient.forest_day", + length: 629.0, + timing: Some(Night), + biome: Some(Forest), + artist: "https://www.youtube.com/watch?v=FwVTkB-BIvM", + ), + ( + title: "Forest Morning", // Ambience Track + path: "voxygen.audio.ambient.forest_morning", + length: 600.0, + timing: Some(Day), + biome: Some(Forest), + artist: "https://www.youtube.com/watch?v=eq4nfIdK6C4", + ), + ] +) diff --git a/assets/voxygen/audio/soundtrack.ron b/assets/voxygen/audio/soundtrack.ron index 775d63a4e8..7f628765a3 100644 --- a/assets/voxygen/audio/soundtrack.ron +++ b/assets/voxygen/audio/soundtrack.ron @@ -3,18 +3,12 @@ ( tracks: [ - ( - title: "Forest Day", // Ambience Track - path: "voxygen.audio.ambient.forest_day", - length: 629.0, - timing: Some(Day), - artist: "https://www.youtube.com/watch?v=FwVTkB-BIvM", - ), ( title: "A Solemn Quest", path: "voxygen.audio.soundtrack.a_solemn_quest", length: 206.0, timing: Some(Night), + biome: Some(Grassland), artist: "Eden", ), ( @@ -22,6 +16,7 @@ path: "voxygen.audio.soundtrack.into_the_dark_forest", length: 184.0, timing: Some(Night), + biome: Some(Snowlands), artist: "Aeronic", ), ( @@ -29,6 +24,7 @@ path: "voxygen.audio.soundtrack.field_grazing", length: 154.0, timing: Some(Day), + biome: Some(Grassland), artist: "Aeronic", ), ( @@ -36,20 +32,23 @@ path: "voxygen.audio.soundtrack.wandering_voices", length: 137.0, timing: Some(Night), + biome: Some(Snowlands), artist: "Aeronic", ), - /*( + ( title: "Snowtop Volume", //Snow Region path: "voxygen.audio.soundtrack.snowtop_volume", length: 89.0, timing: Some(Day), + biome: Some(Snowlands), artist: "Aeronic", - ),*/ + ), ( title: "Mineral Deposits", path: "voxygen.audio.soundtrack.mineral_deposits", length: 148.0, timing: Some(Day), + biome: Some(Snowlands), artist: "Aeronic", ), ( @@ -57,6 +56,7 @@ path: "voxygen.audio.soundtrack.moonbeams", length: 158.0, timing: Some(Night), + biome: Some(Snowlands), artist: "Aeronic", ), ( @@ -64,6 +64,7 @@ path: "voxygen.audio.soundtrack.serene_meadows", length: 173.0, timing: Some(Night), + biome: Some(Snowlands), artist: "Aeronic", ), /*( @@ -71,6 +72,7 @@ path: "voxygen.audio.soundtrack.rest_assured", length: 185.0, timing: Some(Day), + biome: Some(Snowlands), artist: "badbbad", ),*/ ( @@ -78,6 +80,7 @@ path: "voxygen.audio.soundtrack.just_the_beginning", length: 188.0, timing: Some(Day), + biome: Some(Snowlands), artist: "badbbad", ), ( @@ -85,6 +88,7 @@ path: "voxygen.audio.soundtrack.campfire_stories", length: 100.0, timing: Some(Night), + biome: Some(Snowlands), artist: "badbbad", ), ( @@ -92,6 +96,7 @@ path: "voxygen.audio.soundtrack.limits", length: 203.0, timing: Some(Night), + biome: Some(Snowlands), artist: "badbbad", ), /*( // Dungeon @@ -99,6 +104,7 @@ path: "voxygen.audio.soundtrack.down_the_rabbit_hole", length: 244.0, timing: Some(Night), + biome: Some(Snowlands), artist: "badbbad", ),*/ ( @@ -106,14 +112,8 @@ path: "voxygen.audio.soundtrack.between_the_fairies", length: 175.0, timing: Some(Night), + biome: Some(Snowlands), artist: "badbbad", ), - ( - title: "Forest Morning", // Ambience Track - path: "voxygen.audio.ambient.forest_morning", - length: 600.0, - timing: Some(Day), - artist: "https://www.youtube.com/watch?v=eq4nfIdK6C4", - ), ] -) \ No newline at end of file +) diff --git a/common/src/terrain/biome.rs b/common/src/terrain/biome.rs index 8e196d4e4b..ecb9e2cf27 100644 --- a/common/src/terrain/biome.rs +++ b/common/src/terrain/biome.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq)] pub enum BiomeKind { Void, Grassland, diff --git a/voxygen/src/audio/ambient.rs b/voxygen/src/audio/ambient.rs new file mode 100644 index 0000000000..d5e283e291 --- /dev/null +++ b/voxygen/src/audio/ambient.rs @@ -0,0 +1,183 @@ +//! Handles ambient sound playback and transitions +//! +//! Game ambient sound is controlled though a configuration file found in the +//! source at `/assets/voxygen/audio/ambient.ron`. Each track enabled in game +//! has a configuration corresponding to the +//! [`SoundtrackItem`](struct.SoundtrackItem.html) format, as well as the +//! corresponding `.ogg` file in the `/assets/voxygen/audio/soundtrack/` +//! directory. +//! +//! If there are errors while reading or deserialising the configuration file, a +//! warning is logged and music will be disabled. +//! +//! ## Adding new ambient sound +//! +//! To add a new item, append the details to the audio configuration file, and +//! add the audio file (in `.ogg` format) to the assets directory. +//! +//! The `length` should be provided in seconds. This allows us to know when to +//! transition to another track, without having to spend time determining track +//! length programmatically. +//! +//! An example of a new night time track: +//! ```text +//! ( +//! title: "Sleepy Song", +//! path: "voxygen.audio.soundtrack.sleepy", +//! length: 400.0, +//! timing: Some(Night), +//! biome: Some(Forest), +//! artist: "Elvis", +//! ), +//! ``` +//! +//! Before sending an MR for your new track item: +//! - Be conscious of the file size for your new track. Assets contribute to +//! download sizes +//! - Ensure that the track is mastered to a volume proportionate to other music +//! tracks +//! - If you are not the author of the track, ensure that the song's licensing +//! permits usage of the track for non-commercial use +use crate::audio::AudioFrontend; +use client::Client; +use common::{assets, state::State, terrain::BiomeKind}; +use rand::{seq::IteratorRandom, thread_rng}; +use serde::Deserialize; +use std::time::Instant; +use tracing::warn; + +const DAY_START_SECONDS: u32 = 28800; // 8:00 +const DAY_END_SECONDS: u32 = 70200; // 19:30 + +#[derive(Debug, Default, Deserialize)] +struct AmbientSoundtrackCollection { + tracks: Vec, +} + +/// Configuration for a single music track in the soundtrack +#[derive(Debug, Deserialize)] +pub struct AmbientSoundtrackItem { + title: String, + path: String, + /// Length of the track in seconds + length: f64, + /// Whether this track should play during day or night + timing: Option, + biome: Option, +} + +/// Allows control over when a track should play based on in-game time of day +#[derive(Debug, Deserialize, PartialEq)] +enum DayPeriod { + /// 8:00 AM to 7:30 PM + Day, + /// 7:31 PM to 6:59 AM + Night, +} + +/// Provides methods to control music playback +pub struct AmbientMgr { + ambient_soundtrack: AmbientSoundtrackCollection, + began_playing: Instant, + next_track_change: f64, + /// The title of the last track played. Used to prevent a track + /// being played twice in a row + last_track: String, +} + +impl AmbientMgr { + #[allow(clippy::new_without_default)] // TODO: Pending review in #587 + pub fn new() -> Self { + Self { + ambient_soundtrack: Self::load_ambient_soundtrack_items(), + began_playing: Instant::now(), + next_track_change: 0.0, + last_track: String::from("None"), + } + } + + /// Checks whether the previous track has completed. If so, sends a + /// request to play the next (random) track + pub fn maintain(&mut self, audio: &mut AudioFrontend, state: &State, client: &Client) { + if audio.music_enabled() + && !self.ambient_soundtrack.tracks.is_empty() + && self.began_playing.elapsed().as_secs_f64() > self.next_track_change + { + self.play_random_track(audio, state, client); + } + } + + fn play_random_track(&mut self, audio: &mut AudioFrontend, state: &State, client: &Client) { + const SILENCE_BETWEEN_TRACKS_SECONDS: f64 = 45.0; + + let game_time = (state.get_time_of_day() as u64 % 86400) as u32; + let current_period_of_day = Self::get_current_day_period(game_time); + let current_biome = Self::get_current_biome(client); + println!("Ambient Current biome: {:?}", current_biome); + let mut rng = thread_rng(); + + let maybe_track = self + .ambient_soundtrack + .tracks + .iter() + .filter(|track| { + !track.title.eq(&self.last_track) + && match &track.timing { + Some(period_of_day) => period_of_day == ¤t_period_of_day, + None => true, + } + }) + .filter(|track| match &track.biome { + Some(biome) => biome == ¤t_biome, + None => true, + }) + .choose(&mut rng); + + if let Some(track) = maybe_track { + self.last_track = String::from(&track.title); + self.began_playing = Instant::now(); + self.next_track_change = track.length + SILENCE_BETWEEN_TRACKS_SECONDS; + + audio.play_exploration_ambient(&track.path); + } + } + + fn get_current_day_period(game_time: u32) -> DayPeriod { + if game_time > DAY_START_SECONDS && game_time < DAY_END_SECONDS { + DayPeriod::Day + } else { + DayPeriod::Night + } + } + + fn get_current_biome(client: &Client) -> BiomeKind { + match client.current_chunk() { + Some(chunk) => chunk.meta().biome(), + _ => BiomeKind::Void, + } + } + + fn load_ambient_soundtrack_items() -> AmbientSoundtrackCollection { + match assets::load_file("voxygen.audio.ambient", &["ron"]) { + Ok(file) => match ron::de::from_reader(file) { + Ok(config) => config, + Err(error) => { + warn!( + "Error parsing music config file, music will not be available: {}", + format!("{:#?}", error) + ); + + AmbientSoundtrackCollection::default() + }, + }, + Err(error) => { + warn!( + "Error reading music config file, music will not be available: {}", + format!("{:#?}", error) + ); + + AmbientSoundtrackCollection::default() + }, + } + } +} diff --git a/voxygen/src/audio/channel.rs b/voxygen/src/audio/channel.rs index 3bbdd70672..5780dc7ac8 100644 --- a/voxygen/src/audio/channel.rs +++ b/voxygen/src/audio/channel.rs @@ -134,6 +134,111 @@ impl MusicChannel { } } +/// Each `AmbientChannel` has a `AmbientChannelTag` which help us determine when +/// we should transition between two types of in-game music. For example, we +/// transition between `TitleMusic` and `Exploration` when a player enters the +/// world by crossfading over a slow duration. In the future, transitions in the +/// world such as `Exploration` -> `BossBattle` would transition more rapidly. +#[derive(PartialEq, Clone, Copy)] +pub enum AmbientChannelTag { + TitleMusic, + Exploration, +} + +/// An AmbientChannel uses a non-positional audio sink designed to play ambient +/// sound which is always heard at the player's position. +/// +/// See also: [`Rodio::Sink`](https://docs.rs/rodio/0.11.0/rodio/struct.Sink.html) +pub struct AmbientChannel { + tag: AmbientChannelTag, + sink: Sink, + state: ChannelState, + fader: Fader, +} +impl AmbientChannel { + pub fn new(device: &Device) -> Self { + Self { + sink: Sink::new(device), + tag: AmbientChannelTag::TitleMusic, + state: ChannelState::Stopped, + fader: Fader::default(), + } + } + + // Play an ambient track item on this channel. If the channel has an existing + // track playing, the new sounds will be appended and played once they + // complete. Otherwise it will begin playing immediately. + pub fn play(&mut self, source: S, tag: AmbientChannelTag) + where + S: Source + Send + 'static, + S::Item: Sample, + S::Item: Send, + ::Item: std::fmt::Debug, + { + self.tag = tag; + if !self.sink.len() > 0 { + self.sink.append(source); + } + + self.state = if !self.fader.is_finished() { + ChannelState::Fading + } else { + ChannelState::Playing + }; + } + + /// Set the volume of the current channel. If the channel is currently + /// fading, the volume of the fader is updated to this value. + pub fn set_volume(&mut self, volume: f32) { + if !self.fader.is_finished() { + self.fader.update_target_volume(volume); + } else { + self.sink.set_volume(volume); + } + } + + /// Set a fader for the channel. If a fader exists already, it is replaced. + /// If the channel has not begun playing, and the fader is set to fade in, + /// we set the volume of the channel to the initial volume of the fader so + /// that the volumes match when playing begins. + pub fn set_fader(&mut self, fader: Fader) { + self.fader = fader; + self.state = ChannelState::Fading; + + if self.state == ChannelState::Stopped && fader.direction() == FadeDirection::In { + self.sink.set_volume(fader.get_volume()); + } + } + + /// Returns true if either the channels sink reports itself as empty (no + /// more sounds in the queue) or we have forcibly set the channels state to + /// the 'Stopped' state + pub fn is_done(&self) -> bool { self.sink.empty() || self.state == ChannelState::Stopped } + + pub fn get_tag(&self) -> AmbientChannelTag { self.tag } + + /// Maintain the fader attached to this channel. If the channel is not + /// fading, no action is taken. + pub fn maintain(&mut self, dt: f32) { + if self.state == ChannelState::Fading { + self.fader.update(dt); + self.sink.set_volume(self.fader.get_volume()); + + if self.fader.is_finished() { + match self.fader.direction() { + FadeDirection::Out => { + self.state = ChannelState::Stopped; + self.sink.stop(); + }, + FadeDirection::In => { + self.state = ChannelState::Playing; + }, + } + } + } + } +} + /// An SfxChannel uses a positional audio sink, and is designed for short-lived /// audio which can be spatially controlled, but does not need control over /// playback or fading/transitions diff --git a/voxygen/src/audio/mod.rs b/voxygen/src/audio/mod.rs index 6e38549b4d..9f065bc577 100644 --- a/voxygen/src/audio/mod.rs +++ b/voxygen/src/audio/mod.rs @@ -1,12 +1,13 @@ //! Handles audio device detection and playback of sound effects and music +pub mod ambient; pub mod channel; pub mod fader; pub mod music; pub mod sfx; pub mod soundcache; -use channel::{MusicChannel, MusicChannelTag, SfxChannel}; +use channel::{AmbientChannel, AmbientChannelTag, MusicChannel, MusicChannelTag, SfxChannel}; use fader::Fader; use soundcache::SoundCache; use std::time::Duration; @@ -38,9 +39,11 @@ pub struct AudioFrontend { music_channels: Vec, sfx_channels: Vec, + ambient_channels: Vec, sfx_volume: f32, music_volume: f32, + ambient_volume: f32, listener: Listener, } @@ -61,9 +64,11 @@ impl AudioFrontend { audio_device, sound_cache: SoundCache::default(), music_channels: Vec::new(), + ambient_channels: Vec::new(), sfx_channels, sfx_volume: 1.0, music_volume: 1.0, + ambient_volume: 1.0, listener: Listener::default(), } @@ -77,9 +82,11 @@ impl AudioFrontend { audio_device: None, sound_cache: SoundCache::default(), music_channels: Vec::new(), + ambient_channels: Vec::new(), sfx_channels: Vec::new(), sfx_volume: 1.0, music_volume: 1.0, + ambient_volume: 1.0, listener: Listener::default(), } } @@ -87,10 +94,15 @@ impl AudioFrontend { /// Drop any unused music channels, and update their faders pub fn maintain(&mut self, dt: Duration) { self.music_channels.retain(|c| !c.is_done()); + self.ambient_channels.retain(|c| !c.is_done()); for channel in self.music_channels.iter_mut() { channel.maintain(dt); } + + for channel in self.ambient_channels.iter_mut() { + channel.maintain(dt); + } } fn get_sfx_channel(&mut self) -> Option<&mut SfxChannel> { @@ -141,6 +153,35 @@ impl AudioFrontend { self.music_channels.last_mut() } + fn get_ambient_channel( + &mut self, + next_channel_tag: AmbientChannelTag, + ) -> Option<&mut AmbientChannel> { + if let Some(audio_device) = &self.audio_device { + if self.ambient_channels.is_empty() { + let mut next_ambient_channel = AmbientChannel::new(&audio_device); + next_ambient_channel.set_volume(self.ambient_volume); + + self.ambient_channels.push(next_ambient_channel); + } else { + let existing_channel = self.ambient_channels.last_mut()?; + + if existing_channel.get_tag() != next_channel_tag { + // Fade the existing channel out. It will be removed when the fade completes. + existing_channel.set_fader(Fader::fade_out(2.0, self.ambient_volume)); + + let mut next_ambient_channel = AmbientChannel::new(&audio_device); + + next_ambient_channel.set_fader(Fader::fade_in(12.0, self.ambient_volume)); + + self.ambient_channels.push(next_ambient_channel); + } + } + } + + self.ambient_channels.last_mut() + } + /// Play (once) an sfx file by file path at the give position and volume pub fn play_sfx(&mut self, sound: &str, pos: Vec3, vol: Option) { if self.audio_device.is_some() { @@ -167,6 +208,15 @@ impl AudioFrontend { } } + fn play_ambient(&mut self, sound: &str, channel_tag: AmbientChannelTag) { + if let Some(channel) = self.get_ambient_channel(channel_tag) { + let file = assets::load_file(&sound, &["ogg"]).expect("Failed to load sound"); + let sound = Decoder::new(file).expect("Failed to decode sound"); + + channel.play(sound, channel_tag); + } + } + pub fn set_listener_pos(&mut self, pos: Vec3, ori: Vec3) { self.listener.pos = pos; self.listener.ori = ori.normalized(); @@ -199,14 +249,24 @@ impl AudioFrontend { } } + pub fn play_exploration_ambient(&mut self, item: &str) { + if self.ambient_enabled() { + self.play_ambient(item, AmbientChannelTag::Exploration) + } + } + pub fn get_sfx_volume(&self) -> f32 { self.sfx_volume } pub fn get_music_volume(&self) -> f32 { self.music_volume } + pub fn get_ambient_volume(&self) -> f32 { self.ambient_volume } + pub fn sfx_enabled(&self) -> bool { self.sfx_volume > 0.0 } pub fn music_enabled(&self) -> bool { self.music_volume > 0.0 } + pub fn ambient_enabled(&self) -> bool { self.ambient_volume > 0.0 } + pub fn set_sfx_volume(&mut self, sfx_volume: f32) { self.sfx_volume = sfx_volume; @@ -223,6 +283,14 @@ impl AudioFrontend { } } + pub fn set_ambient_volume(&mut self, ambient_volume: f32) { + self.ambient_volume = ambient_volume; + + for channel in self.ambient_channels.iter_mut() { + channel.set_volume(ambient_volume); + } + } + // TODO: figure out how badly this will break things when it is called pub fn set_device(&mut self, name: String) { self.device = name.clone(); diff --git a/voxygen/src/audio/music.rs b/voxygen/src/audio/music.rs index 643b4826b5..5f11fb9c7d 100644 --- a/voxygen/src/audio/music.rs +++ b/voxygen/src/audio/music.rs @@ -26,6 +26,7 @@ //! path: "voxygen.audio.soundtrack.sleepy", //! length: 400.0, //! timing: Some(Night), +//! biome: Some(Forest), //! artist: "Elvis", //! ), //! ``` @@ -38,7 +39,8 @@ //! - If you are not the author of the track, ensure that the song's licensing //! permits usage of the track for non-commercial use use crate::audio::AudioFrontend; -use common::{assets, state::State}; +use client::Client; +use common::{assets, state::State, terrain::BiomeKind}; use rand::{seq::IteratorRandom, thread_rng}; use serde::Deserialize; use std::time::Instant; @@ -61,6 +63,7 @@ pub struct SoundtrackItem { length: f64, /// Whether this track should play during day or night timing: Option, + biome: Option, } /// Allows control over when a track should play based on in-game time of day @@ -95,20 +98,22 @@ impl MusicMgr { /// Checks whether the previous track has completed. If so, sends a /// request to play the next (random) track - pub fn maintain(&mut self, audio: &mut AudioFrontend, state: &State) { + pub fn maintain(&mut self, audio: &mut AudioFrontend, state: &State, client: &Client) { if audio.music_enabled() && !self.soundtrack.tracks.is_empty() && self.began_playing.elapsed().as_secs_f64() > self.next_track_change { - self.play_random_track(audio, state); + self.play_random_track(audio, state, client); } } - fn play_random_track(&mut self, audio: &mut AudioFrontend, state: &State) { + fn play_random_track(&mut self, audio: &mut AudioFrontend, state: &State, client: &Client) { const SILENCE_BETWEEN_TRACKS_SECONDS: f64 = 45.0; let game_time = (state.get_time_of_day() as u64 % 86400) as u32; let current_period_of_day = Self::get_current_day_period(game_time); + let current_biome = Self::get_current_biome(client); + println!("======> Music Current biome: {:?}", current_biome); let mut rng = thread_rng(); let maybe_track = self @@ -122,6 +127,10 @@ impl MusicMgr { None => true, } }) + .filter(|track| match &track.biome { + Some(biome) => biome == ¤t_biome, + None => true, + }) .choose(&mut rng); if let Some(track) = maybe_track { @@ -141,6 +150,13 @@ impl MusicMgr { } } + fn get_current_biome(client: &Client) -> BiomeKind { + match client.current_chunk() { + Some(chunk) => chunk.meta().biome(), + _ => BiomeKind::Void, + } + } + fn load_soundtrack_items() -> SoundtrackCollection { match assets::load_file("voxygen.audio.soundtrack", &["ron"]) { Ok(file) => match ron::de::from_reader(file) { diff --git a/voxygen/src/scene/mod.rs b/voxygen/src/scene/mod.rs index 0faae1a64f..0c853d217b 100644 --- a/voxygen/src/scene/mod.rs +++ b/voxygen/src/scene/mod.rs @@ -14,7 +14,7 @@ pub use self::{ terrain::Terrain, }; use crate::{ - audio::{music::MusicMgr, sfx::SfxMgr, AudioFrontend}, + audio::{ambient::AmbientMgr, music::MusicMgr, sfx::SfxMgr, AudioFrontend}, render::{ create_clouds_mesh, create_pp_mesh, create_skybox_mesh, CloudsLocals, CloudsPipeline, Consts, GlobalModel, Globals, Light, LodData, Model, PostProcessLocals, @@ -103,6 +103,7 @@ pub struct Scene { figure_mgr: FigureMgr, sfx_mgr: SfxMgr, music_mgr: MusicMgr, + ambient_mgr: AmbientMgr, } pub struct SceneData<'a> { @@ -308,6 +309,7 @@ impl Scene { figure_mgr: FigureMgr::new(renderer), sfx_mgr: SfxMgr::new(), music_mgr: MusicMgr::new(), + ambient_mgr: AmbientMgr::new(), } } @@ -441,6 +443,7 @@ impl Scene { renderer: &mut Renderer, audio: &mut AudioFrontend, scene_data: &SceneData, + client: &Client, ) { span!(_guard, "maintain", "Scene::maintain"); // Get player position. @@ -994,7 +997,8 @@ impl Scene { scene_data.player_entity, &self.camera, ); - self.music_mgr.maintain(audio, scene_data.state); + self.music_mgr.maintain(audio, scene_data.state, client); + self.ambient_mgr.maintain(audio, scene_data.state, client); } /// Render the scene using the provided `Renderer`. diff --git a/voxygen/src/session.rs b/voxygen/src/session.rs index 98a3a075dd..2c15a6984b 100644 --- a/voxygen/src/session.rs +++ b/voxygen/src/session.rs @@ -1077,6 +1077,7 @@ impl PlayState for SessionState { global_state.window.renderer_mut(), &mut global_state.audio, &scene_data, + &client, ); // Process outcomes from client