Skip to content

Commit

Permalink
bevy_audio: ECS-based API redesign (#8424)
Browse files Browse the repository at this point in the history
# Objective

Improve the `bevy_audio` API to make it more user-friendly and
ECS-idiomatic. This PR is a first-pass at addressing some of the most
obvious (to me) problems. In the interest of keeping the scope small,
further improvements can be done in future PRs.

The current `bevy_audio` API is very clunky to work with, due to how it
(ab)uses bevy assets to represent audio sinks.

The user needs to write a lot of boilerplate (accessing
`Res<Assets<AudioSink>>`) and deal with a lot of cognitive overhead
(worry about strong vs. weak handles, etc.) in order to control audio
playback.

Audio playback is initiated via a centralized `Audio` resource, which
makes it difficult to keep track of many different sounds playing in a
typical game.

Further, everything carries a generic type parameter for the sound
source type, making it difficult to mix custom sound sources (such as
procedurally generated audio or unofficial formats) with regular audio
assets.

Let's fix these issues.

## Solution

Refactor `bevy_audio` to a more idiomatic ECS API. Remove the `Audio`
resource. Do everything via entities and components instead.

Audio playback data is now stored in components:
- `PlaybackSettings`, `SpatialSettings`, `Handle<AudioSource>` are now
components. The user inserts them to tell Bevy to play a sound and
configure the initial playback parameters.
- `AudioSink`, `SpatialAudioSink` are now components instead of special
magical "asset" types. They are inserted by Bevy when it actually begins
playing the sound, and can be queried for by the user in order to
control the sound during playback.

Bundles: `AudioBundle` and `SpatialAudioBundle` are available to make it
easy for users to play sounds. Spawn an entity with one of these bundles
(or insert them to a complex entity alongside other stuff) to play a
sound.

Each entity represents a sound to be played.

There is also a new "auto-despawn" feature (activated using
`PlaybackSettings`), which, if enabled, tells Bevy to despawn entities
when the sink playback finishes. This allows for "fire-and-forget" sound
playback. Users can simply
spawn entities whenever they want to play sounds and not have to worry
about leaking memory.

## Unsolved Questions

I think the current design is *fine*. I'd be happy for it to be merged.
It has some possibly-surprising usability pitfalls, but I think it is
still much better than the old `bevy_audio`. Here are some discussion
questions for things that we could further improve. I'm undecided on
these questions, which is why I didn't implement them. We should decide
which of these should be addressed in this PR, and what should be left
for future PRs. Or if they should be addressed at all.

### What happens when sounds start playing?

Currently, the audio sink components are inserted and the bundle
components are kept. Should Bevy remove the bundle components? Something
else?

The current design allows an entity to be reused for playing the same
sound with the same parameters repeatedly. This is a niche use case I'd
like to be supported, but if we have to give it up for a simpler design,
I'd be fine with that.

### What happens if users remove any of the components themselves?

As described above, currently, entities can be reused. Removing the
audio sink causes it to be "detached" (I kept the old `Drop` impl), so
the sound keeps playing. However, if the audio bundle components are not
removed, Bevy will detect this entity as a "queued" sound entity again
(has the bundle compoenents, without a sink component), just like before
playing the sound the first time, and start playing the sound again.

This behavior might be surprising? Should we do something different?

### Should mutations to `PlaybackSettings` be applied to the audio sink?

We currently do not do that. `PlaybackSettings` is just for the initial
settings when the sound starts playing. This is clearly documented.

Do we want to keep this behavior, or do we want to allow users to use
`PlaybackSettings` instead of `AudioSink`/`SpatialAudioSink` to control
sounds during playback too?

I think I prefer for them to be kept separate. It is not a bad mental
model once you understand it, and it is documented.

### Should `AudioSink` and `SpatialAudioSink` be unified into a single
component type?

They provide a similar API (via the `AudioSinkPlayback` trait) and it
might be annoying for users to have to deal with both of them. The
unification could be done using an enum that is matched on internally by
the methods. Spatial audio has extra features, so this might make it
harder to access. I think we shouldn't.

### Automatic synchronization of spatial sound properties from
Transforms?

Should Bevy automatically apply changes to Transforms to spatial audio
entities? How do we distinguish between listener and emitter? Which one
does the transform represent? Where should the other one come from?

Alternatively, leave this problem for now, and address it in a future
PR. Or do nothing, and let users deal with it, as shown in the
`spatial_audio_2d` and `spatial_audio_3d` examples.

---

## Changelog

Added:
- `AudioBundle`/`SpatialAudioBundle`, add them to entities to play
sounds.

Removed:
 - The `Audio` resource.
 - `AudioOutput` is no longer `pub`.

Changed:
 - `AudioSink`, `SpatialAudioSink` are now components instead of assets.

## Migration Guide

// TODO: write a more detailed migration guide, after the "unsolved
questions" are answered and this PR is finalized.

Before:

```rust

/// Need to store handles somewhere
#[derive(Resource)]
struct MyMusic {
    sink: Handle<AudioSink>,
}

fn play_music(
    asset_server: Res<AssetServer>,
    audio: Res<Audio>,
    audio_sinks: Res<Assets<AudioSink>>,
    mut commands: Commands,
) {
    let weak_handle = audio.play_with_settings(
        asset_server.load("music.ogg"),
        PlaybackSettings::LOOP.with_volume(0.5),
    );
    // upgrade to strong handle and store it
    commands.insert_resource(MyMusic {
        sink: audio_sinks.get_handle(weak_handle),
    });
}

fn toggle_pause_music(
    audio_sinks: Res<Assets<AudioSink>>,
    mymusic: Option<Res<MyMusic>>,
) {
    if let Some(mymusic) = &mymusic {
        if let Some(sink) = audio_sinks.get(&mymusic.sink) {
            sink.toggle();
        }
    }
}
```

Now:

```rust
/// Marker component for our music entity
#[derive(Component)]
struct MyMusic;

fn play_music(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
) {
    commands.spawn((
        AudioBundle::from_audio_source(asset_server.load("music.ogg"))
            .with_settings(PlaybackSettings::LOOP.with_volume(0.5)),
        MyMusic,
    ));
}

fn toggle_pause_music(
    // `AudioSink` will be inserted by Bevy when the audio starts playing
    query_music: Query<&AudioSink, With<MyMusic>>,
) {
    if let Ok(sink) = query.get_single() {
        sink.toggle();
    }
}
```
  • Loading branch information
inodentry authored Jul 7, 2023
1 parent 95ade6d commit fb4c21e
Show file tree
Hide file tree
Showing 11 changed files with 451 additions and 568 deletions.
423 changes: 140 additions & 283 deletions crates/bevy_audio/src/audio.rs

Large diffs are not rendered by default.

310 changes: 190 additions & 120 deletions crates/bevy_audio/src/audio_output.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
use crate::{
Audio, AudioSource, Decodable, GlobalVolume, SpatialAudioSink, SpatialSettings, Volume,
AudioSourceBundle, Decodable, GlobalVolume, PlaybackMode, PlaybackSettings, SpatialAudioSink,
SpatialAudioSourceBundle, SpatialSettings, Volume,
};
use bevy_asset::{Asset, Assets};
use bevy_ecs::system::{Res, ResMut, Resource};
use bevy_asset::{Asset, Assets, Handle};
use bevy_ecs::prelude::*;
use bevy_utils::tracing::warn;
use rodio::{OutputStream, OutputStreamHandle, Sink, Source, SpatialSink};
use std::marker::PhantomData;

use crate::AudioSink;

Expand All @@ -22,157 +22,227 @@ use crate::AudioSink;
/// since the memory cost will be the same.
/// However, repeatedly inserting this resource into the app will **leak more memory**.
#[derive(Resource)]
pub struct AudioOutput<Source = AudioSource>
where
Source: Decodable,
{
pub(crate) struct AudioOutput {
stream_handle: Option<OutputStreamHandle>,
phantom: PhantomData<Source>,
}

impl<Source> Default for AudioOutput<Source>
where
Source: Decodable,
{
impl Default for AudioOutput {
fn default() -> Self {
if let Ok((stream, stream_handle)) = OutputStream::try_default() {
// We leak `OutputStream` to prevent the audio from stopping.
std::mem::forget(stream);
Self {
stream_handle: Some(stream_handle),
phantom: PhantomData,
}
} else {
warn!("No audio device found.");
Self {
stream_handle: None,
phantom: PhantomData,
}
}
}
}

impl<Source> AudioOutput<Source>
where
Source: Asset + Decodable,
/// Marker for internal use, to despawn entities when playback finishes.
#[derive(Component)]
pub struct PlaybackDespawnMarker;

/// Marker for internal use, to remove audio components when playback finishes.
#[derive(Component)]
pub struct PlaybackRemoveMarker;

/// 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.
///
/// This system detects such entities, checks if their source asset
/// data is available, and creates/inserts the sink.
pub(crate) fn play_queued_audio_system<Source: Asset + Decodable>(
audio_output: Res<AudioOutput>,
audio_sources: Res<Assets<Source>>,
global_volume: Res<GlobalVolume>,
query_nonplaying: Query<
(
Entity,
&Handle<Source>,
&PlaybackSettings,
Option<&SpatialSettings>,
),
(Without<AudioSink>, Without<SpatialAudioSink>),
>,
mut commands: Commands,
) where
f32: rodio::cpal::FromSample<Source::DecoderItem>,
{
fn play_source(&self, audio_source: &Source, repeat: bool) -> Option<Sink> {
self.stream_handle
.as_ref()
.and_then(|stream_handle| match Sink::try_new(stream_handle) {
Ok(sink) => {
if repeat {
sink.append(audio_source.decoder().repeat_infinite());
} else {
sink.append(audio_source.decoder());
}
Some(sink)
}
Err(err) => {
warn!("Error playing sound: {err:?}");
None
}
})
}
let Some(stream_handle) = audio_output.stream_handle.as_ref() else {
// audio output unavailable; cannot play sound
return;
};

fn play_spatial_source(
&self,
audio_source: &Source,
repeat: bool,
spatial: SpatialSettings,
) -> Option<SpatialSink> {
self.stream_handle.as_ref().and_then(|stream_handle| {
match SpatialSink::try_new(
stream_handle,
spatial.emitter,
spatial.left_ear,
spatial.right_ear,
) {
Ok(sink) => {
if repeat {
sink.append(audio_source.decoder().repeat_infinite());
} else {
sink.append(audio_source.decoder());
for (entity, source_handle, settings, spatial) 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 {
match SpatialSink::try_new(
stream_handle,
spatial.emitter,
spatial.left_ear,
spatial.right_ear,
) {
Ok(sink) => {
sink.set_speed(settings.speed);
match settings.volume {
Volume::Relative(vol) => {
sink.set_volume(vol.0 * global_volume.volume.0);
}
Volume::Absolute(vol) => sink.set_volume(vol.0),
}
if settings.paused {
sink.pause();
}
match settings.mode {
PlaybackMode::Loop => {
sink.append(audio_source.decoder().repeat_infinite());
commands
.entity(entity)
.insert(SpatialAudioSink { sink: Some(sink) });
}
PlaybackMode::Once => {
sink.append(audio_source.decoder());
commands
.entity(entity)
.insert(SpatialAudioSink { sink: Some(sink) });
}
PlaybackMode::Despawn => {
sink.append(audio_source.decoder());
commands
.entity(entity)
// PERF: insert as bundle to reduce archetype moves
.insert((
SpatialAudioSink { sink: Some(sink) },
PlaybackDespawnMarker,
));
}
PlaybackMode::Remove => {
sink.append(audio_source.decoder());
commands
.entity(entity)
// PERF: insert as bundle to reduce archetype moves
.insert((
SpatialAudioSink { sink: Some(sink) },
PlaybackRemoveMarker,
));
}
};
}
Err(err) => {
warn!("Error playing spatial sound: {err:?}");
}
Some(sink)
}
Err(err) => {
warn!("Error playing spatial sound: {err:?}");
None
}
}
})
}

fn try_play_queued(
&self,
audio_sources: &Assets<Source>,
audio: &mut Audio<Source>,
sinks: &mut Assets<AudioSink>,
spatial_sinks: &mut Assets<SpatialAudioSink>,
global_volume: &GlobalVolume,
) {
let mut queue = audio.queue.write();
let len = queue.len();
let mut i = 0;
while i < len {
let config = queue.pop_front().unwrap();
if let Some(audio_source) = audio_sources.get(&config.source_handle) {
if let Some(spatial) = config.spatial {
if let Some(sink) =
self.play_spatial_source(audio_source, config.settings.repeat, spatial)
{
sink.set_speed(config.settings.speed);
match config.settings.volume {
} else {
match Sink::try_new(stream_handle) {
Ok(sink) => {
sink.set_speed(settings.speed);
match settings.volume {
Volume::Relative(vol) => {
sink.set_volume(vol.0 * global_volume.volume.0);
}
Volume::Absolute(vol) => sink.set_volume(vol.0),
}

// don't keep the strong handle. there is no way to return it to the user here as it is async
let _ = spatial_sinks
.set(config.sink_handle, SpatialAudioSink { sink: Some(sink) });
if settings.paused {
sink.pause();
}
match settings.mode {
PlaybackMode::Loop => {
sink.append(audio_source.decoder().repeat_infinite());
commands
.entity(entity)
.insert(AudioSink { sink: Some(sink) });
}
PlaybackMode::Once => {
sink.append(audio_source.decoder());
commands
.entity(entity)
.insert(AudioSink { sink: Some(sink) });
}
PlaybackMode::Despawn => {
sink.append(audio_source.decoder());
commands
.entity(entity)
// PERF: insert as bundle to reduce archetype moves
.insert((
AudioSink { sink: Some(sink) },
PlaybackDespawnMarker,
));
}
PlaybackMode::Remove => {
sink.append(audio_source.decoder());
commands
.entity(entity)
// PERF: insert as bundle to reduce archetype moves
.insert((AudioSink { sink: Some(sink) }, PlaybackRemoveMarker));
}
};
}
} else if let Some(sink) = self.play_source(audio_source, config.settings.repeat) {
sink.set_speed(config.settings.speed);

match config.settings.volume {
Volume::Relative(vol) => sink.set_volume(vol.0 * global_volume.volume.0),
Volume::Absolute(vol) => sink.set_volume(vol.0),
Err(err) => {
warn!("Error playing sound: {err:?}");
}

// don't keep the strong handle. there is no way to return it to the user here as it is async
let _ = sinks.set(config.sink_handle, AudioSink { sink: Some(sink) });
}
} else {
// audio source hasn't loaded yet. add it back to the queue
queue.push_back(config);
}
i += 1;
}
}
}

/// Plays audio currently queued in the [`Audio`] resource through the [`AudioOutput`] resource
pub fn play_queued_audio_system<Source: Asset + Decodable>(
audio_output: Res<AudioOutput<Source>>,
audio_sources: Option<Res<Assets<Source>>>,
global_volume: Res<GlobalVolume>,
mut audio: ResMut<Audio<Source>>,
mut sinks: ResMut<Assets<AudioSink>>,
mut spatial_sinks: ResMut<Assets<SpatialAudioSink>>,
) where
f32: rodio::cpal::FromSample<Source::DecoderItem>,
{
if let Some(audio_sources) = audio_sources {
audio_output.try_play_queued(
&*audio_sources,
&mut *audio,
&mut sinks,
&mut spatial_sinks,
&global_volume,
);
};
pub(crate) fn cleanup_finished_audio<T: Decodable + Asset>(
mut commands: Commands,
query_nonspatial_despawn: Query<
(Entity, &AudioSink),
(With<PlaybackDespawnMarker>, With<Handle<T>>),
>,
query_spatial_despawn: Query<
(Entity, &SpatialAudioSink),
(With<PlaybackDespawnMarker>, With<Handle<T>>),
>,
query_nonspatial_remove: Query<
(Entity, &AudioSink),
(With<PlaybackRemoveMarker>, With<Handle<T>>),
>,
query_spatial_remove: Query<
(Entity, &SpatialAudioSink),
(With<PlaybackRemoveMarker>, With<Handle<T>>),
>,
) {
for (entity, sink) in &query_nonspatial_despawn {
if sink.sink.as_ref().unwrap().empty() {
commands.entity(entity).despawn();
}
}
for (entity, sink) in &query_spatial_despawn {
if sink.sink.as_ref().unwrap().empty() {
commands.entity(entity).despawn();
}
}
for (entity, sink) in &query_nonspatial_remove {
if sink.sink.as_ref().unwrap().empty() {
commands
.entity(entity)
.remove::<(AudioSourceBundle<T>, AudioSink, PlaybackRemoveMarker)>();
}
}
for (entity, sink) in &query_spatial_remove {
if sink.sink.as_ref().unwrap().empty() {
commands.entity(entity).remove::<(
SpatialAudioSourceBundle<T>,
SpatialAudioSink,
PlaybackRemoveMarker,
)>();
}
}
}

/// Run Condition to only play audio if the audio output is available
pub(crate) fn audio_output_available(audio_output: Res<AudioOutput>) -> bool {
audio_output.stream_handle.is_some()
}
Loading

0 comments on commit fb4c21e

Please sign in to comment.