Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More ergonomic spatial audio #9800

Merged
merged 17 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 65 additions & 46 deletions crates/bevy_audio/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ use bevy_asset::{Asset, Handle};
use bevy_derive::{Deref, DerefMut};
use bevy_ecs::prelude::*;
use bevy_math::Vec3;
use bevy_transform::prelude::Transform;

/// Defines the volume to play an audio source at.
#[derive(Clone, Copy, Debug)]
Expand Down Expand Up @@ -82,6 +81,13 @@ pub struct PlaybackSettings {
/// Useful for "deferred playback", if you want to prepare
/// the entity, but hear the sound later.
pub paused: bool,
/// Enables spatial audio for this source.
///
/// See also: [`SpatialListener`].
///
/// Note: Bevy does not currently support HRTF or any other high-quality 3D sound rendering
/// features. Spatial audio is implemented via simple left-right stereo panning.
pub spatial: bool,
}

impl Default for PlaybackSettings {
Expand All @@ -98,6 +104,7 @@ impl PlaybackSettings {
volume: Volume::Relative(VolumeLevel(1.0)),
speed: 1.0,
paused: false,
spatial: false,
};

/// Will play the associated audio source in a loop.
Expand All @@ -106,6 +113,7 @@ impl PlaybackSettings {
volume: Volume::Relative(VolumeLevel(1.0)),
speed: 1.0,
paused: false,
spatial: false,
};

/// Will play the associated audio source once and despawn the entity afterwards.
Expand All @@ -114,6 +122,7 @@ impl PlaybackSettings {
volume: Volume::Relative(VolumeLevel(1.0)),
speed: 1.0,
paused: false,
spatial: false,
};

/// Will play the associated audio source once and remove the audio components afterwards.
Expand All @@ -122,6 +131,7 @@ impl PlaybackSettings {
volume: Volume::Relative(VolumeLevel(1.0)),
speed: 1.0,
paused: false,
spatial: false,
};

/// Helper to start in a paused state.
Expand All @@ -141,30 +151,40 @@ impl PlaybackSettings {
self.speed = speed;
self
}

/// Helper to enable or disable spatial audio.
pub const fn with_spatial(mut self, spatial: bool) -> Self {
self.spatial = spatial;
self
}
}

/// Settings for playing spatial audio.
/// Settings for the listener for a spatial audio source.
///
/// Note: Bevy does not currently support HRTF or any other high-quality 3D sound rendering
/// features. Spatial audio is implemented via simple left-right stereo panning.

