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:
Generated
+60
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user