use bevy_ecs::prelude::{Entity, Resource, World};
use super::{EventQueue, SoundEvent};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BeatClock {
pub bpm: u32,
pub start_tick: u64,
pub ticks_per_minute: u64,
}
impl BeatClock {
#[must_use]
pub fn new(
bpm: u32,
start_tick: u64,
fixed_delta: std::time::Duration,
) -> Self {
Self {
bpm: bpm.max(1),
start_tick,
ticks_per_minute: (60.0 / fixed_delta.as_secs_f64()).round() as u64,
}
}
#[must_use]
pub fn tick_of(&self, beat: u64) -> u64 {
let bpm = u64::from(self.bpm);
self.start_tick + (beat * self.ticks_per_minute).div_ceil(bpm)
}
#[must_use]
pub fn beat_at(&self, tick: u64) -> Option<u64> {
let since = tick.checked_sub(self.start_tick)?;
Some(since * u64::from(self.bpm) / self.ticks_per_minute)
}
#[must_use]
pub fn is_beat(&self, tick: u64) -> bool {
self.beat_at(tick)
.is_some_and(|beat| self.tick_of(beat) == tick)
}
#[must_use]
pub fn ticks_to_next(&self, tick: u64) -> u64 {
let next = self.beat_at(tick).map_or(0, |beat| beat + 1);
self.tick_of(next) - tick
}
#[must_use]
pub fn phase(&self, tick: u64) -> f64 {
let Some(since) = tick.checked_sub(self.start_tick) else {
return 0.0;
};
let scaled = since * u64::from(self.bpm);
(scaled % self.ticks_per_minute) as f64 / self.ticks_per_minute as f64
}
#[must_use]
pub fn beats_between(&self, after: u64, upto: u64) -> std::ops::Range<u64> {
let first = self.beat_at(after).map_or(0, |beat| beat + 1);
let end = self.beat_at(upto).map_or(0, |beat| beat + 1);
first..end.max(first)
}
}
pub const QUEUE_LIMIT: usize = 1024;
const SPEED_OF_SOUND: f32 = 343.0;
pub const DEFAULT_VOICE_LIMIT: usize = 64;
pub const MAX_VOICE_LIMIT: usize = 256;
#[derive(Clone, Debug, PartialEq)]
pub struct Sound {
pub volume: f32,
pub pan: f32,
pub bus: String,
pub looped: bool,
pub at_tick: Option<u64>,
pub position: Option<[f32; 3]>,
pub rate: f32,
pub reverse: bool,
pub follow: Option<Entity>,
pub occlude: bool,
pub priority: u8,
pub captions: Vec<Caption>,
pub doppler: f32,
pub falloff: Falloff,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Falloff {
Inverse { near: f32 },
InverseSquare { near: f32 },
Linear { near: f32, far: f32 },
Off,
}
impl Default for Falloff {
fn default() -> Self {
Self::Inverse { near: 2.0 }
}
}
impl Falloff {
#[must_use]
pub fn gain(self, distance: f32) -> f32 {
match self {
Self::Inverse { near } => (near / distance).min(1.0),
Self::InverseSquare { near } => (near / distance).min(1.0).powi(2),
Self::Linear { near, far: _ } if distance <= near => 1.0,
Self::Linear { near, far } => {
(1.0 - (distance - near) / (far - near).max(1e-6)).max(0.0)
}
Self::Off => 1.0,
}
}
}
impl Default for Sound {
fn default() -> Self {
Self {
volume: 1.0,
pan: 0.0,
bus: String::new(),
looped: false,
at_tick: None,
position: None,
rate: 1.0,
reverse: false,
follow: None,
occlude: false,
priority: 128,
captions: Vec::new(),
doppler: 0.0,
falloff: Falloff::default(),
}
}
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Caption {
pub start: f32,
pub end: f32,
pub text: String,
}
impl Caption {
#[must_use]
pub fn new(start: f32, end: f32, text: impl Into<String>) -> Self {
Self {
start,
end,
text: text.into(),
}
}
}
#[derive(Resource, Clone, Debug, PartialEq)]
pub struct CaptionSettings {
pub enabled: bool,
pub size: f32,
}
impl Default for CaptionSettings {
fn default() -> Self {
Self {
enabled: true,
size: 22.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)]
#[serde(tag = "kind")]
pub enum BusEffect {
LowPass { cutoff_hz: f32 },
Reverb { room: f32, damping: f32, mix: f32 },
Distortion { drive: f32, mix: f32 },
Eq {
low_db: f32,
mid_db: f32,
high_db: f32,
},
Compressor {
threshold_db: f32,
ratio: f32,
makeup_db: f32,
},
}
impl BusEffect {
#[must_use]
pub fn active(&self) -> bool {
match *self {
Self::LowPass { cutoff_hz } => cutoff_hz < 20_000.0,
Self::Reverb { mix, .. } | Self::Distortion { mix, .. } => {
mix > 0.0
}
Self::Eq {
low_db,
mid_db,
high_db,
} => [low_db, mid_db, high_db] != [0.0; 3],
Self::Compressor { ratio, .. } => ratio > 1.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SoundId(pub u64);
#[derive(Clone, Debug, PartialEq)]
pub struct ActiveSound {
pub id: SoundId,
pub clip: String,
pub bus: String,
pub looped: bool,
pub paused: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub enum AudioCommand {
Play {
id: SoundId,
clip: String,
tick: u64,
sound: Sound,
},
Stop(SoundId),
SetVolume {
id: SoundId,
volume: f32,
fade: f32,
},
SetRate {
id: SoundId,
rate: f32,
fade: f32,
},
SetPosition {
id: SoundId,
position: [f32; 3],
},
SetOcclusion {
id: SoundId,
amount: f32,
},
SetListener {
position: [f32; 3],
right: [f32; 3],
},
Pause(SoundId),
Resume(SoundId),
Seek {
id: SoundId,
seconds: f32,
},
SetBusVolume {
bus: String,
volume: f32,
fade: f32,
},
SetBusEffect {
bus: String,
effect: BusEffect,
fade: f32,
},
SetBusMute {
bus: String,
muted: bool,
},
SetBusSolo {
bus: String,
solo: bool,
},
SetDoppler {
id: SoundId,
pitch: f32,
},
SetBusVoiceLimit {
bus: String,
limit: usize,
},
StopAll,
SetMasterVolume(f32),
}
#[derive(Clone, Debug)]
struct Tracked {
follow: Option<Entity>,
position: Option<[f32; 3]>,
occlude: bool,
occlusion: f32,
captions: Vec<Caption>,
start: u64,
clock: f64,
rate: f32,
paused: bool,
doppler: f32,
distance: Option<f32>,
pitch: f32,
}
#[derive(
bevy_ecs::component::Component,
Clone,
Debug,
PartialEq,
serde::Serialize,
serde::Deserialize,
)]
#[serde(default)]
pub struct ReverbZone {
pub bus: String,
pub room: f32,
pub damping: f32,
pub mix: f32,
pub fade: f32,
}
impl Default for ReverbZone {
fn default() -> Self {
Self {
bus: String::new(),
room: 0.8,
damping: 0.5,
mix: 0.4,
fade: 0.5,
}
}
}
impl ReverbZone {
fn effect(&self, mix: f32) -> BusEffect {
BusEffect::Reverb {
room: self.room,
damping: self.damping,
mix,
}
}
}
#[derive(
bevy_ecs::component::Component,
Clone,
Debug,
PartialEq,
serde::Serialize,
serde::Deserialize,
)]
#[serde(default)]
pub struct SlideSound {
pub clip: String,
pub volume: f32,
pub full_volume_speed: f32,
pub min_speed: f32,
pub bus: String,
#[serde(skip)]
pub playing: Option<SoundId>,
#[serde(skip)]
pub level: f32,
}
impl Default for SlideSound {
fn default() -> Self {
Self {
clip: String::new(),
volume: 1.0,
full_volume_speed: 4.0,
min_speed: 0.2,
bus: String::new(),
playing: None,
level: 0.0,
}
}
}
fn route_slide_sounds(world: &mut World, tick: u64) {
let slides: Vec<(Entity, SlideSound)> = world
.query::<(Entity, &SlideSound)>()
.iter(world)
.map(|(entity, slide)| (entity, slide.clone()))
.collect();
if slides.is_empty() {
return;
}
let velocity = |entity| {
world
.get::<super::RigidBody>(entity)
.map_or(nalgebra::Vector3::zeros(), |body| {
body.linear_velocity.into()
})
};
let mut speeds = std::collections::BTreeMap::<Entity, f32>::new();
if let Some(physics) = world.get_resource::<super::PhysicsWorld>() {
for contact in physics.contacts().iter().filter(|c| !c.sensor) {
let normal = nalgebra::Vector3::from(contact.normal);
let relative = velocity(contact.a) - velocity(contact.b);
let slide = (relative - normal * relative.dot(&normal)).norm();
for body in [contact.a, contact.b] {
let speed = speeds.entry(body).or_default();
*speed = speed.max(slide);
}
}
}
let mut changed = Vec::new();
let Some(mut queue) = world.get_resource_mut::<AudioQueue>() else {
return;
};
for (entity, slide) in slides {
let speed = speeds.get(&entity).copied().unwrap_or(0.0);
let level = if speed < slide.min_speed {
0.0
} else {
slide.volume * (speed / slide.full_volume_speed.max(1e-3)).min(1.0)
};
let mut next = slide.clone();
match slide.playing {
None if level > 0.0 => {
let sound = Sound {
volume: level,
bus: slide.bus.clone(),
looped: true,
follow: Some(entity),
..Sound::default()
};
next.playing = Some(queue.play(&slide.clip, &sound, tick));
}
Some(id) if level == 0.0 => {
queue.stop(id);
next.playing = None;
}
Some(id) if (level - slide.level).abs() > 0.01 => {
queue.set_volume(id, level, SLIDE_FADE_SECONDS);
}
_ => continue,
}
next.level = level;
changed.push((entity, next));
}
for (entity, slide) in changed {
world.entity_mut(entity).insert(slide);
}
}
const SLIDE_FADE_SECONDS: f32 = 0.05;
#[derive(Resource, Clone, Debug, Default)]
pub struct AudioQueue {
commands: Vec<AudioCommand>,
next_id: u64,
requested: u64,
clips: std::collections::BTreeMap<String, u64>,
listener: Option<Entity>,
heard_from: Option<([f32; 3], [f32; 3])>,
reverb_zone: Option<(Entity, ReverbZone)>,
tracked: std::collections::BTreeMap<SoundId, Tracked>,
active: std::collections::BTreeMap<SoundId, ActiveSound>,
caption_tick: u64,
}
impl AudioQueue {
pub fn play(&mut self, clip: &str, sound: &Sound, tick: u64) -> SoundId {
let id = SoundId(self.next_id);
self.next_id += 1;
self.requested += 1;
*self.clips.entry(clip.to_owned()).or_default() += 1;
let start = sound.at_tick.unwrap_or(tick);
self.active.insert(
id,
ActiveSound {
id,
clip: clip.to_owned(),
bus: sound.bus.clone(),
looped: sound.looped,
paused: false,
},
);
if sound.follow.is_some()
|| ((sound.occlude || sound.doppler > 0.0)
&& sound.position.is_some())
|| !sound.captions.is_empty()
{
self.tracked.insert(
id,
Tracked {
follow: sound.follow,
position: sound.position,
occlude: sound.occlude,
occlusion: 0.0,
captions: sound.captions.clone(),
start,
clock: 0.0,
rate: sound.rate,
paused: false,
doppler: sound.doppler,
distance: None,
pitch: 1.0,
},
);
}
let mut sound = sound.clone();
sound.pan = sound.pan.clamp(-1.0, 1.0);
sound.captions.clear();
self.push(AudioCommand::Play {
id,
clip: clip.to_string(),
tick: start,
sound,
});
id
}
fn push(&mut self, command: AudioCommand) {
if self.commands.len() >= QUEUE_LIMIT {
self.commands.remove(0);
}
self.commands.push(command);
}
pub fn stop(&mut self, id: SoundId) {
self.tracked.remove(&id);
self.active.remove(&id);
self.push(AudioCommand::Stop(id));
}
pub fn stop_all(&mut self) {
self.tracked.clear();
self.active.clear();
self.push(AudioCommand::StopAll);
}
pub fn set_master_volume(&mut self, volume: f32) {
self.push(AudioCommand::SetMasterVolume(volume));
}
pub fn set_volume(&mut self, id: SoundId, volume: f32, fade: f32) {
self.push(AudioCommand::SetVolume { id, volume, fade });
}
pub fn set_rate(&mut self, id: SoundId, rate: f32, fade: f32) {
if let Some(tracked) = self.tracked.get_mut(&id) {
tracked.rate = rate;
}
self.push(AudioCommand::SetRate { id, rate, fade });
}
pub fn set_position(&mut self, id: SoundId, position: [f32; 3]) {
if let Some(tracked) = self.tracked.get_mut(&id) {
tracked.position = Some(position);
}
self.push(AudioCommand::SetPosition { id, position });
}
pub fn pause(&mut self, id: SoundId) {
if let Some(tracked) = self.tracked.get_mut(&id) {
tracked.paused = true;
}
if let Some(active) = self.active.get_mut(&id) {
active.paused = true;
}
self.push(AudioCommand::Pause(id));
}
pub fn resume(&mut self, id: SoundId) {
if let Some(tracked) = self.tracked.get_mut(&id) {
tracked.paused = false;
}
if let Some(active) = self.active.get_mut(&id) {
active.paused = false;
}
self.push(AudioCommand::Resume(id));
}
pub fn pause_bus(&mut self, bus: Option<&str>) {
for id in self.on_bus(bus, false) {
self.pause(id);
}
}
pub fn resume_bus(&mut self, bus: Option<&str>) {
for id in self.on_bus(bus, true) {
self.resume(id);
}
}
fn on_bus(&self, bus: Option<&str>, paused: bool) -> Vec<SoundId> {
self.active
.values()
.filter(|sound| sound.paused == paused)
.filter(|sound| bus.is_none_or(|bus| sound.bus == bus))
.map(|sound| sound.id)
.collect()
}
#[must_use]
pub fn playing(&self) -> Vec<ActiveSound> {
self.active.values().cloned().collect()
}
pub fn seek(&mut self, id: SoundId, seconds: f32) {
if let Some(tracked) = self.tracked.get_mut(&id) {
tracked.clock = f64::from(seconds.max(0.0));
}
self.push(AudioCommand::Seek { id, seconds });
}
pub fn set_listener(&mut self, entity: Option<Entity>) {
self.listener = entity;
}
pub fn set_bus_volume(&mut self, bus: &str, volume: f32, fade: f32) {
self.push(AudioCommand::SetBusVolume {
bus: bus.to_owned(),
volume,
fade,
});
}
pub fn set_bus_effect(&mut self, bus: &str, effect: BusEffect, fade: f32) {
self.push(AudioCommand::SetBusEffect {
bus: bus.to_owned(),
effect,
fade,
});
}
pub fn mute_bus(&mut self, bus: &str, muted: bool) {
self.push(AudioCommand::SetBusMute {
bus: bus.to_owned(),
muted,
});
}
pub fn solo_bus(&mut self, bus: &str, solo: bool) {
self.push(AudioCommand::SetBusSolo {
bus: bus.to_owned(),
solo,
});
}
pub fn set_bus_voice_limit(&mut self, bus: &str, limit: usize) {
self.push(AudioCommand::SetBusVoiceLimit {
bus: bus.to_owned(),
limit: limit.clamp(1, MAX_VOICE_LIMIT),
});
}
#[must_use]
pub fn requested(&self) -> u64 {
self.requested
}
#[must_use]
pub fn requested_clips(&self) -> &std::collections::BTreeMap<String, u64> {
&self.clips
}
#[must_use]
pub fn captions(&self) -> Vec<&str> {
self.tracked
.values()
.filter(|tracked| tracked.caption_tick_started(self.caption_tick))
.flat_map(|tracked| {
let clock = tracked.clock as f32;
tracked
.captions
.iter()
.filter(move |line| line.start <= clock && clock < line.end)
.map(|line| line.text.as_str())
})
.collect()
}
pub fn retain_sounds(&mut self, mut playing: impl FnMut(SoundId) -> bool) {
self.active.retain(|id, _| playing(*id));
self.tracked.retain(|id, tracked| {
self.active.contains_key(id) || !tracked.captions.is_empty()
});
}
pub fn drain(&mut self) -> Vec<AudioCommand> {
std::mem::take(&mut self.commands)
}
}
impl Tracked {
fn caption_tick_started(&self, tick: u64) -> bool {
!self.captions.is_empty() && !self.paused && tick >= self.start
}
}
#[must_use]
pub fn spatialize(
ear: [f32; 3],
right: [f32; 3],
position: [f32; 3],
falloff: Falloff,
) -> (f32, f32) {
let offset = [0, 1, 2].map(|axis| position[axis] - ear[axis]);
let distance = offset.iter().map(|v| v * v).sum::<f32>().sqrt();
let pan = if distance > 1e-4 {
(offset.iter().zip(right).map(|(a, b)| a * b).sum::<f32>() / distance)
.clamp(-1.0, 1.0)
} else {
0.0
};
(pan, falloff.gain(distance))
}
#[must_use]
pub fn end_tick(
start: u64,
seconds: f64,
rate: f32,
fixed_delta: std::time::Duration,
) -> u64 {
let real = seconds / f64::from(rate.abs().max(1e-3));
start + (real / fixed_delta.as_secs_f64()).ceil() as u64
}
pub fn active_camera(world: &mut World) -> Option<(Entity, super::Camera)> {
let mut cameras = world.query::<(Entity, &super::Camera)>();
cameras
.iter(world)
.filter(|(_, camera)| camera.active)
.max_by_key(|(entity, camera)| {
(
camera.viewport.is_none(),
camera.priority,
std::cmp::Reverse(entity.to_bits()),
)
})
.map(|(entity, camera)| (entity, *camera))
}
pub fn route_sound_events(world: &mut World) {
let tick = world
.get_resource::<crate::runtime::FrameTime>()
.map_or(0, |time| time.fixed_tick);
route_slide_sounds(world, tick);
let step = world
.get_resource::<crate::runtime::FrameTime>()
.map_or(1.0 / 60.0, |time| time.fixed_delta.as_secs_f64());
let clips: Vec<SoundEvent> = world
.get_resource::<EventQueue<SoundEvent>>()
.map(|events| events.iter().cloned().collect())
.unwrap_or_default();
let Some(listener) = world
.get_resource::<AudioQueue>()
.map(|queue| queue.listener)
else {
return;
};
let listener = listener
.filter(|entity| world.get_entity(*entity).is_ok())
.or_else(|| active_camera(world).map(|(entity, _)| entity));
let ear = listener.and_then(|entity| {
let matrix = super::ik::world_matrix(world, entity)?;
let position = matrix.transform_point(&nalgebra::Point3::origin());
let right = matrix
.transform_vector(&nalgebra::Vector3::x())
.try_normalize(1e-6)
.unwrap_or_else(nalgebra::Vector3::x);
Some((position.coords.into(), right.into()))
});
let tracked: Vec<(SoundId, Tracked)> = world
.resource::<AudioQueue>()
.tracked
.iter()
.map(|(id, tracked)| (*id, tracked.clone()))
.collect();
let zone = ear.and_then(|(at, _)| {
let physics = world.get_resource::<super::PhysicsWorld>()?;
physics
.overlap_sphere(at, 0.01, u32::MAX)
.into_iter()
.find_map(|entity| {
Some((entity, world.get::<ReverbZone>(entity)?.clone()))
})
});
let mut placed = Vec::new();
for (id, tracked) in &tracked {
let mut position = tracked.position;
if let Some(entity) = tracked.follow {
if let Some(matrix) = super::ik::world_matrix(world, entity) {
let at = matrix.transform_point(&nalgebra::Point3::origin());
position = Some(at.coords.into());
}
}
let occlusion = match (tracked.occlude, position, ear) {
(true, Some(at), Some((ear, _))) => {
occlusion(world, ear, at, [listener, tracked.follow])
}
_ => 0.0,
};
placed.push((*id, position, occlusion));
}
let Some(mut queue) = world.get_resource_mut::<AudioQueue>() else {
return;
};
for event in clips {
let sound = Sound {
volume: event.volume,
captions: (!event.caption.is_empty())
.then(|| {
Caption::new(0.0, super::CUE_CAPTION_SECONDS, event.caption)
})
.into_iter()
.collect(),
..Sound::default()
};
queue.play(&event.clip, &sound, tick);
}
if queue.reverb_zone != zone {
if let Some((_, left)) = queue.reverb_zone.take() {
queue.set_bus_effect(&left.bus, left.effect(0.0), left.fade);
}
if let Some((_, inside)) = &zone {
queue.set_bus_effect(
&inside.bus,
inside.effect(inside.mix),
inside.fade,
);
}
queue.reverb_zone = zone;
}
if let Some((position, right)) = ear {
if queue.heard_from != Some((position, right)) {
queue.heard_from = Some((position, right));
queue.push(AudioCommand::SetListener { position, right });
}
}
let elapsed = tick.saturating_sub(queue.caption_tick) as f64 * step;
for (id, position, amount) in placed {
let Some(tracked) = queue.tracked.get_mut(&id) else {
continue;
};
let mut pitch = None;
if let (true, Some(at), Some((ear, _))) =
(tracked.doppler > 0.0, position, ear)
{
let distance = (0..3)
.map(|axis| (at[axis] - ear[axis]).powi(2))
.sum::<f32>()
.sqrt();
if elapsed > 0.0 {
if let Some(before) = tracked.distance {
let closing = (before - distance) / elapsed as f32;
let factor = (SPEED_OF_SOUND
/ (SPEED_OF_SOUND - closing * tracked.doppler))
.clamp(0.5, 2.0);
if (factor - tracked.pitch).abs() > 1e-3 {
tracked.pitch = factor;
pitch = Some(factor);
}
}
tracked.distance = Some(distance);
}
}
if let Some(pitch) = pitch {
queue.push(AudioCommand::SetDoppler { id, pitch });
}
let Some(tracked) = queue.tracked.get_mut(&id) else {
continue;
};
let moved = position.filter(|_| tracked.follow.is_some());
let moved = moved.filter(|at| tracked.position != Some(*at));
let occluded = (tracked.occlusion != amount).then_some(amount);
tracked.position = position;
tracked.occlusion = amount;
if let Some(position) = moved {
queue.push(AudioCommand::SetPosition { id, position });
}
if let Some(amount) = occluded {
queue.push(AudioCommand::SetOcclusion { id, amount });
}
}
let since = queue.caption_tick;
queue.caption_tick = tick;
queue.tracked.retain(|_, tracked| {
if !tracked.paused {
let ticks = tick.saturating_sub(since.max(tracked.start));
tracked.clock += ticks as f64 * step * f64::from(tracked.rate);
}
tracked.follow.is_some()
|| tracked.doppler > 0.0
|| tracked.occlude
|| tracked
.captions
.iter()
.any(|line| f64::from(line.end) > tracked.clock)
});
}
fn occlusion(
world: &World,
ear: [f32; 3],
at: [f32; 3],
skip: [Option<Entity>; 2],
) -> f32 {
let Some(physics) = world.get_resource::<super::PhysicsWorld>() else {
return 0.0;
};
let direction = [0, 1, 2].map(|axis| at[axis] - ear[axis]);
let distance = direction.iter().map(|v| v * v).sum::<f32>().sqrt();
let reach = distance - 0.25;
if reach <= 0.0 {
return 0.0;
}
let hit =
physics.raycast_where(ear, direction, reach, u32::MAX, |entity| {
!skip.contains(&Some(entity))
});
if hit.is_some() {
1.0
} else {
0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn playing_lists_live_sounds_and_pauses_them_by_bus() {
let mut queue = AudioQueue::default();
let on = |bus: &str| Sound {
bus: bus.to_owned(),
..Sound::default()
};
let music = queue.play("music.ogg", &on("music"), 0);
let hum = queue.play("hum.ogg", &on("music"), 0);
let step = queue.play("step.ogg", &on("sfx"), 0);
queue.drain();
queue.pause_bus(Some("music"));
let paused = |queue: &AudioQueue| {
let sounds = queue.playing();
sounds
.iter()
.filter(|s| s.paused)
.map(|s| s.id)
.collect::<Vec<_>>()
};
assert_eq!(paused(&queue), [music, hum]);
assert_eq!(
queue.drain(),
[AudioCommand::Pause(music), AudioCommand::Pause(hum)]
);
queue.pause_bus(None);
assert_eq!(queue.drain(), [AudioCommand::Pause(step)]);
queue.resume_bus(Some("sfx"));
assert_eq!(paused(&queue), [music, hum]);
queue.resume_bus(None);
assert_eq!(paused(&queue), []);
queue.retain_sounds(|id| id != hum);
queue.stop(step);
let live = queue.playing();
assert_eq!(live.len(), 1);
assert_eq!((live[0].id, live[0].clip.as_str()), (music, "music.ogg"));
assert_eq!(live[0].bus, "music");
}
#[test]
fn beat_clock_lands_on_whole_ticks() {
let step = std::time::Duration::from_secs_f64(1.0 / 60.0);
let clock = BeatClock::new(128, 2, step);
let ticks: Vec<_> = (0..5).map(|beat| clock.tick_of(beat)).collect();
assert_eq!(ticks, [2, 31, 59, 87, 115]);
assert_eq!(clock.beat_at(1), None);
assert_eq!(clock.beat_at(58), Some(1));
assert!(clock.is_beat(59) && !clock.is_beat(60));
assert_eq!(clock.ticks_to_next(59), 28);
assert_eq!(clock.ticks_to_next(0), 2);
assert_eq!(clock.beats_between(30, 90), 1..4);
assert_eq!(clock.beats_between(0, 1), 0..0);
let even = BeatClock::new(120, 0, step);
assert_eq!(even.tick_of(3), 90);
assert_eq!(even.phase(45), 0.5);
}
#[test]
fn requests_get_distinct_ids_and_drain_once() {
let mut queue = AudioQueue::default();
let a = queue.play("sfx/a.wav", &Sound::default(), 0);
let b = queue.play("sfx/b.wav", &Sound::default(), 0);
queue.stop(a);
assert_ne!(a, b);
assert_eq!(queue.requested(), 2);
assert_eq!(queue.requested_clips()["sfx/b.wav"], 1);
assert_eq!(queue.drain().len(), 3);
assert!(queue.drain().is_empty());
for _ in 0..QUEUE_LIMIT + 10 {
queue.set_master_volume(1.0);
}
assert_eq!(queue.drain().len(), QUEUE_LIMIT);
assert_eq!(queue.requested(), 2);
}
#[test]
fn half_rate_doubles_the_end_tick() {
let step = std::time::Duration::from_secs_f64(1.0 / 60.0);
assert_eq!(end_tick(10, 2.0, 1.0, step), 130);
assert_eq!(end_tick(10, 2.0, 0.5, step), 250);
assert_eq!(end_tick(10, 2.0, 2.0, step), 70);
}
#[test]
fn spatialize_pans_to_the_side_and_falls_off_past_two_metres() {
let right = [1.0, 0.0, 0.0];
let (pan, gain) =
spatialize([0.0; 3], right, [-5.0, 0.0, 0.0], Falloff::default());
assert_eq!((pan, gain), (-1.0, 0.4));
let (pan, gain) =
spatialize([0.0; 3], right, [0.0, 0.0, -1.0], Falloff::default());
assert_eq!((pan, gain), (0.0, 1.0));
assert_eq!(spatialize([0.0; 3], right, [0.0; 3], Falloff::Off).0, 0.0);
let near =
|falloff: Falloff| [1.0, 4.0, 8.0, 16.0].map(|d| falloff.gain(d));
assert_eq!(
near(Falloff::InverseSquare { near: 2.0 }),
[1.0, 0.25, 0.0625, 0.015625]
);
assert_eq!(
near(Falloff::Linear {
near: 2.0,
far: 10.0
}),
[1.0, 0.75, 0.25, 0.0]
);
assert_eq!(near(Falloff::Off), [1.0; 4]);
}
#[test]
fn bus_voice_limits_clamp_and_inactive_effects_report_off() {
let mut queue = AudioQueue::default();
queue.set_bus_voice_limit("bears", 0);
queue.set_bus_voice_limit("bears", 10_000);
let limits: Vec<_> = queue
.drain()
.into_iter()
.filter_map(|command| match command {
AudioCommand::SetBusVoiceLimit { limit, .. } => Some(limit),
_ => None,
})
.collect();
assert_eq!(limits, [1, MAX_VOICE_LIMIT]);
assert!(!BusEffect::LowPass {
cutoff_hz: 20_000.0
}
.active());
assert!(!BusEffect::Distortion {
drive: 6.0,
mix: 0.0
}
.active());
assert!(!BusEffect::Compressor {
threshold_db: -20.0,
ratio: 1.0,
makeup_db: 0.0
}
.active());
assert!(BusEffect::Reverb {
room: 0.5,
damping: 0.5,
mix: 0.2
}
.active());
}
}