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}