diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 04a3524..8aaf10a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -63,6 +63,8 @@ fn default_settings() -> Settings { pst_last_path: None, pst_auto_sync: false, audio_output_device: None, + microphone_enabled: true, + audio_input_device: None, } } @@ -286,14 +288,39 @@ pub async fn start_recording( let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::>(8); // Level updates + device-change notices (FR-CAP-5/6); low-volume, forwarded to events below. let (event_tx, event_rx) = std::sync::mpsc::sync_channel::(8); - let capture = WasapiCapture - .start( - &wav_path, - settings.audio_output_device.as_deref(), - frame_tx, - event_tx, - ) - .map_err(|e| WaError::new("audio", e.to_string()))?; + + // When the mic is enabled (FR-CAP-7), capture it alongside loopback and let + // the mixer sum both into `frame_tx`; with it off, loopback feeds the + // transcription worker directly, exactly as before (no mixer overhead). A + // microphone that fails to open must not sink the meeting: we log and fall + // back to loopback-only (the mixer forwards loopback alone once its sink + // drops). + let (capture, mic_capture) = if settings.microphone_enabled { + let (loop_sink, mic_sink) = crate::audio::spawn_mixer(frame_tx); + let capture = WasapiCapture + .start( + &wav_path, + settings.audio_output_device.as_deref(), + loop_sink, + event_tx.clone(), + ) + .map_err(|e| WaError::new("audio", e.to_string()))?; + let mic = WasapiCapture + .start_microphone(settings.audio_input_device.as_deref(), mic_sink, event_tx) + .map_err(|e| tracing::warn!("microphone capture unavailable: {e}")) + .ok(); + (capture, mic) + } else { + let capture = WasapiCapture + .start( + &wav_path, + settings.audio_output_device.as_deref(), + frame_tx, + event_tx, + ) + .map_err(|e| WaError::new("audio", e.to_string()))?; + (capture, None) + }; // Fire-and-forget: exits on its own once `event_tx` drops at capture stop; // nothing downstream needs to join it. @@ -449,6 +476,7 @@ pub async fn start_recording( *guard = Some(RecordingSession { meeting_id: meeting_id.clone(), capture, + mic_capture, retention: args.record, wav_path, started_at: std::time::Instant::now(), @@ -497,8 +525,13 @@ pub async fn stop_recording( let summary = WasapiCapture .stop(session.capture) .map_err(|e| WaError::new("audio", e.to_string()))?; - // The capture thread has dropped its `FrameSink`; the transcription worker's - // `recv()` now returns `Err` and the worker exits on its own — join to + // Stop the mic too (FR-CAP-7) so the mixer sees both sinks drop and closes + // `frame_tx`; ignore its summary/errors — the loopback WAV is the recording. + if let Some(mic) = session.mic_capture { + let _ = WasapiCapture.stop(mic); + } + // Both capture threads have now dropped their `FrameSink`s; the transcription + // worker's `recv()` returns `Err` and the worker exits on its own — join to // guarantee it has fully drained the audio before we act on retention. let _ = session.transcription_worker.join(); @@ -621,6 +654,11 @@ pub async fn pause_recording( WasapiCapture .pause(&session.capture) .map_err(|e| WaError::new("audio", e.to_string()))?; + if let Some(mic) = session.mic_capture.as_ref() { + WasapiCapture + .pause(mic) + .map_err(|e| WaError::new("audio", e.to_string()))?; + } drop(guard); let _ = app.emit( "recording://state", @@ -643,6 +681,11 @@ pub async fn resume_recording( WasapiCapture .resume(&session.capture) .map_err(|e| WaError::new("audio", e.to_string()))?; + if let Some(mic) = session.mic_capture.as_ref() { + WasapiCapture + .resume(mic) + .map_err(|e| WaError::new("audio", e.to_string()))?; + } drop(guard); let _ = app.emit( "recording://state", @@ -923,6 +966,16 @@ pub async fn list_audio_devices() -> WaResult .map_err(|e| WaError::new("audio", e.to_string())) } +/// Enumerate capture (microphone) devices for the Settings "Microphone" picker +/// (FR-CAP-7). Blocking COM enumeration, so it runs off the async runtime thread. +#[tauri::command] +pub async fn list_input_devices() -> WaResult> { + tauri::async_runtime::spawn_blocking(crate::audio::list_capture_devices) + .await + .map_err(|e| WaError::new("audio", e.to_string()))? + .map_err(|e| WaError::new("audio", e.to_string())) +} + #[derive(Deserialize)] pub struct SetPreferredBackendArgs { pub backend: String, // "auto"|"npu"|"nvidia"|"amd"|"intel"|"cpu"