From b4d0f2db7b6359d955dd90f47ac3636ed1144d15 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 2 Jun 2026 14:36:04 -0400 Subject: [PATCH] feat(audio): live mic meter with draggable noise gate Adds a mic input meter to Settings for gate calibration, replacing the blind noise-gate slider with a unified Discord/OBS-style control: the bar shows the live mic level and a draggable handle sets the gate threshold on the same axis. Fill is green above the gate (transmitting), dim below it (muted), with a live status word; the handle is bright red with a dark edge so it stays legible when the green level sweeps past it. Two level sources: - In-call: the capture thread peak-holds the raw (pre-gate, pre-mute) frame level and emits UiEvent::MicLevel ~10/sec. - Off-call: a "Test mic" toggle runs CoreCommand::SetMicMonitor, spinning up a standalone capture-only stream feeding run_mic_monitor. It shares the backend's single capture stream, so Join tears it down first and leaving Settings releases it; ignored while a session is active. The gate handle drags live via NoiseGateDragging (no disk write per pixel) and persists once on release via NoiseGateChanged. Meter axis is 0..0.3 so a normal voice doesn't peg. Enables the iced "canvas" feature for the custom GateMeter widget. Build + clippy clean, tests pass. Field-verified on desktop. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 60 +++++++++++ Cargo.toml | 2 +- src/app/mod.rs | 243 ++++++++++++++++++++++++++++++++++++++++++- src/core/messages.rs | 7 ++ src/core/mod.rs | 88 ++++++++++++++++ 5 files changed, 394 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5edddaf..5a6ddc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1466,6 +1466,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + [[package]] name = "fnv" version = "1.0.7" @@ -2275,6 +2281,7 @@ dependencies = [ "iced_core", "iced_futures", "log", + "lyon_path", "raw-window-handle", "rustc-hash 2.1.2", "thiserror 2.0.18", @@ -2349,6 +2356,7 @@ dependencies = [ "iced_debug", "iced_graphics", "log", + "lyon", "rustc-hash 2.1.2", "thiserror 2.0.18", "wgpu", @@ -3085,6 +3093,58 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + [[package]] name = "mac-addr" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 11b678a..6086f9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ async-trait = "0.1.89" base64 = "0.22.1" bytes = "1.11.1" dirs = "6.0.0" -iced = "0.14.0" +iced = { version = "0.14.0", features = ["canvas"] } iroh = "1.0.0-rc.0" iroh-gossip = "0.99.0" opus = "0.3.1" diff --git a/src/app/mod.rs b/src/app/mod.rs index 4982321..dcbe1da 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,10 +5,13 @@ use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices}; use crate::config::{AppConfig, NetworkMode}; use iced::widget::{ - container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, Column, + container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, + canvas, Canvas, Column, }; +use iced::widget::canvas::{Action, Frame, Geometry, Path, Program}; use iced::{ - Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, + Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse, + Point, Rectangle, Renderer, Size, }; use iroh::EndpointId; use std::collections::{HashMap, HashSet}; @@ -41,6 +44,9 @@ pub enum AppMessage { InputDeviceSelected(AudioDevice), OutputDeviceSelected(AudioDevice), NoiseGateChanged(f32), + /// Live value while dragging the gate handle on the meter — updates the gate + /// immediately but does not persist (saved once on release via NoiseGateChanged). + NoiseGateDragging(f32), NetworkModeSelected(NetworkMode), EventOccurred(Event), NavigateToSettings, @@ -48,6 +54,7 @@ pub enum AppMessage { ToggleNotifications(bool), ToggleEchoCancellation(bool), CustomSoundPathChanged(Sound, String), + ToggleMicTest(bool), } fn core_subscription() -> impl iced::futures::Stream { @@ -84,6 +91,10 @@ pub struct AppState { peers: HashMap, peer_volumes: HashMap, audio_levels: HashMap, + /// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter. + mic_level: f32, + /// Whether the standalone (off-call) mic test stream is running. + mic_test_active: bool, /// Peers whose audio link is currently down (initial connect or reconnect). connecting: HashSet, /// Peers we've had a live link to at least once — used to say "Reconnecting" @@ -147,6 +158,8 @@ impl Default for AppState { peers: HashMap::new(), peer_volumes: HashMap::new(), audio_levels: HashMap::new(), + mic_level: 0.0, + mic_test_active: false, connecting: HashSet::new(), ever_connected: HashSet::new(), controller, @@ -224,6 +237,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { let output_device = state.selected_output.as_ref().map(|d| d.name.clone()); if !state.ticket_input.is_empty() { state.status_message = "Joining room...".to_string(); + // Core releases any standalone mic monitor on join. + state.mic_test_active = false; let _ = state.controller.send(CoreCommand::Join { name: state.name.clone(), ticket: state.ticket_input.clone(), @@ -237,6 +252,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { let input_device = state.selected_input.as_ref().map(|d| d.name.clone()); let output_device = state.selected_output.as_ref().map(|d| d.name.clone()); state.status_message = "Creating room...".to_string(); + // Core releases any standalone mic monitor on join. + state.mic_test_active = false; let _ = state.controller.send(CoreCommand::Join { name: state.name.clone(), ticket: "create".to_string(), @@ -265,6 +282,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.self_id = self_id; state.status_message = "Connected".to_string(); state.current_screen = Screen::Room; + // The core tore down any standalone mic monitor when joining; + // the in-call meter now drives mic_level. + state.mic_test_active = false; notify::play(Sound::SelfJoin, state.config.custom_sound_self_join.as_deref()); } UiEvent::RoomLeft => { @@ -275,6 +295,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.ever_connected.clear(); state.status_message = "Ready to connect".to_string(); state.current_screen = Screen::Home; + state.mic_level = 0.0; notify::play(Sound::SelfLeave, state.config.custom_sound_self_leave.as_deref()); } UiEvent::PeerJoined { id, state: peer_state } => { @@ -317,6 +338,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.audio_levels.insert(id, val); } } + UiEvent::MicLevel(level) => { + state.mic_level = level; + } UiEvent::Error(err) => { state.status_message = format!("Error: {}", err); } @@ -353,6 +377,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.config.save(); let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val)); } + AppMessage::NoiseGateDragging(val) => { + // Live drag: apply immediately, defer the disk write to release. + state.config.noise_gate_threshold = val; + let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val)); + } AppMessage::NetworkModeSelected(mode) => { state.config.network_mode = mode; state.config.save(); @@ -382,6 +411,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt, } } + AppMessage::ToggleMicTest(enabled) => { + state.mic_test_active = enabled; + if !enabled { + state.mic_level = 0.0; + } + let input_device = state.selected_input.as_ref().map(|d| d.name.clone()); + let _ = state + .controller + .send(CoreCommand::SetMicMonitor { enabled, input_device }); + } AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => { if state.is_setting_hotkey { state.ptt_hotkey = key.clone(); @@ -403,6 +442,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } AppMessage::NavigateBack => { state.config.save(); + // Release the mic when leaving Settings if the test was running. + if state.mic_test_active { + state.mic_test_active = false; + state.mic_level = 0.0; + let _ = state.controller.send(CoreCommand::SetMicMonitor { + enabled: false, + input_device: None, + }); + } if state.ticket.is_empty() { state.current_screen = Screen::Home; } else { @@ -523,6 +571,62 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ].spacing(4).width(iced::Length::Fill) }; + // Live mic level meter for gate calibration. Shares the gate slider's + // 0..0.1 scale so you can read your voice against the threshold directly. + // During a call the in-call meter feeds it; otherwise a "Test mic" toggle + // spins up a standalone capture stream. + let in_call = !state.ticket.is_empty(); + let mic_test_control: Element<'_, AppMessage> = if in_call { + text("Live (in call)").size(11).color(color_green).into() + } else { + let (label, bg) = if state.mic_test_active { + ("⏹ Stop mic test", color_red) + } else { + ("🎙 Test mic", color_surface) + }; + button(text(label).size(12)) + .on_press(AppMessage::ToggleMicTest(!state.mic_test_active)) + .style(b_style(bg, color_blue, color_text, 6.0)) + .padding(6) + .into() + }; + // Unified meter + draggable gate (Discord/OBS-style): the live mic level + // fills the bar and the yellow handle is the gate threshold, dragged + // directly on the same axis. Green fill = above the gate (transmitting), + // dim = below it (muted). Live status word reinforces the colour. + let gate_thresh = state.config.noise_gate_threshold; + let speaking = state.mic_level >= 0.001; + let passing = speaking && state.mic_level >= gate_thresh; + let (status_label, status_color) = if !speaking { + ("○ Idle", color_subtext) + } else if passing { + ("● Transmitting", color_green) + } else { + ("● Muted by gate", color_red) + }; + let gate_meter = Canvas::new(GateMeter { + level: state.mic_level, + threshold: gate_thresh, + track: color_crust, + border: color_surface, + fill_on: color_green, + fill_off: color_surface, + handle: Color::from_rgb8(255, 40, 40), + handle_edge: color_crust, + }) + .width(iced::Length::Fill) + .height(iced::Length::Fixed(20.0)); + let mic_meter = column![ + gate_meter, + row![ + text(status_label).size(12).color(status_color), + horizontal_space(), + text(format!("gate {:.1}%", gate_thresh * 100.0)).size(11).color(color_subtext), + horizontal_space(), + mic_test_control, + ].align_y(iced::alignment::Vertical::Center).spacing(8), + ].spacing(6).width(iced::Length::Fill); + let settings_content = scrollable( column![ text("Settings").size(24).color(color_blue), @@ -550,9 +654,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { vertical_space(12.0), row![ column![ - text(format!("Mic Sensitivity (Noise Gate): {:.1}%", state.config.noise_gate_threshold * 100.0)).size(14).color(color_subtext), - slider(0.0..=0.1, state.config.noise_gate_threshold, AppMessage::NoiseGateChanged).step(0.001), - text("Smoothly fades out audio below this level. 0% disables the gate.").size(11).color(color_subtext), + text("Mic Level & Noise Gate").size(14).color(color_subtext), + mic_meter, + text("Drag the yellow handle to set the gate. While talking, place it just above your quiet-room level so silence is muted but your voice passes through.").size(11).color(color_subtext), vertical_space(4.0), checkbox(state.config.echo_cancellation_enabled) .label("Echo cancellation") @@ -915,6 +1019,135 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { } } +/// Full-scale of the meter's RMS axis. Speech RMS runs to ~0.3 normalized, so +/// this keeps a normal voice off the ceiling while leaving the gate threshold +/// (usually a few percent) draggable across the lower part of the bar. +const METER_MAX: f32 = 0.3; + +/// A unified mic-level meter with a draggable noise-gate handle (Discord/OBS +/// style). The bar fills to the live mic level; the yellow handle marks the gate +/// threshold on the same axis and can be dragged to set it. The fill turns green +/// when the level is above the gate (transmitting), dim when below it (muted). +struct GateMeter { + level: f32, + threshold: f32, + track: Color, + border: Color, + fill_on: Color, + fill_off: Color, + /// Bright core of the gate handle. + handle: Color, + /// Dark outline behind the handle, so it stays visible over the green fill. + handle_edge: Color, +} + +impl GateMeter { + /// Maps a cursor x (relative to the bar) to a gate threshold on the meter axis. + fn x_to_threshold(x: f32, width: f32) -> f32 { + (x / width.max(1.0)).clamp(0.0, 1.0) * METER_MAX + } +} + +#[derive(Default)] +struct GateMeterState { + dragging: bool, +} + +impl Program for GateMeter { + type State = GateMeterState; + + fn update( + &self, + state: &mut Self::State, + event: &Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if let Some(p) = cursor.position_in(bounds) { + state.dragging = true; + let t = Self::x_to_threshold(p.x, bounds.width); + return Some(Action::publish(AppMessage::NoiseGateDragging(t)).and_capture()); + } + } + // Track moves anywhere on screen so the drag survives leaving the bar. + Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => { + if let Some(p) = cursor.position() { + let t = Self::x_to_threshold(p.x - bounds.x, bounds.width); + return Some(Action::publish(AppMessage::NoiseGateDragging(t)).and_capture()); + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => { + state.dragging = false; + let x = cursor.position().map(|p| p.x - bounds.x).unwrap_or(0.0); + let t = Self::x_to_threshold(x, bounds.width); + // Persist the final value on release. + return Some(Action::publish(AppMessage::NoiseGateChanged(t)).and_capture()); + } + _ => {} + } + None + } + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + let w = bounds.width; + let h = bounds.height; + + // Track. + frame.fill_rectangle(Point::ORIGIN, Size::new(w, h), self.track); + + // Level fill, coloured by whether we're above the gate. + let level_frac = (self.level / METER_MAX).clamp(0.0, 1.0); + let fill = if self.level >= self.threshold { self.fill_on } else { self.fill_off }; + if level_frac > 0.0 { + frame.fill_rectangle(Point::ORIGIN, Size::new(w * level_frac, h), fill); + } + + // Gate handle: a bright vertical line + grip caps, each backed by a dark + // edge so the handle stays legible even when the green level sweeps past it. + let thr_frac = (self.threshold / METER_MAX).clamp(0.0, 1.0); + let x = (w * thr_frac).clamp(3.0, (w - 3.0).max(3.0)); + // Dark edge (slightly larger), then bright core. + frame.fill(&Path::rectangle(Point::new(x - 3.0, 0.0), Size::new(6.0, h)), self.handle_edge); + frame.fill(&Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)), self.handle); + // Grip caps top and bottom. + frame.fill(&Path::rectangle(Point::new(x - 5.5, 0.0), Size::new(11.0, 6.0)), self.handle_edge); + frame.fill(&Path::rectangle(Point::new(x - 4.0, 1.0), Size::new(8.0, 4.0)), self.handle); + frame.fill(&Path::rectangle(Point::new(x - 5.5, h - 6.0), Size::new(11.0, 6.0)), self.handle_edge); + frame.fill(&Path::rectangle(Point::new(x - 4.0, h - 5.0), Size::new(8.0, 4.0)), self.handle); + + // Border. + frame.stroke( + &Path::rectangle(Point::ORIGIN, Size::new(w, h)), + canvas::Stroke::default().with_color(self.border).with_width(1.0), + ); + + vec![frame.into_geometry()] + } + + fn mouse_interaction( + &self, + state: &Self::State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if state.dragging || cursor.is_over(bounds) { + mouse::Interaction::ResizingHorizontally + } else { + mouse::Interaction::default() + } + } +} + #[cfg(test)] mod tests { use super::{reconnect_attempt_chime, reconnected_chime}; diff --git a/src/core/messages.rs b/src/core/messages.rs index 4ab8ed2..7497f38 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -12,6 +12,10 @@ pub enum CoreCommand { SetPttActive(bool), SetPeerVolume(EndpointId, f32), SetNoiseGateThreshold(f32), + /// Start/stop a standalone capture-only stream that reports the raw mic + /// level via [`UiEvent::MicLevel`], for gate calibration outside a call. + /// Ignored while a room session is active (the in-call meter covers that). + SetMicMonitor { enabled: bool, input_device: Option }, /// Set the relay/discovery posture. Takes effect on the next room join, /// since the endpoint is (re)built then. SetNetworkMode(NetworkMode), @@ -30,5 +34,8 @@ pub enum UiEvent { /// Audio link to a peer is up and carrying audio. PeerConnected { id: EndpointId }, AudioLevels(Vec<(EndpointId, f32)>), + /// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`, + /// for the settings level meter. Throttled to ~10/sec. + MicLevel(f32), Error(String), } diff --git a/src/core/mod.rs b/src/core/mod.rs index fa729d6..ccdc844 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -131,6 +131,47 @@ fn frame_level(frame: &[i16]) -> f32 { (rms / 32768.0).clamp(0.0, 1.0) } +/// Peak-hold every this many captured samples (~100ms @ 48kHz) before emitting a +/// [`UiEvent::MicLevel`], so the meter doesn't flood the UI runtime at frame rate. +const MIC_LEVEL_REPORT_SAMPLES: usize = 4800; + +/// A standalone, capture-only mic monitor for gate calibration outside a call. +/// Owns the worker thread that reads raw PCM and reports its level; the PipeWire +/// capture stream itself lives in the shared backend. Tear down by stopping the +/// backend's capture (which closes the channel) and joining this thread. +struct MicMonitor { + thread: std::thread::JoinHandle<()>, +} + +/// Drains a capture channel, reporting the raw (un-gated) mic level to the UI. +/// Returns when the channel closes (i.e. the backend's capture stream stopped). +fn run_mic_monitor(rx: std::sync::mpsc::Receiver>, ui_tx: mpsc::Sender) { + let mut peak = 0.0f32; + let mut acc = 0usize; + while let Ok(pcm) = rx.recv() { + peak = peak.max(frame_level(&pcm)); + acc += pcm.len(); + if acc >= MIC_LEVEL_REPORT_SAMPLES { + // Drop on a full channel — a stale meter reading is harmless. + let _ = ui_tx.try_send(UiEvent::MicLevel(peak)); + peak = 0.0; + acc = 0; + } + } + // Channel closed: the monitor was stopped. Snap the meter back to zero. + let _ = ui_tx.try_send(UiEvent::MicLevel(0.0)); +} + +/// Stops a standalone mic monitor if one is running. MUST NOT be called while a +/// room session is active — `backend.stop()` would also tear down the call's +/// capture/playback. Monitor and session are mutually exclusive by construction. +fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option) { + if let Some(m) = monitor { + let _ = backend.stop(); + let _ = m.thread.join(); + } +} + /// Sum per-peer frames sample-by-sample into one `frame_len`-sample output, /// saturating each summed sample to the i16 range so a loud mix clips rather than /// wrapping. Peers shorter than `frame_len` contribute 0 past their end; an empty @@ -308,6 +349,8 @@ async fn run_core_loop( let mut network_mode = NetworkMode::default(); let mut active_session: Option = None; + // Standalone capture-only mic meter, live only when no session exists. + let mut mic_monitor: Option = None; while let Some(cmd) = cmd_rx.recv().await { match cmd { @@ -320,6 +363,11 @@ async fn run_core_loop( session.shutdown(audio_backend.clone()).await; } + // Release a standalone mic monitor if running — it shares the + // backend's single capture stream, so it must stop before the + // call claims it. (Safe here: any session was just shut down.) + stop_mic_monitor(&audio_backend, mic_monitor.take()); + // Build the endpoint per the configured relay/discovery posture. // All postures keep the in-memory address lookup (fed by tickets // and gossip); they differ in whether n0's relay and DNS presence @@ -469,6 +517,7 @@ async fn run_core_loop( let ptt_active_clone = ptt_active.clone(); let noise_gate_threshold_clone = noise_gate_threshold.clone(); let transport_clone = transport.clone(); + let ui_tx_capture = ui_tx.clone(); let capture_thread = std::thread::spawn(move || { use opus::{Channels, Application}; @@ -486,8 +535,20 @@ async fn run_core_loop( // carrying envelope state across frames. The live slider value // is read per frame so changes apply immediately. let mut gate = crate::audio::gate::NoiseGate::new(48000); + // Peak-held raw mic level for the settings meter, reported + // pre-gate/pre-mute so calibration reflects the true input. + let mut mic_peak = 0.0f32; + let mut mic_acc = 0usize; while let Ok(mut pcm) = capture_rx.recv() { + mic_peak = mic_peak.max(frame_level(&pcm)); + mic_acc += pcm.len(); + if mic_acc >= MIC_LEVEL_REPORT_SAMPLES { + let _ = ui_tx_capture.try_send(UiEvent::MicLevel(mic_peak)); + mic_peak = 0.0; + mic_acc = 0; + } + if is_muted_clone.load(Ordering::Relaxed) { continue; } @@ -790,6 +851,33 @@ async fn run_core_loop( noise_gate_threshold.store(threshold.to_bits(), Ordering::Relaxed); } + CoreCommand::SetMicMonitor { enabled, input_device } => { + // During a call the in-call capture thread already reports the + // mic level, and it owns the backend's capture stream — leave it be. + if active_session.is_some() { + continue; + } + if enabled { + if mic_monitor.is_none() { + let (tx, rx) = std::sync::mpsc::channel(); + match audio_backend.start_capture(tx, input_device) { + Ok(()) => { + let ui = ui_tx.clone(); + let thread = std::thread::spawn(move || run_mic_monitor(rx, ui)); + mic_monitor = Some(MicMonitor { thread }); + } + Err(e) => { + let _ = ui_tx + .send(UiEvent::Error(format!("Mic test unavailable: {e}"))) + .await; + } + } + } + } else { + stop_mic_monitor(&audio_backend, mic_monitor.take()); + } + } + CoreCommand::SetNetworkMode(mode) => { network_mode = mode; }