Skip to main content

app_ui/
mic_ipc.rs

1//! JS-bridge bindings for the M-MIC.1 / AUT-278 microphone commands
2//! (M-MIC.2 / AUT-279).
3//!
4//! Mirrors [`crate::camera_ipc`] for the audio path. Each function
5//! invokes a `__screen*` helper declared in `index.html`'s inline
6//! script, which wraps `window.__TAURI__.core.invoke(...)`.
7//!
8//! Returned values are deserialised from `JsValue` into typed Rust
9//! structs via `serde_wasm_bindgen` — same pattern as the camera +
10//! player IPC modules.
11
12use serde::{Deserialize, Serialize};
13use wasm_bindgen::JsCast;
14use wasm_bindgen::JsValue;
15use wasm_bindgen::prelude::*;
16
17use crate::camera_ipc::CameraPermission;
18
19/// Mirror of `crates/app/src/commands.rs::MicrophoneView` (M-MIC.1).
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct MicrophoneView {
22    /// Stable device id (`mic-…`).
23    pub id: String,
24    /// Human-readable label.
25    pub label: String,
26    /// `true` for the OS-default mic.
27    pub is_default: bool,
28    /// Native channel count from the gst caps line. `0` = unknown.
29    pub channels: u8,
30    /// Native sample rate from the gst caps line. `0` = unknown.
31    pub sample_rate_hz: u32,
32    /// Platform-native device identifier (M-MIC.3 / AUT-284).
33    /// Round-tripped — the Rust side uses it to route to the
34    /// per-OS gst element. Empty when gst didn't expose
35    /// `unique-id` for this device.
36    #[serde(default)]
37    pub native_id: String,
38}
39
40/// Mirror of `crates/app/src/audio/mod.rs::MicLifecycle`. Tagged
41/// representation matches the Rust enum's default serde shape (one
42/// of `"Idle"` / `"Starting"` / `"Running"` / `"Stopping"`).
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
44pub enum MicLifecycle {
45    /// No worker running.
46    #[default]
47    Idle,
48    /// Worker spawned; awaiting first chunk.
49    Starting,
50    /// Worker is producing chunks.
51    Running,
52    /// Worker is being torn down.
53    Stopping,
54}
55
56#[wasm_bindgen]
57extern "C" {
58    /// `__screenListMicrophones()` — returns `Promise<MicrophoneView[]>`.
59    #[wasm_bindgen(js_name = __screenListMicrophones, catch)]
60    pub async fn list_microphones_js() -> Result<JsValue, JsValue>;
61
62    /// `__screenStartMicCapture(micId)` — returns `Promise<void>`.
63    #[wasm_bindgen(js_name = __screenStartMicCapture, catch)]
64    pub async fn start_mic_capture_js(mic_id: String) -> Result<JsValue, JsValue>;
65
66    /// `__screenStopMicCapture()` — returns `Promise<void>`.
67    #[wasm_bindgen(js_name = __screenStopMicCapture, catch)]
68    pub async fn stop_mic_capture_js() -> Result<JsValue, JsValue>;
69
70    /// `__screenMicStatus()` — returns `Promise<MicLifecycle>`.
71    #[wasm_bindgen(js_name = __screenMicStatus, catch)]
72    pub async fn mic_status_js() -> Result<JsValue, JsValue>;
73
74    /// `__screenMicrophonePermissionStatus()` — returns
75    /// `Promise<CameraPermission>` (same three-variant shape).
76    #[wasm_bindgen(js_name = __screenMicrophonePermissionStatus, catch)]
77    pub async fn microphone_permission_status_js() -> Result<JsValue, JsValue>;
78
79    /// `__screenOpenSettingsMicrophone()` (M-RECP.8 / AUT-286) —
80    /// shells out to open System Settings → Privacy & Security →
81    /// Microphone.
82    #[wasm_bindgen(js_name = __screenOpenSettingsMicrophone, catch)]
83    pub async fn open_settings_microphone_js() -> Result<JsValue, JsValue>;
84}
85
86/// Async helper: list every microphone the OS exposes.
87///
88/// Returns an empty `Vec` when running outside Tauri (plain
89/// `trunk serve` browser preview), or when no mics are attached.
90pub async fn list_microphones() -> Vec<MicrophoneView> {
91    match list_microphones_js().await {
92        Ok(value) => serde_wasm_bindgen::from_value(value).unwrap_or_default(),
93        Err(_) => Vec::new(),
94    }
95}
96
97/// Async helper: kick off `start_mic_capture` for the given mic id.
98///
99/// Failures are silently swallowed — the picker UX reads back the
100/// effect via [`mic_status`] (and, in a follow-up, via the
101/// `audio-levels` event when the meter wires up). The IPC layer's
102/// re-entrant contract (M-MIC.1) means calling this with a new id
103/// while an older session is running cleanly tears down the previous
104/// pipeline.
105pub async fn start_mic_capture(mic_id: String) {
106    let _ = start_mic_capture_js(mic_id).await;
107}
108
109/// Async helper: tear down the active mic worker.
110pub async fn stop_mic_capture() {
111    let _ = stop_mic_capture_js().await;
112}
113
114/// Async helper: snapshot the worker lifecycle.
115///
116/// Returns `Idle` when running outside Tauri.
117pub async fn mic_status() -> MicLifecycle {
118    match mic_status_js().await {
119        Ok(value) => serde_wasm_bindgen::from_value(value).unwrap_or_default(),
120        Err(_) => MicLifecycle::Idle,
121    }
122}
123
124/// Async helper: probe OS microphone permission state.
125///
126/// Returns `Granted` when running outside Tauri so the picker
127/// renders normally during `trunk serve` dev.
128pub async fn microphone_permission_status() -> CameraPermission {
129    match microphone_permission_status_js().await {
130        Ok(value) => serde_wasm_bindgen::from_value(value).unwrap_or(CameraPermission::Granted),
131        Err(_) => CameraPermission::Granted,
132    }
133}
134
135/// Async helper: shell out to open System Settings → Microphone
136/// (M-RECP.8 / AUT-286). Same shape + silent-failure semantics as
137/// [`crate::camera_ipc::open_settings_camera`].
138pub async fn open_settings_microphone() {
139    let _ = open_settings_microphone_js().await;
140}
141
142/// Subscribe to the `mic-level` Tauri event (M-AUDIO.METER /
143/// AUT-287) emitted by `MicCapturePipeline` at ~20 Hz. The handler
144/// receives an EMA-smoothed RMS in `[0.0, ~1.0]`.
145///
146/// Returns immediately; the listener runs for the lifetime of the
147/// app (no cancellation today — the worker stops emitting when
148/// `stop_mic_capture` drops the pipeline, which is sufficient
149/// cleanup).
150pub fn subscribe_mic_level(handler: impl Fn(f32) + 'static) {
151    use wasm_bindgen::closure::Closure;
152    let Some(window) = web_sys::window() else {
153        return;
154    };
155    let Ok(tauri) = js_sys::Reflect::get(&window, &"__TAURI__".into()) else {
156        return;
157    };
158    let Ok(event) = js_sys::Reflect::get(&tauri, &"event".into()) else {
159        return;
160    };
161    let Ok(listen) = js_sys::Reflect::get(&event, &"listen".into()) else {
162        return;
163    };
164    let Ok(listen_fn) = listen.dyn_into::<js_sys::Function>() else {
165        return;
166    };
167    let cb = Closure::wrap(Box::new(move |payload: JsValue| {
168        // payload shape from Tauri: { event, id, payload }
169        #[allow(
170            clippy::cast_possible_truncation,
171            reason = "RMS in [0, 1] easily fits f32"
172        )]
173        let level = js_sys::Reflect::get(&payload, &"payload".into())
174            .ok()
175            .and_then(|v| v.as_f64())
176            .map_or(0.0_f32, |v| v as f32);
177        handler(level);
178    }) as Box<dyn Fn(JsValue)>);
179    let _ = listen_fn.call2(&event, &"mic-level".into(), cb.as_ref().unchecked_ref());
180    // Leak the closure so the listener stays alive forever — matches
181    // the app's "no cleanup until process exit" event-listener pattern
182    // already established in player_ipc.rs.
183    cb.forget();
184}