diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index 5f21e8b..dd3fe09 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -24,7 +24,10 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::SyncSender; use std::sync::Arc; use std::thread::{self, JoinHandle}; -use wasapi::{Direction, SampleType, ShareMode, WaveFormat}; +use std::time::{Duration, Instant}; +use wasapi::{ + AudioCaptureClient, AudioClient, Direction, Handle, SampleType, ShareMode, WaveFormat, +}; #[derive(Debug, thiserror::Error)] pub enum AudioError { @@ -47,6 +50,30 @@ pub struct CaptureHandle { /// bounded so a slow/absent consumer can never stall the capture thread. pub type FrameSink = SyncSender>; +/// Instantaneous input level for one capture chunk, throttled to ~20Hz by the +/// capture loop (FR-CAP-5 waveform/level meter). +#[derive(Debug, Clone, Copy)] +pub struct AudioLevel { + pub rms: f32, + pub peak: f32, +} + +/// Out-of-band capture notices, lower-volume than `FrameSink`/level updates. +#[derive(Debug, Clone)] +pub enum CaptureEvent { + Level(AudioLevel), + /// The capture device stopped responding mid-recording (unplugged, default + /// device switched, etc.) — `recovered = true` once a reconnect to + /// whatever is now the default device succeeded without losing the + /// session (FR-CAP-6); `false` while recovery is in progress/failed. + DeviceChanged { + recovered: bool, + message: String, + }, +} + +pub type EventSink = SyncSender; + pub struct CaptureSummary { pub duration_ms: u64, pub sample_rate: u32, @@ -54,7 +81,12 @@ pub struct CaptureSummary { } pub trait AudioCapture: Send + Sync { - fn start(&self, wav_path: &Path, sink: FrameSink) -> Result; + fn start( + &self, + wav_path: &Path, + frame_sink: FrameSink, + event_sink: EventSink, + ) -> Result; fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>; fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>; fn stop(&self, h: CaptureHandle) -> Result; @@ -66,7 +98,12 @@ pub struct WasapiCapture; #[cfg(feature = "audio")] impl AudioCapture for WasapiCapture { - fn start(&self, wav_path: &Path, sink: FrameSink) -> Result { + fn start( + &self, + wav_path: &Path, + frame_sink: FrameSink, + event_sink: EventSink, + ) -> Result { let running = Arc::new(AtomicBool::new(true)); let paused = Arc::new(AtomicBool::new(false)); let running_th = running.clone(); @@ -75,7 +112,9 @@ impl AudioCapture for WasapiCapture { let thread = thread::Builder::new() .name("wa-audio-capture".into()) - .spawn(move || capture_loop(&wav_path, &sink, &running_th, &paused_th)) + .spawn(move || { + capture_loop(&wav_path, &frame_sink, &event_sink, &running_th, &paused_th) + }) .map_err(|e| AudioError::Capture(format!("spawn failed: {e}")))?; Ok(CaptureHandle { @@ -103,20 +142,23 @@ impl AudioCapture for WasapiCapture { } } -/// Runs on a dedicated OS thread for the lifetime of a `CaptureHandle`. Owns the -/// WASAPI client and the WAV writer; exits (and finalizes the WAV) once `running` -/// is cleared. +/// One open WASAPI capture session — bundled so it can be torn down and +/// re-opened wholesale on device recovery (FR-CAP-6) without duplicating the +/// open sequence at each call site. #[cfg(feature = "audio")] -fn capture_loop( - wav_path: &Path, - sink: &FrameSink, - running: &AtomicBool, - paused: &AtomicBool, -) -> Result { - wasapi::initialize_mta() - .ok() - .map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?; +struct CaptureSession { + audio_client: AudioClient, + event_handle: Handle, + capture_client: AudioCaptureClient, + format: WaveFormat, +} +/// Opens the current default render device for loopback capture. Called both +/// for the initial open and to reconnect after the device disappears +/// mid-recording (FR-CAP-6) — each call re-resolves "the default device", +/// so it naturally picks up whatever the OS switched to. +#[cfg(feature = "audio")] +fn open_capture_session() -> Result { let device = wasapi::get_default_device(&Direction::Render) .map_err(|e| AudioError::Device(format!("no default render device: {e}")))?; let mut audio_client = device @@ -147,25 +189,116 @@ fn capture_loop( .get_audiocaptureclient() .map_err(|e| AudioError::Capture(format!("GetService(IAudioCaptureClient) failed: {e}")))?; - let spec = wav_spec_for(&format)?; + Ok(CaptureSession { + audio_client, + event_handle, + capture_client, + format, + }) +} + +/// True if two formats are close enough to keep writing into the same +/// already-open WAV file (channels/rate/bit-depth/sample-type all match). +/// A reconnect landing on an incompatible format can't safely continue the +/// same file, so `capture_loop` treats that as unrecoverable rather than +/// silently corrupting `audio.wav`. +#[cfg(feature = "audio")] +fn format_compatible(a: &WaveFormat, b: &WaveFormat) -> bool { + a.get_nchannels() == b.get_nchannels() + && a.get_samplespersec() == b.get_samplespersec() + && a.get_bitspersample() == b.get_bitspersample() + && matches!( + (a.get_subformat(), b.get_subformat()), + (Ok(x), Ok(y)) if std::mem::discriminant(&x) == std::mem::discriminant(&y) + ) +} + +/// Amplitude of one chunk of mono samples, for the live level meter (FR-CAP-5). +#[cfg(feature = "audio")] +fn audio_level(mono: &[f32]) -> AudioLevel { + if mono.is_empty() { + return AudioLevel { + rms: 0.0, + peak: 0.0, + }; + } + let sum_sq: f32 = mono.iter().map(|s| s * s).sum(); + let peak = mono.iter().fold(0.0f32, |m, s| m.max(s.abs())); + AudioLevel { + rms: (sum_sq / mono.len() as f32).sqrt(), + peak, + } +} + +/// How often level updates are forwarded — plenty for a waveform/meter UI +/// without flooding the frontend with an event per ~10ms WASAPI callback. +const LEVEL_EMIT_INTERVAL: Duration = Duration::from_millis(50); + +/// Runs on a dedicated OS thread for the lifetime of a `CaptureHandle`. Owns the +/// WASAPI client and the WAV writer; exits (and finalizes the WAV) once `running` +/// is cleared. +#[cfg(feature = "audio")] +fn capture_loop( + wav_path: &Path, + frame_sink: &FrameSink, + event_sink: &EventSink, + running: &AtomicBool, + paused: &AtomicBool, +) -> Result { + wasapi::initialize_mta() + .ok() + .map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?; + + let mut session = open_capture_session()?; + let spec = wav_spec_for(&session.format)?; let mut writer = WavWriter::create(wav_path, spec).map_err(|e| { AudioError::Capture(format!("could not create {}: {e}", wav_path.display())) })?; - audio_client + session + .audio_client .start_stream() .map_err(|e| AudioError::Capture(format!("start_stream failed: {e}")))?; - let mut resampler = Resampler::new(format.get_samplespersec()); + let mut resampler = Resampler::new(session.format.get_samplespersec()); let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); let mut frames_written: u64 = 0; + let mut last_level_emit = Instant::now() - LEVEL_EMIT_INTERVAL; while running.load(Ordering::Relaxed) { // Short timeout so we periodically re-check `running` even with no data. - let _ = event_handle.wait_for_event(100); - capture_client - .read_from_device_to_deque(&mut queue) - .map_err(|e| AudioError::Capture(format!("GetBuffer failed: {e}")))?; + let _ = session.event_handle.wait_for_event(100); + if let Err(e) = session.capture_client.read_from_device_to_deque(&mut queue) { + // The device likely disappeared mid-recording (unplugged, default + // output switched, etc.) — reconnect to whatever is now the + // default device instead of losing the rest of the meeting + // (FR-CAP-6). Only continue on the same WAV file if the new + // device's format still matches; otherwise this is unrecoverable. + let _ = event_sink.try_send(CaptureEvent::DeviceChanged { + recovered: false, + message: e.to_string(), + }); + session.audio_client.stop_stream().ok(); + let new_session = open_capture_session().map_err(|re| { + AudioError::Capture(format!("device lost, reconnect failed: {re}")) + })?; + if !format_compatible(&session.format, &new_session.format) { + return Err(AudioError::Capture( + "reconnected to a device with an incompatible format; cannot continue the same recording".into(), + )); + } + new_session + .audio_client + .start_stream() + .map_err(|e| AudioError::Capture(format!("restart_stream failed: {e}")))?; + resampler = Resampler::new(new_session.format.get_samplespersec()); + session = new_session; + let _ = event_sink.try_send(CaptureEvent::DeviceChanged { + recovered: true, + message: "reconnected to the default audio device".into(), + }); + continue; + } if queue.is_empty() { continue; } @@ -177,24 +310,30 @@ fn capture_loop( continue; } - frames_written += write_wav_bytes(&mut writer, &bytes, &format)?; - let mono = decode_mono_f32(&bytes, &format)?; + frames_written += write_wav_bytes(&mut writer, &bytes, &session.format)?; + let mono = decode_mono_f32(&bytes, &session.format)?; + + if last_level_emit.elapsed() >= LEVEL_EMIT_INTERVAL { + let _ = event_sink.try_send(CaptureEvent::Level(audio_level(&mono))); + last_level_emit = Instant::now(); + } + let resampled = resampler.process(&mono); if !resampled.is_empty() { - let _ = sink.try_send(resampled); // drop on backpressure; disk write is unaffected + let _ = frame_sink.try_send(resampled); // drop on backpressure; disk write is unaffected } } - audio_client.stop_stream().ok(); + session.audio_client.stop_stream().ok(); writer .finalize() .map_err(|e| AudioError::Capture(format!("wav finalize failed: {e}")))?; - let sample_rate = format.get_samplespersec(); + let sample_rate = session.format.get_samplespersec(); Ok(CaptureSummary { duration_ms: (frames_written * 1000) / sample_rate.max(1) as u64, sample_rate, - channels: format.get_nchannels(), + channels: session.format.get_nchannels(), }) } @@ -547,4 +686,53 @@ mod tests { let input = vec![0.1f32, 0.2, 0.3]; assert_eq!(r.process(&input), input); } + + #[test] + fn audio_level_of_empty_chunk_is_silence() { + let level = audio_level(&[]); + assert_eq!(level.rms, 0.0); + assert_eq!(level.peak, 0.0); + } + + #[test] + fn audio_level_computes_rms_and_peak() { + // Two samples of equal magnitude: RMS equals that magnitude, peak too. + let level = audio_level(&[0.5, -0.5]); + assert!((level.rms - 0.5).abs() < 1e-6); + assert!((level.peak - 0.5).abs() < 1e-6); + } + + #[test] + fn audio_level_peak_tracks_the_largest_magnitude_sample() { + let level = audio_level(&[0.1, -0.9, 0.3]); + assert!((level.peak - 0.9).abs() < 1e-6); + } + + #[test] + fn format_compatible_matches_identical_formats() { + let a = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None); + let b = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None); + assert!(format_compatible(&a, &b)); + } + + #[test] + fn format_compatible_rejects_a_different_sample_rate() { + let a = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None); + let b = WaveFormat::new(32, 32, &SampleType::Float, 44_100, 2, None); + assert!(!format_compatible(&a, &b)); + } + + #[test] + fn format_compatible_rejects_a_different_channel_count() { + let a = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None); + let b = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 1, None); + assert!(!format_compatible(&a, &b)); + } + + #[test] + fn format_compatible_rejects_a_different_sample_type() { + let a = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None); + let b = WaveFormat::new(16, 16, &SampleType::Int, 48_000, 2, None); + assert!(!format_compatible(&a, &b)); + } }