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:
+238
-5
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user