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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 15:46:38 -04:00
co-authored by Claude Opus 4.8
parent a15c70623d
commit b4d0f2db7b
5 changed files with 394 additions and 6 deletions
Generated
+60
View File
@@ -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"
+1 -1
View File
@@ -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"
+238 -5
View File
@@ -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<Item = UiEvent> {
@@ -84,6 +91,10 @@ pub struct AppState {
peers: HashMap<EndpointId, PeerState>,
peer_volumes: HashMap<EndpointId, f32>,
audio_levels: HashMap<EndpointId, f32>,
/// 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<EndpointId>,
/// 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<AppMessage> {
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<AppMessage> {
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<AppMessage> {
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<AppMessage> {
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<AppMessage> {
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<AppMessage> {
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<AppMessage> {
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> {
}
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<AppMessage> for GateMeter {
type State = GateMeterState;
fn update(
&self,
state: &mut Self::State,
event: &Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<AppMessage>> {
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<Geometry> {
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};
+7
View File
@@ -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<String> },
/// 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),
}
+88
View File
@@ -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<Vec<i16>>, ui_tx: mpsc::Sender<UiEvent>) {
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<MicMonitor>) {
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<ActiveSession> = None;
// Standalone capture-only mic meter, live only when no session exists.
let mut mic_monitor: Option<MicMonitor> = 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;
}