//! 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, pub muted: Option, } /// Mutable runtime mixer state used by gameplay and options menus. #[derive(Resource, Debug, Clone, Default)] pub struct AudioBusRuntime { overrides: HashMap, } impl AudioBusRuntime { pub fn set_gain_db(&mut self, bus_id: impl Into, 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, 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 { 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, pub message: String, pub codecs: Vec, } 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); #[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::() .init_resource::() .init_resource::() .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) { 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::().0; let mut candidates = Vec::new(); { let mut query = world.query_filtered::<( Entity, &AudioListenerDesc, Option<&ActorId>, Option<&AuthoringComponentStates>, Option<&InspectorOrder>, ), With>(); 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::>(); 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::() .remove::(); } } } if let Some((entity, ear_gap)) = selected { let ear_gap = ear_gap.max(0.001); let is_current = world .get::(entity) .is_some_and(|listener| listener.ear_gap == ear_gap) && world.get::(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::(); if active.0 != selected_entity { active.0 = selected_entity; } } fn reconcile_audio_sources(world: &mut World) { let sim_enabled = world.resource::().0; let mut sources = Vec::new(); { let mut query = world.query_filtered::<( Entity, &AudioSourceDesc, Option<&AuthoringComponentStates>, Option<&InspectorOrder>, ), With>(); 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 = sources.iter().map(|(entity, _)| *entity).collect(); let mut voice_query = world.query::<&AuthoredAudioVoice>(); let mut voiced_sources: HashSet = voice_query.iter(world).map(|voice| voice.source).collect(); let mut state_query = world.query_filtered::>(); let stale: Vec = 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::(); } } for (entity, descriptor) in sources { let prior = world.get::(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::().clone(); let handle: Handle = 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::() .0 .and_then(|entity| world.get::(entity)) .map(|listener| listener.ear_gap) .unwrap_or(0.2) } fn voice_entities(world: &mut World, source: Entity) -> Vec { 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::(entity) { sink.stop(); } if let Some(sink) = world.get::(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::(entity) { settings.paused = paused; } if let Some(sink) = world.get::(entity) { if paused { sink.pause(); } else { sink.play(); } } if let Some(sink) = world.get::(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::(source) .cloned() .ok_or_else(|| "entity has no AudioSourceDesc".to_string())?; stop_voice_entities(world, source); let sim_enabled = world.resource::().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::(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::>(); let sources: Vec = 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::(); } } let mut listener_query = world.query_filtered::>(); let listeners: Vec = listener_query.iter(world).collect(); for listener in listeners { if let Ok(mut entity_mut) = world.get_entity_mut(listener) { entity_mut .remove::() .remove::(); } } world.resource_mut::().0 = None; } fn update_voice_mix(world: &mut World) { let project_audio = world.resource::().audio.clone(); let bus_runtime = world.resource::().clone(); let listener_position = world .resource::() .0 .and_then(|entity| world.get::(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)> = 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::(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::(entity) { sink.set_volume(Volume::Linear(gain)); } if let Some(mut sink) = world.get_mut::(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::(entity) .is_some_and(AudioSinkPlayback::empty) || world .get::(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::(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::() .unwrap() .is_changed()); assert!(!world .entity(source) .get_ref::() .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::() .insert_resource(SimEnabled(true)) .init_resource::(); 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::().0, Some(listener) ); assert!(app.world().get::(listener).is_some()); assert!(app.world().get::(source).is_some()); let voices = voice_entities(app.world_mut(), source); assert_eq!(voices.len(), 2); assert!(voices .iter() .all(|voice| app.world().get::(*voice).is_some())); reset_authored_audio_runtime(app.world_mut()); assert_eq!(app.world().resource::().0, None); assert!(app.world().get::(listener).is_none()); assert!(app.world().get::(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::().0, Some(tied_first)); assert_ne!( world.resource::().0, Some(low_priority) ); world .get_mut::(tied_first) .unwrap() .enabled = false; reconcile_audio_listener(&mut world); assert_eq!(world.resource::().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 ); } }