rparrett marked this conversation as resolved.
Show resolved Hide resolved
#[derive(Component, Clone, Debug)]
pub struct SpatialSettings {
pub(crate) left_ear: [f32; 3],
pub(crate) right_ear: [f32; 3],
pub(crate) emitter: [f32; 3],
pub struct SpatialListener {
/// Left ear position relative to the `GlobalTransform`.
pub left_ear_offset: Vec3,
/// Right ear position relative to the `GlobalTransform`.
pub right_ear_offset: Vec3,
}

impl SpatialSettings {
impl Default for SpatialListener {
fn default() -> Self {
Self::new(4.)
}
}

impl SpatialListener {
/// Configure spatial audio coming from the `emitter` position and heard by a `listener`.
///
/// The `listener` transform provides the position and rotation where the sound is to be
/// heard from. `gap` is the distance between the left and right "ears" of the listener.
/// `emitter` is the position where the sound comes from.
pub fn new(listener: Transform, gap: f32, emitter: Vec3) -> Self {
SpatialSettings {
left_ear: (listener.translation + listener.left() * gap / 2.0).to_array(),
right_ear: (listener.translation + listener.right() * gap / 2.0).to_array(),
emitter: emitter.to_array(),
/// `gap` is the distance between the left and right "ears" of the listener. Ears are
/// positioned on the x axis.
rparrett marked this conversation as resolved.
Show resolved Hide resolved
pub fn new(gap: f32) -> Self {
SpatialListener {
left_ear_offset: Vec3::X * gap / -2.0,
right_ear_offset: Vec3::X * gap / 2.0,
}
}
}
Expand All @@ -187,12 +207,37 @@ impl GlobalVolume {
}
}

/// The scale factor applied to the positions of audio sources and listeners for
/// spatial audio.
///
/// You may need to adjust this scale to fit your world's units.
///
/// Default is `Vec3::ONE`.
#[derive(Resource, Clone, Copy)]
pub struct SpatialScale(pub Vec3);

impl SpatialScale {
/// Create a new `SpatialScale` with the same value for all 3 dimensions.
pub fn new(scale: f32) -> Self {
Self(Vec3::splat(scale))
}

/// Create a new `SpatialScale` with the same value for `x` and `y`, and `0.0`
rparrett marked this conversation as resolved.
Show resolved Hide resolved
/// for `z`.
pub fn new_2d(scale: f32) -> Self {
Self(Vec3::new(scale, scale, 0.0))
}
}

impl Default for SpatialScale {
fn default() -> Self {
Self(Vec3::ONE)
}
}

/// Bundle for playing a standard bevy audio asset
pub type AudioBundle = AudioSourceBundle<AudioSource>;

/// Bundle for playing a standard bevy audio asset with a 3D position
pub type SpatialAudioBundle = SpatialAudioSourceBundle<AudioSource>;

/// Bundle for playing a sound.
///
/// Insert this bundle onto an entity to trigger a sound source to begin playing.
Expand Down Expand Up @@ -224,29 +269,3 @@ impl<T: Decodable + Asset> Default for AudioSourceBundle<T> {
}
}
}

/// Bundle for playing a sound with a 3D position.
///
/// Insert this bundle onto an entity to trigger a sound source to begin playing.
///
/// If the handle refers to an unavailable asset (such as if it has not finished loading yet),
/// the audio will not begin playing immediately. The audio will play when the asset is ready.
///
/// When Bevy begins the audio playback, a [`SpatialAudioSink`][crate::SpatialAudioSink]
/// component will be added to the entity. You can use that component to control the audio
/// settings during playback.
#[derive(Bundle)]
pub struct SpatialAudioSourceBundle<Source = AudioSource>
where
Source: Asset + Decodable,
{
/// Asset containing the audio data to play.
pub source: Handle<Source>,
/// Initial settings that the audio starts playing with.
/// If you would like to control the audio while it is playing,
/// query for the [`SpatialAudioSink`][crate::SpatialAudioSink] component.
/// Changes to this component will *not* be applied to already-playing audio.
pub settings: PlaybackSettings,
/// Spatial audio configuration. Specifies the positions of the source and listener.
pub spatial: SpatialSettings,
}
121 changes: 106 additions & 15 deletions crates/bevy_audio/src/audio_output.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use crate::{
AudioSourceBundle, Decodable, GlobalVolume, PlaybackMode, PlaybackSettings, SpatialAudioSink,
SpatialAudioSourceBundle, SpatialSettings, Volume,
SpatialListener, SpatialScale, Volume,
};
use bevy_asset::{Asset, Assets, Handle};
use bevy_ecs::prelude::*;
use bevy_ecs::{prelude::*, system::SystemParam};
use bevy_math::Vec3;
use bevy_transform::prelude::GlobalTransform;
use bevy_utils::tracing::warn;
use rodio::{OutputStream, OutputStreamHandle, Sink, Source, SpatialSink};

Expand Down Expand Up @@ -51,11 +53,49 @@ pub struct PlaybackDespawnMarker;
#[derive(Component)]
pub struct PlaybackRemoveMarker;

#[derive(SystemParam)]
pub(crate) struct EarPositions<'w, 's> {
pub(crate) query: Query<'w, 's, (Entity, &'static GlobalTransform, &'static SpatialListener)>,
pub(crate) scale: Res<'w, SpatialScale>,
}
impl<'w, 's> EarPositions<'w, 's> {
/// Gets a set of transformed and scaled ear positions.
///
/// If there are no listeners, use a default values. If a user has added multiple
rparrett marked this conversation as resolved.
Show resolved Hide resolved
/// listeners for whatever reason, using a default value might unexpected, so we will
/// return the first value.
rparrett marked this conversation as resolved.
Show resolved Hide resolved
pub(crate) fn get(&self) -> (Vec3, Vec3) {
let (left_ear, right_ear) = self
.query
.iter()
.next()
.map(|(_, transform, settings)| {
(
transform.transform_point(settings.left_ear_offset) * self.scale.0,
transform.transform_point(settings.right_ear_offset) * self.scale.0,
)
})
.unwrap_or_else(|| {
let settings = SpatialListener::default();
(
(settings.left_ear_offset * self.scale.0),
(settings.right_ear_offset * self.scale.0),
)
});

(left_ear, right_ear)
}

pub(crate) fn multiple_listeners(&self) -> bool {
self.query.iter().len() > 1
}
}

/// Plays "queued" audio through the [`AudioOutput`] resource.
///
/// "Queued" audio is any audio entity (with the components from
/// [`AudioBundle`][crate::AudioBundle] or [`SpatialAudioBundle`][crate::SpatialAudioBundle])
/// that does not have an [`AudioSink`]/[`SpatialAudioSink`] component.
/// [`AudioBundle`][crate::AudioBundle] that does not have an
/// [`AudioSink`]/[`SpatialAudioSink`] component.
///
/// This system detects such entities, checks if their source asset
/// data is available, and creates/inserts the sink.
Expand All @@ -68,10 +108,11 @@ pub(crate) fn play_queued_audio_system<Source: Asset + Decodable>(
Entity,
&Handle<Source>,
&PlaybackSettings,
Option<&SpatialSettings>,
Option<&GlobalTransform>,
),
(Without<AudioSink>, Without<SpatialAudioSink>),
>,
ear_positions: EarPositions,
mut commands: Commands,
) where
f32: rodio::cpal::FromSample<Source::DecoderItem>,
Expand All @@ -81,15 +122,33 @@ pub(crate) fn play_queued_audio_system<Source: Asset + Decodable>(
return;
};

