From 9e1f0f7d8120665ef554e2085d90346b2e8d848e Mon Sep 17 00:00:00 2001 From: atomchild411 <143453386+atomchild411@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:52:21 -0800 Subject: [PATCH] audio: [audio] host picks the host audio system; PulseAudio on request `[audio] host` (`--audio-host`): "default" keeps each platform's usual host (CoreAudio, WASAPI, and ALSA on Linux, as before); "alsa"; "pulseaudio" plays through cpal's own PulseAudio client, which PipeWire answers too through pipewire-pulse, and falls back to ALSA when no server answers. The client is cpal's `pulseaudio` feature, pure Rust over the server's socket: the build links nothing new. Why: on some Linux setups cpal's ALSA backend spins a whole core. In a Debian 12 arm64 VM its worker returned from poll() about five million times a second (the descriptor stays ready while avail < avail_min), through PipeWire's ALSA plugin and the card's own device alike, while aplay with the same sizes slept. The starved emulator underran constantly, and an IP28 once sat in its power-on diagnostics. The PulseAudio client played the same stream on 0.01 s of CPU in 5 s. The default stays ALSA until that is confirmed on real hardware. The feature makes cpal's own default_host() prefer PulseAudio whenever its socket exists, so HAL2 now picks the host by id (output_host). `[audio] cpal_buffer_frames` was parsed but never reached HAL2 (HPC3 dropped the config); it now sets the host buffer size, falling back to the host's default if the device refuses it. Unset, PulseAudio gets a 512-frame period (cpal targets two, ~21 ms at 48 kHz) instead of its own default target of about two seconds, which made IRIX's sound arrive that late. `prebuf_ms` is still not used. iris-gui: a Host audio choice in the Audio section, on Linux. Docs: FEATURES.md, HELP.md, iris.toml, docs/hal2.md, CHANGELOG, rules/testing/cpal-alsa-busy-poll.md. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++ Cargo.toml | 4 + FEATURES.md | 6 +- HELP.md | 4 +- docs/hal2.md | 7 +- iris-gui/src/config_ui.rs | 18 ++- iris.toml | 3 +- rules/testing/cpal-alsa-busy-poll.md | 32 ++++ src/config.rs | 41 ++++- src/dev/hal2.rs | 228 +++++++++++++++++---------- src/dev/hpc3.rs | 3 +- 11 files changed, 267 insertions(+), 94 deletions(-) create mode 100644 rules/testing/cpal-alsa-busy-poll.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e142bd3..642e5cd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,21 @@ hashes are given where a change is easiest to understand by reading the commit. ### Build / features +- **`[audio] host` picks the host audio system; on Linux, PulseAudio on + request.** `host = "pulseaudio"` (or `--audio-host pulseaudio`) plays + through cpal's own PulseAudio client, which PipeWire answers too through + pipewire-pulse; the client is pure Rust, so the build links nothing new. + The default is unchanged: ALSA on Linux. On some setups cpal's ALSA + backend spins a whole core (a Debian 12 arm64 VM, through PipeWire's ALSA + plugin and the card's own device alike: the poll descriptor stays ready + while there is no room to write), starving the emulator into a stream of + underruns, while the PulseAudio client used 0.01 s of CPU in 5 s. See + `rules/testing/cpal-alsa-busy-poll.md`. `[audio] cpal_buffer_frames`, + which was parsed but never reached HAL2, now sets the host buffer size; a + size the device refuses falls back to the host's default. Unset, + PulseAudio gets 512 frames (two of them, ~21 ms at 48 kHz) instead of its + own default target of about two seconds, which made IRIX's sound arrive + that late. - **`gpujit` turns on every graphics JIT** (`rex-jit` and `gr4-jit`) and is the recommended build: `--features lightning,gpujit`. The GUI and the release binaries now carry it, so IMPACT gets its raster JIT out of the diff --git a/Cargo.toml b/Cargo.toml index 9b7ce2f2..5db48197 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -305,6 +305,10 @@ iris-hostgl = { path = "iris-hostgl", optional = true } nokhwa = { version = "0.10", features = ["input-avfoundation"] } [target.'cfg(target_os = "linux")'.dependencies] +# `pulseaudio`: cpal's own PulseAudio client (pure Rust, links nothing), for +# `[audio] host = "pulseaudio"`. HAL2 picks the host itself, so the default +# stays ALSA even though this feature makes cpal's default_host() prefer it. +cpal = { version = "0.18", features = ["pulseaudio"] } nokhwa = { version = "0.10" } v4l = { version = "0.14", features = ["v4l2-sys"] } diff --git a/FEATURES.md b/FEATURES.md index 9e376009..a1637bf9 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -236,8 +236,9 @@ Omit `[nfs]` to disable the share; the default forward list is empty. | Key | Default | Values and behavior | |---|---|---| -| `audio.prebuf_ms` | `20` | HAL2 prebuffer duration in milliseconds. | -| `audio.cpal_buffer_frames` | `host default when unset` | Requested host cpal buffer size in frames. | +| `audio.host` | `"default"` | Host audio system: `"default"` (CoreAudio on macOS, WASAPI on Windows, ALSA on Linux), `"alsa"`, or `"pulseaudio"` (Linux: a PulseAudio server or PipeWire's pipewire-pulse, through cpal's own client; ALSA if none answers). `--audio-host` overrides. | +| `audio.prebuf_ms` | `20` | HAL2 prebuffer duration in milliseconds. Not used yet: HAL2 prebuffers 20 ms. | +| `audio.cpal_buffer_frames` | `host default when unset` | Host buffer size in frames (a period; PulseAudio buffers two). Unset: the host's default, except PulseAudio's (about 2 s), which gets 512. A size the device refuses falls back to the default. | ### [jitv2] @@ -337,6 +338,7 @@ iris-gui does not parse this standalone option set; use its named-machine UI. | `--2x` | 2× window scaling for HiDPI/4K monitors | | `--headless` | Run headless: no window, no REX3 graphics (audio unaffected; use --noaudio to disable) | | `--noaudio` | Disable audio emulation (no HAL2); graphics still works | +| `--audio-host ` | Host audio system for HAL2: `default`, `alsa` or `pulseaudio` (overrides `[audio] host`) | | `--nfs-dir` | Enable NFS share: path to the directory to export (enables NFS) | | `--nat-subnet` | NAT subnet in CIDR notation (e.g. 192.168.5.0/24). Gateway gets .1, guest (IRIX) gets .2. Default: 192.168.0.0/24. | | `--net-mode` | Networking backend: "nat" (default, software gateway) or "pcap" (bridge onto a real host interface; requires --features pcap). | diff --git a/HELP.md b/HELP.md index c813c4ef..83d39e76 100644 --- a/HELP.md +++ b/HELP.md @@ -537,8 +537,9 @@ standard = "ntsc" # or "pal" camera_index = 0 # host camera, source = "camera" only [audio] +# host = "default" # Linux: "alsa" (the default) or "pulseaudio" (PulseAudio or PipeWire) prebuf_ms = 20 -# cpal_buffer_frames = 512 +# cpal_buffer_frames = 512 # unset: the host's default (PulseAudio: 512) # [ultra64] # N64 dev board # enabled = true @@ -612,6 +613,7 @@ Display and audio --2x 2× window scaling --headless No window, no REX3 graphics (audio unaffected) --noaudio Disable HAL2 audio (graphics unaffected) + --audio-host HOST Host audio for HAL2: default, alsa or pulseaudio (Linux) Networking --nat-subnet NAT subnet, e.g. 192.168.5.0/24 diff --git a/docs/hal2.md b/docs/hal2.md index e5c69e03..dfa2c6d6 100644 --- a/docs/hal2.md +++ b/docs/hal2.md @@ -269,8 +269,11 @@ avoid advancing into descriptors the guest has not prepared. - BRES rate is derived from its selected master, increment, and modulus. Codec CLKID is the generator number 1–3, not a zero-based index. - Codec A keeps a host cpal stream open across enable/disable cycles and - resamples guest-rate stereo samples to the host output rate. `[audio]` - selects prebuffering and host buffer size. + resamples guest-rate stereo samples to the host output rate. `[audio] + host` picks the host audio system (on Linux ALSA by default, or cpal's own + PulseAudio client, which PipeWire answers too), and `cpal_buffer_frames` + the host buffer size; PulseAudio, whose default target is about two + seconds, gets 512 frames unless told otherwise. - Mono is duplicated to L/R. Stereo consumes interleaved pairs. Single-stream quad consumes all four samples but discards the rear pair. - Codec B is an input DMA silence writer, not a second host output stream. diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index be04b26f..189cbe5c 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -1,7 +1,7 @@ use egui::{Color32, ComboBox, DragValue, Grid, RichText, ScrollArea, TextEdit, Ui}; use iris::build_features; use std::path::Path; -use iris::config::{ +use iris::config::{AudioHost, format_unix_utc, CpuModel, ForwardBind, ForwardProto, GraphicsBoard, KeyboardLayout, MachineConfig, MachineProfile, NetMode, NfsConfig, PortForwardConfig, RtcOffset, ScsiDeviceConfig, ScsiKind, VinoSource, VinoStandard, VALID_BANK_SIZES, @@ -711,6 +711,22 @@ fn show_display(ui: &mut Ui, cfg: &mut MachineConfig, running: bool) { .small(), ); Grid::new("audio_grid").num_columns(2).striped(true).show(ui, |ui| { + if cfg!(target_os = "linux") { + ui.label("Host audio"); + let label = |h: AudioHost| match h { + AudioHost::Default => "Default (ALSA)", + AudioHost::Alsa => "ALSA", + AudioHost::Pulseaudio => "PulseAudio / PipeWire", + }; + ComboBox::from_id_salt("audio_host") + .selected_text(label(cfg.audio.host)) + .show_ui(ui, |ui| { + for h in [AudioHost::Default, AudioHost::Alsa, AudioHost::Pulseaudio] { + ui.selectable_value(&mut cfg.audio.host, h, label(h)); + } + }); + ui.end_row(); + } ui.label("Pre-buffer (ms)"); ui.add(DragValue::new(&mut cfg.audio.prebuf_ms).range(5..=200)); ui.end_row(); diff --git a/iris.toml b/iris.toml index 3058140e..81bc6efd 100644 --- a/iris.toml +++ b/iris.toml @@ -244,8 +244,9 @@ mode = "nat" # HAL2 audio output buffering. #[audio] +#host = "default" # Linux: "alsa" (default) or "pulseaudio" (PulseAudio or PipeWire) #prebuf_ms = 20 -#cpal_buffer_frames = 512 +#cpal_buffer_frames = 512 # unset: the host's default (PulseAudio: 512) # Host thread pinning. #[perf] diff --git a/rules/testing/cpal-alsa-busy-poll.md b/rules/testing/cpal-alsa-busy-poll.md new file mode 100644 index 00000000..904e5f04 --- /dev/null +++ b/rules/testing/cpal-alsa-busy-poll.md @@ -0,0 +1,32 @@ +# cpal's ALSA backend can spin a core: `[audio] host = "pulseaudio"` + +**Keywords:** hal2,audio,alsa,cpal,pulseaudio,pipewire,busy-poll,xrun,underrun,linux,red led + +**Symptom.** On Linux, HAL2 logs a steady stream of xruns / underruns and the +guest runs slowly; `top -H` shows cpal's ALSA worker thread at 100% of a core. +Once (Debian 12 arm64, Parallels VM, 4 vCPUs) the IP28 sat in its power-on +diagnostics with the red LED lit because the emulator was starved. + +**Cause (cpal 0.18).** The PCM's poll descriptor stays readable while `avail` +is below `avail_min`, so cpal's worker returns from `poll()` immediately and +loops, about five million times a second. Seen through PipeWire's ALSA plugin +(`default`) and through the card's own device (`hw`/`plughw`) alike. `aplay` +with the same period and buffer sizes sleeps normally, so it is cpal's loop, +not the driver. Not reproduced yet on bare metal; it may need the VM's timing. + +**Workaround.** `[audio] host = "pulseaudio"` (or `--audio-host pulseaudio`): +cpal's own PulseAudio client (pure Rust, the `pulseaudio` feature) talks to a +PulseAudio server or PipeWire's pipewire-pulse over the socket. The same +stream used 0.01 s of CPU in 5 s. The default stays ALSA until the bug is +confirmed on real hardware and reported upstream; then ALSA could become the +fallback only. + +**Buffer size.** Left to itself, PulseAudio (and pipewire-pulse) targets about +two seconds of buffering, and IRIX's sound came that late. HAL2 asks for a +fixed period of 512 frames there (cpal makes the target two periods, ~21 ms +at 48 kHz, with adjust-latency) unless `cpal_buffer_frames` says otherwise. + +**Picking the host.** With the `pulseaudio` feature compiled in, cpal's own +`default_host()` prefers PulseAudio whenever its socket exists, so HAL2 must +not call it on Linux: `output_host()` in `src/dev/hal2.rs` asks for ALSA by id +unless PulseAudio was requested. diff --git a/src/config.rs b/src/config.rs index 80bba5b0..5f489843 100644 --- a/src/config.rs +++ b/src/config.rs @@ -967,9 +967,27 @@ impl Default for PerfConfig { } } +/// Which host audio system HAL2 plays through. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, clap::ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum AudioHost { + /// The platform's usual one: CoreAudio on macOS, WASAPI on Windows, ALSA + /// on Linux. + #[default] + Default, + /// ALSA (Linux). + Alsa, + /// A PulseAudio server, or PipeWire's pipewire-pulse (Linux). Falls back + /// to ALSA when none answers. + Pulseaudio, +} + /// HAL2 / cpal audio output tuning. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AudioConfig { + /// Host audio system to play through. Default: the platform's usual one. + #[serde(default)] + pub host: AudioHost, /// Pre-buffer duration (ms) before feeding the cpal ring. Default 20. #[serde(default = "default_audio_prebuf_ms")] pub prebuf_ms: u64, @@ -982,7 +1000,7 @@ fn default_audio_prebuf_ms() -> u64 { 20 } impl Default for AudioConfig { fn default() -> Self { - Self { prebuf_ms: default_audio_prebuf_ms(), cpal_buffer_frames: None } + Self { host: AudioHost::Default, prebuf_ms: default_audio_prebuf_ms(), cpal_buffer_frames: None } } } @@ -1651,6 +1669,10 @@ pub struct Cli { #[arg(long = "noaudio", default_value_t = false)] pub no_audio: bool, + /// Host audio system for HAL2: default, alsa or pulseaudio (overrides [audio] host) + #[arg(long = "audio-host", value_enum, value_name = "HOST")] + pub audio_host: Option, + /// Enable NFS share: path to the directory to export (enables NFS) #[arg(long = "nfs-dir", value_name = "DIR")] pub nfs_dir: Option, @@ -1827,6 +1849,7 @@ impl Cli { if self.scale2x { cfg.scale = 2; } if self.headless { cfg.headless = true; } if self.no_audio { cfg.no_audio = true; } + if let Some(h) = self.audio_host { cfg.audio.host = h; } if self.no_scsi_deferred_int { cfg.scsi_deferred_int = false; } if self.ci { cfg.ci = true; } @@ -1959,6 +1982,22 @@ mod export_tests { println!("--- exported toml ---\n{s}"); } + #[test] + fn audio_host_parses_and_defaults() { + let cfg: MachineConfig = toml::from_str("").expect("parse"); + assert_eq!(cfg.audio.host, AudioHost::Default); + let cfg: MachineConfig = toml::from_str(r#" + [audio] + host = "pulseaudio" + cpal_buffer_frames = 256 + "#).expect("parse"); + assert_eq!(cfg.audio.host, AudioHost::Pulseaudio); + assert_eq!(cfg.audio.cpal_buffer_frames, Some(256)); + let back: MachineConfig = toml::from_str(&toml::to_string_pretty(&cfg).expect("serialize")).expect("deserialize"); + assert_eq!(back.audio.host, AudioHost::Pulseaudio); + assert!(toml::from_str::("[audio]\nhost = \"oss\"").is_err()); + } + #[test] fn scsi_kind_daynaport_parses_and_round_trips() { let cfg: MachineConfig = toml::from_str(r#" diff --git a/src/dev/hal2.rs b/src/dev/hal2.rs index e79858d7..8625b10f 100644 --- a/src/dev/hal2.rs +++ b/src/dev/hal2.rs @@ -5,6 +5,7 @@ use crate::devlog::LogModule; use std::time::{Duration, Instant}; use std::io::Write; use crate::traits::{BusRead8, BusRead16, BusRead32, BusRead64, BUS_OK, BUS_ERR, Device, DmaClient}; +use crate::config::{AudioConfig, AudioHost}; use crate::hptimer::{TimerManager, TimerId, TimerReturn}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use rtrb::{RingBuffer, Producer}; @@ -195,6 +196,10 @@ impl Pacer { // Sample rates to try when opening the persistent output stream, in order. const PREFERRED_RATES: &[u32] = &[48000, 44100, 22050]; +/// The PulseAudio stream's period, in frames, unless `[audio] +/// cpal_buffer_frames` says otherwise (see `output_buffer`). +#[cfg(target_os = "linux")] +const PULSE_FRAMES: u32 = 512; // ─── Audio output (owned by Codec A, opened once at start, closed at stop) ─── @@ -428,6 +433,8 @@ pub struct Hal2 { ar_state: Arc>, // cpal output-callback underrun count (ring buffer empty when the host pulled samples). underruns: Arc, + // [audio]: which host to play through, and its buffer size. + audio: AudioConfig, } // ─── cpal helpers ───────────────────────────────────────────────────────────── @@ -436,100 +443,147 @@ fn prebuf_samples(rate: u32) -> usize { (rate as usize * 2 * PREBUF_MS as usize) / 1000 } +/// The host `[audio] host` asks for. cpal's `pulseaudio` feature makes its +/// own `default_host()` prefer PulseAudio whenever a server runs, so the +/// default is chosen here instead: the platform's usual host (ALSA on Linux). +fn output_host(want: AudioHost) -> cpal::Host { + #[cfg(target_os = "linux")] + { + if want == AudioHost::Pulseaudio { + match cpal::host_from_id(cpal::HostId::PulseAudio) { + Ok(h) => return h, + Err(e) => eprintln!("HAL2: [audio] host = \"pulseaudio\", but no PulseAudio server answers ({e}); using ALSA"), + } + } + if let Ok(h) = cpal::host_from_id(cpal::HostId::Alsa) { + return h; + } + } + #[cfg(not(target_os = "linux"))] + if want != AudioHost::Default { + eprintln!("HAL2: [audio] host = {want:?} is for Linux; using this platform's default"); + } + cpal::default_host() +} + +/// How much the host buffers: `[audio] cpal_buffer_frames` if set, else the +/// host's default -- except PulseAudio's, a target of about two seconds +/// (pipewire-pulse's too), which made IRIX's sound that late: there a fixed +/// period of PULSE_FRAMES asks for 2 x 512 frames, ~21 ms at 48 kHz. +fn output_buffer(audio: &AudioConfig, host: cpal::HostId) -> cpal::BufferSize { + match (audio.cpal_buffer_frames, host) { + (Some(frames), _) => cpal::BufferSize::Fixed(frames), + #[cfg(target_os = "linux")] + (None, cpal::HostId::PulseAudio) => cpal::BufferSize::Fixed(PULSE_FRAMES), + _ => cpal::BufferSize::Default, + } +} + /// Open a persistent stereo i16 cpal output stream, trying PREFERRED_RATES in order. /// The stream plays silence when the ring buffer is empty. -fn open_persistent_output(underruns: Arc, playing: Arc) -> Option { - let host = cpal::default_host(); +fn open_persistent_output(audio: &AudioConfig, underruns: Arc, playing: Arc) -> Option { + let host = output_host(audio.host); let device = host.default_output_device()?; + // A fixed size the device refuses falls back to the host's default. + let mut sizes = vec![output_buffer(audio, host.id())]; + if sizes[0] != cpal::BufferSize::Default { + sizes.push(cpal::BufferSize::Default); + } - for &rate in PREFERRED_RATES { - let config = cpal::StreamConfig { - channels: 2, - sample_rate: rate, - buffer_size: cpal::BufferSize::Default, - }; - let ring_size = prebuf_samples(rate) * RING_BUF_MULTIPLIER; - let err_fn = |err: cpal::Error| { eprintln!("HAL2: cpal stream error: {:?}", err); }; - - // Try f32 first (macOS CoreAudio native), then i16 (Linux ALSA). - let (producer, stream) = { - let (p, mut c) = RingBuffer::::new(ring_size); - let underruns_cb = underruns.clone(); - let playing_cb = playing.clone(); - // IRIS_HAL2_CAPTURE=: also write what the host plays, raw - // interleaved 16-bit little-endian stereo at the stream rate -- - // the end of the whole chain, to check or listen to offline. - let mut capture = std::env::var_os("IRIS_HAL2_CAPTURE") - .and_then(|p| std::fs::File::create(p).ok()) - .map(std::io::BufWriter::new); - let data_fn = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { - let mut short = false; - for sample in data.iter_mut() { - let v = match c.pop() { - Ok(v) => v, - Err(_) => { - if playing_cb.load(Ordering::Relaxed) { - underruns_cb.fetch_add(1, Ordering::Relaxed); - short = true; + for buffer_size in sizes { + for &rate in PREFERRED_RATES { + let config = cpal::StreamConfig { + channels: 2, + sample_rate: rate, + buffer_size, + }; + let ring_size = prebuf_samples(rate) * RING_BUF_MULTIPLIER; + let err_fn = |err: cpal::Error| { eprintln!("HAL2: cpal stream error: {:?}", err); }; + + // Try f32 first (macOS CoreAudio native), then i16 (Linux ALSA). + let (producer, stream) = { + let (p, mut c) = RingBuffer::::new(ring_size); + let underruns_cb = underruns.clone(); + let playing_cb = playing.clone(); + // IRIS_HAL2_CAPTURE=: also write what the host plays, raw + // interleaved 16-bit little-endian stereo at the stream rate -- + // the end of the whole chain, to check or listen to offline. + let mut capture = std::env::var_os("IRIS_HAL2_CAPTURE") + .and_then(|p| std::fs::File::create(p).ok()) + .map(std::io::BufWriter::new); + let data_fn = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let mut short = false; + for sample in data.iter_mut() { + let v = match c.pop() { + Ok(v) => v, + Err(_) => { + if playing_cb.load(Ordering::Relaxed) { + underruns_cb.fetch_add(1, Ordering::Relaxed); + short = true; + } + 0 } - 0 + }; + if let Some(w) = capture.as_mut() { + use std::io::Write; + let _ = w.write_all(&v.to_le_bytes()); } - }; - if let Some(w) = capture.as_mut() { - use std::io::Write; - let _ = w.write_all(&v.to_le_bytes()); + *sample = v as f32 / 32768.0; } - *sample = v as f32 / 32768.0; - } - if short { - crate::pf_instant!(crate::perfetto::events::HAL2_UNDERRUN, underruns_cb.load(Ordering::Relaxed)); - } - }; - match device.build_output_stream(config, data_fn, err_fn.clone(), None) { - Ok(s) => (p, s), - Err(_) => { - // f32 failed, try i16 - let (p, mut c) = RingBuffer::::new(ring_size); - let underruns_cb = underruns.clone(); - let playing_cb = playing.clone(); - let data_fn = move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { - let mut short = false; - for sample in data.iter_mut() { - *sample = match c.pop() { - Ok(v) => v, - Err(_) => { - if playing_cb.load(Ordering::Relaxed) { - underruns_cb.fetch_add(1, Ordering::Relaxed); - short = true; + if short { + crate::pf_instant!(crate::perfetto::events::HAL2_UNDERRUN, underruns_cb.load(Ordering::Relaxed)); + } + }; + match device.build_output_stream(config, data_fn, err_fn.clone(), None) { + Ok(s) => (p, s), + Err(_) => { + // f32 failed, try i16 + let (p, mut c) = RingBuffer::::new(ring_size); + let underruns_cb = underruns.clone(); + let playing_cb = playing.clone(); + let data_fn = move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { + let mut short = false; + for sample in data.iter_mut() { + *sample = match c.pop() { + Ok(v) => v, + Err(_) => { + if playing_cb.load(Ordering::Relaxed) { + underruns_cb.fetch_add(1, Ordering::Relaxed); + short = true; + } + 0 } - 0 - } - }; - } - if short { - crate::pf_instant!(crate::perfetto::events::HAL2_UNDERRUN, underruns_cb.load(Ordering::Relaxed)); - } - }; - match device.build_output_stream(config, data_fn, err_fn.clone(), None) { - Ok(s) => (p, s), - Err(e) => { - eprintln!("HAL2: cpal build_output_stream failed at {}Hz: {:?}", rate, e); - continue; + }; + } + if short { + crate::pf_instant!(crate::perfetto::events::HAL2_UNDERRUN, underruns_cb.load(Ordering::Relaxed)); + } + }; + match device.build_output_stream(config, data_fn, err_fn.clone(), None) { + Ok(s) => (p, s), + Err(e) => { + eprintln!("HAL2: cpal build_output_stream failed at {}Hz: {:?}", rate, e); + continue; + } } } } - } - }; - if stream.play().is_err() { continue; } - // cpal 0.18 dropped DeviceTrait::name(); a Device's Display impl is its name. - println!("HAL2: audio output: {} via {:?} at {}Hz", device, host.id(), rate); - return Some(AudioOut { - stream_rate: rate, - producer, - underruns, - playing, - _stream: stream, - }); + }; + if stream.play().is_err() { continue; } + // cpal 0.18 dropped DeviceTrait::name(); a Device's Display impl is its name. + println!("HAL2: audio output: {} via {:?} at {}Hz", device, host.id(), rate); + return Some(AudioOut { + stream_rate: rate, + producer, + underruns, + playing, + _stream: stream, + }); + } + + if buffer_size != cpal::BufferSize::Default { + eprintln!("HAL2: no output stream with a {:?} buffer; trying the host's default", buffer_size); + } } eprintln!("HAL2: failed to open audio output at any rate (tried {:?})", PREFERRED_RATES); @@ -541,6 +595,11 @@ fn open_persistent_output(underruns: Arc, playing: Arc) - impl Hal2 { pub fn new(dma_clients: Vec>) -> Self { + Self::with_audio(dma_clients, AudioConfig::default()) + } + + /// HAL2 playing through the host and buffer `[audio]` asks for. + pub fn with_audio(dma_clients: Vec>, audio: AudioConfig) -> Self { Self { state: Arc::new(Mutex::new(Hal2State { isr: 0, @@ -567,6 +626,7 @@ impl Hal2 { at_state: Arc::new(Mutex::new(AesTxState { timer_id: None })), ar_state: Arc::new(Mutex::new(AesRxState { loopback: std::collections::VecDeque::new(), timer_id: None })), underruns: Arc::new(AtomicU64::new(0)), + audio, } } @@ -1237,7 +1297,7 @@ impl Device for Hal2 { fn start(&self) { // Open persistent audio output once. Codec A timer will push into it. - let audio = open_persistent_output(self.underruns.clone(), Arc::new(AtomicBool::new(false))); + let audio = open_persistent_output(&self.audio, self.underruns.clone(), Arc::new(AtomicBool::new(false))); if audio.is_none() { eprintln!("HAL2: no audio output available"); } diff --git a/src/dev/hpc3.rs b/src/dev/hpc3.rs index c111237a..e4265b49 100644 --- a/src/dev/hpc3.rs +++ b/src/dev/hpc3.rs @@ -1242,8 +1242,7 @@ impl Hpc3 { None }; - let _ = audio; - let hal2 = if no_audio { None } else { Some(Arc::new(Hal2::new(dma_clients[0..8].to_vec()))) }; + let hal2 = if no_audio { None } else { Some(Arc::new(Hal2::with_audio(dma_clients[0..8].to_vec(), audio))) }; Self { state,