Add ability to mute audio sinks (#16813)

# Objective

- Allow users to mute audio.

```rust
fn mute(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut sink: Single<&mut AudioSink, With<MyMusic>>,
) {
    if keyboard_input.just_pressed(KeyCode::KeyM) {
        sink.toggle_mute();
    }
}
```

- I want to be able to press, say, `M` and mute all my audio. I want
this for dev, but I'm sure it's a useful player setting as well.
- Muting is different to pausing—I don't want to pause my sounds, I want
them to keep playing but with no volume. For example if I have
background music playing which is made up of 5 tracks, I want to be able
to temporarily mute my background music, and if I unmute at, say, track
4, I want to play track 4 rather than have had everything paused and
still be on the first track.
- I want to be able to continue to control the volume of my audio even
when muted. Like in the example, if I have muted my audio but I use the
volume up/down controls, I want Bevy to remember those volume changes so
that when I unmute, the volume corresponds to that.

## Solution

- Add methods to audio to allow muting, unmuting and toggling muting.
- To preserve the user's intended volume, each sink needs to keep track
of a "managed volume".
- I checked `rodio` and I don't see any built in support for doing this,
so I added it to `bevy_audio`.
- I'm interested to hear if this is a good idea or a bad idea. To me,
this API looks nice and looks usable, but I'm aware it involves some
changes to the existing API and now also requires mutable access in some
places compared to before.
- I'm also aware of work on *Better Audio*, but I'm hoping that if this
change isn't too wild it might be a useful addition considering we don't
really know when we'll eventually get better audio.

## Testing

- Update and run the example:  `cargo run --example audio_control`
- Run the example:  `cargo run --example soundtrack`
- Update and run the example:  `cargo run --example spatial_audio_3d`
- Add unit tests.

---

## Showcase

See 2 changed examples that show how you can mute an audio sink and a
spatial audio sink.

## Migration Guide

- The `AudioSinkPlayback` trait now has 4 new methods to allow you to
mute audio sinks: `is_muted`, `mute`, `unmute` and `toggle_mute`. You
can use these methods on `bevy_audio`'s `AudioSink` and
`SpatialAudioSink` components to manage the sink's mute state.
- `AudioSinkPlayback`'s `set_volume` method now takes a mutable
reference instead of an immutable one. Update your code which calls
`set_volume` on `AudioSink` and `SpatialAudioSink` components to take a
mutable reference. E.g.:

Before:

```rust
fn increase_volume(sink: Single<&AudioSink>) {
    sink.set_volume(sink.volume() + 0.1);
}
```

After:

```rust
fn increase_volume(mut sink: Single<&mut AudioSink>) {
    let current_volume = sink.volume();
    sink.set_volume(current_volume + 0.1);
}
```

- The `PlaybackSettings` component now has a `muted` field which you can
use to spawn your audio in a muted state. `PlaybackSettings` also now
has a helper method `muted` which you can use when building the
component. E.g.:

```rust
commands.spawn((
    // ...
    AudioPlayer::new(asset_server.load("sounds/Windless Slopes.ogg")),
    PlaybackSettings::LOOP.with_spatial(true).muted(),
));
```

---------

Co-authored-by: Nathan Graule <solarliner@gmail.com>
This commit is contained in:
mgi388 2024-12-16 06:19:16 +11:00 committed by GitHub
parent 3af0b29809
commit 7749c9945b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 293 additions and 66 deletions

View File

@ -69,6 +69,11 @@ pub struct PlaybackSettings {
/// Useful for "deferred playback", if you want to prepare
/// the entity, but hear the sound later.
pub paused: bool,
/// Whether to create the sink in muted state or not.
///
/// This is useful for audio that should be initially muted. You can still
/// set the initial volume and it is applied when the audio is unmuted.
pub muted: bool,
/// Enables spatial audio for this source.
///
/// See also: [`SpatialListener`].
@ -100,6 +105,7 @@ impl PlaybackSettings {
volume: Volume(1.0),
speed: 1.0,
paused: false,
muted: false,
spatial: false,
spatial_scale: None,
};
@ -128,6 +134,12 @@ impl PlaybackSettings {
self
}
/// Helper to start muted.
pub const fn muted(mut self) -> Self {
self.muted = true;
self
}
/// Helper to set the volume from start of playback.
pub const fn with_volume(mut self, volume: Volume) -> Self {
self.volume = volume;

View File

@ -10,7 +10,7 @@ use bevy_transform::prelude::GlobalTransform;
use bevy_utils::tracing::warn;
use rodio::{OutputStream, OutputStreamHandle, Sink, Source, SpatialSink};
use crate::AudioSink;
use crate::{AudioSink, AudioSinkPlayback};
/// Used internally to play audio on the current "audio device"
///
@ -157,6 +157,19 @@ pub(crate) fn play_queued_audio_system<Source: Asset + Decodable>(
}
};
match settings.mode {
PlaybackMode::Loop => sink.append(audio_source.decoder().repeat_infinite()),
PlaybackMode::Once | PlaybackMode::Despawn | PlaybackMode::Remove => {
sink.append(audio_source.decoder());
}
};
let mut sink = SpatialAudioSink::new(sink);
if settings.muted {
sink.mute();
}
sink.set_speed(settings.speed);
sink.set_volume(settings.volume.0 * global_volume.volume.0);
@ -165,28 +178,15 @@ pub(crate) fn play_queued_audio_system<Source: Asset + Decodable>(
}
match settings.mode {
PlaybackMode::Loop => {
sink.append(audio_source.decoder().repeat_infinite());
commands.entity(entity).insert(SpatialAudioSink { sink });
}
PlaybackMode::Once => {
sink.append(audio_source.decoder());
commands.entity(entity).insert(SpatialAudioSink { sink });
}
PlaybackMode::Despawn => {
sink.append(audio_source.decoder());
commands
.entity(entity)
// PERF: insert as bundle to reduce archetype moves
.insert((SpatialAudioSink { sink }, PlaybackDespawnMarker));
}
PlaybackMode::Remove => {
sink.append(audio_source.decoder());
commands
.entity(entity)
// PERF: insert as bundle to reduce archetype moves
.insert((SpatialAudioSink { sink }, PlaybackRemoveMarker));
}
PlaybackMode::Loop | PlaybackMode::Once => commands.entity(entity).insert(sink),
PlaybackMode::Despawn => commands
.entity(entity)
// PERF: insert as bundle to reduce archetype moves
.insert((sink, PlaybackDespawnMarker)),
PlaybackMode::Remove => commands
.entity(entity)
// PERF: insert as bundle to reduce archetype moves
.insert((sink, PlaybackRemoveMarker)),
};
} else {
let sink = match Sink::try_new(stream_handle) {
@ -197,6 +197,19 @@ pub(crate) fn play_queued_audio_system<Source: Asset + Decodable>(
}
};
match settings.mode {
PlaybackMode::Loop => sink.append(audio_source.decoder().repeat_infinite()),
PlaybackMode::Once | PlaybackMode::Despawn | PlaybackMode::Remove => {
sink.append(audio_source.decoder());
}
};
let mut sink = AudioSink::new(sink);
if settings.muted {
sink.mute();
}
sink.set_speed(settings.speed);
sink.set_volume(settings.volume.0 * global_volume.volume.0);
@ -205,28 +218,15 @@ pub(crate) fn play_queued_audio_system<Source: Asset + Decodable>(
}
match settings.mode {
PlaybackMode::Loop => {
sink.append(audio_source.decoder().repeat_infinite());
commands.entity(entity).insert(AudioSink { sink });
}
PlaybackMode::Once => {
sink.append(audio_source.decoder());
commands.entity(entity).insert(AudioSink { sink });
}
PlaybackMode::Despawn => {
sink.append(audio_source.decoder());
commands
.entity(entity)
// PERF: insert as bundle to reduce archetype moves
.insert((AudioSink { sink }, PlaybackDespawnMarker));
}
PlaybackMode::Remove => {
sink.append(audio_source.decoder());
commands
.entity(entity)
// PERF: insert as bundle to reduce archetype moves
.insert((AudioSink { sink }, PlaybackRemoveMarker));
}
PlaybackMode::Loop | PlaybackMode::Once => commands.entity(entity).insert(sink),
PlaybackMode::Despawn => commands
.entity(entity)
// PERF: insert as bundle to reduce archetype moves
.insert((sink, PlaybackDespawnMarker)),
PlaybackMode::Remove => commands
.entity(entity)
// PERF: insert as bundle to reduce archetype moves
.insert((sink, PlaybackRemoveMarker)),
};
}
}

