Blacksite/crates/editor/src/ui/audio_inspector.rs
Rbanh 0553a85220
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Build production-ready editor authoring workflows
2026-07-11 12:41:04 -04:00

284 lines
10 KiB
Rust

//! Audio source/listener authoring cards and transient audition controls.
use bevy::prelude::*;
use bevy_egui::egui;
use egui_phosphor_icons::icons;
use settings::{AUDIO_GAIN_DB_MAX, AUDIO_GAIN_DB_MIN};
use shared::{
AudioListenerDesc, AudioRolloff, AudioSourceDesc, COMPONENT_AUDIO_LISTENER_DESC,
COMPONENT_AUDIO_SOURCE_DESC,
};
use crate::assets::{audio_clip_ref_for_asset, EditorAsset, EditorAssetKind, EditorAssets};
use crate::history::{set_audio_listener_with_history, set_audio_source_with_history};
use crate::play::audio_preview::{audition_audio_source, stop_audio_preview, AudioPreviewState};
use crate::ui::inspector::{
apply_component_card_response, component_card, component_card_context, property_row,
ComponentCardOptions,
};
use crate::ui::theme::{SELECTION, TEXT_DIM};
pub fn audio_source_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut source) = world.get::<AudioSourceDesc>(entity).cloned() else {
return;
};
let original = source.clone();
let audio_assets: Vec<EditorAsset> = world
.resource::<EditorAssets>()
.assets
.iter()
.filter(|asset| matches!(asset.kind, EditorAssetKind::AudioClip))
.cloned()
.collect();
let dragged_audio = world
.resource::<EditorAssets>()
.dragging_selection()
.and_then(|selection| {
world
.resource::<EditorAssets>()
.asset_for_selection(selection)
})
.filter(|asset| matches!(asset.kind, EditorAssetKind::AudioClip))
.cloned();
let buses = world
.resource::<settings::ProjectSettings>()
.audio
.buses
.clone();
let previewing = world.resource::<AudioPreviewState>().source == Some(entity);
let diagnostics = world
.resource::<game::audio::AudioRuntimeDiagnostics>()
.clone();
let mut pending_clip: Option<EditorAsset> = None;
let mut clear_clip = false;
let mut audition = false;
let mut stop = false;
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(
COMPONENT_AUDIO_SOURCE_DESC,
"Audio Source",
icons::SPEAKER_HIGH,
),
);
let card_response = component_card(ui, &card, |ui| {
let clip_response = property_row(ui, "Clip", |ui| {
ui.horizontal_wrapped(|ui| {
let selected_text = source
.clip
.as_ref()
.map(|clip| clip.label.as_str())
.filter(|label| !label.trim().is_empty())
.unwrap_or("None");
egui::ComboBox::from_id_salt(("audio_clip", entity))
.selected_text(selected_text)
.width(150.0)
.show_ui(ui, |ui| {
for asset in &audio_assets {
let selected = source.clip.as_ref().is_some_and(|clip| {
clip.source_path.as_deref() == asset.path.as_deref()
});
if ui.selectable_label(selected, &asset.label).clicked() {
pending_clip = Some(asset.clone());
}
}
});
if ui
.add_enabled(source.clip.is_some(), egui::Button::new(icons::X.as_str()))
.on_hover_text("Clear clip")
.clicked()
{
clear_clip = true;
}
})
.response
});
if dragged_audio.is_some() && clip_response.hovered() {
ui.painter().rect_stroke(
clip_response.rect.expand(3.0),
3.0,
egui::Stroke::new(1.5, SELECTION),
egui::StrokeKind::Outside,
);
if ui.input(|input| input.pointer.any_released()) {
pending_clip = dragged_audio.clone();
}
}
property_row(ui, "Playback", |ui| {
ui.horizontal_wrapped(|ui| {
if ui
.add_enabled(
source.clip.is_some() && !previewing,
egui::Button::new(format!("{} Audition", icons::PLAY.as_str())),
)
.clicked()
{
audition = true;
}
if ui
.add_enabled(
previewing,
egui::Button::new(format!("{} Stop", icons::STOP.as_str())),
)
.clicked()
{
stop = true;
}
});
});
property_row(ui, "Gain", |ui| {
ui.add(
egui::Slider::new(&mut source.gain_db, AUDIO_GAIN_DB_MIN..=AUDIO_GAIN_DB_MAX)
.suffix(" dB"),
);
});
property_row(ui, "Pitch", |ui| {
ui.add(
egui::DragValue::new(&mut source.pitch)
.range(0.25..=4.0)
.speed(0.01),
);
});
property_row(ui, "Mode", |ui| {
ui.horizontal_wrapped(|ui| {
ui.checkbox(&mut source.autoplay, "Autoplay");
ui.checkbox(&mut source.looping, "Loop");
});
});
property_row(ui, "Spatial blend", |ui| {
ui.add(egui::Slider::new(&mut source.spatial_blend, 0.0..=1.0));
});
property_row(ui, "Min distance", |ui| {
ui.add(
egui::DragValue::new(&mut source.attenuation.min_distance)
.range(0.01..=10_000.0)
.speed(0.1)
.suffix(" m"),
);
});
property_row(ui, "Max distance", |ui| {
ui.add(
egui::DragValue::new(&mut source.attenuation.max_distance)
.range(0.02..=100_000.0)
.speed(0.25)
.suffix(" m"),
);
});
property_row(ui, "Rolloff", |ui| {
ui.horizontal_wrapped(|ui| {
egui::ComboBox::from_id_salt(("audio_rolloff", entity))
.selected_text(format!("{:?}", source.attenuation.rolloff))
.show_ui(ui, |ui| {
for (rolloff, label) in [
(AudioRolloff::Linear, "Linear"),
(AudioRolloff::Inverse, "Inverse"),
(AudioRolloff::Exponential, "Exponential"),
] {
ui.selectable_value(&mut source.attenuation.rolloff, rolloff, label);
}
});
ui.add(
egui::DragValue::new(&mut source.attenuation.rolloff_factor)
.range(0.01..=8.0)
.speed(0.05),
);
});
});
property_row(ui, "Bus", |ui| {
egui::ComboBox::from_id_salt(("audio_bus", entity))
.selected_text(
buses
.iter()
.find(|bus| bus.id == source.bus)
.map(|bus| bus.label.as_str())
.unwrap_or(source.bus.as_str()),
)
.show_ui(ui, |ui| {
for bus in &buses {
ui.selectable_value(&mut source.bus, bus.id.clone(), &bus.label);
}
});
});
ui.small(
egui::RichText::new(&diagnostics.message)
.color(TEXT_DIM)
.italics(),
);
});
if clear_clip {
source.clip = None;
}
if let Some(asset) = pending_clip {
match audio_clip_ref_for_asset(world, &asset) {
Ok(clip) => {
source.clip = Some(clip);
world.resource_mut::<EditorAssets>().clear_drag();
}
Err(error) => {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Audio clip assignment failed: {error}");
}
}
}
source.attenuation.max_distance = source
.attenuation
.max_distance
.max(source.attenuation.min_distance + 0.01);
if source != original {
set_audio_source_with_history(world, entity, source);
}
if stop {
stop_audio_preview(world);
} else if audition {
if let Err(error) = audition_audio_source(world, entity) {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Audio audition failed: {error}");
}
}
apply_component_card_response(world, entity, card_response);
}
pub fn audio_listener_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut listener) = world.get::<AudioListenerDesc>(entity).copied() else {
return;
};
let original = listener;
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(
COMPONENT_AUDIO_LISTENER_DESC,
"Audio Listener",
icons::HEADPHONES,
),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Enabled", |ui| {
ui.checkbox(&mut listener.enabled, "Receive spatial audio");
});
property_row(ui, "Priority", |ui| {
ui.add(egui::DragValue::new(&mut listener.priority).speed(1));
});
property_row(ui, "Ear gap", |ui| {
ui.add(
egui::DragValue::new(&mut listener.ear_gap)
.range(0.01..=2.0)
.speed(0.01)
.suffix(" m"),
);
});
ui.small(
egui::RichText::new("Highest priority enabled listener wins; actor ID breaks ties.")
.color(TEXT_DIM),
);
});
if listener != original {
set_audio_listener_with_history(world, entity, listener);
}
apply_component_card_response(world, entity, card_response);
}