From 3d1a30b61d984e9185b4a88d27f264ea6c2ac754 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:43:55 -0400 Subject: [PATCH] Audio device: keep the new choice only once the devices reopen with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui.audio_device and Playback Engine › Apply wrote ui.audio before apply_audio_config could refuse (while recording), so the saved choice no longer matched the open device. apply_audio_config now takes the new configuration and stores it only after the refusal check. Adds a test for ui.audio_device: no-op query, a bad rate, refusal while recording, and an applied change. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 10 ++++++---- crates/ui-egui/src/windows.rs | 10 +++------- crates/ui-egui/tests/audio_device.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/tests/audio_device.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 801bd23..2890ff7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -558,13 +558,15 @@ impl SoundApp { || (self.recorder.is_some() && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm)) } - /// Reopens the audio devices with `ui.audio` (Setup › Playback Engine › Apply), keeping the - /// transport position. Plugin instances and their editors are recreated with the new player. - /// Refused while recording, which would lose the take. - pub fn apply_audio_config(&mut self) -> Result<(), String> { + /// Reopens the audio devices with `cfg` (Setup › Playback Engine › Apply), keeping the + /// transport position, and keeps it as `ui.audio`. Plugin instances and their editors are + /// recreated with the new player. Refused while recording, which would lose the take; `ui.audio` + /// then stays the configuration of the open device. + pub fn apply_audio_config(&mut self, cfg: soundcraft_playback::AudioConfig) -> Result<(), String> { if self.engine.transport.recording { return Err("stop recording before changing the audio device".into()); } + self.ui.audio = cfg; let at = self.position(); if let Some(p) = &self.player { p.stop(); diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 7031bab..c9d61fd 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -547,11 +547,8 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { if refresh { audio_systems(ctx, true); } - if apply { - app.ui.audio = draft.clone(); - if let Err(e) = app.apply_audio_config() { - app.ui.status = e; - } + if apply && let Err(e) = app.apply_audio_config(draft.clone()) { + app.ui.status = e; } if open { ctx.data_mut(|d| d.insert_temp(draft_id, draft)); @@ -588,8 +585,7 @@ pub fn audio_device_command(app: &mut SoundApp, p: &serde_json::Value) -> Result } } if changed { - app.ui.audio = cfg; - app.apply_audio_config()?; + app.apply_audio_config(cfg)?; } let device = app.player.as_ref().map(|pl| { json!({"name": pl.device_name, "sample_rate": pl.device_rate, "channels": pl.device_channels, "buffer_size": pl.device_buffer, "silent": pl.silent}) diff --git a/crates/ui-egui/tests/audio_device.rs b/crates/ui-egui/tests/audio_device.rs new file mode 100644 index 0000000..6095a72 --- /dev/null +++ b/crates/ui-egui/tests/audio_device.rs @@ -0,0 +1,27 @@ +//! `ui.audio_device`: the audio device choice is kept only once the devices reopen with it. +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp}; + +#[test] +fn the_audio_device_choice_is_kept_only_when_it_is_applied() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let before = app.ui.audio.clone(); + + let r = app.run("ui.audio_device", json!({})).unwrap(); + assert_eq!(r["config"], json!(before), "no change: just the choice and the device"); + + assert!(app.run("ui.audio_device", json!({"sample_rate": 12})).is_err()); + assert_eq!(app.ui.audio, before, "an out-of-range rate changes nothing"); + + app.engine.transport.recording = true; + let err = app.run("ui.audio_device", json!({"buffer_size": 256, "output": "Elsewhere"})).unwrap_err(); + assert!(err.contains("stop recording"), "{err}"); + assert_eq!(app.ui.audio, before, "refused while recording: the saved choice stays the open device's"); + app.engine.transport.recording = false; + + // Opens the chosen device, or the default, or a silent clock without one. + let r = app.run("ui.audio_device", json!({"buffer_size": 256})).unwrap(); + assert_eq!(app.ui.audio.buffer_size, Some(256)); + assert_eq!(r["config"]["buffer_size"], 256); + assert!(r["device"].is_object(), "{r}"); +}