use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{Sample, SampleFormat, StreamConfig};
use crossbeam_channel::Receiver;
use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
use tauri::ipc::Channel;
use crate::apm::APM_FRAME_SIZE;
use crate::{apm, loopback, resampler, CaptureOptions, Error, FrameEvent, PcmSource};
use base64::Engine as _;
pub const TARGET_SAMPLE_RATE: u32 = 16_000;
const TARGET_CHANNELS: u8 = 1;
const FRAME_SAMPLES: usize = APM_FRAME_SIZE * 2;
const DRAIN_TICK_MS: u64 = 10;
const LEVEL_EMIT_INTERVAL_MS: u128 = 100;
const APM_STREAM_DELAY_MS: i32 = 150;
const RMS_DEDUPE_EPSILON: f32 = 0.005;
pub fn run(
options: CaptureOptions,
channel: Channel<FrameEvent>,
stop_rx: Receiver<()>,
) -> Result<(), Error> {
let emit_pcm = options.emits_pcm();
#[cfg(target_os = "windows")]
if let Err(msg) = preflight_mic_permission() {
return Err(Error::Permission(msg));
}
let host = cpal::default_host();
let mic = host
.default_input_device()
.ok_or_else(|| Error::Device("no default input device".into()))?;
let mic_config = mic.default_input_config().map_err(|e| {
let msg = e.to_string();
if msg.contains("permission") || msg.contains("Access") || msg.contains("denied") {
Error::Permission(msg)
} else {
Error::Device(format!("default input config: {msg}"))
}
})?;
let mic_sample_rate = mic_config.sample_rate().0;
let mic_channels = mic_config.channels();
let buf_mic = Arc::new(Mutex::new(Vec::<f32>::with_capacity(
TARGET_SAMPLE_RATE as usize,
)));
let buf_loopback = Arc::new(Mutex::new(Vec::<f32>::with_capacity(
TARGET_SAMPLE_RATE as usize,
)));
let buf_mic_cb = buf_mic.clone();
let mic_resampler = resampler::Linear::new(
mic_sample_rate,
TARGET_SAMPLE_RATE,
mic_channels,
TARGET_CHANNELS,
);
let mic_resampler = Arc::new(Mutex::new(mic_resampler));
let stream_config = StreamConfig {
channels: mic_config.channels(),
sample_rate: mic_config.sample_rate(),
buffer_size: cpal::BufferSize::Default,
};
let mic_stream = match mic_config.sample_format() {
SampleFormat::F32 => mic.build_input_stream(
&stream_config,
{
let resampler = mic_resampler.clone();
move |data: &[f32], _info| {
let resampled = resampler.lock().process(data);
buf_mic_cb.lock().extend_from_slice(&resampled);
}
},
err_cb,
None,
),
SampleFormat::I16 => mic.build_input_stream(
&stream_config,
{
let resampler = mic_resampler.clone();
move |data: &[i16], _info| {
let samples: Vec<f32> = data.iter().map(|&s| s.to_sample::<f32>()).collect();
let resampled = resampler.lock().process(&samples);
buf_mic_cb.lock().extend_from_slice(&resampled);
}
},
err_cb,
None,
),
SampleFormat::U16 => mic.build_input_stream(
&stream_config,
{
let resampler = mic_resampler.clone();
move |data: &[u16], _info| {
let samples: Vec<f32> = data.iter().map(|&s| s.to_sample::<f32>()).collect();
let resampled = resampler.lock().process(&samples);
buf_mic_cb.lock().extend_from_slice(&resampled);
}
},
err_cb,
None,
),
_ => return Err(Error::Device("unsupported sample format".into())),
}
.map_err(|e| Error::Device(e.to_string()))?;
mic_stream
.play()
.map_err(|e| Error::Device(e.to_string()))?;
let loopback_stream = if options.uses_loopback() {
match loopback::try_start(buf_loopback.clone()) {
Ok(s) => Some(s),
Err(err) => {
log::warn!("[loopback] disabled: {err}");
None
}
}
} else {
None
};
let apm_inst = if options.uses_apm() {
match apm::open() {
Ok(a) => {
a.set_stream_delay_ms(APM_STREAM_DELAY_MS);
Some(a)
}
Err(err) => {
log::warn!("[apm] disabled: {err}");
None
}
}
} else {
None
};
let has_loopback = loopback_stream.is_some();
log::info!(
"[audio] capture loop running — options={:?} mic={}Hz/{}ch loopback={}",
options,
mic_sample_rate,
mic_channels,
has_loopback
);
let mut state = LoopScratch::new();
let mut last_mic_rms = f32::NAN;
let mut last_lp_rms = f32::NAN;
let level_emit_start = std::time::Instant::now();
let mut last_level_emit_at_ms: u128 = 0;
loop {
if stop_rx
.recv_timeout(Duration::from_millis(DRAIN_TICK_MS))
.is_ok()
{
break;
}
let mut emitted_this_tick = 0;
loop {
let have_mic = {
let mut mic_buf = buf_mic.lock();
if mic_buf.len() < FRAME_SAMPLES {
false
} else {
state.mic_frame.clear();
state.mic_frame.extend(mic_buf.drain(..FRAME_SAMPLES));
true
}
};
if !have_mic {
break;
}
{
let mut lp_buf = buf_loopback.lock();
state.lp_frame.clear();
if has_loopback && lp_buf.len() >= FRAME_SAMPLES {
state.lp_frame.extend(lp_buf.drain(..FRAME_SAMPLES));
}
}
if let Some(apm) = &apm_inst {
if has_loopback && !state.lp_frame.is_empty() {
apm.process_far(&state.lp_frame);
}
apm.process_near(&mut state.mic_frame);
}
let rms_mic = rms_f32(&state.mic_frame);
let rms_lp = if state.lp_frame.is_empty() {
0.0
} else {
rms_f32(&state.lp_frame)
};
if emit_pcm {
emit_pcm_frame(
&channel,
state.seq,
PcmSource::Mic,
&state.mic_frame,
&mut state.byte_scratch,
&mut state.b64_scratch,
);
if has_loopback && !state.lp_frame.is_empty() {
emit_pcm_frame(
&channel,
state.seq,
PcmSource::Loopback,
&state.lp_frame,
&mut state.byte_scratch,
&mut state.b64_scratch,
);
}
}
let now_ms = level_emit_start.elapsed().as_millis();
let interval_elapsed = now_ms - last_level_emit_at_ms >= LEVEL_EMIT_INTERVAL_MS;
let changed = !rms_close(rms_mic, last_mic_rms) || !rms_close(rms_lp, last_lp_rms);
if interval_elapsed || last_mic_rms.is_nan() || changed {
let _ = channel.send(FrameEvent::Level {
mic_rms: rms_mic,
loopback_rms: rms_lp,
});
last_mic_rms = rms_mic;
last_lp_rms = rms_lp;
last_level_emit_at_ms = now_ms;
}
state.seq = state.seq.wrapping_add(1);
emitted_this_tick += 1;
if emitted_this_tick >= 50 {
break;
}
}
}
drop(mic_stream);
drop(loopback_stream);
drop(apm_inst);
log::info!("[audio] capture loop stopped — seq={}", state.seq);
Ok(())
}
struct LoopScratch {
seq: u64,
mic_frame: Vec<f32>,
lp_frame: Vec<f32>,
byte_scratch: Vec<u8>,
b64_scratch: String,
}
impl LoopScratch {
fn new() -> Self {
Self {
seq: 0,
mic_frame: Vec::with_capacity(FRAME_SAMPLES),
lp_frame: Vec::with_capacity(FRAME_SAMPLES),
byte_scratch: Vec::with_capacity(FRAME_SAMPLES * 2),
b64_scratch: String::with_capacity(FRAME_SAMPLES * 3),
}
}
}
fn emit_pcm_frame(
channel: &Channel<FrameEvent>,
seq: u64,
source: PcmSource,
samples: &[f32],
byte_scratch: &mut Vec<u8>,
b64_scratch: &mut String,
) {
byte_scratch.clear();
byte_scratch.reserve(samples.len() * 2);
for &s in samples {
let clamped = (s * 32_767.0).clamp(i16::MIN as f32, i16::MAX as f32);
byte_scratch.extend_from_slice(&(clamped as i16).to_le_bytes());
}
b64_scratch.clear();
base64::engine::general_purpose::STANDARD.encode_string(&*byte_scratch, b64_scratch);
let _ = channel.send(FrameEvent::Pcm {
seq,
source,
sample_rate: TARGET_SAMPLE_RATE,
channels: TARGET_CHANNELS,
samples_base64: b64_scratch.clone(),
});
}
#[cfg(target_os = "windows")]
fn preflight_mic_permission() -> Result<(), String> {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ, REG_SZ,
};
fn to_wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
let path = to_wide(
"Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone",
);
let value_name = to_wide("Value");
let mut hkey: HKEY = std::ptr::null_mut();
let open_rc =
unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, path.as_ptr(), 0, KEY_READ, &mut hkey) };
if open_rc != ERROR_SUCCESS {
return Ok(());
}
let mut buf = [0u16; 16];
let mut buf_size: u32 = (buf.len() * 2) as u32;
let mut ty: u32 = 0;
let q_rc = unsafe {
RegQueryValueExW(
hkey,
value_name.as_ptr(),
std::ptr::null_mut(),
&mut ty,
buf.as_mut_ptr() as *mut u8,
&mut buf_size,
)
};
unsafe { RegCloseKey(hkey) };
if q_rc != ERROR_SUCCESS || ty != REG_SZ {
return Ok(()); }
let len_chars = (buf_size as usize / 2).saturating_sub(1); let s = String::from_utf16_lossy(&buf[..len_chars.min(buf.len())]);
if s.eq_ignore_ascii_case("Deny") {
return Err("Windows microphone access is disabled for desktop apps. \
Open Settings → Privacy & Security → Microphone, then turn on \
\"Microphone access\" and \"Let desktop apps access your microphone\"."
.to_string());
}
Ok(())
}
pub(crate) fn check_mic_permission_status() -> &'static str {
#[cfg(target_os = "windows")]
{
match preflight_mic_permission() {
Ok(()) => "allowed",
Err(_) => "denied",
}
}
#[cfg(not(target_os = "windows"))]
{
"unknown"
}
}
fn err_cb(err: cpal::StreamError) {
log::warn!("[audio] stream error: {err}");
}
fn rms_f32(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
let sum_sq: f64 = samples.iter().map(|&s| (s as f64).powi(2)).sum();
(sum_sq / samples.len() as f64).sqrt() as f32
}
fn rms_close(current: f32, previous: f32) -> bool {
previous.is_finite() && (current - previous).abs() < RMS_DEDUPE_EPSILON
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rms_close_returns_false_on_first_emit() {
assert!(!rms_close(0.0, f32::NAN));
assert!(!rms_close(0.5, f32::NAN));
}
#[test]
fn rms_close_respects_epsilon() {
assert!(rms_close(0.100, 0.103));
assert!(rms_close(0.100, 0.097));
assert!(!rms_close(0.100, 0.100 + RMS_DEDUPE_EPSILON));
assert!(!rms_close(0.30, 0.10));
}
#[test]
fn level_only_never_emits_pcm() {
let opts = CaptureOptions {
loopback: true,
processing: true,
level_only: true,
};
assert!(!opts.emits_pcm());
assert!(!opts.uses_apm());
assert!(!opts.uses_loopback());
}
#[test]
fn default_options_use_full_pipeline() {
let opts = CaptureOptions::default();
assert!(opts.emits_pcm());
assert!(opts.uses_apm());
assert!(opts.uses_loopback());
}
#[test]
fn mic_only_runs_apm_skips_loopback() {
let opts = CaptureOptions {
loopback: false,
processing: true,
level_only: false,
};
assert!(opts.emits_pcm());
assert!(opts.uses_apm());
assert!(!opts.uses_loopback());
}
#[test]
fn loop_scratch_preallocates_capacity() {
let state = LoopScratch::new();
assert!(state.mic_frame.capacity() >= FRAME_SAMPLES);
assert!(state.lp_frame.capacity() >= FRAME_SAMPLES);
assert!(state.byte_scratch.capacity() >= FRAME_SAMPLES * 2);
assert!(state.b64_scratch.capacity() >= FRAME_SAMPLES * 8 / 3);
assert_eq!(state.seq, 0);
}
#[test]
fn permission_status_returns_known_value() {
let s = check_mic_permission_status();
assert!(
matches!(s, "allowed" | "denied" | "unknown"),
"unexpected status: {s:?}"
);
}
#[test]
fn apm_stream_delay_seed_unchanged() {
assert_eq!(APM_STREAM_DELAY_MS, 150);
}
#[test]
fn rms_half_scale_constant_is_half() {
let frame = vec![0.5f32; APM_FRAME_SIZE];
let r = rms_f32(&frame);
assert!(
(r - 0.5).abs() < 0.01,
"expected ~0.5 for half-scale, got {r}"
);
}
#[test]
fn capture_run_starts_and_stops_cleanly() {
let host = cpal::default_host();
if host.default_input_device().is_none() {
eprintln!("[smoke] no default input device, skipping");
return;
}
use std::sync::{Arc, Mutex};
use tauri::ipc::InvokeResponseBody;
let (stop_tx, stop_rx) = crossbeam_channel::bounded::<()>(1);
let panicked = Arc::new(Mutex::new(Option::<String>::None));
let channel = Channel::<FrameEvent>::new(|_body: InvokeResponseBody| Ok(()));
let panicked2 = panicked.clone();
let handle = std::thread::spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run(
CaptureOptions {
loopback: false,
processing: false,
level_only: true,
},
channel,
stop_rx,
)
}));
if let Err(payload) = result {
let msg = if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else if let Some(s) = payload.downcast_ref::<&'static str>() {
s.to_string()
} else {
"<non-string panic payload>".to_string()
};
*panicked2.lock().unwrap() = Some(msg);
}
});
std::thread::sleep(std::time::Duration::from_millis(200));
let _ = stop_tx.send(());
let join_result = handle.join();
assert!(
join_result.is_ok(),
"worker thread panicked at join: {join_result:?}"
);
let panic_msg = panicked.lock().unwrap().clone();
if let Some(msg) = panic_msg {
panic!("capture worker panicked: {msg}");
}
}
}