View File

@ -7,8 +7,12 @@ use rodio::{Sink, SpatialSink};
pub trait AudioSinkPlayback {
/// Gets the volume of the sound.
///
/// The value `1.0` is the "normal" volume (unfiltered input). Any value other than `1.0`
/// will multiply each sample by this value.
/// The value `1.0` is the "normal" volume (unfiltered input). Any value
/// other than `1.0` will multiply each sample by this value.
///
/// If the sink is muted, this returns the managed volume rather than the
/// sink's actual volume. This allows you to use the volume as if the sink
/// were not muted, because a muted sink has a volume of 0.
fn volume(&self) -> f32;
/// Changes the volume of the sound.
@ -16,6 +20,12 @@ pub trait AudioSinkPlayback {
/// The value `1.0` is the "normal" volume (unfiltered input). Any value other than `1.0`
/// will multiply each sample by this value.
///
/// If the sink is muted, changing the volume won't unmute it, i.e. the
/// sink's volume will remain at `0.0`. However, the sink will remember the
/// volume change and it will be used when [`unmute`](Self::unmute) is
/// called. This allows you to control the volume even when the sink is
/// muted.
///
/// # Note on Audio Volume
///
/// An increase of 10 decibels (dB) roughly corresponds to the perceived volume doubling in intensity.
@ -23,7 +33,7 @@ pub trait AudioSinkPlayback {
/// For example, to halve the perceived volume you need to decrease the volume by 10 dB.
/// This corresponds to 20log(x) = -10dB, solving x = 10^(-10/20) = 0.316.
/// Multiply the current volume by 0.316 to halve the perceived volume.
fn set_volume(&self, volume: f32);
fn set_volume(&mut self, volume: f32);
/// Gets the speed of the sound.
///
@ -71,6 +81,29 @@ pub trait AudioSinkPlayback {
/// Returns true if this sink has no more sounds to play.
fn empty(&self) -> bool;
/// Returns true if the sink is muted.
fn is_muted(&self) -> bool;
/// Mutes the sink.
///
/// Muting a sink sets the volume to 0. Use [`unmute`](Self::unmute) to
/// unmute the sink and restore the original volume.
fn mute(&mut self);
/// Unmutes the sink.
///
/// Restores the volume to the value it was before it was muted.
fn unmute(&mut self);
/// Toggles whether the sink is muted or not.
fn toggle_mute(&mut self) {
if self.is_muted() {
self.unmute();
} else {
self.mute();
}
}
}
/// Used to control audio during playback.
@ -86,15 +119,42 @@ pub trait AudioSinkPlayback {
#[derive(Component)]
pub struct AudioSink {
pub(crate) sink: Sink,
/// Managed volume allows the sink to be muted without losing the user's
/// intended volume setting.
///
/// This is used to restore the volume when [`unmute`](Self::unmute) is
/// called.
///
/// If the sink is not muted, this is `None`.
///
/// If the sink is muted, this is `Some(volume)` where `volume` is the
/// user's intended volume setting, even if the underlying sink's volume is
/// 0.
pub(crate) managed_volume: Option<f32>,
}
impl AudioSink {
/// Create a new audio sink.
pub fn new(sink: Sink) -> Self {
Self {
sink,
managed_volume: None,
}
}
}
impl AudioSinkPlayback for AudioSink {
fn volume(&self) -> f32 {
self.sink.volume()
self.managed_volume.unwrap_or_else(|| self.sink.volume())
}
fn set_volume(&self, volume: f32) {
self.sink.set_volume(volume);
fn set_volume(&mut self, volume: f32) {
if self.is_muted() {
self.managed_volume = Some(volume);
} else {
self.sink.set_volume(volume);
}
}
fn speed(&self) -> f32 {
@ -124,6 +184,21 @@ impl AudioSinkPlayback for AudioSink {
fn empty(&self) -> bool {
self.sink.empty()
}
fn is_muted(&self) -> bool {
self.managed_volume.is_some()
}
fn mute(&mut self) {
self.managed_volume = Some(self.volume());
self.sink.set_volume(0.0);
}
fn unmute(&mut self) {
if let Some(volume) = self.managed_volume.take() {
self.sink.set_volume(volume);
}
}
}
/// Used to control spatial audio during playback.
@ -139,15 +214,42 @@ impl AudioSinkPlayback for AudioSink {
#[derive(Component)]
pub struct SpatialAudioSink {
pub(crate) sink: SpatialSink,
/// Managed volume allows the sink to be muted without losing the user's
/// intended volume setting.
///
/// This is used to restore the volume when [`unmute`](Self::unmute) is
/// called.
///
/// If the sink is not muted, this is `None`.
///
/// If the sink is muted, this is `Some(volume)` where `volume` is the
/// user's intended volume setting, even if the underlying sink's volume is
/// 0.
pub(crate) managed_volume: Option<f32>,
}
impl SpatialAudioSink {
/// Create a new spatial audio sink.
pub fn new(sink: SpatialSink) -> Self {
Self {
sink,
managed_volume: None,
}
}
}
impl AudioSinkPlayback for SpatialAudioSink {
fn volume(&self) -> f32 {
self.sink.volume()
self.managed_volume.unwrap_or_else(|| self.sink.volume())
}
fn set_volume(&self, volume: f32) {
self.sink.set_volume(volume);
fn set_volume(&mut self, volume: f32) {
if self.is_muted() {
self.managed_volume = Some(volume);
} else {
self.sink.set_volume(volume);
}
}
fn speed(&self) -> f32 {
@ -177,6 +279,21 @@ impl AudioSinkPlayback for SpatialAudioSink {
fn empty(&self) -> bool {
self.sink.empty()
}
fn is_muted(&self) -> bool {
self.managed_volume.is_some()
}
fn mute(&mut self) {
self.managed_volume = Some(self.volume());
self.sink.set_volume(0.0);
}
fn unmute(&mut self) {
if let Some(volume) = self.managed_volume.take() {
self.sink.set_volume(volume);
}
}
}
impl SpatialAudioSink {
@ -199,3 +316,60 @@ impl SpatialAudioSink {
self.sink.set_emitter_position(position.to_array());
}
}
#[cfg(test)]
mod tests {
use rodio::Sink;
use super::*;
fn test_audio_sink_playback<T: AudioSinkPlayback>(mut audio_sink: T) {
// Test volume
assert_eq!(audio_sink.volume(), 1.0); // default volume
audio_sink.set_volume(0.5);
assert_eq!(audio_sink.volume(), 0.5);
audio_sink.set_volume(1.0);
assert_eq!(audio_sink.volume(), 1.0);
// Test speed
assert_eq!(audio_sink.speed(), 1.0); // default speed
audio_sink.set_speed(0.5);
assert_eq!(audio_sink.speed(), 0.5);
audio_sink.set_speed(1.0);
assert_eq!(audio_sink.speed(), 1.0);
// Test pause
assert!(!audio_sink.is_paused()); // default pause state
audio_sink.pause();
assert!(audio_sink.is_paused());
audio_sink.play();
assert!(!audio_sink.is_paused());
// Test mute
assert!(!audio_sink.is_muted()); // default mute state
audio_sink.mute();
assert!(audio_sink.is_muted());
audio_sink.unmute();
assert!(!audio_sink.is_muted());
// Test volume with mute
audio_sink.set_volume(0.5);
audio_sink.mute();
assert_eq!(audio_sink.volume(), 0.5); // returns managed volume even though sink volume is 0
audio_sink.unmute();
assert_eq!(audio_sink.volume(), 0.5); // managed volume is restored
// Test toggle mute
audio_sink.toggle_mute();
assert!(audio_sink.is_muted());
audio_sink.toggle_mute();
assert!(!audio_sink.is_muted());
}
#[test]
fn test_audio_sink() {
let (sink, _queue_rx) = Sink::new_idle();
let audio_sink = AudioSink::new(sink);
test_audio_sink_playback(audio_sink);
}
}

View File

@ -6,7 +6,7 @@ fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, (update_speed, pause, volume))
.add_systems(Update, (update_speed, pause, mute, volume))
.run();
}
@ -15,6 +15,20 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
AudioPlayer::new(asset_server.load("sounds/Windless Slopes.ogg")),
MyMusic,
));
// example instructions
commands.spawn((
Text::new("-/=: Volume Down/Up\nSpace: Pause Playback\nM: Toggle Mute"),
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(12.0),
left: Val::Px(12.0),
..default()
},
));
// camera
commands.spawn(Camera3d::default());
}
#[derive(Component)]
@ -30,10 +44,24 @@ fn pause(keyboard_input: Res<ButtonInput<KeyCode>>, sink: Single<&AudioSink, Wit
}
}
fn volume(keyboard_input: Res<ButtonInput<KeyCode>>, sink: Single<&AudioSink, With<MyMusic>>) {
if keyboard_input.just_pressed(KeyCode::Equal) {
sink.set_volume(sink.volume() + 0.1);
} else if keyboard_input.just_pressed(KeyCode::Minus) {
sink.set_volume(sink.volume() - 0.1);
fn mute(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut sink: Single<&mut AudioSink, With<MyMusic>>,
) {
if keyboard_input.just_pressed(KeyCode::KeyM) {
sink.toggle_mute();
}
}
fn volume(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut sink: Single<&mut AudioSink, With<MyMusic>>,
) {
if keyboard_input.just_pressed(KeyCode::Equal) {
let current_volume = sink.volume();
sink.set_volume(current_volume + 0.1);
} else if keyboard_input.just_pressed(KeyCode::Minus) {
let current_volume = sink.volume();
sink.set_volume(current_volume - 0.1);
}
}

View File

@ -113,8 +113,9 @@ fn fade_in(
mut audio_sink: Query<(&mut AudioSink, Entity), With<FadeIn>>,
time: Res<Time>,
) {
for (audio, entity) in audio_sink.iter_mut() {
audio.set_volume(audio.volume() + time.delta_secs() / FADE_TIME);
for (mut audio, entity) in audio_sink.iter_mut() {
let current_volume = audio.volume();
audio.set_volume(current_volume + time.delta_secs() / FADE_TIME);
if audio.volume() >= 1.0 {
audio.set_volume(1.0);
commands.entity(entity).remove::<FadeIn>();
@ -129,8 +130,9 @@ fn fade_out(
mut audio_sink: Query<(&mut AudioSink, Entity), With<FadeOut>>,
time: Res<Time>,
) {
for (audio, entity) in audio_sink.iter_mut() {
audio.set_volume(audio.volume() - time.delta_secs() / FADE_TIME);
for (mut audio, entity) in audio_sink.iter_mut() {
let current_volume = audio.volume();
audio.set_volume(current_volume - time.delta_secs() / FADE_TIME);
if audio.volume() <= 0.0 {
commands.entity(entity).despawn_recursive();
}

View File

@ -11,6 +11,7 @@ fn main() {
.add_systems(Startup, setup)
.add_systems(Update, update_positions)
.add_systems(Update, update_listener)
.add_systems(Update, mute)
.run();
}
@ -64,7 +65,9 @@ fn setup(
// example instructions
commands.spawn((
Text::new("Up/Down/Left/Right: Move Listener\nSpace: Toggle Emitter Movement"),
Text::new(
"Up/Down/Left/Right: Move Listener\nSpace: Toggle Emitter Movement\nM: Toggle Mute",
),
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(12.0),
@ -128,3 +131,11 @@ fn update_listener(
listeners.translation.z -= speed * time.delta_secs();
}
}
fn mute(keyboard_input: Res<ButtonInput<KeyCode>>, mut sinks: Query<&mut SpatialAudioSink>) {
if keyboard_input.just_pressed(KeyCode::KeyM) {
for mut sink in sinks.iter_mut() {
sink.toggle_mute();
}
}
}

View File

@ -9,7 +9,7 @@ index 3e8082e23..624769443 100644
-use rodio::{OutputStream, OutputStreamHandle, Sink, Source, SpatialSink};
+use rodio::{OutputStreamHandle, Sink, Source, SpatialSink};
use crate::AudioSink;
use crate::{AudioSink, AudioSinkPlayback};
@@ -30,18 +30,10 @@ pub(crate) struct AudioOutput {