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)) }