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,
}
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 chunk_size: usize = 1600;
let mut chunk_buf: Vec<f32> = Vec::with_capacity(chunk_size);
let mut chunk_counter: u64 = 0;
let (stream, device_name, _config) = crate::resample::build_resampled_input_stream(
&device,
&stop,
&err_flag,
move |resampled: &[f32]| {
for &sample in resampled {
chunk_buf.push(sample);
if chunk_buf.len() >= chunk_size {
let samples: Vec<f32> = chunk_buf.drain(..chunk_size).collect();
let rms = compute_rms(&samples);
let level = (rms * 2000.0).min(100.0) as u32;
STREAM_AUDIO_LEVEL.store(level, Ordering::Relaxed);
let idx = chunk_counter;
chunk_counter += 1;
let _ = tx.try_send(AudioChunk {
samples,
rms,
timestamp: Instant::now(),
index: idx,
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 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();
}
}