Blacksite/crates/game/src/audio.rs
Rbanh 0798aa5d57 Build renderer and material component foundations
Add dedicated skinned rendering, pose restoration, shared Material and Material Instance slots, registry-driven components, Surface/Solari integration, transactional schema upgrades, navigation authoring, documentation, and evaluation evidence.
2026-07-12 00:24:06 -04:00

924 lines
31 KiB
Rust

//! Runtime audio hydration, bus controls, listener resolution, and diagnostics.
use std::collections::{HashMap, HashSet};
use bevy::audio::{
AudioPlayer, AudioSink, AudioSinkPlayback, AudioSource, PlaybackMode, PlaybackSettings,
SpatialAudioSink, SpatialListener, SpatialScale, Volume,
};
use bevy::prelude::*;
use cpal::traits::{DeviceTrait, HostTrait};
use settings::{
AudioSettings, ProjectSettings, AUDIO_BUS_MASTER_ID, AUDIO_GAIN_DB_MAX, AUDIO_GAIN_DB_MIN,
};
use shared::{
asset_server_path, authoring_component_active, ActorId, AudioAttenuationDesc,
AudioListenerDesc, AudioRolloff, AudioSourceDesc, AuthoringComponentStates, InspectorOrder,
LevelObject, COMPONENT_AUDIO_LISTENER_DESC, COMPONENT_AUDIO_SOURCE_DESC,
};
use sim::{PlayerCamera, SimEnabled};
/// Runtime override for an authored project bus. `None` preserves the project value.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct AudioBusOverride {
pub gain_db: Option<f32>,
pub muted: Option<bool>,
}
/// Mutable runtime mixer state used by gameplay and options menus.
#[derive(Resource, Debug, Clone, Default)]
pub struct AudioBusRuntime {
overrides: HashMap<String, AudioBusOverride>,
}
impl AudioBusRuntime {
pub fn set_gain_db(&mut self, bus_id: impl Into<String>, gain_db: f32) -> Result<(), String> {
if !gain_db.is_finite() {
return Err("audio bus gain must be finite".into());
}
self.overrides.entry(bus_id.into()).or_default().gain_db =
Some(gain_db.clamp(AUDIO_GAIN_DB_MIN, AUDIO_GAIN_DB_MAX));
Ok(())
}
pub fn set_muted(&mut self, bus_id: impl Into<String>, muted: bool) {
self.overrides.entry(bus_id.into()).or_default().muted = Some(muted);
}
pub fn clear_bus(&mut self, bus_id: &str) {
self.overrides.remove(bus_id);
}
pub fn clear(&mut self) {
self.overrides.clear();
}
pub fn bus_override(&self, bus_id: &str) -> Option<AudioBusOverride> {
self.overrides.get(bus_id).copied()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioDeviceStatus {
Unknown,
Available,
Unavailable,
}
/// User-facing audio backend and codec capability snapshot.
#[derive(Resource, Debug, Clone)]
pub struct AudioRuntimeDiagnostics {
pub status: AudioDeviceStatus,
pub backend: String,
pub output_device: Option<String>,
pub message: String,
pub codecs: Vec<String>,
}
impl Default for AudioRuntimeDiagnostics {
fn default() -> Self {
Self {
status: AudioDeviceStatus::Unknown,
backend: String::new(),
output_device: None,
message: "Audio output has not been probed yet.".into(),
codecs: vec![
"Ogg Vorbis".into(),
"WAV".into(),
"MP3".into(),
"FLAC".into(),
],
}
}
}
#[derive(Resource, Debug, Clone, Copy, Default)]
pub struct ActiveAudioListener(pub Option<Entity>);
#[derive(Component, Debug, Clone, PartialEq)]
struct RuntimeAudioSourceState {
descriptor: AudioSourceDesc,
desired_playing: bool,
paused_by_sim: bool,
}
#[derive(Component, Debug, Clone, Copy, PartialEq)]
struct ManagedAudioListener {
ear_gap: f32,
}
#[derive(Component, Debug, Clone)]
pub struct AuthoredAudioVoice {
pub source: Entity,
pub bus: String,
pub source_gain_db: f32,
pub mix_weight: f32,
pub spatial: bool,
pub attenuation: AudioAttenuationDesc,
pub looping: bool,
}
/// Runtime adapter for scene-authored audio descriptors.
pub struct GameAudioPlugin;
impl Plugin for GameAudioPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AudioBusRuntime>()
.init_resource::<AudioRuntimeDiagnostics>()
.init_resource::<ActiveAudioListener>()
.add_systems(PostStartup, probe_audio_output)
.add_systems(
Update,
(
reconcile_audio_listener,
reconcile_audio_sources,
update_voice_mix,
cleanup_finished_voices,
)
.chain(),
);
}
}
fn probe_audio_output(mut diagnostics: ResMut<AudioRuntimeDiagnostics>) {
let host = cpal::default_host();
diagnostics.backend = host.id().name().to_string();
match host.default_output_device() {
Some(device) => {
let name = device
.description()
.map(|description| description.name().to_string())
.unwrap_or_else(|error| format!("Unknown device ({error})"));
diagnostics.status = AudioDeviceStatus::Available;
diagnostics.output_device = Some(name.clone());
diagnostics.message = format!("Audio output ready on {name}.");
}
None => {
diagnostics.status = AudioDeviceStatus::Unavailable;
diagnostics.output_device = None;
diagnostics.message = "No default audio output device is available.".into();
warn!("{}", diagnostics.message);
}
}
}
/// Returns the effective linear gain for a bus including parent buses and runtime overrides.
pub fn effective_bus_gain_linear(
settings: &AudioSettings,
runtime: &AudioBusRuntime,
requested_bus: &str,
) -> f32 {
let mut current = if settings.bus(requested_bus).is_some() {
requested_bus
} else {
AUDIO_BUS_MASTER_ID
};
let mut visited = HashSet::new();
let mut gain_db = 0.0;
while visited.insert(current.to_string()) {
let Some(bus) = settings.bus(current) else {
break;
};
let runtime_override = runtime.bus_override(current).unwrap_or_default();
if runtime_override.muted.unwrap_or(bus.muted) {
return 0.0;
}
gain_db += runtime_override.gain_db.unwrap_or(bus.gain_db);
let Some(parent) = bus.parent.as_deref() else {
break;
};
current = parent;
}
db_to_linear(gain_db)
}
pub fn db_to_linear(db: f32) -> f32 {
if db <= AUDIO_GAIN_DB_MIN {
0.0
} else {
10.0_f32.powf(db / 20.0)
}
}
/// Equal-power 2D/spatial voice weights for a continuous authored blend.
pub fn audio_voice_mix(spatial_blend: f32) -> Vec<(bool, f32)> {
let blend = spatial_blend.clamp(0.0, 1.0);
if blend <= f32::EPSILON {
vec![(false, 1.0)]
} else if blend >= 1.0 - f32::EPSILON {
vec![(true, 1.0)]
} else {
vec![(false, (1.0 - blend).sqrt()), (true, blend.sqrt())]
}
}
/// Authored attenuation curve, normalized to full gain at `min_distance` and silence at max.
pub fn attenuation_gain(desc: &AudioAttenuationDesc, distance: f32) -> f32 {
if !distance.is_finite() {
return 0.0;
}
let min = desc.min_distance.max(0.001);
let max = desc.max_distance.max(min + 0.001);
if distance <= min {
return 1.0;
}
if distance >= max {
return 0.0;
}
let t = ((distance - min) / (max - min)).clamp(0.0, 1.0);
let factor = desc.rolloff_factor.max(0.001);
match desc.rolloff {
AudioRolloff::Linear => (1.0 - t).powf(factor),
AudioRolloff::Inverse => ((1.0 - t) / (1.0 + factor * 4.0 * t)).clamp(0.0, 1.0),
AudioRolloff::Exponential => ((distance / min).powf(-factor) * (1.0 - t)).clamp(0.0, 1.0),
}
}
/// Coordinate scale used only for rodio's left/right spatial panning.
///
/// Rodio also applies an inverse-square distance gain. Keeping every audible point inside its
/// unity-distance zone preserves the interaural panning ratio while leaving the authored
/// attenuation curve as the sole distance-gain authority.
pub fn spatial_panning_scale(desc: &AudioAttenuationDesc, ear_gap: f32) -> f32 {
const RODIO_UNITY_ZONE_FRACTION: f32 = 0.25;
let audible_radius = if desc.max_distance.is_finite() {
desc.max_distance.max(0.001)
} else {
0.001
};
let ear_gap = if ear_gap.is_finite() {
ear_gap.max(0.001)
} else {
0.001
};
(RODIO_UNITY_ZONE_FRACTION / (audible_radius + ear_gap * 0.5)).max(f32::MIN_POSITIVE)
}
fn reconcile_audio_listener(world: &mut World) {
let enabled = world.resource::<SimEnabled>().0;
let mut candidates = Vec::new();
{
let mut query = world.query_filtered::<(
Entity,
&AudioListenerDesc,
Option<&ActorId>,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
), With<LevelObject>>();
for (entity, desc, actor_id, states, legacy_order) in query.iter(world) {
if desc.enabled
&& authoring_component_active(states, legacy_order, COMPONENT_AUDIO_LISTENER_DESC)
{
candidates.push((
entity,
*desc,
actor_id.map(|id| id.0.clone()).unwrap_or_default(),
));
}
}
}
candidates.sort_by(|a, b| {
b.1.priority
.cmp(&a.1.priority)
.then_with(|| a.2.cmp(&b.2))
.then_with(|| a.0.to_bits().cmp(&b.0.to_bits()))
});
let selected = if enabled {
candidates
.first()
.map(|(entity, desc, _)| (*entity, desc.ear_gap))
.or_else(|| {
let mut query = world.query_filtered::<Entity, With<PlayerCamera>>();
query
.iter(world)
.min_by_key(|entity| entity.to_bits())
.map(|entity| (entity, 0.2))
})
} else {
None
};
let mut managed_query = world.query::<(Entity, &ManagedAudioListener)>();
let managed: Vec<(Entity, ManagedAudioListener)> = managed_query
.iter(world)
.map(|(entity, listener)| (entity, *listener))
.collect();
for (entity, _) in managed {
if selected.is_none_or(|(selected, _)| selected != entity) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut
.remove::<SpatialListener>()
.remove::<ManagedAudioListener>();
}
}
}
if let Some((entity, ear_gap)) = selected {
let ear_gap = ear_gap.max(0.001);
let is_current = world
.get::<ManagedAudioListener>(entity)
.is_some_and(|listener| listener.ear_gap == ear_gap)
&& world.get::<SpatialListener>(entity).is_some();
if !is_current {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert((
SpatialListener::new(ear_gap),
ManagedAudioListener { ear_gap },
));
}
}
}
let selected_entity = selected.map(|(entity, _)| entity);
let mut active = world.resource_mut::<ActiveAudioListener>();
if active.0 != selected_entity {
active.0 = selected_entity;
}
}
fn reconcile_audio_sources(world: &mut World) {
let sim_enabled = world.resource::<SimEnabled>().0;
let mut sources = Vec::new();
{
let mut query = world.query_filtered::<(
Entity,
&AudioSourceDesc,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
), With<LevelObject>>();
for (entity, desc, states, legacy_order) in query.iter(world) {
if authoring_component_active(states, legacy_order, COMPONENT_AUDIO_SOURCE_DESC) {
sources.push((entity, desc.clone()));
}
}
}
let source_ids: HashSet<Entity> = sources.iter().map(|(entity, _)| *entity).collect();
let mut voice_query = world.query::<&AuthoredAudioVoice>();
let mut voiced_sources: HashSet<Entity> =
voice_query.iter(world).map(|voice| voice.source).collect();
let mut state_query = world.query_filtered::<Entity, With<RuntimeAudioSourceState>>();
let stale: Vec<Entity> = state_query
.iter(world)
.filter(|entity| !source_ids.contains(entity))
.collect();
for entity in stale {
stop_voice_entities(world, entity);
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.remove::<RuntimeAudioSourceState>();
}
}
for (entity, descriptor) in sources {
let prior = world.get::<RuntimeAudioSourceState>(entity).cloned();
let mut state = prior.clone().unwrap_or_else(|| RuntimeAudioSourceState {
descriptor: descriptor.clone(),
desired_playing: descriptor.autoplay,
paused_by_sim: false,
});
if state.descriptor != descriptor {
let autoplay_changed = state.descriptor.autoplay != descriptor.autoplay;
stop_voice_entities(world, entity);
voiced_sources.remove(&entity);
state.descriptor = descriptor.clone();
if autoplay_changed {
state.desired_playing = descriptor.autoplay;
}
}
if !sim_enabled {
if !state.paused_by_sim {
set_source_paused(world, entity, true);
state.paused_by_sim = true;
}
} else {
if state.paused_by_sim {
set_source_paused(world, entity, false);
state.paused_by_sim = false;
}
if state.desired_playing && !voiced_sources.contains(&entity) {
if let Err(error) = spawn_source_voices(world, entity, &descriptor) {
warn!("Could not start authored audio source {entity:?}: {error}");
state.desired_playing = false;
} else {
voiced_sources.insert(entity);
}
}
}
if prior.as_ref() != Some(&state) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(state);
}
}
}
}
fn spawn_source_voices(
world: &mut World,
source: Entity,
descriptor: &AudioSourceDesc,
) -> Result<(), String> {
let clip = descriptor
.clip
.as_ref()
.ok_or_else(|| "no audio clip is assigned".to_string())?;
let source_path = clip
.source_path
.as_deref()
.filter(|path| !path.trim().is_empty())
.ok_or_else(|| "audio clip has no runtime source path".to_string())?;
let asset_server = world.resource::<AssetServer>().clone();
let handle: Handle<AudioSource> = asset_server.load(asset_server_path(source_path));
let mode = if descriptor.looping {
PlaybackMode::Loop
} else {
PlaybackMode::Once
};
let ear_gap = active_listener_ear_gap(world);
let panning_scale = spatial_panning_scale(&descriptor.attenuation, ear_gap);
for (spatial, mix_weight) in audio_voice_mix(descriptor.spatial_blend) {
let child = world
.spawn((
Name::new(if spatial {
"Authored Spatial Audio Voice"
} else {
"Authored 2D Audio Voice"
}),
Transform::IDENTITY,
AudioPlayer::new(handle.clone()),
PlaybackSettings {
mode,
volume: Volume::Linear(0.0),
speed: descriptor.pitch,
paused: false,
muted: false,
spatial,
spatial_scale: spatial.then(|| SpatialScale::new(panning_scale)),
start_position: None,
duration: None,
},
AuthoredAudioVoice {
source,
bus: descriptor.bus.clone(),
source_gain_db: descriptor.gain_db,
mix_weight,
spatial,
attenuation: descriptor.attenuation,
looping: descriptor.looping,
},
ChildOf(source),
))
.id();
trace!("Spawned authored audio voice {child:?} for {source:?}");
}
Ok(())
}
fn active_listener_ear_gap(world: &World) -> f32 {
world
.resource::<ActiveAudioListener>()
.0
.and_then(|entity| world.get::<ManagedAudioListener>(entity))
.map(|listener| listener.ear_gap)
.unwrap_or(0.2)
}
fn voice_entities(world: &mut World, source: Entity) -> Vec<Entity> {
let mut query = world.query::<(Entity, &AuthoredAudioVoice)>();
query
.iter(world)
.filter_map(|(entity, voice)| (voice.source == source).then_some(entity))
.collect()
}
fn stop_voice_entities(world: &mut World, source: Entity) {
for entity in voice_entities(world, source) {
if let Some(sink) = world.get::<AudioSink>(entity) {
sink.stop();
}
if let Some(sink) = world.get::<SpatialAudioSink>(entity) {
sink.stop();
}
if let Ok(entity_mut) = world.get_entity_mut(entity) {
entity_mut.despawn();
}
}
}
fn set_source_paused(world: &mut World, source: Entity, paused: bool) {
for entity in voice_entities(world, source) {
if let Some(mut settings) = world.get_mut::<PlaybackSettings>(entity) {
settings.paused = paused;
}
if let Some(sink) = world.get::<AudioSink>(entity) {
if paused {
sink.pause();
} else {
sink.play();
}
}
if let Some(sink) = world.get::<SpatialAudioSink>(entity) {
if paused {
sink.pause();
} else {
sink.play();
}
}
}
}
/// Starts an authored source through the same runtime adapter used by autoplay.
pub fn play_authored_audio_source(world: &mut World, source: Entity) -> Result<(), String> {
let descriptor = world
.get::<AudioSourceDesc>(source)
.cloned()
.ok_or_else(|| "entity has no AudioSourceDesc".to_string())?;
stop_voice_entities(world, source);
let sim_enabled = world.resource::<SimEnabled>().0;
let state = RuntimeAudioSourceState {
descriptor: descriptor.clone(),
desired_playing: true,
paused_by_sim: !sim_enabled,
};
world.entity_mut(source).insert(state);
if sim_enabled {
spawn_source_voices(world, source, &descriptor)?;
}
Ok(())
}
/// Stops an authored source and disables replay until another explicit play or autoplay change.
pub fn stop_authored_audio_source(world: &mut World, source: Entity) {
stop_voice_entities(world, source);
if let Some(mut state) = world.get_mut::<RuntimeAudioSourceState>(source) {
state.desired_playing = false;
state.paused_by_sim = false;
}
}
/// Clears PIE-owned playback/listeners so the next session starts from authored state.
pub fn reset_authored_audio_runtime(world: &mut World) {
let mut query = world.query_filtered::<Entity, With<RuntimeAudioSourceState>>();
let sources: Vec<Entity> = query.iter(world).collect();
for source in sources {
stop_voice_entities(world, source);
if let Ok(mut entity_mut) = world.get_entity_mut(source) {
entity_mut.remove::<RuntimeAudioSourceState>();
}
}
let mut listener_query = world.query_filtered::<Entity, With<ManagedAudioListener>>();
let listeners: Vec<Entity> = listener_query.iter(world).collect();
for listener in listeners {
if let Ok(mut entity_mut) = world.get_entity_mut(listener) {
entity_mut
.remove::<SpatialListener>()
.remove::<ManagedAudioListener>();
}
}
world.resource_mut::<ActiveAudioListener>().0 = None;
}
fn update_voice_mix(world: &mut World) {
let project_audio = world.resource::<ProjectSettings>().audio.clone();
let bus_runtime = world.resource::<AudioBusRuntime>().clone();
let listener_position = world
.resource::<ActiveAudioListener>()
.0
.and_then(|entity| world.get::<GlobalTransform>(entity))
.map(GlobalTransform::translation);
let listener_ear_gap = active_listener_ear_gap(world);
let mut query = world.query::<(Entity, &AuthoredAudioVoice, Option<&GlobalTransform>)>();
let voices: Vec<(Entity, AuthoredAudioVoice, Option<Vec3>)> = query
.iter(world)
.map(|(entity, voice, transform)| {
(
entity,
voice.clone(),
transform.map(GlobalTransform::translation),
)
})
.collect();
for (entity, voice, source_position) in voices {
if voice.spatial {
let scale = spatial_panning_scale(&voice.attenuation, listener_ear_gap);
let desired = SpatialScale::new(scale);
if let Some(mut settings) = world.get_mut::<PlaybackSettings>(entity) {
if settings
.spatial_scale
.is_none_or(|current| current.0 != desired.0)
{
settings.spatial_scale = Some(desired);
}
}
}
let bus_gain = effective_bus_gain_linear(&project_audio, &bus_runtime, &voice.bus);
let attenuation = if voice.spatial {
match (listener_position, source_position) {
(Some(listener), Some(source)) => {
attenuation_gain(&voice.attenuation, listener.distance(source))
}
_ => 0.0,
}
} else {
1.0
};
let gain = db_to_linear(voice.source_gain_db) * bus_gain * voice.mix_weight * attenuation;
if let Some(mut sink) = world.get_mut::<AudioSink>(entity) {
sink.set_volume(Volume::Linear(gain));
}
if let Some(mut sink) = world.get_mut::<SpatialAudioSink>(entity) {
sink.set_volume(Volume::Linear(gain));
}
}
}
fn cleanup_finished_voices(world: &mut World) {
let mut query = world.query::<(Entity, &AuthoredAudioVoice)>();
let voices: Vec<(Entity, Entity, bool)> = query
.iter(world)
.map(|(entity, voice)| (entity, voice.source, voice.looping))
.collect();
let mut completed_sources = HashSet::new();
for (entity, source, looping) in voices {
if looping {
continue;
}
let finished = world
.get::<AudioSink>(entity)
.is_some_and(AudioSinkPlayback::empty)
|| world
.get::<SpatialAudioSink>(entity)
.is_some_and(AudioSinkPlayback::empty);
if finished {
if let Ok(entity_mut) = world.get_entity_mut(entity) {
entity_mut.despawn();
}
completed_sources.insert(source);
}
}
for source in completed_sources {
if voice_entities(world, source).is_empty() {
if let Some(mut state) = world.get_mut::<RuntimeAudioSourceState>(source) {
state.desired_playing = false;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::asset::{AssetApp, AssetPlugin};
use settings::{AudioBusSettings, AUDIO_BUS_MUSIC_ID, AUDIO_BUS_SFX_ID};
use shared::{EditorAssetRef, AUDIO_CLIP_SUB_ASSET_ID};
#[test]
fn bus_gain_accumulates_parents_and_runtime_overrides() {
let settings = AudioSettings::default();
let mut runtime = AudioBusRuntime::default();
runtime.set_gain_db(AUDIO_BUS_MASTER_ID, -6.0).unwrap();
runtime.set_gain_db(AUDIO_BUS_MUSIC_ID, -6.0).unwrap();
let gain = effective_bus_gain_linear(&settings, &runtime, AUDIO_BUS_MUSIC_ID);
assert!((gain - db_to_linear(-12.0)).abs() < 0.0001);
runtime.set_muted(AUDIO_BUS_MASTER_ID, true);
assert_eq!(
effective_bus_gain_linear(&settings, &runtime, AUDIO_BUS_MUSIC_ID),
0.0
);
}
#[test]
fn missing_bus_falls_back_to_master() {
let mut settings = AudioSettings::default();
settings.bus_mut(AUDIO_BUS_MASTER_ID).unwrap().gain_db = -3.0;
assert!(
(effective_bus_gain_linear(&settings, &AudioBusRuntime::default(), "missing")
- db_to_linear(-3.0))
.abs()
< 0.0001
);
}
#[test]
fn spatial_blend_is_equal_power_and_keeps_endpoint_voice_counts() {
assert_eq!(audio_voice_mix(0.0), vec![(false, 1.0)]);
assert_eq!(audio_voice_mix(1.0), vec![(true, 1.0)]);
let mixed = audio_voice_mix(0.5);
assert_eq!(mixed.len(), 2);
let power: f32 = mixed.iter().map(|(_, weight)| weight * weight).sum();
assert!((power - 1.0).abs() < 0.0001);
}
#[test]
fn attenuation_is_full_inside_min_and_silent_at_max() {
for rolloff in [
AudioRolloff::Linear,
AudioRolloff::Inverse,
AudioRolloff::Exponential,
] {
let desc = AudioAttenuationDesc {
min_distance: 2.0,
max_distance: 10.0,
rolloff,
rolloff_factor: 1.0,
};
assert_eq!(attenuation_gain(&desc, 1.0), 1.0);
assert_eq!(attenuation_gain(&desc, 10.0), 0.0);
assert!(attenuation_gain(&desc, 5.0) > 0.0);
}
}
#[test]
fn panning_scale_keeps_rodio_distance_gain_neutral_in_authored_radius() {
let attenuation = AudioAttenuationDesc {
min_distance: 1.0,
max_distance: 25.0,
rolloff: AudioRolloff::Inverse,
rolloff_factor: 1.0,
};
let ear_gap = 0.2;
let scale = spatial_panning_scale(&attenuation, ear_gap);
// Rodio's distance gain remains 1.0 while each ear is at most one rodio unit away.
let furthest_audible_ear = (attenuation.max_distance + ear_gap * 0.5) * scale;
assert!(scale.is_finite() && scale > 0.0);
assert!(furthest_audible_ear <= 0.25 + f32::EPSILON);
}
#[test]
fn unchanged_reconciliation_does_not_reinsert_runtime_components() {
let mut world = World::new();
world.insert_resource(SimEnabled(true));
world.insert_resource(ActiveAudioListener::default());
let listener = world
.spawn((
LevelObject,
ActorId("audio-listener".into()),
AudioListenerDesc::default(),
))
.id();
let source = world
.spawn((
LevelObject,
AudioSourceDesc {
autoplay: false,
..Default::default()
},
))
.id();
reconcile_audio_listener(&mut world);
reconcile_audio_sources(&mut world);
world.clear_trackers();
reconcile_audio_listener(&mut world);
reconcile_audio_sources(&mut world);
assert!(!world
.entity(listener)
.get_ref::<ManagedAudioListener>()
.unwrap()
.is_changed());
assert!(!world
.entity(source)
.get_ref::<RuntimeAudioSourceState>()
.unwrap()
.is_changed());
}
#[test]
fn authored_audio_hydrates_and_reset_removes_all_runtime_state() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
.init_asset::<AudioSource>()
.insert_resource(SimEnabled(true))
.init_resource::<ActiveAudioListener>();
let listener = app
.world_mut()
.spawn((
LevelObject,
ActorId("audio-listener".into()),
AudioListenerDesc::default(),
Transform::IDENTITY,
))
.id();
let source = app
.world_mut()
.spawn((
LevelObject,
Transform::from_xyz(0.0, 0.0, -2.0),
AudioSourceDesc {
clip: Some(
EditorAssetRef::new("audio-id", AUDIO_CLIP_SUB_ASSET_ID, "Tone")
.with_source_path("audio/tone.ogg"),
),
spatial_blend: 0.5,
..Default::default()
},
))
.id();
reconcile_audio_listener(app.world_mut());
reconcile_audio_sources(app.world_mut());
assert_eq!(
app.world().resource::<ActiveAudioListener>().0,
Some(listener)
);
assert!(app.world().get::<SpatialListener>(listener).is_some());
assert!(app.world().get::<RuntimeAudioSourceState>(source).is_some());
let voices = voice_entities(app.world_mut(), source);
assert_eq!(voices.len(), 2);
assert!(voices
.iter()
.all(|voice| app.world().get::<AudioPlayer>(*voice).is_some()));
reset_authored_audio_runtime(app.world_mut());
assert_eq!(app.world().resource::<ActiveAudioListener>().0, None);
assert!(app.world().get::<SpatialListener>(listener).is_none());
assert!(app.world().get::<RuntimeAudioSourceState>(source).is_none());
assert!(voice_entities(app.world_mut(), source).is_empty());
}
#[test]
fn listener_selection_prefers_priority_then_stable_actor_id() {
let mut world = World::new();
world.insert_resource(SimEnabled(true));
world.insert_resource(ActiveAudioListener::default());
let low_priority = world
.spawn((
LevelObject,
ActorId("listener-low".into()),
AudioListenerDesc {
priority: 1,
..Default::default()
},
))
.id();
let tied_later = world
.spawn((
LevelObject,
ActorId("listener-z".into()),
AudioListenerDesc {
priority: 10,
..Default::default()
},
))
.id();
let tied_first = world
.spawn((
LevelObject,
ActorId("listener-a".into()),
AudioListenerDesc {
priority: 10,
..Default::default()
},
))
.id();
reconcile_audio_listener(&mut world);
assert_eq!(world.resource::<ActiveAudioListener>().0, Some(tied_first));
assert_ne!(
world.resource::<ActiveAudioListener>().0,
Some(low_priority)
);
world
.get_mut::<AudioListenerDesc>(tied_first)
.unwrap()
.enabled = false;
reconcile_audio_listener(&mut world);
assert_eq!(world.resource::<ActiveAudioListener>().0, Some(tied_later));
}
#[test]
fn bus_cycle_is_defensively_bounded_at_runtime() {
let settings = AudioSettings {
buses: vec![
AudioBusSettings {
id: AUDIO_BUS_MASTER_ID.into(),
label: "Master".into(),
parent: Some(AUDIO_BUS_SFX_ID.into()),
gain_db: 0.0,
muted: false,
},
AudioBusSettings {
id: AUDIO_BUS_SFX_ID.into(),
label: "SFX".into(),
parent: Some(AUDIO_BUS_MASTER_ID.into()),
gain_db: 0.0,
muted: false,
},
],
};
assert_eq!(
effective_bus_gain_linear(&settings, &AudioBusRuntime::default(), AUDIO_BUS_SFX_ID),
1.0
);
}
}