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.
924 lines
31 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|