feat(audio): wire mic capture + mixer into recording lifecycle (FR-CAP-7)

This commit is contained in:
iamdoubz
2026-07-06 16:26:17 -05:00
parent 55ddaad544
commit 97b5f67049
+63 -10
View File
@@ -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::<Vec<f32>>(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::<crate::audio::CaptureEvent>(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<Vec<crate::audio::AudioDeviceInfo>
.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<Vec<crate::audio::AudioDeviceInfo>> {
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"