From fbeefd9f549432ba438a0b67ec071bdc2dbb6c7d Mon Sep 17 00:00:00 2001 From: Lee Hobson Date: Sun, 5 Jul 2026 20:57:07 +0100 Subject: [PATCH] audio: host output-device selection and stereo/mono controls Add host audio-output configuration on top of the existing cpal sink: choose the output device and shape the stereo image. Pure cpal -- one code path, no per-platform FFI -- so it enumerates and selects the same on CoreAudio, WASAPI and ALSA. Nothing here touches the emulated audio. Device selection: --list-audio-devices, --audio-device NAME (case-insensitive substring) and [audio] output_device, falling back to the system default with a warning if the name no longer matches. Also a launcher field ("Audio output", top of the A/V & Emu tab) and a runtime-menu item that switches the device live (rebuilt on the main thread; the cpal Stream is !Send on macOS). Both pickers re-read the device list on each step, so a reconnected device reappears without a restart. If the live device disappears, DeviceNotAvailable is flagged from the stream error callback and the window reopens on the current default the next frame, resetting the selection to Default. Disabled option: the launcher picker and runtime menu cycle Default -> devices -> Disabled, where Disabled swaps in a null sink for no sound -- the same effect as --noaudio, switchable live while emulating. It persists as [audio] output_enabled = false; the --audio/--noaudio CLI flags still override it and the CLI is otherwise unchanged. Linux list: cpal's ALSA enumeration lists each card many times over and libasound prints probe chatter to stderr; the list is filtered to the clean routes by ALSA's fixed plugin-type token (no device names hard-coded) and a no-op ALSA error handler silences the chatter (the one cfg(linux) FFI). The GUI also drops ALSA's "default" when it is the system default, since the picker offers a synthetic "Default". Stereo shaping: a channel mode (stereo/mono) and a stereo separation (0-100) -- a mid/side width control where 100 leaves the hardware panning bit-for-bit unchanged and 0 is mono; mono mode is a forced 0. Applied in Paula's push next to the master volume, so it covers live and WAV output and survives device switches. Host preferences, not machine state: serde-skipped on Paula, applied from config at build and carried across save-state loads, so no STATE_VERSION change and existing saves keep loading. Config/CLI/GUI throughout; docs in the configuration guide and copperline.example.toml. Off by nothing -- a config with no [audio] keys behaves exactly as before. Co-Authored-By: Claude Opus 4.8 --- README.md | 17 ++ copperline.example.toml | 18 ++ docs/guide/configuration.md | 21 ++ src/audio.rs | 381 +++++++++++++++++++++++++++++++++++- src/chipset/paula.rs | 100 +++++++++- src/config.rs | 218 ++++++++++++++++++++- src/emulator.rs | 17 ++ src/main.rs | 110 ++++++++++- src/video/launcher.rs | 182 ++++++++++++++++- src/video/ui.rs | 69 +++++-- src/video/window.rs | 130 ++++++++++-- src/video/window/tests.rs | 1 + 12 files changed, 1213 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 9c5f970..653b9d5 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,23 @@ The full reference -- every key, machine profiles, Zorro boards, CD/HDD images, validation rules, and audio options -- is in the [configuration guide](docs/guide/configuration.md). +## Audio output + +Real-time audio goes through [cpal](https://crates.io/crates/cpal), so the +same code drives CoreAudio, WASAPI and ALSA. By default it uses the system +default output; `--list-audio-devices` prints the alternatives and +`--audio-device NAME` (or `[audio] output_device`) selects one by +case-insensitive substring, falling back to the default if it disappears. The +device is also selectable in the configuration screen and switchable live from +the in-window menu, which additionally offers "Disabled" to turn sound off +entirely (equivalent to `--noaudio`). + +Two host-side shaping options leave the emulated audio untouched: +`--audio-channel-mode mono` averages the left/right output into both channels, +and `--audio-stereo-separation 0-100` narrows the Amiga's hardware left/right +panning (100 = full, 0 = mono). Both are also `[audio]` keys and configuration- +screen fields. + ## MIDI Copperline can bridge Paula's serial port to the host's MIDI system, so an diff --git a/copperline.example.toml b/copperline.example.toml index 4337fc8..1e549aa 100644 --- a/copperline.example.toml +++ b/copperline.example.toml @@ -245,6 +245,24 @@ floppy_sounds = true # Drive sound level, 0-100, relative to Paula's output. floppy_sounds_volume = 100 +# Host audio output device, matched by a case-insensitive substring against +# the names `--list-audio-devices` prints (also set with `--audio-device`). +# Commented out / omitted uses the system default output. +# output_device = "External Headphones" + +# Whether live audio output is produced at all. Set false for no sound (the +# runtime menu / launcher "Disabled" option); the CLI `--noaudio`/`--audio` +# flags still override it. Omitted / true produces sound normally. +# output_enabled = true + +# Output channel mode: "stereo" (default, the Amiga's hardware left/right +# panning) or "mono" (both channels averaged together). Also `--audio-channel-mode`. +# channel_mode = "stereo" + +# Stereo separation, 0-100. 100 (default) keeps the full hardware panning; lower +# values narrow it and 0 collapses to mono. Also `--audio-stereo-separation`. +# stereo_separation = 100 + # Host input preferences. # [input] diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ce05cff..0889b0c 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -344,12 +344,33 @@ synchronous render path for comparison. [audio] floppy_sounds = true # synthesized drive sounds (not sampled) floppy_sounds_volume = 100 # 0-100, relative to Paula's output +# output_device = "..." # host output device (substring); omit = system default +# output_enabled = true # false = no sound (GUI "Disabled"); --audio/--noaudio still win +channel_mode = "stereo" # "stereo" (default) or "mono" +stereo_separation = 100 # 0-100; 100 = hardware panning, 0 = mono ``` The drive sounds are generated from scratch: motor hum with spin-up/down, head-step clicks for seeks and the empty-drive poll, and faint read/write hiss during disk DMA. +`output_device` picks the host output by a case-insensitive substring of the +names `--list-audio-devices` prints (`--audio-device` overrides it); an omitted +or unmatched name uses the system default. `channel_mode = "mono"` averages the +left and right output into both channels, and `stereo_separation` narrows the +Amiga's hardware left/right panning between full (100) and mono (0) -- so it is +ignored when `channel_mode` is mono. `output_enabled = false` runs with no sound +at all (the launcher and runtime-menu "Disabled" option); the `--audio` and +`--noaudio` CLI flags still override it. These are host-output settings that do +not change the emulated audio and are not stored in save states. The equivalent +CLI flags are `--audio-device`, `--audio-channel-mode`, `--audio-stereo-separation` +and `--list-audio-devices`. + +On Linux with PipeWire/PulseAudio, individual sinks are not ALSA devices, so +only the `default`/`pipewire` route is offered; pick the output in the desktop +sound settings (or route Copperline in `pavucontrol`) and it follows. macOS and +Windows select each device directly. + ## `[input]` ```toml diff --git a/src/audio.rs b/src/audio.rs index 87d6fdf..e01e205 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -76,6 +76,12 @@ pub trait AudioSink { fn is_null_sink(&self) -> bool { false } + /// True once the live output device has gone away (e.g. unplugged) and the + /// stream can no longer play. The host polls this to rebuild the sink on the + /// current default device. Sinks without a host device never report it. + fn device_lost(&self) -> bool { + false + } } pub fn audio_profile_enabled() -> bool { @@ -118,6 +124,9 @@ pub struct CpalSink { underruns: Arc, total_underruns: Arc, live_output_suspended: Arc, + // Set by the stream error callback when the output device disappears + // (unplugged); polled by the host so it can rebuild on the default device. + device_lost: Arc, profile_callbacks: Arc, profile_callback_frames: Arc, profile_callback_device_cck: Arc, @@ -129,16 +138,261 @@ pub struct CpalSink { prebuffer_frames: usize, } +// libasound's `snd_lib_error_handler_t` is variadic (`..., const char *fmt, +// ...`). A handler that ignores the trailing varargs is safe to install under +// the C calling convention (the caller cleans the stack), so it is declared +// without them. +#[cfg(target_os = "linux")] +type AlsaErrorHandler = extern "C" fn( + *const std::ffi::c_char, + std::ffi::c_int, + *const std::ffi::c_char, + std::ffi::c_int, + *const std::ffi::c_char, +); + +#[cfg(target_os = "linux")] +#[link(name = "asound")] +extern "C" { + fn snd_lib_error_set_handler(handler: Option) -> std::ffi::c_int; +} + +#[cfg(target_os = "linux")] +extern "C" fn alsa_ignore_error( + _file: *const std::ffi::c_char, + _line: std::ffi::c_int, + _function: *const std::ffi::c_char, + _err: std::ffi::c_int, + _fmt: *const std::ffi::c_char, +) { +} + +/// Silence libasound's stderr chatter (the `ALSA lib pcm_...` lines) that it +/// prints while cpal probes devices for enumeration. Those are informational +/// plugin messages, not failures we can act on -- cpal still reports real +/// errors through its `Result` API, so nothing user-facing is hidden. Installs +/// a no-op error handler once, process-wide, keeping `--list-audio-devices` and +/// the picker readable. No-op off Linux, where the handler does not exist. +#[cfg(target_os = "linux")] +fn quiet_alsa_probe_logging() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| unsafe { + snd_lib_error_set_handler(Some(alsa_ignore_error)); + }); +} + +#[cfg(not(target_os = "linux"))] +fn quiet_alsa_probe_logging() {} + +/// Whether an ALSA device name is a low-level *plugin* handle rather than a +/// device a user would pick: the channel-layout plugins (`front`, `surround51`, +/// ...), the software mix/snoop plugins (`dmix`, `dsnoop`), and the raw/wrapped +/// per-card hardware handles (`hw`, `plughw`, `sysdefault`) -- ALSA advertises +/// all of these for every card, so one physical output shows up many times over. +/// The clean routes (`default`, `pipewire`, `pulse`, `jack`) and any +/// friendly-named devices pass through. This keys off ALSA's fixed plugin-type +/// vocabulary (the token before the first `:`), identical on every distro, not +/// off any card or device name -- and hidden handles are still selectable by +/// name in the config/CLI, since only the displayed list is filtered. A no-op on +/// macOS/Windows, whose device names never take this form. +fn is_alsa_plugin_variant(name: &str) -> bool { + let plugin = name.split(':').next().unwrap_or(name); + matches!( + plugin, + "front" + | "rear" + | "center_lfe" + | "side" + | "surround21" + | "surround40" + | "surround41" + | "surround50" + | "surround51" + | "surround71" + | "dmix" + | "dsnoop" + | "hw" + | "plughw" + | "sysdefault" + ) +} + +/// A GUI audio-output selection: the system default, a specific named device, or +/// disabled -- a [`NullSink`], i.e. no sound, the same effect as `--noaudio`. The +/// launcher picker and runtime menu cycle Default -> devices -> Disabled. This is +/// a GUI-only concept; the CLI keeps its separate `--audio-device`/`--noaudio`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum AudioOutput { + /// The host's system default output device. + #[default] + Default, + /// A specific device, matched by name against the enumerated outputs. + Device(String), + /// No audio output at all (null sink). + Disabled, +} + +impl AudioOutput { + /// Build the selection from config: disabled when output is off, otherwise + /// the named device, or the default when none is set. A blank/whitespace-only + /// name is treated as the default, matching how config resolution handles it. + pub fn from_config(enabled: bool, device: Option<&str>) -> Self { + let device = device.filter(|name| !name.trim().is_empty()); + match (enabled, device) { + (false, _) => AudioOutput::Disabled, + (true, Some(name)) => AudioOutput::Device(name.to_string()), + (true, None) => AudioOutput::Default, + } + } + + /// The named device, if one is selected (`None` for Default and Disabled). + pub fn device(&self) -> Option<&str> { + match self { + AudioOutput::Device(name) => Some(name), + _ => None, + } + } + + /// Whether live audio output is produced (false only for Disabled). + pub fn is_enabled(&self) -> bool { + !matches!(self, AudioOutput::Disabled) + } + + /// Picker label: "Default", the device name, or "Disabled". + pub fn label(&self) -> &str { + match self { + AudioOutput::Default => "Default", + AudioOutput::Device(name) => name, + AudioOutput::Disabled => "Disabled", + } + } + + /// Step to the next selection, cycling Default -> device0 -> ... -> deviceN + /// -> Disabled and back. With no devices this is just Default <-> Disabled. + pub fn cycle(&self, devices: &[String], forward: bool) -> Self { + // Positions: 0 = Default, 1..=len = devices, len + 1 = Disabled. + let len = devices.len(); + let here = match self { + AudioOutput::Default => 0, + AudioOutput::Device(name) => { + devices.iter().position(|n| n == name).map_or(0, |i| i + 1) + } + AudioOutput::Disabled => len + 1, + }; + let count = len + 2; + let next = if forward { + (here + 1) % count + } else { + (here + count - 1) % count + }; + if next == 0 { + AudioOutput::Default + } else if next <= len { + AudioOutput::Device(devices[next - 1].clone()) + } else { + AudioOutput::Disabled + } + } +} + +/// Open the audio sink for a picker selection: a [`NullSink`] when disabled, +/// otherwise a [`CpalSink`] on the chosen (or default) device. Device-open +/// errors propagate so callers can report them; Disabled never fails. +pub fn open_output_sink( + realtime_priority: bool, + output: &AudioOutput, +) -> Result> { + Ok(match output { + AudioOutput::Disabled => { + // Mirror CpalSink's "sink ready" log so the CLI shows the change too. + log::info!("audio: disabled (null sink); no sound"); + Box::new(NullSink) + } + _ => Box::new(CpalSink::new(realtime_priority, output.device())?), + }) +} + +/// Names of the host's audio output devices, for `--list-audio-devices` and as +/// the base for the GUI picker, with ALSA's low-level plugin handles filtered +/// out (see [`is_alsa_plugin_variant`]). ALSA's "default" is kept here so the +/// CLI can name it; the GUI drops it separately (see [`picker_output_devices`]). +/// Empty if the host cannot enumerate. Selection by name still matches the full +/// set, so a hidden entry can be named explicitly. +/// +/// On Linux, cpal enumerates via ALSA, and PipeWire/PulseAudio expose only their +/// `default`/`pipewire` bridge there, not one ALSA device per sink -- so a +/// specific sink cannot be named, only the system default (routed in the desktop +/// mixer). Naming individual sinks would need cpal's `jack` backend against +/// pipewire-jack, which adds a libjack build dependency; not worth it for a niche +/// control when macOS/Windows enumerate every device directly. +pub fn list_output_devices() -> Vec { + quiet_alsa_probe_logging(); + cpal::default_host() + .output_devices() + .map(|devs| { + devs.filter_map(|d| d.name().ok()) + .filter(|name| !is_alsa_plugin_variant(name)) + .collect() + }) + .unwrap_or_default() +} + +/// Whether `name` is redundant with the GUI picker's own "Default" entry: it is +/// ALSA's `default` pseudo-device *and* that is what the system default resolves +/// to, so selecting it and selecting "Default" (the `None` option) do the same +/// thing. `default_name` is the host's default output device name. When the +/// default is some other device, `default` is a distinct choice and kept. +fn is_redundant_default(name: &str, default_name: Option<&str>) -> bool { + name.eq_ignore_ascii_case("default") + && default_name.is_some_and(|d| d.eq_ignore_ascii_case("default")) +} + +/// Output-device names for the GUI picker (launcher field + runtime menu). Same +/// as [`list_output_devices`], but drops ALSA's "default" when it is the system +/// default, since the picker already offers a synthetic "Default" (the `None` +/// selection) for that. Still selectable by name in the config/CLI. +pub fn picker_output_devices() -> Vec { + let host = cpal::default_host(); + let default_name = host.default_output_device().and_then(|d| d.name().ok()); + list_output_devices() + .into_iter() + .filter(|name| !is_redundant_default(name, default_name.as_deref())) + .collect() +} + +/// The output device to open: the first whose name contains `want` +/// (case-insensitive), otherwise the system default. A named-but-missing +/// device warns and falls back to the default rather than leaving the machine +/// silent. +fn select_output_device(host: &cpal::Host, want: Option<&str>) -> Result { + if let Some(name) = want { + let needle = name.to_lowercase(); + let matched = host.output_devices().ok().and_then(|mut devs| { + devs.find(|d| { + d.name() + .map(|n| n.to_lowercase().contains(&needle)) + .unwrap_or(false) + }) + }); + match matched { + Some(device) => return Ok(device), + None => log::warn!("audio: no output device matches {name:?}; using the default"), + } + } + host.default_output_device() + .ok_or_else(|| anyhow!("no default audio output device")) +} + impl CpalSink { /// Build the live cpal output sink. When `realtime_priority` is set, the /// audio callback thread promotes itself on its first invocation (see /// [`crate::priority`]); the flag is resolved by the caller from config and /// the `COPPERLINE_REALTIME_PRIORITY` env var. - pub fn new(realtime_priority: bool) -> Result { + pub fn new(realtime_priority: bool, output_device: Option<&str>) -> Result { + quiet_alsa_probe_logging(); let host = cpal::default_host(); - let device = host - .default_output_device() - .ok_or_else(|| anyhow!("no default audio output device"))?; + let device = select_output_device(&host, output_device)?; let supported = device .default_output_config() .map_err(|e| anyhow!("query default output config: {e}"))?; @@ -176,6 +430,8 @@ impl CpalSink { let underruns_for_cb = Arc::clone(&underruns); let total_underruns = Arc::new(AtomicU64::new(0)); let total_underruns_for_cb = Arc::clone(&total_underruns); + let device_lost = Arc::new(AtomicBool::new(false)); + let device_lost_for_cb = Arc::clone(&device_lost); let live_output_suspended = Arc::new(AtomicBool::new(false)); let live_output_suspended_for_cb = Arc::clone(&live_output_suspended); let profile_enabled = audio_profile_enabled(); @@ -246,7 +502,15 @@ impl CpalSink { } } }, - |err| log::warn!("cpal stream error: {err}"), + move |err| { + log::warn!("cpal stream error: {err}"); + // A vanished device (unplugged, or the default switched away) + // cannot recover on its own; flag it so the host reopens on + // the current default output. + if matches!(err, cpal::StreamError::DeviceNotAvailable) { + device_lost_for_cb.store(true, Ordering::Relaxed); + } + }, None, ) .map_err(|e| anyhow!("build_output_stream: {e}"))?; @@ -263,6 +527,7 @@ impl CpalSink { Ok(Self { producer, _stream: stream, + device_lost, playback_started, clear_buffer, drop_old_frames, @@ -501,6 +766,10 @@ impl AudioSink for CpalSink { ), } } + + fn device_lost(&self) -> bool { + self.device_lost.load(Ordering::Relaxed) + } } impl CpalSink { @@ -605,13 +874,111 @@ fn callback_device_cck(output_frames: usize, output_sample_rate: u32) -> u64 { #[cfg(test)] mod tests { use super::{ - callback_device_cck, live_output_prebuffering, sample_is_audible, - stale_live_audio_frames_to_skip, CpalResampler, + callback_device_cck, is_alsa_plugin_variant, is_redundant_default, + live_output_prebuffering, open_output_sink, sample_is_audible, + stale_live_audio_frames_to_skip, AudioOutput, CpalResampler, }; use ringbuf::traits::{Producer, Split}; use ringbuf::HeapRb; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + #[test] + fn alsa_plugin_variants_filtered_but_real_devices_kept() { + // The low-level ALSA handles ALSA emits for every card: routing/mixing + // plugins and the raw/wrapped/sysdefault per-card hardware handles. + for noise in [ + "front:CARD=Intel,DEV=0", + "surround51:CARD=Intel,DEV=0", + "dmix:CARD=Intel,DEV=0", + "dsnoop:CARD=Intel,DEV=0", + "hw:CARD=Intel,DEV=0", + "plughw:CARD=Intel,DEV=0", + "sysdefault:CARD=Intel", + ] { + assert!(is_alsa_plugin_variant(noise), "should hide {noise}"); + } + // Clean routes and non-ALSA (macOS/Windows) names pass through. Note + // "default" is not a plugin variant; the GUI drops it separately, only + // when it is the system default (see is_redundant_default). + for keep in [ + "default", + "pipewire", + "pulse", + "jack", + "MacBook Air Speakers", + "BlackHole 2ch", + ] { + assert!(!is_alsa_plugin_variant(keep), "should keep {keep}"); + } + } + + #[test] + fn default_is_only_redundant_when_it_is_the_system_default() { + // "default" is the system default -> redundant with the picker's Default + // (case-insensitive). + assert!(is_redundant_default("default", Some("default"))); + assert!(is_redundant_default("Default", Some("DEFAULT"))); + // "default" exists but the system default is a different device -> keep. + assert!(!is_redundant_default("default", Some("pipewire"))); + assert!(!is_redundant_default("default", None)); + // A normal device is never treated as the redundant default. + assert!(!is_redundant_default("pipewire", Some("default"))); + assert!(!is_redundant_default( + "MacBook Air Speakers", + Some("default") + )); + } + + #[test] + fn audio_output_cycles_default_devices_then_disabled() { + let devices = vec!["BlackHole".to_string(), "Speakers".to_string()]; + // Forward: Default -> dev0 -> dev1 -> Disabled -> back to Default. + let a = AudioOutput::Default; + let b = a.cycle(&devices, true); + assert_eq!(b, AudioOutput::Device("BlackHole".to_string())); + let c = b.cycle(&devices, true); + assert_eq!(c, AudioOutput::Device("Speakers".to_string())); + let d = c.cycle(&devices, true); + assert_eq!(d, AudioOutput::Disabled); + assert_eq!(d.cycle(&devices, true), AudioOutput::Default); + // Backward from Default lands on Disabled (the last slot). + assert_eq!(a.cycle(&devices, false), AudioOutput::Disabled); + // With no devices it is just Default <-> Disabled. + assert_eq!(AudioOutput::Default.cycle(&[], true), AudioOutput::Disabled); + assert_eq!(AudioOutput::Disabled.cycle(&[], true), AudioOutput::Default); + } + + #[test] + fn audio_output_maps_to_and_from_config() { + assert_eq!(AudioOutput::from_config(true, None), AudioOutput::Default); + assert_eq!( + AudioOutput::from_config(true, Some("BlackHole")), + AudioOutput::Device("BlackHole".to_string()) + ); + // Disabled regardless of any device name. + assert_eq!( + AudioOutput::from_config(false, Some("BlackHole")), + AudioOutput::Disabled + ); + // A blank/whitespace-only device name falls back to the default. + assert_eq!( + AudioOutput::from_config(true, Some(" ")), + AudioOutput::Default + ); + assert_eq!(AudioOutput::Default.device(), None); + assert_eq!(AudioOutput::Disabled.device(), None); + assert!(AudioOutput::Default.is_enabled()); + assert!(!AudioOutput::Disabled.is_enabled()); + assert_eq!(AudioOutput::Disabled.label(), "Disabled"); + } + + #[test] + fn open_output_sink_is_silent_when_disabled() { + // Disabled never touches the host device and never errors. + let sink = open_output_sink(false, &AudioOutput::Disabled).unwrap(); + assert!(!sink.device_lost()); + } + #[test] fn live_audio_backlog_uses_hysteresis_before_dropping_stale_frames() { assert_eq!(stale_live_audio_frames_to_skip(1024, 2048, 4096), 0); diff --git a/src/chipset/paula.rs b/src/chipset/paula.rs index 1bbae98..ee243ff 100644 --- a/src/chipset/paula.rs +++ b/src/chipset/paula.rs @@ -474,6 +474,12 @@ fn null_audio_sink() -> Box { Box::new(crate::audio::NullSink) } +/// serde default for the skipped `stereo_separation` field: full width, so a +/// restored state that never stored it plays at hardware panning, not mono. +fn default_stereo_separation() -> f32 { + 1.0 +} + #[derive(serde::Serialize, serde::Deserialize)] pub struct Paula { pub serper: u16, @@ -518,6 +524,15 @@ pub struct Paula { led_filter_enabled: bool, led_filter: StereoLedFilter, output_volume: f32, + // Host output preference: average L/R into both channels. Not part of the + // emulated machine state, so it is skipped in save states and re-applied + // from config (carried over across a state load, see Emulator::load_state). + #[serde(skip)] + mono_output: bool, + // Stereo width, 0.0 (mono) to 1.0 (full hardware panning, the default). + // Host preference; skipped and carried across state loads like mono_output. + #[serde(skip, default = "default_stereo_separation")] + stereo_separation: f32, // CD audio samples streamed by the CD controller (CD32 Akiko), mixed // into the host output at the shared 44.1 kHz mixer rate. cd_audio: CdAudioRing, @@ -584,6 +599,8 @@ impl Paula { led_filter_enabled: true, led_filter: StereoLedFilter::new(), output_volume: 1.0, + mono_output: false, + stereo_separation: 1.0, cd_audio: CdAudioRing::default(), drive_sounds: DriveSounds::new(), dma_addr_mask: 0x001F_FFFF, @@ -757,6 +774,27 @@ impl Paula { self.output_volume = f32::from(percent.min(100)) / 100.0; } + /// Average the stereo output into both channels (mono) instead of the + /// hardware's hard left/right panning. Host preference; does not affect the + /// emulated audio state. + pub fn set_mono_output(&mut self, mono: bool) { + self.mono_output = mono; + } + + pub fn mono_output(&self) -> bool { + self.mono_output + } + + /// Stereo width, `0.0` (mono) to `1.0` (full hardware panning). Values are + /// clamped to that range. Host preference; does not affect emulated state. + pub fn set_stereo_separation(&mut self, separation: f32) { + self.stereo_separation = separation.clamp(0.0, 1.0); + } + + pub fn stereo_separation(&self) -> f32 { + self.stereo_separation + } + pub fn drive_sounds_mut(&mut self) -> &mut DriveSounds { &mut self.drive_sounds } @@ -1710,8 +1748,23 @@ impl Paula { if let Some(capture) = &mut self.capture { capture.push((left, right)); } - self.audio - .push(left * self.output_volume, right * self.output_volume); + let (mut out_left, mut out_right) = (left * self.output_volume, right * self.output_volume); + // Stereo width via mid/side: `sep` 1.0 leaves the hardware panning + // untouched (out == in, the default), 0.0 collapses to mono. Mono mode + // is just a forced 0. Skipped entirely at full width so the default path + // is unchanged. + let sep = if self.mono_output { + 0.0 + } else { + self.stereo_separation + }; + if sep < 1.0 { + let mid = (out_left + out_right) * 0.5; + let side = (out_left - out_right) * 0.5 * sep; + out_left = mid + side; + out_right = mid - side; + } + self.audio.push(out_left, out_right); } fn channel_mixed_sample(&self, ch_idx: usize) -> i32 { @@ -3340,6 +3393,49 @@ mod tests { assert_eq!(frames[0].1, 0.0); } + #[test] + fn mono_output_averages_left_and_right_into_both_channels() { + let (mut paula, frames) = paula_with_collect_sink(); + paula.set_led_filter_enabled(false); + paula.set_mono_output(true); + // Drive only a left channel (0); the right side stays silent, so stereo + // output would be (0.5, 0.0) and mono is the average, 0.25, in both. + paula.chans[0].current = 64; + paula.chans[0].vol = 64; + + paula.push_mixed_frame(); + + let frames = frames.borrow(); + assert_eq!(frames[0].0, frames[0].1, "mono means identical channels"); + assert!((frames[0].0 - 0.25).abs() < f32::EPSILON); + } + + #[test] + fn stereo_separation_narrows_from_hardware_panning_toward_mono() { + // Drive left channel only: hardware panning gives (0.5, 0.0). + let out = |sep: f32| { + let (mut paula, frames) = paula_with_collect_sink(); + paula.set_led_filter_enabled(false); + paula.set_stereo_separation(sep); + paula.chans[0].current = 64; + paula.chans[0].vol = 64; + paula.push_mixed_frame(); + let frame = frames.borrow()[0]; + frame + }; + // 100%: untouched. + let full = out(1.0); + assert!((full.0 - 0.5).abs() < f32::EPSILON && full.1 == 0.0); + // 0%: mono (both = the 0.25 average). + let mono = out(0.0); + assert_eq!(mono.0, mono.1); + assert!((mono.0 - 0.25).abs() < f32::EPSILON); + // 50%: mid 0.25 +/- side (0.25 * 0.5) -> (0.375, 0.125). + let half = out(0.5); + assert!((half.0 - 0.375).abs() < f32::EPSILON); + assert!((half.1 - 0.125).abs() < f32::EPSILON); + } + #[test] fn pot_hsync_counter_ramps_once_per_line() { let (mut paula, _) = paula_with_collect_sink(); diff --git a/src/config.rs b/src/config.rs index 10138ca..3ad3f79 100644 --- a/src/config.rs +++ b/src/config.rs @@ -426,7 +426,38 @@ impl WarpSpeed { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// How Paula's stereo output is presented to the host. The Amiga hardware pans +/// channels 0/3 hard left and 1/2 hard right; `Mono` averages them into both +/// output channels for listeners who dislike that hard separation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ChannelMode { + #[default] + Stereo, + Mono, +} + +impl ChannelMode { + pub fn label(self) -> &'static str { + match self { + ChannelMode::Stereo => "stereo", + ChannelMode::Mono => "mono", + } + } + + pub fn is_mono(self) -> bool { + matches!(self, ChannelMode::Mono) + } +} + +pub(crate) fn parse_channel_mode(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "stereo" => Ok(ChannelMode::Stereo), + "mono" => Ok(ChannelMode::Mono), + other => bail!("unknown [audio] channel_mode {other:?}; expected \"stereo\" or \"mono\""), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct AudioConfig { /// Synthesized floppy-drive sound effects: motor hum, head-step /// clicks for seeks (and the empty-drive change-line poll), and a @@ -434,6 +465,18 @@ pub struct AudioConfig { pub floppy_sounds: bool, /// Drive sound level, 0-100, relative to Paula's output. pub floppy_sounds_volume: u8, + /// Host audio output device, matched by case-insensitive substring against + /// the names cpal enumerates. `None` uses the system default output. + pub output_device: Option, + /// Whether live audio output is produced at all. `false` runs with a null + /// sink (no sound), the GUI's "Disabled" picker option; it is separate from + /// the `--noaudio`/`--audio` CLI flags, which still override it. + pub output_enabled: bool, + /// Stereo (hardware panning) or mono (L/R averaged into both channels). + pub channel_mode: ChannelMode, + /// Stereo width, 0-100. 100 keeps the hardware left/right panning (default), + /// 0 collapses to mono; values between narrow the separation. + pub stereo_separation: u8, } impl Default for AudioConfig { @@ -441,6 +484,10 @@ impl Default for AudioConfig { Self { floppy_sounds: true, floppy_sounds_volume: 100, + output_device: None, + output_enabled: true, + channel_mode: ChannelMode::Stereo, + stereo_separation: 100, } } } @@ -959,6 +1006,12 @@ pub struct ConfigOverrides { pub midi_out: Option, /// Host MIDI input endpoint (`--midi-in`), implying `--serial midi`. pub midi_in: Option, + /// Host audio output device (`--audio-device`), substring match. + pub audio_device: Option, + /// Output channel mode (`--audio-channel-mode`): "stereo" or "mono". + pub audio_channel_mode: Option, + /// Stereo separation percent (`--audio-stereo-separation`), 0-100. + pub audio_stereo_separation: Option, } impl ConfigOverrides { @@ -977,6 +1030,9 @@ impl ConfigOverrides { && self.serial.is_none() && self.midi_out.is_none() && self.midi_in.is_none() + && self.audio_device.is_none() + && self.audio_channel_mode.is_none() + && self.audio_stereo_separation.is_none() } /// Inject the set overrides into the raw config, replacing the values @@ -1026,6 +1082,15 @@ impl ConfigOverrides { if self.serial.is_none() && (self.midi_out.is_some() || self.midi_in.is_some()) { raw.serial.mode = Some(SerialMode::Midi.label().to_string()); } + if let Some(dev) = &self.audio_device { + raw.audio.output_device = Some(dev.clone()); + } + if let Some(mode) = &self.audio_channel_mode { + raw.audio.channel_mode = Some(mode.clone()); + } + if let Some(sep) = self.audio_stereo_separation { + raw.audio.stereo_separation = Some(sep); + } } } @@ -1093,6 +1158,13 @@ impl RawConfig { pub(crate) fn to_toml_string(&self) -> Result { toml::to_string_pretty(self).context("serializing configuration to TOML") } + + /// The configured live-audio state (`[audio] output_enabled`), defaulting to + /// on when unset -- matching [`AudioConfig`]'s default. Lets the binary seed + /// the config-screen session audio without reaching into private raw fields. + pub fn audio_output_enabled(&self) -> bool { + self.audio.output_enabled.unwrap_or(true) + } } #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] @@ -1405,6 +1477,14 @@ pub(crate) struct RawAudio { pub(crate) floppy_sounds: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) floppy_sounds_volume: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) output_device: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) output_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) channel_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) stereo_separation: Option, } #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] @@ -1636,6 +1716,33 @@ impl TryFrom for Config { defaults.audio.floppy_sounds_volume } }, + output_device: raw + .audio + .output_device + .clone() + .filter(|name| !name.trim().is_empty()), + output_enabled: raw + .audio + .output_enabled + .unwrap_or(defaults.audio.output_enabled), + channel_mode: match raw.audio.channel_mode.as_deref() { + None => defaults.audio.channel_mode, + Some(s) => match parse_channel_mode(s) { + Ok(mode) => mode, + Err(e) => { + errors.push(e); + defaults.audio.channel_mode + } + }, + }, + stereo_separation: match raw.audio.stereo_separation { + None => defaults.audio.stereo_separation, + Some(v) if v <= 100 => v as u8, + Some(v) => { + errors.push(anyhow!("[audio] stereo_separation must be 0-100, got {v}")); + defaults.audio.stereo_separation + } + }, }; let (floppy, floppy_connected, floppy_playlists) = parse_floppy(raw.floppy)?; let overscan = match raw.display.overscan.as_deref() { @@ -3978,6 +4085,115 @@ mod tests { Ok(()) } + #[test] + fn audio_section_selects_output_device() -> Result<()> { + let cfg = parse_config("[audio]\noutput_device = \"External Speakers\"\n")?; + assert_eq!( + cfg.audio.output_device.as_deref(), + Some("External Speakers") + ); + + // A blank name means "use the system default". + let cfg = parse_config("[audio]\noutput_device = \" \"\n")?; + assert_eq!(cfg.audio.output_device, None); + + // Omitting it entirely is the default. + assert_eq!(parse_config("")?.audio.output_device, None); + // A pre-existing [audio] block that never mentions output_device still + // parses and leaves it None (system default) -- older configs are safe. + let cfg = parse_config("[audio]\nfloppy_sounds = true\nfloppy_sounds_volume = 80\n")?; + assert_eq!(cfg.audio.output_device, None); + Ok(()) + } + + #[test] + fn audio_output_enabled_defaults_true_and_parses() -> Result<()> { + // Default and older configs (no key) stay enabled. + assert!(parse_config("")?.audio.output_enabled); + assert!( + parse_config("[audio]\noutput_device = \"Speakers\"\n")? + .audio + .output_enabled + ); + // The GUI "Disabled" option persists as output_enabled = false. + assert!( + !parse_config("[audio]\noutput_enabled = false\n")? + .audio + .output_enabled + ); + assert!( + parse_config("[audio]\noutput_enabled = true\n")? + .audio + .output_enabled + ); + Ok(()) + } + + #[test] + fn cli_audio_device_overrides_config() -> Result<()> { + let overrides = ConfigOverrides { + audio_device: Some("BlackHole".to_string()), + ..Default::default() + }; + let cfg = load_overrides(&overrides)?; + assert_eq!(cfg.audio.output_device.as_deref(), Some("BlackHole")); + Ok(()) + } + + #[test] + fn audio_channel_mode_defaults_to_stereo_and_parses() -> Result<()> { + assert_eq!(parse_config("")?.audio.channel_mode, ChannelMode::Stereo); + assert_eq!( + parse_config("[audio]\nchannel_mode = \"mono\"\n")? + .audio + .channel_mode, + ChannelMode::Mono + ); + assert_eq!( + parse_config("[audio]\nchannel_mode = \"STEREO\"\n")? + .audio + .channel_mode, + ChannelMode::Stereo + ); + assert!(parse_config("[audio]\nchannel_mode = \"quad\"\n").is_err()); + + // CLI override. + let overrides = ConfigOverrides { + audio_channel_mode: Some("mono".to_string()), + ..Default::default() + }; + assert_eq!( + load_overrides(&overrides)?.audio.channel_mode, + ChannelMode::Mono + ); + Ok(()) + } + + #[test] + fn audio_stereo_separation_defaults_to_100_and_validates() -> Result<()> { + assert_eq!(parse_config("")?.audio.stereo_separation, 100); + assert_eq!( + parse_config("[audio]\nstereo_separation = 0\n")? + .audio + .stereo_separation, + 0 + ); + assert_eq!( + parse_config("[audio]\nstereo_separation = 60\n")? + .audio + .stereo_separation, + 60 + ); + assert!(parse_config("[audio]\nstereo_separation = 150\n").is_err()); + + let overrides = ConfigOverrides { + audio_stereo_separation: Some(20), + ..Default::default() + }; + assert_eq!(load_overrides(&overrides)?.audio.stereo_separation, 20); + Ok(()) + } + #[test] fn cli_midi_endpoint_implies_midi_mode() -> Result<()> { // Naming an endpoint is enough to switch the serial port to MIDI. diff --git a/src/emulator.rs b/src/emulator.rs index b557312..11d6a07 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -534,7 +534,13 @@ impl Emulator { /// timeline, so the real-time pacing anchor is re-baselined to "now"; on /// failure the running machine is untouched. pub fn load_state(&mut self, path: &std::path::Path) -> Result { + // Channel mode and stereo separation are host preferences, not part of + // the saved machine, so carry the current choices across the load. + let mono = self.bus_mut().paula.mono_output(); + let separation = self.bus_mut().paula.stereo_separation(); let loaded = crate::savestate::load(&mut self.machine, path)?; + self.bus_mut().paula.set_mono_output(mono); + self.bus_mut().paula.set_stereo_separation(separation); let reconfigured = loaded != self.descriptor; if reconfigured { let diffs = self.descriptor.differences(&loaded).join(", "); @@ -681,8 +687,14 @@ impl Emulator { /// coordinate to `pos`. The pacing anchor is re-baselined like a normal /// save-state load. fn restore_blob(&mut self, blob: &[u8], pos: u64) -> Result<()> { + // Preserve host-side channel mode + separation across the restore (see + // load_state). + let mono = self.bus_mut().paula.mono_output(); + let separation = self.bus_mut().paula.stereo_separation(); let mut cursor = std::io::Cursor::new(blob); self.machine.apply_state(&mut cursor)?; + self.bus_mut().paula.set_mono_output(mono); + self.bus_mut().paula.set_stereo_separation(separation); self.retired_instructions = pos; self.reset_live_audio_after_timeline_jump(); self.reanchor_realtime_clock(); @@ -1859,6 +1871,11 @@ pub fn build_machine( paula .drive_sounds_mut() .set_volume_percent(cfg.audio.floppy_sounds_volume); + paula.set_mono_output(cfg.audio.channel_mode.is_mono()); + paula.set_stereo_separation(f32::from(cfg.audio.stereo_separation) / 100.0); + if cfg.audio.channel_mode.is_mono() && cfg.audio.stereo_separation != 100 { + log::warn!("[audio] stereo_separation is ignored while channel_mode is mono"); + } let mut bus = Bus::new(mem, paula, floppy); bus.set_video_standard(cfg.video_standard); bus.set_chipset_revisions(cfg.agnus_revision, cfg.denise_revision); diff --git a/src/main.rs b/src/main.rs index d94c311..27cb973 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,6 +78,10 @@ pub struct CliArgs { /// Real-time stereo audio output through cpal. Enabled by default; /// `--noaudio` disables it, and `--audio-wav` selects WAV output. pub audio_live: bool, + /// Whether `--audio` was passed explicitly. When set, live audio is forced + /// on regardless of `[audio] output_enabled`; otherwise that config key (the + /// GUI "Disabled" option) can turn default-on audio off. + pub audio_live_forced: bool, /// `--audio-wav PATH`: dump the mixed stereo output to a WAV file /// (32-bit float, 44100 Hz). No live output. Useful for headless /// verification of the audio path. @@ -91,6 +95,8 @@ pub struct CliArgs { pub calibrate_gamepad: bool, /// `--list-midi`: print the host MIDI endpoints and exit. pub list_midi: bool, + /// `--list-audio-devices`: print the host audio output devices and exit. + pub list_audio_devices: bool, /// Command-line machine overrides (`--model`, `--chipset`, `--cpu`, /// `--fpu`/`--no-fpu`, `--cpu-clock`, `--chip`, `--fast`, `--slow`, /// `--floppy-drives`). @@ -246,6 +252,7 @@ where let mut live_audio_profile_secs: Option = None; let mut calibrate_gamepad = false; let mut list_midi = false; + let mut list_audio_devices = false; let mut overrides = ConfigOverrides::default(); let mut args = args.into_iter(); while let Some(a) = args.next() { @@ -256,6 +263,9 @@ where "--list-midi" => { list_midi = true; } + "--list-audio-devices" => { + list_audio_devices = true; + } "--config" | "-c" => { let v = args .next() @@ -341,6 +351,27 @@ where .ok_or_else(|| anyhow!("--midi-in requires a device name"))?, ); } + "--audio-device" => { + overrides.audio_device = Some( + args.next() + .ok_or_else(|| anyhow!("--audio-device requires a device name"))?, + ); + } + "--audio-channel-mode" => { + overrides.audio_channel_mode = Some( + args.next() + .ok_or_else(|| anyhow!("--audio-channel-mode requires stereo or mono"))?, + ); + } + "--audio-stereo-separation" => { + let v = args.next().ok_or_else(|| { + anyhow!("--audio-stereo-separation requires a percent (0-100)") + })?; + overrides.audio_stereo_separation = + Some(v.parse::().map_err(|_| { + anyhow!("--audio-stereo-separation must be a number 0-100") + })?); + } "--click-after" => { const USAGE: &str = "--click-after requires SECS BUTTON DURATION_MS"; let secs: f32 = next_arg(&mut args, USAGE, "--click-after SECS must be a number")?; @@ -590,10 +621,12 @@ where record_input, disk_insert_after, audio_live, + audio_live_forced: explicit_audio_live, audio_wav, live_audio_profile_secs, calibrate_gamepad, list_midi, + list_audio_devices, overrides, }) } @@ -658,6 +691,10 @@ fn print_help() { \x20 its configured disk image after SECS seconds\n \ --audio enable real-time stereo audio output via cpal (default)\n \ --noaudio disable real-time audio output\n \ + --audio-device NAME host audio output device (substring match)\n \ + --audio-channel-mode MODE output channels: stereo (default) or mono\n \ + --audio-stereo-separation PCT stereo width 0-100 (100 default, 0 = mono)\n \ + --list-audio-devices list host audio output devices and exit\n \ --audio-wav PATH dump mixed stereo audio to a 32-bit float WAV file\n \ \x20 instead of live output\n \ --profile-live-audio SECS run a no-window Paula-to-cpal profile workload;\n \ @@ -875,6 +912,28 @@ fn run_headless_benchmark(mut emu: Emulator, target_secs: f32) -> Result<()> { Ok(()) } +/// Whether to open a live audio sink. `[audio] output_enabled = false` (the GUI +/// "Disabled" option) silences default-on audio, but an explicit `--audio` +/// (`forced_on`) overrides it. `--noaudio` (which clears `audio_live`) and +/// `--audio-wav` still win; those are handled by the caller. +fn live_audio_enabled(audio_live: bool, forced_on: bool, config_enabled: bool) -> bool { + audio_live && (forced_on || config_enabled) +} + +/// Print the host audio output devices for `--list-audio-devices`. These are the +/// names `--audio-device` and `[audio] output_device` match against. +fn print_audio_output_devices() -> Result<()> { + println!("Audio output devices (for --audio-device / [audio] output_device):"); + let devices = copperline::audio::list_output_devices(); + if devices.is_empty() { + println!(" (none found)"); + } + for name in devices { + println!(" {name}"); + } + Ok(()) +} + /// Print the host MIDI endpoints for `--list-midi`. This is how a user finds the /// names `--midi-out`/`--midi-in` and `[serial]` expect. Without the `midi` /// feature it says how to get MIDI support rather than printing nothing. @@ -934,6 +993,9 @@ fn main() -> Result<()> { if cli.list_midi { return list_midi_endpoints(); } + if cli.list_audio_devices { + return print_audio_output_devices(); + } let (cfg, mut raw_cfg) = load_config(cli.config_path.as_deref(), &cli.overrides)?; if let Some(p) = &cli.rom_path { raw_cfg.rom = Some(p.to_string_lossy().into_owned()); @@ -997,11 +1059,25 @@ fn main() -> Result<()> { if realtime_priority { info!("priority: realtime-like thread scheduling requested (best effort)"); } + // `[audio] output_enabled = false` (the GUI "Disabled" option) silences + // default-on audio, but an explicit `--audio` still forces it on and + // `--noaudio`/`--audio-wav` still win. CLI flags are unchanged. + let live_audio = live_audio_enabled( + cli.audio_live, + cli.audio_live_forced, + cfg.audio.output_enabled, + ); let audio: Box = if let Some(ref wav_path) = cli.audio_wav { Box::new(WavSink::new(wav_path)?) - } else if cli.audio_live { - Box::new(CpalSink::new(realtime_priority)?) + } else if live_audio { + Box::new(CpalSink::new( + realtime_priority, + cfg.audio.output_device.as_deref(), + )?) } else { + // Log the silent path so `--noaudio` (or an output_enabled=false config) + // is visible alongside the "cpal sink ready" line the live path prints. + info!("audio: disabled (null sink); no sound"); Box::new(NullSink) }; // Headless capture runs (screenshot / frame dump) advance the @@ -1071,6 +1147,7 @@ fn main() -> Result<()> { cfg.joystick_input_mode, config::about_machine_lines(&cfg), raw_cfg, + live_audio, ); // Elevate the thread that is about to run the event loop and the pacer. @@ -1135,6 +1212,9 @@ fn run_configuration_screen(raw_cfg: config::RawConfig) -> Result<()> { info!("no machine specified; opening the configuration screen"); let emu = build_placeholder_machine()?; video::set_pixel_aspect(config::resolve_pixel_aspect(config::PixelAspect::Tv)); + // The placeholder is always silent; seed the session's audio from the config + // intent so a state loaded over the launcher gets the configured output. + let audio_output_enabled = raw_cfg.audio_output_enabled(); let mut app = App::new( emu, false, @@ -1155,6 +1235,7 @@ fn run_configuration_screen(raw_cfg: config::RawConfig) -> Result<()> { config::JoystickInputMode::default(), vec!["Configure a machine, then press Run.".to_string()], raw_cfg, + audio_output_enabled, ); app.open_launcher(); app.run() @@ -1190,8 +1271,9 @@ fn run_live_audio_profile(secs: f32) -> Result<()> { "audio profile mode: running Paula DMA to cpal for {:.3}s without window rendering", secs ); - // This diagnostic mode loads no config, so the realtime knob is env-only. - let audio = Box::new(CpalSink::new(priority::requested(false))?); + // This diagnostic mode loads no config, so the realtime knob is env-only + // and it always uses the default output device. + let audio = Box::new(CpalSink::new(priority::requested(false), None)?); let mut paula = Paula::new(Box::new(StdoutSink::new()), audio); paula.set_led_filter_enabled(true); @@ -1444,6 +1526,26 @@ mod tests { Ok(()) } + #[test] + fn explicit_audio_marks_forced() -> Result<()> { + assert!(!parse(&[])?.audio_live_forced); + assert!(parse(&["--audio"])?.audio_live_forced); + assert!(!parse(&["--noaudio"])?.audio_live_forced); + Ok(()) + } + + #[test] + fn config_disable_silences_default_audio_but_not_explicit_audio() { + // No CLI audio flag: the config's output_enabled decides. + assert!(live_audio_enabled(true, false, true)); + assert!(!live_audio_enabled(true, false, false)); + // Explicit --audio forces sound on even if the config disabled it. + assert!(live_audio_enabled(true, true, false)); + // --noaudio (clears audio_live) always wins. + assert!(!live_audio_enabled(false, false, true)); + assert!(!live_audio_enabled(false, true, true)); + } + #[test] fn audio_wav_selects_wav_output_without_live_audio() -> Result<()> { let args = parse(&["--audio-wav", "/tmp/out.wav"])?; diff --git a/src/video/launcher.rs b/src/video/launcher.rs index d67c3b4..732fedd 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -22,9 +22,9 @@ use crate::chipset::agnus::{AgnusRevision, VideoStandard}; use crate::chipset::denise::DeniseRevision; use crate::config::{ - format_size, machine_profile_defaults, Chipset, Config, CpuModel, JoystickInputMode, - MachineModel, Overscan, PacingBudget, PixelAspect, RawConfig, RawDrive, RawFloppyDrive, - RawZorroBoard, SerialMode, WarpSpeed, + format_size, machine_profile_defaults, ChannelMode, Chipset, Config, CpuModel, + JoystickInputMode, MachineModel, Overscan, PacingBudget, PixelAspect, RawConfig, RawDrive, + RawFloppyDrive, RawZorroBoard, SerialMode, WarpSpeed, }; use crate::zorro::{ConfigOption, ConfigOptionKind, LoadedZorroBoard}; use anyhow::Result; @@ -269,6 +269,9 @@ pub enum LauncherField { #[cfg(feature = "midi")] MidiIn, // A/V and emulation + AudioDevice, + AudioChannelMode, + AudioStereoSeparation, Overscan, PixelAspect, Phosphor, @@ -373,7 +376,10 @@ const SERIAL_ROWS: [Row; 3] = [ row(F::MidiIn, "MIDI input", Cycle), row(F::MidiOut, "MIDI output", Cycle), ]; -const AV_EMULATION_ROWS: [Row; 10] = [ +const AV_EMULATION_ROWS: [Row; 13] = [ + row(F::AudioDevice, "Audio output", Cycle), + row(F::AudioChannelMode, "Channel mode", Cycle), + row(F::AudioStereoSeparation, "Stereo separation", Cycle), row(F::Overscan, "Overscan", Cycle), row(F::PixelAspect, "Pixel aspect", Cycle), row(F::Phosphor, "Phosphor", Cycle), @@ -493,6 +499,11 @@ const JOYSTICK_MODES: [JoystickInputMode; 2] = #[cfg(feature = "midi")] const SERIAL_MODES: [SerialMode; 3] = [SerialMode::Off, SerialMode::Stdout, SerialMode::Midi]; +/// Stereo-separation presets the picker steps through (percent), ascending so +/// the right arrow steps up (wrapping 100 -> 0) and the left arrow steps down. +/// The config/CLI accept any 0-100; an off-grid value snaps to the nearest here. +const STEREO_SEPARATION_STEPS: [usize; 11] = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + /// A fully-typed, editable mirror of a configurable machine. See the module /// docs for how it round-trips through [`RawConfig`]. #[derive(Debug, Clone)] @@ -553,6 +564,16 @@ pub struct MachineSetup { #[cfg(feature = "midi")] midi_endpoints: crate::midi::MidiEndpoints, // A/V and emulation + /// Host audio output selection: system default, a named device, or Disabled + /// (no sound). Carried in every build so `[audio]` round-trips. + audio_output: crate::audio::AudioOutput, + /// Output device names for the picker: filled when the screen opens and + /// re-read each time the field is cycled, so a reconnected device appears. + audio_devices: Vec, + /// Stereo (hardware panning) or mono (L/R averaged). + audio_channel_mode: ChannelMode, + /// Stereo width, 0-100 (100 = full hardware panning). + audio_stereo_separation: u8, overscan: Overscan, pixel_aspect: PixelAspect, phosphor: f32, @@ -635,6 +656,14 @@ impl MachineSetup { // config screen fills it via refresh_midi_endpoints on open. #[cfg(feature = "midi")] midi_endpoints: crate::midi::MidiEndpoints::default(), + audio_output: crate::audio::AudioOutput::from_config( + cfg.audio.output_enabled, + cfg.audio.output_device.as_deref(), + ), + // Filled by refresh_audio_devices on open, like the MIDI endpoints. + audio_devices: Vec::new(), + audio_channel_mode: cfg.audio.channel_mode, + audio_stereo_separation: cfg.audio.stereo_separation, overscan: cfg.overscan, pixel_aspect: cfg.pixel_aspect, phosphor: cfg.phosphor, @@ -674,6 +703,21 @@ impl MachineSetup { self.midi_endpoints = crate::midi::enumerate(); } + /// Re-read the host audio output devices for the "Audio output" picker. + pub fn refresh_audio_devices(&mut self) { + self.audio_devices = crate::audio::picker_output_devices(); + } + + /// Re-read every host device list (MIDI endpoints + audio outputs) for the + /// pickers. Call after (re)building the setup -- e.g. loading a config or + /// resetting to defaults -- so the pickers show what is connected now + /// instead of an empty list that can only land on "Default"/"None". + pub fn refresh_host_devices(&mut self) { + #[cfg(feature = "midi")] + self.refresh_midi_endpoints(); + self.refresh_audio_devices(); + } + /// The bare-profile config this setup is compared against when emitting /// minimal TOML: the machine the selected profile produces with no /// overrides, resolved through the same `TryFrom` as a real boot so the @@ -836,6 +880,17 @@ impl MachineSetup { } raw.serial.midi_out = self.midi_out.clone(); raw.serial.midi_in = self.midi_in.clone(); + // The Audio output picker is one of default / a named device / Disabled. + // A named device sets output_device; Disabled sets output_enabled=false + // (the resolved default is true, so it is omitted otherwise). + raw.audio.output_device = self.audio_output.device().map(str::to_string); + raw.audio.output_enabled = (!self.audio_output.is_enabled()).then_some(false); + // Emit only the non-default mode; Stereo is the resolved default, so + // omitting it keeps a default machine's TOML minimal. + raw.audio.channel_mode = (self.audio_channel_mode != ChannelMode::Stereo) + .then(|| self.audio_channel_mode.label().to_string()); + raw.audio.stereo_separation = (self.audio_stereo_separation != 100) + .then_some(u16::from(self.audio_stereo_separation)); // Zorro boards: emit the metadata path plus any per-board overrides // (typed per the option schema), only when the user changed something. raw.zorro = self @@ -980,6 +1035,16 @@ impl MachineSetup { F::Df3Image | F::Df3WriteProtect => reason(self.floppy_drives >= 4, "drive off"), #[cfg(feature = "midi")] F::MidiOut | F::MidiIn => reason(self.serial_mode == SerialMode::Midi, "MIDI off"), + // Channel mode and separation shape the output, so they do nothing + // once audio is disabled; separation also does nothing in mono. + F::AudioChannelMode => reason(self.audio_output.is_enabled(), "off"), + F::AudioStereoSeparation => { + if !self.audio_output.is_enabled() { + Some("off") + } else { + reason(self.audio_channel_mode != ChannelMode::Mono, "mono") + } + } _ => None, } } @@ -1148,6 +1213,12 @@ impl MachineSetup { F::MidiOut => self.midi_out.clone().unwrap_or_else(|| "None".to_string()), #[cfg(feature = "midi")] F::MidiIn => self.midi_in.clone().unwrap_or_else(|| "None".to_string()), + F::AudioDevice => self.audio_output.label().to_string(), + F::AudioChannelMode => match self.audio_channel_mode { + ChannelMode::Stereo => "Stereo".to_string(), + ChannelMode::Mono => "Mono".to_string(), + }, + F::AudioStereoSeparation => format!("{}%", self.audio_stereo_separation), // Path/drive fields: the file name, or a placeholder. F::Rom => self.path_label(field, "(bundled AROS)"), _ if rows_contains_kind(field, RowKind::Path) @@ -1234,6 +1305,25 @@ impl MachineSetup { F::MidiOut => cycle_endpoint(&mut self.midi_out, &self.midi_endpoints.outputs, forward), #[cfg(feature = "midi")] F::MidiIn => cycle_endpoint(&mut self.midi_in, &self.midi_endpoints.inputs, forward), + F::AudioDevice => { + // Re-read on each step so a device connected since the screen + // opened appears; on-demand only, so no background polling. + self.refresh_audio_devices(); + self.audio_output = self.audio_output.cycle(&self.audio_devices, forward); + } + F::AudioChannelMode => { + self.audio_channel_mode = match self.audio_channel_mode { + ChannelMode::Stereo => ChannelMode::Mono, + ChannelMode::Mono => ChannelMode::Stereo, + } + } + F::AudioStereoSeparation => { + self.audio_stereo_separation = cycle_nearest( + &STEREO_SEPARATION_STEPS, + usize::from(self.audio_stereo_separation), + forward, + ) as u8 + } _ => {} } } @@ -1409,12 +1499,10 @@ pub struct LauncherState { impl LauncherState { pub fn new(setup: MachineSetup) -> Self { - #[cfg_attr(not(feature = "midi"), allow(unused_mut))] let mut setup = setup; - // Read the host MIDI devices once, as the screen opens, so the pickers - // show what is connected now. - #[cfg(feature = "midi")] - setup.refresh_midi_endpoints(); + // Read the host devices as the screen opens so the pickers show what is + // connected now. + setup.refresh_host_devices(); Self { setup, tab: LauncherTab::System, @@ -1972,6 +2060,82 @@ mod tests { assert_eq!(back.midi_out.as_deref(), Some("USB MIDI")); } + #[test] + fn stereo_separation_cycles_up_on_right_and_greys_out_in_mono() { + let mut s = MachineSetup::default(); + assert_eq!(s.audio_stereo_separation, 100); + assert_eq!( + s.disabled_reason(LauncherField::AudioStereoSeparation), + None + ); + + // Right arrow (forward) steps up in 10s, wrapping 100 -> 0 -> 10. + s.cycle(LauncherField::AudioStereoSeparation, true); + assert_eq!(s.audio_stereo_separation, 0); + s.cycle(LauncherField::AudioStereoSeparation, true); + assert_eq!(s.audio_stereo_separation, 10); + + // Left arrow (backward) from 100 steps down to 90. + let mut s = MachineSetup::default(); + s.cycle(LauncherField::AudioStereoSeparation, false); + assert_eq!(s.audio_stereo_separation, 90); + + // Once the output is mono, separation is greyed out. + s.cycle(LauncherField::AudioChannelMode, true); + assert_eq!(s.audio_channel_mode, ChannelMode::Mono); + assert_eq!( + s.disabled_reason(LauncherField::AudioStereoSeparation), + Some("mono") + ); + } + + #[test] + fn disabled_audio_greys_out_channel_mode_and_separation() { + use crate::audio::AudioOutput; + let mut s = MachineSetup::default(); + // Enabled: channel mode is active, separation active in stereo. + assert_eq!(s.disabled_reason(LauncherField::AudioChannelMode), None); + assert_eq!( + s.disabled_reason(LauncherField::AudioStereoSeparation), + None + ); + + // Disabled audio greys both shaping controls. + s.audio_output = AudioOutput::Disabled; + assert_eq!( + s.disabled_reason(LauncherField::AudioChannelMode), + Some("off") + ); + assert_eq!( + s.disabled_reason(LauncherField::AudioStereoSeparation), + Some("off") + ); + } + + #[test] + fn audio_output_disabled_round_trips_through_raw_config() { + use crate::audio::AudioOutput; + let mut s = MachineSetup::default(); + // Default is the resolved default, so it emits nothing. + assert_eq!(s.value_label(LauncherField::AudioDevice), "Default"); + let raw = s.to_raw(); + assert_eq!(raw.audio.output_enabled, None); + assert_eq!(raw.audio.output_device, None); + + // "Disabled" persists as output_enabled = false, no device. + s.audio_output = AudioOutput::Disabled; + assert_eq!(s.value_label(LauncherField::AudioDevice), "Disabled"); + let raw = s.to_raw(); + assert_eq!(raw.audio.output_enabled, Some(false)); + assert_eq!(raw.audio.output_device, None); + + // A named device persists as output_device, with output_enabled omitted. + s.audio_output = AudioOutput::Device("BlackHole".to_string()); + let raw = s.to_raw(); + assert_eq!(raw.audio.output_device.as_deref(), Some("BlackHole")); + assert_eq!(raw.audio.output_enabled, None); + } + #[cfg(feature = "midi")] #[test] fn midi_device_rows_are_disabled_off_midi_mode() { diff --git a/src/video/ui.rs b/src/video/ui.rs index ea8abc2..73b486f 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -84,6 +84,7 @@ pub enum MenuItem { #[cfg(feature = "midi")] MidiOutput, PixelAspect, + AudioOutput, Warp, WarpLimit, Record, @@ -98,14 +99,15 @@ pub enum MenuItem { /// serial port is in MIDI mode, so the list is built per open rather than fixed. pub fn menu_items(midi_active: bool) -> Vec { let _ = midi_active; - // 7 leading + up to 2 MIDI + 9 trailing items, sized so appending never + // 7 leading + up to 2 MIDI + 10 trailing items, sized so appending never // reallocates. - let mut items = Vec::with_capacity(18); + let mut items = Vec::with_capacity(19); items.extend([ MenuItem::MachineConfig, MenuItem::FrameAnalyzer, MenuItem::Debugger, MenuItem::Console, + MenuItem::AudioOutput, MenuItem::Calibration, MenuItem::JoystickInput, ]); @@ -143,6 +145,9 @@ pub struct MenuLabels<'a> { pub midi_in: &'a str, #[cfg_attr(not(feature = "midi"), allow(dead_code))] pub midi_out: &'a str, + /// Current audio output label: "Default", a device name, or "Disabled" + /// (empty is treated as "Default"). + pub audio_output: &'a str, } fn menu_item_label(item: MenuItem, s: MenuLabels) -> String { @@ -165,6 +170,14 @@ fn menu_item_label(item: MenuItem, s: MenuLabels) -> String { MenuItem::MidiInput => format!("MIDI In [{}]", clip_menu_value(s.midi_in)), #[cfg(feature = "midi")] MenuItem::MidiOutput => format!("MIDI Out [{}]", clip_menu_value(s.midi_out)), + MenuItem::AudioOutput => { + let name = if s.audio_output.is_empty() { + "Default" + } else { + s.audio_output + }; + format!("Audio Out [{}]", clip_menu_value(name)) + } MenuItem::Warp if s.warp => "Warp Speed [on]".to_string(), MenuItem::Warp => "Warp Speed [off]".to_string(), // Right-pad so the closing bracket stays put as the value width @@ -186,10 +199,10 @@ fn menu_item_label(item: MenuItem, s: MenuLabels) -> String { } } -/// Clip a device name so the "MIDI In [name]" label stays within the popup. -#[cfg(feature = "midi")] +/// Clip a device name so a "MIDI Out [name]" / "Audio Out [name]" label stays +/// within the popup. fn clip_menu_value(name: &str) -> String { - const MAX: usize = MENU_MAX_LABEL_CHARS - 11; // "MIDI Out [" plus "]" + const MAX: usize = MENU_MAX_LABEL_CHARS - 12; // widest prefix "Audio Out [" plus "]" if name.chars().count() <= MAX { return name.to_string(); } @@ -3644,16 +3657,25 @@ fn draw_launcher_row( scale, ); // Greyed: explain why instead of drawing controls (e.g. "needs 32-bit CPU"). + // The audio shaping rows are the exception -- channel mode and separation are + // merely inapplicable (audio disabled, or separation in mono), so the greyed + // label alone says enough and column 2 is left blank. + let blank_when_greyed = matches!( + r.field, + LauncherField::AudioChannelMode | LauncherField::AudioStereoSeparation + ); if let Some(reason) = reason { - draw_panel_text( - frame, - launcher_control_x(rect), - row_y + 8, - reason, - PANEL_TEXT_DIM, - 1, - scale, - ); + if !blank_when_greyed { + draw_panel_text( + frame, + launcher_control_x(rect), + row_y + 8, + reason, + PANEL_TEXT_DIM, + 1, + scale, + ); + } return; } match r.kind { @@ -4377,7 +4399,9 @@ mod tests { ui.control_at(pos, false), Some(UiControl::MenuItem(MenuItem::MachineConfig)) ); - let joystick = menu_item_rect(5, n); + // Leading block is MachineConfig, FrameAnalyzer, Debugger, Console, + // AudioOutput, Calibration, so Joystick Input sits at index 6. + let joystick = menu_item_rect(6, n); let pos = (joystick.x as i32 + 4, joystick.y as i32 + 4); assert_eq!( ui.control_at(pos, false), @@ -4415,6 +4439,7 @@ mod tests { pixel_aspect: aspect, midi_in: long, midi_out: long, + audio_output: long, }; let label = menu_item_label(item, labels); let text_w = @@ -4950,6 +4975,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); let menu = menu_rect(menu_items(false).len()); @@ -4988,6 +5014,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5014,6 +5041,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5057,6 +5085,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5110,6 +5139,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5160,6 +5190,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5262,6 +5293,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5326,6 +5358,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5401,6 +5434,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5513,6 +5547,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5555,6 +5590,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5588,6 +5624,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5661,6 +5698,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5701,6 +5739,7 @@ mod tests { pixel_aspect: PixelAspect::Tv, midi_in: "", midi_out: "", + audio_output: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); diff --git a/src/video/window.rs b/src/video/window.rs index 0dd1b9c..797127a 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -607,6 +607,17 @@ pub struct App { /// Whether the serial port is bridged to MIDI, so the runtime menu offers /// the device items. Fixed for the machine's life. serial_is_midi: bool, + /// Host audio output selection for machines started from this session: + /// system default, a named device (from `[audio] output_device` / + /// `--audio-device`), or Disabled (no sound, GUI-only). A session-level + /// setting: the config-screen launcher rebuilds the machine config from its + /// own fields, so this is held here rather than read back from that config. + audio_output: crate::audio::AudioOutput, + /// The session's `[emulation] realtime_priority` request (config value, before + /// the env override). Re-fed to `priority::requested` whenever the audio sink + /// is rebuilt live (device switch, disconnect recovery, post-load install) so + /// the new stream/callback thread keeps the same scheduling as the first sink. + realtime_priority: bool, /// Output frame-skip level for warp/turbo mode: how many emulated frames /// are retired per presented frame while warp is engaged. Presentation is /// vsync-gated, so this is what decouples warp speed from the host monitor @@ -833,6 +844,10 @@ impl App { joystick_input_mode: JoystickInputMode, about_machine_lines: Vec, machine_config: RawConfig, + // Effective live-audio state for this machine: for a real machine the + // caller's --audio/--noaudio-resolved value; for the config-screen + // placeholder the config intent (so a state loaded over it gets sound). + audio_output_enabled: bool, ) -> Self { // Headless capture runs drive themselves off emulated time, so a // powered-off start would simply hang. Force power on for those. @@ -854,9 +869,23 @@ impl App { }; #[cfg(not(feature = "midi"))] let serial_is_midi = false; + // `audio_output_enabled` is the effective state the caller resolved + // (--audio/--noaudio applied over output_enabled for a real machine, or + // the config intent for the silent config-screen placeholder), so the + // menu label matches what is actually running. from_config treats a + // blank device name as the default. + let audio_output = crate::audio::AudioOutput::from_config( + audio_output_enabled, + machine_config.audio.output_device.as_deref(), + ); + // Config's realtime-priority request, re-fed to priority::requested when + // the audio sink is rebuilt live so those streams keep the same setting. + let realtime_priority = machine_config.emulation.realtime_priority.unwrap_or(false); Self { emu, serial_is_midi, + audio_output, + realtime_priority, fb: vec![0u32; MAX_FB_PIXELS], deinterlacer: Deinterlacer::with_phosphor(phosphor), present_fb: vec![0u32; FB_WIDTH * OUT_HEIGHT], @@ -1035,6 +1064,28 @@ impl App { } } + /// Step the live audio output through "Default", the host devices, then + /// "Disabled", rebuilding the sink so the change takes effect at once. + /// "Disabled" swaps in a null sink -- live audio off, exactly like + /// `--noaudio`. Freshly re-reads the device list so a just-connected device + /// appears. + fn cycle_audio_output(&mut self) { + let devices = crate::audio::picker_output_devices(); + self.audio_output = self.audio_output.cycle(&devices, true); + let realtime = crate::priority::requested(self.realtime_priority); + match crate::audio::open_output_sink(realtime, &self.audio_output) { + Ok(sink) => { + self.emu.bus_mut().paula.audio = sink; + self.sync_live_audio_suspension(); + } + Err(e) => { + warn!("audio: could not open the selected device; keeping silence: {e:#}"); + self.emu.bus_mut().paula.audio = Box::new(crate::audio::NullSink); + } + } + self.show_osd(format!("Audio output: {}", self.audio_output.label())); + } + fn set_joystick_input_mode(&mut self, mode: JoystickInputMode) { if self.joystick_input_mode == mode { return; @@ -1697,6 +1748,7 @@ impl ApplicationHandler for App { pixel_aspect: super::pixel_aspect(), midi_in: &midi_in_label, midi_out: &midi_out_label, + audio_output: self.audio_output.label(), }, ); if let Err(e) = r.pixels.render() { @@ -1784,6 +1836,9 @@ impl ApplicationHandler for App { // when Agnus has crossed into a new frame; the expensive renderer // reconstructs a completed hardware frame, not an instruction slice. if running { + // If the live output device vanished (unplugged), reopen on the + // current default so sound continues. + self.recover_audio_if_device_lost(); // Presentation is vsync-gated, so emulating exactly one frame per // presented frame would cap warp at the host monitor refresh rate // (about 1.2x for 50 Hz PAL on a 60 Hz display). In warp, retire @@ -2679,6 +2734,7 @@ impl App { #[cfg(feature = "midi")] ui::MenuItem::MidiOutput => self.cycle_midi_output(), ui::MenuItem::PixelAspect => self.toggle_pixel_aspect(), + ui::MenuItem::AudioOutput => self.cycle_audio_output(), ui::MenuItem::Warp => self.toggle_warp(), ui::MenuItem::WarpLimit => self.cycle_warp_speed(), ui::MenuItem::Record => self.toggle_recording(), @@ -2865,6 +2921,7 @@ impl App { let model = state.setup.model(); state.setup = MachineSetup::default(); state.setup.select_model(model); + state.setup.refresh_host_devices(); state.status = Some(StatusMessage::ok("Reset to defaults")); } } @@ -3672,6 +3729,9 @@ impl App { Ok(setup) => { if let Some(state) = self.launcher_state_mut() { state.setup = setup; + // Re-read host device lists so the loaded setup's pickers + // are populated, not stuck on "Default"/"None". + state.setup.refresh_host_devices(); state.status = Some(StatusMessage::ok(format!( "Loaded {}", display_file_name(&path) @@ -3747,17 +3807,27 @@ impl App { self.set_launcher_status(StatusMessage::err(short_status_error(&e))); return; } - let realtime = crate::priority::requested(cfg.emulation.realtime_priority); - let audio: Box = match CpalSink::new(realtime) { - Ok(sink) => Box::new(sink), - Err(e) => { - self.set_launcher_status(StatusMessage::err(format!( - "Audio init failed: {}", - short_status_error(&e) - ))); - return; - } - }; + // Remember the session's realtime request so later live sink rebuilds + // (device switch, disconnect recovery) reuse it. + self.realtime_priority = cfg.emulation.realtime_priority; + let realtime = crate::priority::requested(self.realtime_priority); + // The launcher's Audio output picker drives the session selection + // (default device, a named device, or Disabled). + self.audio_output = crate::audio::AudioOutput::from_config( + cfg.audio.output_enabled, + cfg.audio.output_device.as_deref(), + ); + let audio: Box = + match crate::audio::open_output_sink(realtime, &self.audio_output) { + Ok(sink) => sink, + Err(e) => { + self.set_launcher_status(StatusMessage::err(format!( + "Audio init failed: {}", + short_status_error(&e) + ))); + return; + } + }; // The launcher boots a fresh machine, never a save state, so a real // ROM is required here. let emu = match crate::emulator::build_machine(&cfg, audio, true, false) { @@ -5845,9 +5915,12 @@ impl App { /// load -- gets real sound. On audio-init failure the state stays loaded and /// the machine simply runs without sound, exactly as a failed Run does. fn install_live_audio_after_placeholder_load(&mut self) { - match CpalSink::new(crate::priority::requested(false)) { + match crate::audio::open_output_sink( + crate::priority::requested(self.realtime_priority), + &self.audio_output, + ) { Ok(sink) => { - self.emu.bus_mut().paula.audio = Box::new(sink); + self.emu.bus_mut().paula.audio = sink; // Apply the current suspension state to the freshly installed // stream (it should be live now: powered on and not paused). self.sync_live_audio_suspension(); @@ -5858,6 +5931,37 @@ impl App { } } + /// If the live output device was lost mid-run (unplugged, or the system + /// default switched away), rebuild the sink on the current default output and + /// reset the session's selected device to Default (so the runtime menu shows + /// it) so sound continues. The cpal error callback only flags the loss; the + /// stream is rebuilt here on the main thread, where creating a (macOS + /// `!Send`) cpal stream is allowed. Falls back to a silent sink if no device + /// can be opened, so this never spins retrying a dead machine. + fn recover_audio_if_device_lost(&mut self) { + if !self.emu.bus().paula.audio.device_lost() { + return; + } + warn!("audio: output device lost; falling back to the default output device"); + // The named device is gone, so the session is back on the default; reset + // the selection so the runtime menu reflects "Default" too. (A disabled + // sink never reports a lost device, so we can only get here from a device.) + self.audio_output = crate::audio::AudioOutput::Default; + // Reopen on the system default, not the previously named device, which + // is the one that went away. + match CpalSink::new(crate::priority::requested(self.realtime_priority), None) { + Ok(sink) => { + self.emu.bus_mut().paula.audio = Box::new(sink); + self.sync_live_audio_suspension(); + self.show_osd("Audio device lost! Switched to Default".to_string()); + } + Err(e) => { + warn!("audio: no fallback output device; continuing without sound: {e:#}"); + self.emu.bus_mut().paula.audio = Box::new(crate::audio::NullSink); + } + } + } + fn finish_host_io_pause(&mut self) { self.emu.reanchor_realtime_clock(); self.sync_live_audio_suspension(); diff --git a/src/video/window/tests.rs b/src/video/window/tests.rs index 6085c08..32e33cb 100644 --- a/src/video/window/tests.rs +++ b/src/video/window/tests.rs @@ -1663,6 +1663,7 @@ fn test_app_with_audio(audio: Box) -> super::App { crate::config::JoystickInputMode::Gamepad, vec!["Machine: test".to_string()], crate::config::RawConfig::default(), + true, ) }