use crate::error::CaptureError;
use crossbeam_channel::{bounded, Receiver, Sender};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceRole {
Voice,
Call,
Default,
}
#[derive(Clone)]
pub struct AudioChunk {
pub samples: Vec<f32>,
pub rms: f32,
pub timestamp: Instant,
pub index: u64,
pub source: SourceRole,
}
pub const CHUNK_SAMPLES: usize = 1600;
pub struct ChunkAccumulator {
buf: Vec<f32>,
next_index: u64,
}
impl Default for ChunkAccumulator {
fn default() -> Self {
Self::new()
}
}
impl ChunkAccumulator {
pub fn new() -> Self {
Self {
buf: Vec::with_capacity(CHUNK_SAMPLES * 2),
next_index: 0,
}
}
pub fn push<F>(&mut self, samples: &[f32], mut emit: F)
where
F: FnMut(u64, Vec<f32>),
{
self.buf.extend_from_slice(samples);
while self.buf.len() >= CHUNK_SAMPLES {
let chunk: Vec<f32> = self.buf.drain(..CHUNK_SAMPLES).collect();
let index = self.next_index;
self.next_index += 1;
emit(index, chunk);
}
}
pub fn buffered(&self) -> usize {
self.buf.len()
}
}
static STREAM_AUDIO_LEVEL: AtomicU32 = AtomicU32::new(0);
pub fn stream_audio_level() -> u32 {
STREAM_AUDIO_LEVEL.load(Ordering::Relaxed)
}
static MIC_MUTED: AtomicBool = AtomicBool::new(false);
pub fn is_mic_muted() -> bool {
MIC_MUTED.load(Ordering::Relaxed)
}
pub fn set_mic_muted(muted: bool) {
MIC_MUTED.store(muted, Ordering::Relaxed);
}
pub fn mic_mute_sentinel_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".minutes")
.join("mic_mute")
}
pub fn set_mic_muted_with_sentinel(muted: bool) -> bool {
let path = mic_mute_sentinel_path();
let previous = path.exists();
MIC_MUTED.store(muted, Ordering::Relaxed);
if muted {
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
tracing::warn!(error = %e, "failed to create mic_mute sentinel parent dir");
}
}
if let Err(e) = std::fs::write(&path, b"") {
tracing::warn!(error = %e, "failed to write mic_mute sentinel");
}
} else if path.exists() {
if let Err(e) = std::fs::remove_file(&path) {
tracing::warn!(error = %e, "failed to remove mic_mute sentinel");
}
}
if previous != muted {
append_mic_mute_event(muted, "toggle");
}
muted
}
pub fn toggle_mic_mute_with_sentinel() -> bool {
let currently_muted = mic_mute_sentinel_path().exists();
set_mic_muted_with_sentinel(!currently_muted)
}
fn append_mic_mute_event(muted: bool, source: &str) {
let event = if muted {
crate::events::MinutesEvent::MicMuted {
source: source.to_string(),
}
} else {
crate::events::MinutesEvent::MicUnmuted {
source: source.to_string(),
}
};
crate::events::append_event(event);
}
pub fn refresh_mic_mute_from_sentinel() {
let present = mic_mute_sentinel_path().exists();
MIC_MUTED.store(present, Ordering::Relaxed);
}
pub fn clear_mic_mute_for_new_recording() {
MIC_MUTED.store(false, Ordering::Relaxed);
let path = mic_mute_sentinel_path();
if path.exists() {
let _ = std::fs::remove_file(&path);
}
}
pub struct AudioStream {
_stream: cpal::Stream,
stop: Arc<AtomicBool>,
err_flag: Arc<AtomicBool>,
pub receiver: Receiver<AudioChunk>,
pub sample_rate: u32,
pub device_name: String,
}
impl AudioStream {
pub fn start(device_override: Option<&str>) -> Result<Self, CaptureError> {
let host = crate::capture::cached_default_host();
let device = crate::capture::select_input_device(host, device_override)?;
let (tx, rx): (Sender<AudioChunk>, Receiver<AudioChunk>) = bounded(64);
let stop = Arc::new(AtomicBool::new(false));
let err_flag = Arc::new(AtomicBool::new(false));
let mut accumulator = ChunkAccumulator::new();
let (stream, device_name, _config) = crate::resample::build_resampled_input_stream(
&device,
&stop,
&err_flag,
move |resampled: &[f32]| {
accumulator.push(resampled, |index, samples| {
let rms = compute_rms(&samples);
let level = (rms * 2000.0).min(100.0) as u32;
STREAM_AUDIO_LEVEL.store(level, Ordering::Relaxed);
let _ = tx.try_send(AudioChunk {
samples,
rms,
timestamp: Instant::now(),
index,
source: SourceRole::Default,
});
});
},
)?;
tracing::info!(device = %device_name, "streaming audio capture started");
Ok(AudioStream {
_stream: stream,
stop,
err_flag,
receiver: rx,
sample_rate: 16000,
device_name,
})
}
pub fn has_error(&self) -> bool {
self.err_flag.load(Ordering::Relaxed)
}
pub fn stop(&self) {
self.stop.store(true, Ordering::Relaxed);
}
}
impl Drop for AudioStream {
fn drop(&mut self) {
self.stop();
}
}
fn compute_rms(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
let sum: f64 = samples.iter().map(|&s| (s as f64) * (s as f64)).sum();
(sum / samples.len() as f64).sqrt() as f32
}
pub struct MultiAudioStream {
voice: AudioStream,
call: AudioStream,
_merge_thread: std::thread::JoinHandle<()>,
stop: Arc<AtomicBool>,
pub receiver: Receiver<AudioChunk>,
}
impl MultiAudioStream {
pub fn start(voice_device: Option<&str>, call_device: &str) -> Result<Self, CaptureError> {
let voice = AudioStream::start(voice_device)?;
let call = AudioStream::start(Some(call_device))?;
let (tx, rx): (Sender<AudioChunk>, Receiver<AudioChunk>) = bounded(128);
let stop = Arc::new(AtomicBool::new(false));
let voice_rx = voice.receiver.clone();
let call_rx = call.receiver.clone();
let stop_clone = Arc::clone(&stop);
let tx_clone = tx.clone();
let merge_thread = std::thread::spawn(move || {
let timeout = std::time::Duration::from_millis(50);
while !stop_clone.load(Ordering::Relaxed) {
while let Ok(mut chunk) = voice_rx.try_recv() {
chunk.source = SourceRole::Voice;
if MIC_MUTED.load(Ordering::Relaxed) {
for s in chunk.samples.iter_mut() {
*s = 0.0;
}
chunk.rms = 0.0;
}
let _ = tx.try_send(chunk);
}
while let Ok(mut chunk) = call_rx.try_recv() {
chunk.source = SourceRole::Call;
let _ = tx_clone.try_send(chunk);
}
std::thread::sleep(timeout);
}
});
tracing::info!(
voice = %voice.device_name,
call = %call.device_name,
"multi-source audio capture started"
);
Ok(MultiAudioStream {
voice,
call,
_merge_thread: merge_thread,
stop,
receiver: rx,
})
}
pub fn has_error(&self) -> bool {
self.voice.has_error() || self.call.has_error()
}
pub fn voice_device_name(&self) -> &str {
&self.voice.device_name
}
pub fn call_device_name(&self) -> &str {
&self.call.device_name
}
}
impl Drop for MultiAudioStream {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
self.voice.stop();
self.call.stop();
}
}
#[cfg(test)]
mod chunk_accumulator_tests {
use super::*;
const TAP_CALLBACK_SAMPLES: usize = 171;
fn drain(accumulator: &mut ChunkAccumulator, samples: &[f32]) -> Vec<(u64, Vec<f32>)> {
let mut out = Vec::new();
accumulator.push(samples, |index, chunk| out.push((index, chunk)));
out
}
#[test]
fn emits_nothing_until_a_full_chunk_is_available() {
let mut accumulator = ChunkAccumulator::new();
let almost_a_chunk = vec![0.5; CHUNK_SAMPLES - 1];
let emitted = drain(&mut accumulator, &almost_a_chunk);
assert!(emitted.is_empty());
assert_eq!(accumulator.buffered(), CHUNK_SAMPLES - 1);
let emitted = drain(&mut accumulator, &[0.5]);
assert_eq!(emitted.len(), 1);
assert_eq!(emitted[0].1.len(), CHUNK_SAMPLES);
assert_eq!(accumulator.buffered(), 0);
}
#[test]
fn every_emitted_chunk_is_exactly_chunk_samples_long() {
let mut accumulator = ChunkAccumulator::new();
for size in [1usize, 171, 512, 1600, 4801] {
let callback = vec![0.25; size];
for (_, chunk) in drain(&mut accumulator, &callback) {
assert_eq!(chunk.len(), CHUNK_SAMPLES);
}
}
}
#[test]
fn indices_are_monotonic_and_gapless() {
let mut accumulator = ChunkAccumulator::new();
let mut indices = Vec::new();
let callback = vec![0.1; TAP_CALLBACK_SAMPLES];
for _ in 0..100 {
for (index, _) in drain(&mut accumulator, &callback) {
indices.push(index);
}
}
assert!(!indices.is_empty());
assert_eq!(indices, (0..indices.len() as u64).collect::<Vec<_>>());
}
#[test]
fn tap_sized_callbacks_produce_dense_chunks_not_zero_stuffed_ones() {
let mut accumulator = ChunkAccumulator::new();
let mut chunks = Vec::new();
let callback = vec![0.3; TAP_CALLBACK_SAMPLES];
for _ in 0..1000 {
for (_, chunk) in drain(&mut accumulator, &callback) {
chunks.push(chunk);
}
}
assert!(!chunks.is_empty());
let total: usize = chunks.iter().map(Vec::len).sum();
let nonzero: usize = chunks
.iter()
.flatten()
.filter(|sample| **sample != 0.0)
.count();
assert_eq!(
nonzero, total,
"every sample from a continuously-nonzero source must survive chunking"
);
let pushed = 1000 * TAP_CALLBACK_SAMPLES;
assert_eq!(total + accumulator.buffered(), pushed);
}
#[test]
fn oversized_callbacks_are_split_rather_than_truncated() {
let mut accumulator = ChunkAccumulator::new();
let pushed = CHUNK_SAMPLES * 3 + 7;
let oversized = vec![0.75; pushed];
let emitted = drain(&mut accumulator, &oversized);
assert_eq!(emitted.len(), 3);
let total: usize = emitted.iter().map(|(_, chunk)| chunk.len()).sum();
assert_eq!(total + accumulator.buffered(), pushed);
assert_eq!(accumulator.buffered(), 7);
}
}
#[cfg(test)]
mod mic_mute_tests {
use super::*;
use std::sync::Mutex;
static GUARD: Mutex<()> = Mutex::new(());
fn reset() {
let _ = std::fs::remove_file(mic_mute_sentinel_path());
MIC_MUTED.store(false, Ordering::Relaxed);
}
#[test]
fn set_and_read_flag() {
let _g = GUARD.lock().unwrap();
reset();
assert!(!is_mic_muted());
set_mic_muted(true);
assert!(is_mic_muted());
set_mic_muted(false);
assert!(!is_mic_muted());
reset();
}
#[test]
fn sentinel_round_trip() {
let _g = GUARD.lock().unwrap();
reset();
assert!(!mic_mute_sentinel_path().exists());
set_mic_muted_with_sentinel(true);
assert!(is_mic_muted());
assert!(mic_mute_sentinel_path().exists());
set_mic_muted_with_sentinel(false);
assert!(!is_mic_muted());
assert!(!mic_mute_sentinel_path().exists());
reset();
}
#[test]
fn refresh_syncs_from_sentinel() {
let _g = GUARD.lock().unwrap();
reset();
MIC_MUTED.store(true, Ordering::Relaxed);
refresh_mic_mute_from_sentinel();
assert!(!is_mic_muted());
std::fs::create_dir_all(mic_mute_sentinel_path().parent().unwrap()).unwrap();
std::fs::write(mic_mute_sentinel_path(), b"").unwrap();
refresh_mic_mute_from_sentinel();
assert!(is_mic_muted());
reset();
}
#[test]
fn clear_for_new_recording_removes_sentinel_and_flag() {
let _g = GUARD.lock().unwrap();
reset();
set_mic_muted_with_sentinel(true);
assert!(is_mic_muted());
assert!(mic_mute_sentinel_path().exists());
clear_mic_mute_for_new_recording();
assert!(!is_mic_muted());
assert!(!mic_mute_sentinel_path().exists());
reset();
}
#[test]
fn toggle_flips_state() {
let _g = GUARD.lock().unwrap();
reset();
assert!(toggle_mic_mute_with_sentinel());
assert!(is_mic_muted());
assert!(!toggle_mic_mute_with_sentinel());
assert!(!is_mic_muted());
reset();
}
}