From 469cca40d55f1c1b6f26197c663dacb63b143c2f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:04:01 +0000 Subject: [PATCH] Type your own sound card buffer size, used by the mic too (#34) The Sound card buffer setting keeps its presets and gets a box to type any value from 1 to 1000 ms. The devices reopen once the typing or dragging is done, never every frame. The mic input now asks for the same buffer, and the mic queue is capped to a few buffers so its delay can't creep up to a second when the input and output clocks drift. Settings shows the buffer each output actually got (frames and ms), or that the driver refused it. Closes #34 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016m2QHYB4iztqZ2rQPnXw76 --- lang/template.json | 3 +- src/app.rs | 27 ++++++++++++++-- src/audio.rs | 77 ++++++++++++++++++++++++++++++++++++++-------- src/engine.rs | 18 +++++++++-- src/mic.rs | 45 ++++++++++++++++++++++----- 5 files changed, 144 insertions(+), 26 deletions(-) diff --git a/lang/template.json b/lang/template.json index 4bd6aea..91eeff3 100644 --- a/lang/template.json +++ b/lang/template.json @@ -308,7 +308,7 @@ "If every track has played recently, the least recently played ones are used.": "", "If late": "", "If nobody changed the server's certificate, someone may be listening in. Don't trust it unless you're sure.": "", - "If the sound crackles or stutters on a slow or busy computer, pick a bigger buffer. A bigger buffer adds a little delay between pressing Play and hearing it.": "", + "If the sound crackles or stutters on a slow or busy computer, pick a bigger buffer. A bigger buffer adds a little delay between pressing Play and hearing it; a fast computer with a good audio interface can use a much smaller one. The mic uses it too, the next time it is turned on.": "", "If you don't save, the server's playlists and settings stay in this app after you disconnect.": "", "Import…": "", "In AzuraCast: your account (top right) → API Keys → Add API key. Paste it on the 🔑 Connection page.": "", @@ -776,6 +776,7 @@ "Type": "", "Type a time like 1:30 or 90": "", "Type one above (https://…) and press Enter.": "", + "Type your own buffer size in milliseconds. The sound card may round it to a size it supports; the size in use is shown below.": "", "Up next:": "", "Up one folder": "", "Up to 16 bands; each picks its filter, frequency, gain and width": "", diff --git a/src/app.rs b/src/app.rs index 62120ef..a5463b2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -253,6 +253,8 @@ pub struct App { dev_listed: bool, dev_listing: bool, dev_inputs: Vec, + /// sound card buffer being typed or dragged in Settings, applied when the edit ends + buffer_edit: Option, ctx: egui::Context, sel: BTreeSet, anchor: Option, @@ -656,6 +658,7 @@ impl App { dev_listed: false, dev_listing: false, dev_inputs: vec![], + buffer_edit: None, ctx: cc.egui_ctx.clone(), sel: BTreeSet::new(), anchor: None, @@ -2193,7 +2196,8 @@ impl App { } else { // while connected the mic goes to the server, which puts it on air let device = &core.cfg.audio_devices.input; - let started = self.server_mic(device).unwrap_or_else(|| crate::mic::start(core.engine.clone(), device)); + let started = + self.server_mic(device).unwrap_or_else(|| crate::mic::start(core.engine.clone(), device, core.cfg.audio_devices.buffer_ms)); match started { Ok(s) => { self.mic_stream = Some(s); @@ -3063,13 +3067,32 @@ impl App { for v in [40, 0, 150, 300] { if ui.selectable_label(ms == v, label(v)).clicked() && ms != v { core.cfg.audio_devices.buffer_ms = v; + self.buffer_edit = None; changed = true; } } }); + // or type any value in (fast PCs with a good interface can go much lower) + use crate::audio::{buffer_ms_of, MAX_BUFFER_MS, MIN_BUFFER_MS}; + let mut v = self.buffer_edit.unwrap_or(buffer_ms_of(ms)); + let r = ui + .add(egui::DragValue::new(&mut v).range(MIN_BUFFER_MS..=MAX_BUFFER_MS).speed(0.5).suffix(" ms").update_while_editing(false)) + .on_hover_text(tr("Type your own buffer size in milliseconds. The sound card may round it to a size it supports; the size in use is shown below.")); + if r.changed() { + self.buffer_edit = Some(v); + } + // reopening the sound cards waits until the drag or the typing is done + if !r.dragged() && !r.has_focus() { + if let Some(v) = self.buffer_edit.take() { + if v != buffer_ms_of(ms) { + core.cfg.audio_devices.buffer_ms = v; + changed = true; + } + } + } }) .response - .on_hover_text(tr("If the sound crackles or stutters on a slow or busy computer, pick a bigger buffer. A bigger buffer adds a little delay between pressing Play and hearing it.")); + .on_hover_text(tr("If the sound crackles or stutters on a slow or busy computer, pick a bigger buffer. A bigger buffer adds a little delay between pressing Play and hearing it; a fast computer with a good audio interface can use a much smaller one. The mic uses it too, the next time it is turned on.")); if changed { let engine = self.output_engine(core); self.audio.apply(&engine, &core.cfg.audio_devices); diff --git a/src/audio.rs b/src/audio.rs index 9345bf3..6d80af7 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -35,11 +35,39 @@ pub fn default_output_name() -> Option { /// Sound card buffer when the setting is left on its default. pub const DEFAULT_BUFFER_MS: u32 = 80; +/// Smallest and largest buffer that can be typed in. The driver may still raise or lower it to +/// what it supports (WASAPI shared mode rarely goes below about 10 ms). +pub const MIN_BUFFER_MS: u32 = 1; +pub const MAX_BUFFER_MS: u32 = 1000; + +/// The setting in milliseconds (0 = default), clamped to what can be asked for. +pub fn buffer_ms_of(setting: u32) -> u32 { + if setting == 0 { + DEFAULT_BUFFER_MS + } else { + setting.clamp(MIN_BUFFER_MS, MAX_BUFFER_MS) + } +} + +/// Picks a fixed buffer of about `ms` for a device whose driver supports `sup`. +/// Returns the buffer size and a short note on what was asked for, for the device report. +pub(crate) fn pick_buffer(sup: &cpal::SupportedBufferSize, rate: u32, ms: u32) -> (cpal::BufferSize, String) { + let mut frames = ((rate as u64 * ms as u64 / 1000) as u32).max(16); + if let cpal::SupportedBufferSize::Range { min, max } = sup { + if *min <= *max { + frames = frames.clamp(*min, *max); + } + } + let got = frames as f32 * 1000.0 / rate.max(1) as f32; + (cpal::BufferSize::Fixed(frames), format!("{frames} frames, about {got:.1} ms")) +} /// Plays `make()` on `device` through cpal, with a buffer of about `buffer_ms` milliseconds. -/// A bigger buffer than the driver's default rides out a busy CPU (slow PCs) instead of crackling. +/// A bigger buffer than the driver's default rides out a busy CPU (slow PCs) instead of crackling; +/// a small one (a fast PC with a good interface) gets the delay down. /// `make` is called again if the driver refuses that buffer size and its own default is used. -fn open_output(device: &cpal::Device, buffer_ms: u32, mut make: F) -> anyhow::Result +/// Also returns a note on the buffer in use. +fn open_output(device: &cpal::Device, buffer_ms: u32, mut make: F) -> anyhow::Result<(cpal::Stream, String)> where S: Source + Send + 'static, F: FnMut() -> S, @@ -47,21 +75,18 @@ where let sup = device.default_output_config()?; let format = sup.sample_format(); let mut cfg = sup.config(); - let ms = if buffer_ms == 0 { DEFAULT_BUFFER_MS } else { buffer_ms }; - let mut frames = (cfg.sample_rate.0 as u64 * ms as u64 / 1000) as u32; - if let cpal::SupportedBufferSize::Range { min, max } = sup.buffer_size() { - frames = frames.clamp(*min, *max); - } - cfg.buffer_size = cpal::BufferSize::Fixed(frames); + let (size, mut note) = pick_buffer(sup.buffer_size(), cfg.sample_rate.0, buffer_ms_of(buffer_ms)); + cfg.buffer_size = size; let stream = match build_output(device, &cfg, format, make()) { Ok(s) => s, Err(_) => { cfg.buffer_size = cpal::BufferSize::Default; + note = "driver's own buffer (it refused the size asked for)".into(); build_output(device, &cfg, format, make())? } }; stream.play()?; - Ok(stream) + Ok((stream, note)) } fn build_output(device: &cpal::Device, cfg: &cpal::StreamConfig, format: SampleFormat, src: S) -> anyhow::Result @@ -264,6 +289,7 @@ impl AudioManager { if let Some(s) = self.null_stop.take() { s.store(true, Ordering::Relaxed); } + engine.set_mic_latency_ms(buffer_ms_of(cfg.buffer_ms)); let host = cpal::default_host(); let mut wanted: Vec<(String, f32, bool)> = cfg.outputs.iter().filter(|o| o.enabled).map(|o| (o.name.clone(), o.gain, o.studio)).collect(); @@ -297,10 +323,10 @@ impl AudioManager { } }; match opened { - Ok(stream) => { + Ok((stream, buf)) => { let role = if have_primary { "monitor" } else { "primary" }; let extra = if studio { ", muted while the mic is on" } else { "" }; - self.report.push((name.clone(), format!("playing ({role}{extra})"))); + self.report.push((name.clone(), format!("playing ({role}{extra}, {buf})"))); self.running.push(Running { name, primary: !have_primary, @@ -342,7 +368,7 @@ impl PreviewPlayer { let dev = find_output(device).ok_or_else(|| anyhow::anyhow!("no output device for preview"))?; let engine = Engine::new_detached(); engine.set_volume(1.0); - let stream = open_output(&dev, 0, || engine.primary_source())?; + let (stream, _) = open_output(&dev, 0, || engine.primary_source())?; engine.play_source(src, None, 0.0, crate::engine::CueSpec::default(), Some(0.0), false); let stop = Arc::new(AtomicBool::new(false)); let (s2, e2) = (stop.clone(), engine.clone()); @@ -363,7 +389,7 @@ impl PreviewPlayer { let dev = find_output(device).ok_or_else(|| anyhow::anyhow!("no output device for preview"))?; let engine = Engine::new_detached(); engine.set_volume(1.0); - let stream = open_output(&dev, 0, || engine.primary_source())?; + let (stream, _) = open_output(&dev, 0, || engine.primary_source())?; let ca = a.cues; let from = from.clamp(0.0, (ca.cue_out - 0.05).max(0.0)); let fade_in = if from <= ca.cue_in + 1e-3 { ca.fade_in } else { 0.0 }; @@ -436,3 +462,28 @@ impl Drop for PreviewPlayer { self.stop.store(true, Ordering::Relaxed); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn buffer_setting_is_clamped() { + assert_eq!(buffer_ms_of(0), DEFAULT_BUFFER_MS); + assert_eq!(buffer_ms_of(3), 3); + assert_eq!(buffer_ms_of(5000), MAX_BUFFER_MS); + } + + #[test] + fn small_buffers_follow_the_driver_range() { + let range = cpal::SupportedBufferSize::Range { min: 32, max: 4096 }; + // 3 ms at 48 kHz is 144 frames + let (size, note) = pick_buffer(&range, 48_000, 3); + assert_eq!(size, cpal::BufferSize::Fixed(144)); + assert!(note.contains("144 frames")); + // 1 ms would be 48 frames, but this driver goes no lower than 64 + let range = cpal::SupportedBufferSize::Range { min: 64, max: 4096 }; + assert_eq!(pick_buffer(&range, 48_000, 1).0, cpal::BufferSize::Fixed(64)); + assert_eq!(pick_buffer(&cpal::SupportedBufferSize::Unknown, 44_100, 1000).0, cpal::BufferSize::Fixed(44_100)); + } +} diff --git a/src/engine.rs b/src/engine.rs index 093d356..8c678d3 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -310,6 +310,9 @@ struct Shared { /// music level right now (follows `duck` smoothly) duck_now: f32, mic_buf: VecDeque, + /// most mic audio (interleaved samples) allowed to queue up before the oldest is dropped, + /// so the mic's delay can't creep up when the input and output clocks drift apart + mic_max: usize, monitors: Vec>>>, /// mics of apps connected to RadioMaster Server (summed with the local mic while the mic is on) net_mics: Vec<(u64, NetSource)>, @@ -537,6 +540,7 @@ impl Engine { duck: 0.35, duck_now: 1.0, mic_buf: VecDeque::new(), + mic_max: SR as usize * 2, monitors: vec![], net_mics: vec![], next_mic: 0, @@ -732,11 +736,21 @@ impl Engine { return; } s.mic_buf.extend(samples.iter().copied()); - while s.mic_buf.len() > SR as usize * 2 { - s.mic_buf.pop_front(); + // never less than a few of the input's own chunks, whatever size its driver settled on + let max = s.mic_max.max(samples.len() * 3).min(SR as usize * 2); + if s.mic_buf.len() > max { + // drop back to half the limit in one go (one small skip rather than many) + let drop = (s.mic_buf.len() - max / 2) & !1; + s.mic_buf.drain(..drop); } } + /// Lets the mic queue hold a few sound card buffers of `buffer_ms` (at least 60 ms, at most 1 s). + pub fn set_mic_latency_ms(&self, buffer_ms: u32) { + let ms = (buffer_ms as usize * 4).clamp(60, 1000); + self.shared.lock().mic_max = (SR as usize * 2 * ms / 1000) & !1; + } + pub fn seek(&self, secs: f32) -> Result<()> { let (path, total) = { let s = self.shared.lock(); diff --git a/src/mic.rs b/src/mic.rs index ce6d723..519a417 100644 --- a/src/mic.rs +++ b/src/mic.rs @@ -70,6 +70,12 @@ where /// Open the input device (empty name = system default) and send stereo 44.1 kHz audio to `sink`. pub fn capture(device_name: &str, sink: F) -> Result { + capture_with(device_name, 0, sink) +} + +/// Like `capture`, with a sound card buffer of about `buffer_ms` (0 = the driver's own). +/// If the driver refuses that size its own buffer is used. +pub fn capture_with(device_name: &str, buffer_ms: u32, sink: F) -> Result { let host = cpal::default_host(); let chosen = if device_name.is_empty() { None @@ -80,18 +86,41 @@ pub fn capture(device_name: &str, sink: F) -> let cfg = dev.default_input_config()?; let rate = cfg.sample_rate().0 as f32; let fmt = cfg.sample_format(); - let conf: cpal::StreamConfig = cfg.into(); - let stream = match fmt { - cpal::SampleFormat::F32 => build::(&dev, &conf, rate, sink)?, - cpal::SampleFormat::I16 => build::(&dev, &conf, rate, sink)?, - cpal::SampleFormat::U16 => build::(&dev, &conf, rate, sink)?, - other => return Err(anyhow!("unsupported input sample format {other:?}")), + let sizes = *cfg.buffer_size(); + let mut conf: cpal::StreamConfig = cfg.into(); + if buffer_ms > 0 { + conf.buffer_size = crate::audio::pick_buffer(&sizes, conf.sample_rate.0, crate::audio::buffer_ms_of(buffer_ms)).0; + } + // the sink moves into the stream, so it is shared to allow a second try at the default size + let sink = std::sync::Arc::new(std::sync::Mutex::new(sink)); + let open = |conf: &cpal::StreamConfig| { + let sink = sink.clone(); + let f = move |s: &[f32]| { + if let Ok(mut k) = sink.lock() { + (*k)(s) + } + }; + match fmt { + cpal::SampleFormat::F32 => build::(&dev, conf, rate, f), + cpal::SampleFormat::I16 => build::(&dev, conf, rate, f), + cpal::SampleFormat::U16 => build::(&dev, conf, rate, f), + other => Err(anyhow!("unsupported input sample format {other:?}")), + } + }; + let stream = match open(&conf) { + Ok(s) => s, + Err(_) if conf.buffer_size != cpal::BufferSize::Default => { + conf.buffer_size = cpal::BufferSize::Default; + open(&conf)? + } + Err(e) => return Err(e), }; stream.play()?; Ok(stream) } /// Mic button: input goes straight into the master bus (music is ducked by the engine). -pub fn start(engine: Engine, device_name: &str) -> Result { - capture(device_name, move |s| engine.mic_push(s)) +/// `buffer_ms` is the sound card buffer setting (0 = default). +pub fn start(engine: Engine, device_name: &str, buffer_ms: u32) -> Result { + capture_with(device_name, buffer_ms, move |s| engine.mic_push(s)) }