for (entity, source_handle, settings, spatial) in &query_nonplaying {
for (entity, source_handle, settings, maybe_emitter_transform) in &query_nonplaying {
if let Some(audio_source) = audio_sources.get(source_handle) {
// audio data is available (has loaded), begin playback and insert sink component
if let Some(spatial) = spatial {
if settings.spatial {
let (left_ear, right_ear) = ear_positions.get();

// We can only use one `SpatialListener`. If there are more than that, then
// the user may have made a mistake.
if ear_positions.multiple_listeners() {
warn!(
"Multiple SpatialListeners found. Using {:?}.",
ear_positions.query.iter().next().unwrap().0
);
}

let emitter_translation = maybe_emitter_transform
.map(|t| (t.translation() * ear_positions.scale.0).into())
.unwrap_or_else(|| {
warn!("Spatial AudioBundle with no GlobalTransform component. Using zero.");
Vec3::ZERO.into()
});

match SpatialSink::try_new(
stream_handle,
spatial.emitter,
spatial.left_ear,
spatial.right_ear,
emitter_translation,
left_ear.into(),
right_ear.into(),
) {
Ok(sink) => {
sink.set_speed(settings.speed);
Expand Down Expand Up @@ -216,11 +275,9 @@ pub(crate) fn cleanup_finished_audio<T: Decodable + Asset>(
}
for (entity, sink) in &query_spatial_remove {
if sink.sink.empty() {
commands.entity(entity).remove::<(
SpatialAudioSourceBundle<T>,
SpatialAudioSink,
PlaybackRemoveMarker,
)>();
commands
.entity(entity)
.remove::<(AudioSourceBundle<T>, SpatialAudioSink, PlaybackRemoveMarker)>();
}
}
}
Expand All @@ -229,3 +286,37 @@ pub(crate) fn cleanup_finished_audio<T: Decodable + Asset>(
pub(crate) fn audio_output_available(audio_output: Res<AudioOutput>) -> bool {
audio_output.stream_handle.is_some()
}

/// Updates spatial audio sinks when emitter positions change.
pub(crate) fn update_emitter_positions(
mut emitters: Query<(&mut GlobalTransform, &SpatialAudioSink), Changed<GlobalTransform>>,
spatial_scale: Res<SpatialScale>,
) {
for (transform, sink) in emitters.iter_mut() {
let translation = transform.translation() * spatial_scale.0;
sink.set_emitter_position(translation);
}
}

/// Updates spatial audio sink ear positions when spatial listeners change.
pub(crate) fn update_listener_positions(
rparrett marked this conversation as resolved.
Show resolved Hide resolved
mut emitters: Query<&SpatialAudioSink>,
changed_listener: Query<
(),
(
Or<(Changed<SpatialListener>, Changed<GlobalTransform>)>,
With<SpatialListener>,
),
>,
ear_positions: EarPositions,
) {
if !ear_positions.scale.is_changed() && changed_listener.is_empty() {
return;
}

let (left_ear, right_ear) = ear_positions.get();

for sink in emitters.iter_mut() {
sink.set_ears_position(left_ear, right_ear);
}
}
Loading