Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lang/template.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.": "",
Expand Down Expand Up @@ -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": "",
Expand Down
27 changes: 25 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ pub struct App {
dev_listed: bool,
dev_listing: bool,
dev_inputs: Vec<String>,
/// sound card buffer being typed or dragged in Settings, applied when the edit ends
buffer_edit: Option<u32>,
ctx: egui::Context,
sel: BTreeSet<usize>,
anchor: Option<usize>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
77 changes: 64 additions & 13 deletions src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,33 +35,58 @@ pub fn default_output_name() -> Option<String> {

/// 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<S, F>(device: &cpal::Device, buffer_ms: u32, mut make: F) -> anyhow::Result<cpal::Stream>
/// Also returns a note on the buffer in use.
fn open_output<S, F>(device: &cpal::Device, buffer_ms: u32, mut make: F) -> anyhow::Result<(cpal::Stream, String)>
where
S: Source<Item = f32> + Send + 'static,
F: FnMut() -> S,
{
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<S>(device: &cpal::Device, cfg: &cpal::StreamConfig, format: SampleFormat, src: S) -> anyhow::Result<cpal::Stream>
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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());
Expand All @@ -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 };
Expand Down Expand Up @@ -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));
}
}
18 changes: 16 additions & 2 deletions src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,9 @@ struct Shared {
/// music level right now (follows `duck` smoothly)
duck_now: f32,
mic_buf: VecDeque<f32>,
/// 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<Sender<Arc<Vec<f32>>>>,
/// mics of apps connected to RadioMaster Server (summed with the local mic while the mic is on)
net_mics: Vec<(u64, NetSource)>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ MergeGuard · 🟠 medium · bug

Bitwise AND operation on potentially odd-sized sample buffer. Line 740 uses samples.len() * 3 which may be odd, then .min(SR as usize * 2) which is even (SR=48000, so 96000 samples). The subsequent line 743 applies & !1 to ensure even count, but the intermediate max value could be odd, leading to inconsistent mic_buf length tracking.

💡 Ensure max is always even by applying & !1 after computing it: let max = (s.mic_max.max(samples.len() * 3).min(SR as usize * 2)) & !1;

Reply with @mergeguards fix to apply an auto-fix on this line.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I traced this and the queue length stays even, so I'm leaving the line as it is. Here's why:

  • samples always holds interleaved stereo pairs: mic::build pushes two values for every frame, so samples.len() is even and samples.len() * 3 is even too.
  • SR is 44 100 here, so SR * 2 is even.
  • mic_max is already rounded down to even in set_mic_latency_ms.

So max is always even. Even if it weren't, drop = (len - max / 2) & !1 is what decides how much gets drained, and it is even whenever len is. mic_buf therefore always gets drained by an even count, and left and right never swap.


Generated by Claude Code

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();
Expand Down
45 changes: 37 additions & 8 deletions src/mic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<F: FnMut(&[f32]) + Send + 'static>(device_name: &str, sink: F) -> Result<cpal::Stream> {
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<F: FnMut(&[f32]) + Send + 'static>(device_name: &str, buffer_ms: u32, sink: F) -> Result<cpal::Stream> {
let host = cpal::default_host();
let chosen = if device_name.is_empty() {
None
Expand All @@ -80,18 +86,41 @@ pub fn capture<F: FnMut(&[f32]) + Send + 'static>(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::<f32, F>(&dev, &conf, rate, sink)?,
cpal::SampleFormat::I16 => build::<i16, F>(&dev, &conf, rate, sink)?,
cpal::SampleFormat::U16 => build::<u16, F>(&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::<f32, _>(&dev, conf, rate, f),
cpal::SampleFormat::I16 => build::<i16, _>(&dev, conf, rate, f),
cpal::SampleFormat::U16 => build::<u16, _>(&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<cpal::Stream> {
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<cpal::Stream> {
capture_with(device_name, buffer_ms, move |s| engine.mic_push(s))
}
Loading