desktop: Allow changing the audio output device

This commit is contained in:
Nathan Adams 2024-03-02 23:11:46 +01:00
parent 09dfa6427e
commit c713f56ac0
7 changed files with 147 additions and 5 deletions

View File

@ -14,3 +14,6 @@ graphics-power-low = Low (e.g. iGPU)
graphics-power-high = High (e.g. GPU) graphics-power-high = High (e.g. GPU)
language = Language language = Language
audio-output-device = Audio Output Device
audio-output-device-default = System Default

View File

@ -1,3 +1,4 @@
use crate::preferences::GlobalPreferences;
use anyhow::{anyhow, Context, Error}; use anyhow::{anyhow, Context, Error};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use ruffle_core::backend::audio::{ use ruffle_core::backend::audio::{
@ -16,11 +17,10 @@ pub struct CpalAudioBackend {
} }
impl CpalAudioBackend { impl CpalAudioBackend {
pub fn new() -> Result<Self, Error> { pub fn new(preferences: &GlobalPreferences) -> Result<Self, Error> {
// Create CPAL audio device. // Create CPAL audio device.
let host = cpal::default_host(); let host = cpal::default_host();
let device = host let device = get_suitable_output_device(preferences, &host)
.default_output_device()
.ok_or_else(|| anyhow!("No audio devices available"))?; .ok_or_else(|| anyhow!("No audio devices available"))?;
// Create audio stream for device. // Create audio stream for device.
@ -89,3 +89,22 @@ impl AudioBackend for CpalAudioBackend {
self.stream.pause().expect("Error trying to pause CPAL audio stream. This feature may not be supported by your audio device."); self.stream.pause().expect("Error trying to pause CPAL audio stream. This feature may not be supported by your audio device.");
} }
} }
fn get_suitable_output_device(
preferences: &GlobalPreferences,
host: &cpal::Host,
) -> Option<cpal::Device> {
// First let's check for any user preference...
if let Some(preferred_device_name) = preferences.output_device_name() {
if let Ok(mut devices) = host.output_devices() {
if let Some(device) =
devices.find(|device| device.name().ok().as_deref() == Some(&preferred_device_name))
{
return Some(device);
}
}
}
// Then let's fall back to the device default
host.default_output_device()
}

View File

@ -1,5 +1,6 @@
use crate::gui::{available_languages, optional_text, text}; use crate::gui::{available_languages, optional_text, text};
use crate::preferences::GlobalPreferences; use crate::preferences::GlobalPreferences;
use cpal::traits::{DeviceTrait, HostTrait};
use egui::{Align2, Button, ComboBox, Grid, Ui, Widget, Window}; use egui::{Align2, Button, ComboBox, Grid, Ui, Widget, Window};
use ruffle_render_wgpu::clap::{GraphicsBackend, PowerPreference}; use ruffle_render_wgpu::clap::{GraphicsBackend, PowerPreference};
use ruffle_render_wgpu::descriptors::Descriptors; use ruffle_render_wgpu::descriptors::Descriptors;
@ -20,6 +21,10 @@ pub struct PreferencesDialog {
language: LanguageIdentifier, language: LanguageIdentifier,
language_changed: bool, language_changed: bool,
output_device: Option<String>,
available_output_devices: Vec<String>,
output_device_changed: bool,
} }
impl PreferencesDialog { impl PreferencesDialog {
@ -31,6 +36,16 @@ impl PreferencesDialog {
available_backends |= backend_availability(descriptors, wgpu::Backends::METAL); available_backends |= backend_availability(descriptors, wgpu::Backends::METAL);
available_backends |= backend_availability(descriptors, wgpu::Backends::DX12); available_backends |= backend_availability(descriptors, wgpu::Backends::DX12);
let audio_host = cpal::default_host();
let mut available_output_devices = Vec::new();
if let Ok(devices) = audio_host.output_devices() {
for device in devices {
if let Ok(name) = device.name() {
available_output_devices.push(name);
}
}
}
Self { Self {
available_backends, available_backends,
graphics_backend: preferences.graphics_backends(), graphics_backend: preferences.graphics_backends(),
@ -44,6 +59,10 @@ impl PreferencesDialog {
language: preferences.language(), language: preferences.language(),
language_changed: false, language_changed: false,
output_device: preferences.output_device_name(),
available_output_devices,
output_device_changed: false,
preferences, preferences,
} }
} }
@ -67,6 +86,8 @@ impl PreferencesDialog {
self.show_graphics_preferences(locale, &locked_text, ui); self.show_graphics_preferences(locale, &locked_text, ui);
self.show_language_preferences(locale, ui); self.show_language_preferences(locale, ui);
self.show_audio_preferences(locale, ui);
}); });
if self.restart_required() { if self.restart_required() {
@ -93,6 +114,7 @@ impl PreferencesDialog {
fn restart_required(&self) -> bool { fn restart_required(&self) -> bool {
self.graphics_backend != self.preferences.graphics_backends() self.graphics_backend != self.preferences.graphics_backends()
|| self.power_preference != self.preferences.graphics_power_preference() || self.power_preference != self.preferences.graphics_power_preference()
|| self.output_device != self.preferences.output_device_name()
} }
fn show_graphics_preferences( fn show_graphics_preferences(
@ -196,6 +218,25 @@ impl PreferencesDialog {
ui.end_row(); ui.end_row();
} }
fn show_audio_preferences(&mut self, locale: &LanguageIdentifier, ui: &mut Ui) {
ui.label(text(locale, "audio-output-device"));
let previous = self.output_device.clone();
let default = text(locale, "audio-output-device-default");
ComboBox::from_id_source("audio-output-device")
.selected_text(self.output_device.as_deref().unwrap_or(default.as_ref()))
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.output_device, None, default);
for device in &self.available_output_devices {
ui.selectable_value(&mut self.output_device, Some(device.to_string()), device);
}
});
if self.output_device != previous {
self.output_device_changed = true;
}
ui.end_row();
}
fn save(&mut self) { fn save(&mut self) {
if let Err(e) = self.preferences.write_preferences(|preferences| { if let Err(e) = self.preferences.write_preferences(|preferences| {
if self.graphics_backend_changed { if self.graphics_backend_changed {
@ -207,6 +248,10 @@ impl PreferencesDialog {
if self.language_changed { if self.language_changed {
preferences.set_language(self.language.clone()); preferences.set_language(self.language.clone());
} }
if self.output_device_changed {
preferences.set_output_device(self.output_device.clone());
// [NA] TODO: Inform the running player that the device changed
}
}) { }) {
// [NA] TODO: Better error handling... everywhere in desktop, really // [NA] TODO: Better error handling... everywhere in desktop, really
tracing::error!("Could not save preferences: {e}"); tracing::error!("Could not save preferences: {e}");

View File

@ -110,7 +110,7 @@ impl ActivePlayer {
) -> Self { ) -> Self {
let mut builder = PlayerBuilder::new(); let mut builder = PlayerBuilder::new();
match CpalAudioBackend::new() { match CpalAudioBackend::new(&preferences) {
Ok(audio) => { Ok(audio) => {
builder = builder.with_audio(audio); builder = builder.with_audio(audio);
} }

View File

@ -73,6 +73,15 @@ impl GlobalPreferences {
.clone() .clone()
} }
pub fn output_device_name(&self) -> Option<String> {
self.preferences
.lock()
.expect("Preferences is not reentrant")
.values
.output_device
.clone()
}
pub fn write_preferences(&self, fun: impl FnOnce(&mut PreferencesWriter)) -> Result<(), Error> { pub fn write_preferences(&self, fun: impl FnOnce(&mut PreferencesWriter)) -> Result<(), Error> {
let mut preferences = self let mut preferences = self
.preferences .preferences
@ -99,6 +108,7 @@ pub struct SavedGlobalPreferences {
pub graphics_backend: GraphicsBackend, pub graphics_backend: GraphicsBackend,
pub graphics_power_preference: PowerPreference, pub graphics_power_preference: PowerPreference,
pub language: LanguageIdentifier, pub language: LanguageIdentifier,
pub output_device: Option<String>,
} }
impl Default for SavedGlobalPreferences { impl Default for SavedGlobalPreferences {
@ -111,6 +121,7 @@ impl Default for SavedGlobalPreferences {
graphics_backend: Default::default(), graphics_backend: Default::default(),
graphics_power_preference: Default::default(), graphics_power_preference: Default::default(),
language: locale, language: locale,
output_device: None,
} }
} }
} }

View File

@ -54,6 +54,12 @@ pub fn read_preferences(input: &str) -> (ParseResult, Document) {
Err(e) => result.add_warning(format!("Invalid language: {e}")), Err(e) => result.add_warning(format!("Invalid language: {e}")),
}; };
match parse_item_from_str(document.get("output_device")) {
Ok(Some(value)) => result.result.output_device = Some(value),
Ok(None) => {}
Err(e) => result.add_warning(format!("Invalid output_device: {e}")),
};
(result, document) (result, document)
} }
@ -222,4 +228,38 @@ mod tests {
result result
); );
} }
#[test]
fn correct_output_device() {
let result = read_preferences("output_device = \"Speakers\"").0;
assert_eq!(
ParseResult {
result: SavedGlobalPreferences {
output_device: Some("Speakers".to_string()),
..Default::default()
},
warnings: vec![]
},
result
);
}
#[test]
fn invalid_output_device() {
let result = read_preferences("output_device = 5").0;
assert_eq!(
ParseResult {
result: SavedGlobalPreferences {
output_device: None,
..Default::default()
},
warnings: vec![
"Invalid output_device: expected string but found integer".to_string()
]
},
result
);
}
} }

View File

@ -24,6 +24,15 @@ impl<'a> PreferencesWriter<'a> {
self.0.document["language"] = value(language.to_string()); self.0.document["language"] = value(language.to_string());
self.0.values.language = language; self.0.values.language = language;
} }
pub fn set_output_device(&mut self, name: Option<String>) {
if let Some(name) = &name {
self.0.document["output_device"] = value(name);
} else {
self.0.document.remove("output_device");
}
self.0.values.output_device = name;
}
} }
#[cfg(test)] #[cfg(test)]
@ -100,4 +109,19 @@ mod tests {
"language = \"en-Latn-US-valencia\"\n", "language = \"en-Latn-US-valencia\"\n",
); );
} }
#[test]
fn set_output_device() {
test(
"",
|writer| writer.set_output_device(Some("Speakers".to_string())),
"output_device = \"Speakers\"\n",
);
test(
"output_device = \"Speakers\"",
|writer| writer.set_output_device(None),
"",
);
}
} }