feat(audio): wire mic capture + mixer into recording lifecycle (FR-CAP-7)
This commit is contained in:
+63
-10
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user