A (planned) collection of lightweight tools for streaming.

feat: info panel and simple waveform

Changed files
+543 -16
app
assets
core
src
+111
Cargo.lock
··· 49 ] 50 51 [[package]] 52 name = "allocator-api2" 53 version = "0.2.21" 54 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2012 ] 2013 2014 [[package]] 2015 name = "lebe" 2016 version = "0.5.3" 2017 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2176 ] 2177 2178 [[package]] 2179 name = "memchr" 2180 version = "2.7.6" 2181 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2313 "cfg_aliases 0.2.1", 2314 "libc", 2315 "memoffset", 2316 ] 2317 2318 [[package]] ··· 3172 ] 3173 3174 [[package]] 3175 name = "renderdoc-sys" 3176 version = "1.1.0" 3177 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3371 ] 3372 3373 [[package]] 3374 name = "shlex" 3375 version = "1.3.0" 3376 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3562 "dirs 6.0.0", 3563 "iced", 3564 "rfd", 3565 ] 3566 3567 [[package]] 3568 name = "streamtools-core" 3569 version = "0.1.0" 3570 3571 [[package]] 3572 name = "strict-num" ··· 3696 ] 3697 3698 [[package]] 3699 name = "tiff" 3700 version = "0.9.1" 3701 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3839 checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" 3840 dependencies = [ 3841 "once_cell", 3842 ] 3843 3844 [[package]] ··· 3970 "serde_core", 3971 "wasm-bindgen", 3972 ] 3973 3974 [[package]] 3975 name = "version_check"
··· 49 ] 50 51 [[package]] 52 + name = "aho-corasick" 53 + version = "1.1.4" 54 + source = "registry+https://github.com/rust-lang/crates.io-index" 55 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 56 + dependencies = [ 57 + "memchr", 58 + ] 59 + 60 + [[package]] 61 name = "allocator-api2" 62 version = "0.2.21" 63 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2021 ] 2022 2023 [[package]] 2024 + name = "lazy_static" 2025 + version = "1.5.0" 2026 + source = "registry+https://github.com/rust-lang/crates.io-index" 2027 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 2028 + 2029 + [[package]] 2030 name = "lebe" 2031 version = "0.5.3" 2032 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2191 ] 2192 2193 [[package]] 2194 + name = "matchers" 2195 + version = "0.2.0" 2196 + source = "registry+https://github.com/rust-lang/crates.io-index" 2197 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 2198 + dependencies = [ 2199 + "regex-automata", 2200 + ] 2201 + 2202 + [[package]] 2203 name = "memchr" 2204 version = "2.7.6" 2205 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2337 "cfg_aliases 0.2.1", 2338 "libc", 2339 "memoffset", 2340 + ] 2341 + 2342 + [[package]] 2343 + name = "nu-ansi-term" 2344 + version = "0.50.3" 2345 + source = "registry+https://github.com/rust-lang/crates.io-index" 2346 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 2347 + dependencies = [ 2348 + "windows-sys 0.61.2", 2349 ] 2350 2351 [[package]] ··· 3205 ] 3206 3207 [[package]] 3208 + name = "regex-automata" 3209 + version = "0.4.13" 3210 + source = "registry+https://github.com/rust-lang/crates.io-index" 3211 + checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" 3212 + dependencies = [ 3213 + "aho-corasick", 3214 + "memchr", 3215 + "regex-syntax", 3216 + ] 3217 + 3218 + [[package]] 3219 + name = "regex-syntax" 3220 + version = "0.8.8" 3221 + source = "registry+https://github.com/rust-lang/crates.io-index" 3222 + checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 3223 + 3224 + [[package]] 3225 name = "renderdoc-sys" 3226 version = "1.1.0" 3227 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3421 ] 3422 3423 [[package]] 3424 + name = "sharded-slab" 3425 + version = "0.1.7" 3426 + source = "registry+https://github.com/rust-lang/crates.io-index" 3427 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 3428 + dependencies = [ 3429 + "lazy_static", 3430 + ] 3431 + 3432 + [[package]] 3433 name = "shlex" 3434 version = "1.3.0" 3435 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3621 "dirs 6.0.0", 3622 "iced", 3623 "rfd", 3624 + "streamtools-core", 3625 + "tracing", 3626 + "tracing-subscriber", 3627 ] 3628 3629 [[package]] 3630 name = "streamtools-core" 3631 version = "0.1.0" 3632 + dependencies = [ 3633 + "anyhow", 3634 + "cpal", 3635 + ] 3636 3637 [[package]] 3638 name = "strict-num" ··· 3762 ] 3763 3764 [[package]] 3765 + name = "thread_local" 3766 + version = "1.1.9" 3767 + source = "registry+https://github.com/rust-lang/crates.io-index" 3768 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 3769 + dependencies = [ 3770 + "cfg-if", 3771 + ] 3772 + 3773 + [[package]] 3774 name = "tiff" 3775 version = "0.9.1" 3776 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3914 checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" 3915 dependencies = [ 3916 "once_cell", 3917 + "valuable", 3918 + ] 3919 + 3920 + [[package]] 3921 + name = "tracing-log" 3922 + version = "0.2.0" 3923 + source = "registry+https://github.com/rust-lang/crates.io-index" 3924 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 3925 + dependencies = [ 3926 + "log", 3927 + "once_cell", 3928 + "tracing-core", 3929 + ] 3930 + 3931 + [[package]] 3932 + name = "tracing-subscriber" 3933 + version = "0.3.22" 3934 + source = "registry+https://github.com/rust-lang/crates.io-index" 3935 + checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" 3936 + dependencies = [ 3937 + "matchers", 3938 + "nu-ansi-term", 3939 + "once_cell", 3940 + "regex-automata", 3941 + "sharded-slab", 3942 + "smallvec", 3943 + "thread_local", 3944 + "tracing", 3945 + "tracing-core", 3946 + "tracing-log", 3947 ] 3948 3949 [[package]] ··· 4075 "serde_core", 4076 "wasm-bindgen", 4077 ] 4078 + 4079 + [[package]] 4080 + name = "valuable" 4081 + version = "0.1.1" 4082 + source = "registry+https://github.com/rust-lang/crates.io-index" 4083 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 4084 4085 [[package]] 4086 name = "version_check"
+24 -1
README.md
··· 1 - # Stream Tools
··· 1 + # StreamTools 2 + 3 + A (planned) collection of lightweight tools for streaming, built with Rust. 4 + 5 + ## Mic Activity 6 + 7 + A simple microphone activity indicator. 8 + 9 + ### What it does 10 + 11 + StreamTools displays a customizable image that shows when your microphone is active with an emerald green border, 12 + similar to an app like Discord's mic indicator. 13 + 14 + I made this to show user's I'm talking even with my camera. 15 + 16 + ### Quick Start 17 + 18 + ![Using the cover of a fun F# book](./assets/screenshot.png) 19 + 20 + ```bash 21 + cargo run 22 + ``` 23 + 24 + Drag an image onto the window, speak into your mic, and watch the green border appear!
+3
app/Cargo.toml
··· 9 anyhow = "1" 10 dirs = "6.0.0" 11 rfd = "0.16.0"
··· 9 anyhow = "1" 10 dirs = "6.0.0" 11 rfd = "0.16.0" 12 + streamtools-core = { path = "../core" } 13 + tracing = "0.1" 14 + tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+344 -1
app/src/main.rs
··· 1 - fn main() {}
··· 1 + use iced::mouse; 2 + use iced::widget::{self, Canvas, button, canvas, column, container, image, mouse_area, text}; 3 + use iced::{Color, Font, Length, Point, Rectangle, Size}; 4 + use iced::{Subscription, Task, Theme, time}; 5 + use std::collections::VecDeque; 6 + use std::path::PathBuf; 7 + use std::sync::Mutex; 8 + use std::sync::{ 9 + Arc, 10 + atomic::{AtomicBool, Ordering}, 11 + }; 12 + use std::time::{Duration, Instant}; 13 + use streamtools_core::{MicInfo, SharedLevels, spawn_mic_listener}; 14 + 15 + #[derive(Debug)] 16 + struct Model { 17 + is_active: bool, 18 + flag: Arc<AtomicBool>, 19 + /// current image (if any) 20 + current_image: Option<image::Handle>, 21 + /// path to last used image 22 + last_image_path: Option<PathBuf>, 23 + show_panel: bool, 24 + /// copy of the recent mic RMS values for drawing 25 + levels: SharedLevels, 26 + /// device metadata 27 + info: MicInfo, 28 + /// track last click for double-click detection 29 + last_click: Option<Instant>, 30 + /// current audio level for border intensity (0.0 - 1.0) 31 + current_level: f32, 32 + } 33 + 34 + #[derive(Debug, Clone)] 35 + enum Message { 36 + Tick, 37 + WindowEvent(iced::window::Event), 38 + ReplaceImagePressed, 39 + ImageChosen(Option<PathBuf>), 40 + ImageClicked, 41 + ClosePanel, 42 + } 43 + 44 + const JETBRAINS_MONO: Font = Font::with_name("JetBrains Mono"); 45 + 46 + /// Waveform visualizer for audio levels 47 + #[derive(Debug)] 48 + struct Waveform { 49 + levels: SharedLevels, 50 + } 51 + 52 + impl<Message> canvas::Program<Message> for Waveform { 53 + type State = (); 54 + 55 + /// Some Notes: 56 + /// 57 + /// We amplify the visual representation significantly for better visibility (bright cyan/green) 58 + /// Using 50x amplification -> typical mic levels are 0.01-0.1 RMS 59 + fn draw( 60 + &self, _: &Self::State, renderer: &iced::Renderer, _: &Theme, bounds: Rectangle, _: mouse::Cursor, 61 + ) -> Vec<canvas::Geometry> { 62 + let mut frame = canvas::Frame::new(renderer, bounds.size()); 63 + 64 + frame.fill_rectangle(Point::new(0.0, 0.0), bounds.size(), Color::from_rgb(0.1, 0.1, 0.12)); 65 + 66 + if let Ok(levels) = self.levels.lock() { 67 + if !levels.is_empty() { 68 + let width = bounds.width; 69 + let height = bounds.height; 70 + let count = levels.len(); 71 + let bar_width = width / count as f32; 72 + 73 + for (i, &level) in levels.iter().enumerate() { 74 + let x = i as f32 * bar_width; 75 + 76 + let amplified = (level * 50.0).min(1.0); 77 + let bar_height = (amplified * height).min(height); 78 + let y = height - bar_height; 79 + 80 + frame.fill_rectangle( 81 + Point::new(x, y), 82 + Size::new(bar_width * 0.9, bar_height), 83 + Color::from_rgb(0.2, 1.0, 0.8), 84 + ); 85 + } 86 + } 87 + } 88 + 89 + vec![frame.into_geometry()] 90 + } 91 + } 92 + 93 + impl Model { 94 + fn update(&mut self, message: Message) -> iced::Task<Message> { 95 + match message { 96 + Message::Tick => { 97 + let was_active = self.is_active; 98 + self.is_active = self.flag.load(Ordering::Relaxed); 99 + 100 + if let Ok(levels) = self.levels.lock() { 101 + self.current_level = levels.back().copied().unwrap_or(0.0); 102 + } 103 + 104 + if was_active != self.is_active { 105 + tracing::info!("Mic activity changed: is_active={}", self.is_active); 106 + } 107 + 108 + Task::none() 109 + } 110 + Message::ImageClicked => { 111 + let now = Instant::now(); 112 + if let Some(last) = self.last_click { 113 + let elapsed = now.duration_since(last); 114 + if elapsed < Duration::from_millis(500) { 115 + self.show_panel = !self.show_panel; 116 + self.last_click = None; 117 + } else { 118 + self.last_click = Some(now); 119 + } 120 + } else { 121 + self.last_click = Some(now); 122 + } 123 + 124 + Task::none() 125 + } 126 + Message::WindowEvent(iced::window::Event::FileDropped(path)) => { 127 + if let Some(ext) = path.extension().and_then(|s| s.to_str()) { 128 + let ext_lower = ext.to_ascii_lowercase(); 129 + if !["png", "jpg", "jpeg", "gif", "webp"].contains(&ext_lower.as_str()) { 130 + eprintln!("Unsupported image type: {ext_lower}"); 131 + return Task::none(); 132 + } 133 + } 134 + 135 + let handle = image::Handle::from_path(&path); 136 + self.current_image = Some(handle); 137 + self.last_image_path = Some(path.clone()); 138 + 139 + if let Some(config_file) = Self::get_config_file() { 140 + if let Err(e) = std::fs::write(config_file, path.to_string_lossy().as_ref()) { 141 + eprintln!("failed to write config file: {e}"); 142 + } 143 + } 144 + 145 + Task::none() 146 + } 147 + Message::ReplaceImagePressed => { 148 + let path = rfd::FileDialog::new() 149 + .add_filter("Images", &["png", "jpg", "jpeg", "gif", "webp"]) 150 + .set_title("Choose mic indicator image") 151 + .pick_file(); 152 + 153 + Task::done(Message::ImageChosen(path)) 154 + } 155 + Message::ImageChosen(Some(path)) => { 156 + let handle = image::Handle::from_path(&path); 157 + self.current_image = Some(handle); 158 + self.last_image_path = Some(path.clone()); 159 + if let Some(config_file) = Self::get_config_file() { 160 + let _ = std::fs::write(config_file, path.to_string_lossy().as_ref()); 161 + } 162 + Task::none() 163 + } 164 + Message::ImageChosen(None) | Message::WindowEvent(_) => Task::none(), 165 + Message::ClosePanel => { 166 + self.show_panel = false; 167 + Task::none() 168 + } 169 + } 170 + } 171 + 172 + fn get_config_file() -> Option<PathBuf> { 173 + dirs::config_dir().map(|mut config| { 174 + config.push("streamtools"); 175 + std::fs::create_dir_all(&config).ok()?; 176 + config.push("last_image.txt"); 177 + Some(config) 178 + })? 179 + } 180 + 181 + fn load_last_image() -> (Option<PathBuf>, Option<image::Handle>) { 182 + if let Some(config_file) = Self::get_config_file() { 183 + if let Ok(path) = std::fs::read_to_string(config_file) { 184 + let path = PathBuf::from(path.trim()); 185 + if path.exists() { 186 + let handle = image::Handle::from_path(&path); 187 + return (Some(path), Some(handle)); 188 + } 189 + } 190 + } 191 + (None, None) 192 + } 193 + 194 + fn new(flag: Arc<AtomicBool>, levels: SharedLevels, info: MicInfo) -> Self { 195 + let (last_image_path, current_image) = Self::load_last_image(); 196 + 197 + Self { 198 + show_panel: false, 199 + is_active: false, 200 + flag, 201 + current_image, 202 + last_image_path, 203 + levels, 204 + info, 205 + last_click: None, 206 + current_level: 0.0, 207 + } 208 + } 209 + fn build_panel(&self) -> iced::Element<'_, Message> { 210 + let close_button = button(text("Close").font(JETBRAINS_MONO)) 211 + .on_press(Message::ClosePanel) 212 + .padding(12); 213 + 214 + let info_text = column![ 215 + text("Microphone Information").size(18).font(JETBRAINS_MONO), 216 + text(format!("Device: {}", self.info.name)) 217 + .size(14) 218 + .font(JETBRAINS_MONO), 219 + text(format!("Sample Rate: {} Hz", self.info.sample_rate)) 220 + .size(14) 221 + .font(JETBRAINS_MONO), 222 + text(format!("Channels: {}", self.info.channels)) 223 + .size(14) 224 + .font(JETBRAINS_MONO), 225 + ] 226 + .spacing(8); 227 + 228 + let waveform = Canvas::new(Waveform { levels: self.levels.clone() }) 229 + .width(Length::Fill) 230 + .height(Length::Fixed(150.0)); 231 + 232 + let replace_button = button(text("Replace Image")) 233 + .on_press(Message::ReplaceImagePressed) 234 + .padding(10); 235 + 236 + let panel_content = column![close_button, info_text, waveform, replace_button] 237 + .spacing(16) 238 + .padding(20); 239 + 240 + container(panel_content) 241 + .width(Length::Fill) 242 + .height(Length::Fill) 243 + .padding(10) 244 + .style(|_: &Theme| container::Style { 245 + background: Some(Color::from_rgb(0.3, 0.3, 0.35).into()), 246 + border: iced::Border { color: Color::from_rgb(0.4, 0.4, 0.45), width: 1.0, radius: 0.0.into() }, 247 + ..Default::default() 248 + }) 249 + .into() 250 + } 251 + 252 + fn view(&self) -> iced::Element<'_, Message> { 253 + let image_content: iced::Element<'_, Message> = if let Some(img) = &self.current_image { 254 + container( 255 + mouse_area(widget::image(img.clone()).width(Length::Shrink).height(Length::Shrink)) 256 + .on_press(Message::ImageClicked), 257 + ) 258 + .padding(8) 259 + .style(move |_: &Theme| Self::mic_style(self.current_level)) 260 + .into() 261 + } else { 262 + widget::text("Drag an image onto this window to use it as the mic indicator.") 263 + .size(20) 264 + .into() 265 + }; 266 + 267 + let image_with_border = container(image_content).padding(20); 268 + 269 + if self.show_panel { 270 + column![ 271 + container(image_with_border) 272 + .center_x(Length::Fill) 273 + .width(Length::Fill) 274 + .height(Length::FillPortion(3)), 275 + container(self.build_panel()) 276 + .width(Length::Fill) 277 + .height(Length::FillPortion(2)) 278 + ] 279 + .width(Length::Fill) 280 + .height(Length::Fill) 281 + .into() 282 + } else { 283 + container(image_with_border) 284 + .center_x(Length::Fill) 285 + .center_y(Length::Fill) 286 + .width(Length::Fill) 287 + .height(Length::Fill) 288 + .into() 289 + } 290 + } 291 + 292 + fn mic_style(level: f32) -> container::Style { 293 + let amplified = (level * 10.0).min(1.0); 294 + let emerald_green = Color::from_rgb(0.0, 0.84, 0.47); 295 + 296 + container::Style { 297 + background: None, 298 + border: iced::Border { 299 + color: if amplified > 0.01 { emerald_green } else { Color::from_rgba(0.0, 0.0, 0.0, 0.0) }, 300 + width: if amplified > 0.01 { 5.0 } else { 0.0 }, 301 + radius: 4.0.into(), 302 + }, 303 + ..Default::default() 304 + } 305 + } 306 + 307 + fn subscription(&self) -> Subscription<Message> { 308 + Subscription::batch([ 309 + time::every(std::time::Duration::from_millis(100)).map(|_| Message::Tick), 310 + iced::window::events().map(|(_, ev)| Message::WindowEvent(ev)), 311 + ]) 312 + } 313 + } 314 + 315 + pub fn main() -> iced::Result { 316 + tracing_subscriber::fmt() 317 + .with_env_filter( 318 + tracing_subscriber::EnvFilter::from_default_env().add_directive("streamtools=info".parse().unwrap()), 319 + ) 320 + .init(); 321 + 322 + tracing::info!("Starting StreamTools"); 323 + tracing::info!("Note: On macOS, you may be prompted to grant microphone permissions"); 324 + 325 + let flag = Arc::new(AtomicBool::new(false)); 326 + let shared_levels: SharedLevels = Arc::new(Mutex::new(VecDeque::new())); 327 + 328 + let mic_info = MicInfo::new(); 329 + tracing::info!("Microphone device: {}", mic_info.name); 330 + 331 + spawn_mic_listener(flag.clone(), shared_levels.clone()).expect("failed to start mic listener"); 332 + tracing::info!("Microphone listener started successfully"); 333 + 334 + iced::application("Mic Activity", Model::update, Model::view) 335 + .subscription(Model::subscription) 336 + .font(include_bytes!("../../fonts/JetBrainsMono-VariableFont_wght.ttf").as_slice()) 337 + .centered() 338 + .run_with(move || { 339 + ( 340 + Model::new(flag.clone(), shared_levels.clone(), mic_info.clone()), 341 + iced::Task::none(), 342 + ) 343 + }) 344 + }
assets/screenshot.png

This is a binary file and will not be displayed.

+61 -14
core/src/lib.rs
··· 5 }; 6 use std::{ 7 collections::VecDeque, 8 - sync::{Arc, Mutex, atomic::AtomicBool}, 9 - time, 10 }; 11 12 #[derive(Debug, Clone)] ··· 38 } 39 40 pub type SharedLevels = Arc<Mutex<VecDeque<f32>>>; 41 42 - fn process_input_f32(data: &[f32], active: &AtomicBool, levels: &SharedLevels) { 43 if data.is_empty() { 44 return; 45 } ··· 50 } 51 let rms = (sum / data.len() as f32).sqrt(); 52 53 - let threshold = 0.02; 54 - active.store(rms > threshold, std::sync::atomic::Ordering::Relaxed); 55 push_level(levels, rms); 56 } 57 58 - fn process_input_i16(data: &[i16], active: &AtomicBool, levels: &SharedLevels) { 59 if data.is_empty() { 60 return; 61 } ··· 66 sum += v * v; 67 } 68 let rms = (sum / data.len() as f32).sqrt(); 69 - let threshold = 0.02; 70 - active.store(rms > threshold, std::sync::atomic::Ordering::Relaxed); 71 push_level(levels, rms); 72 } 73 74 - fn process_input_u16(data: &[u16], active: &AtomicBool, levels: &SharedLevels) { 75 if data.is_empty() { 76 return; 77 } ··· 83 sum += v * v; 84 } 85 let rms = (sum / data.len() as f32).sqrt(); 86 - let threshold = 0.02; 87 - active.store(rms > threshold, std::sync::atomic::Ordering::Relaxed); 88 push_level(levels, rms); 89 } 90 ··· 120 .map_err(|e| anyhow::anyhow!("failed to get default input config: {e}"))?; 121 let sample_format = supported_config.sample_format(); 122 let config: StreamConfig = supported_config.into(); 123 124 let stream = match sample_format { 125 SampleFormat::F32 => { 126 let active = active.clone(); 127 let levels = levels.clone(); 128 let err_fn = |err| eprintln!("cpal input stream error: {err}"); 129 device.build_input_stream( 130 &config, 131 - move |data: &[f32], _| process_input_f32(data, &active, &levels), 132 err_fn, 133 None, 134 )? ··· 136 SampleFormat::I16 => { 137 let active = active.clone(); 138 let levels = levels.clone(); 139 let err_fn = |err| eprintln!("cpal input stream error: {err}"); 140 device.build_input_stream( 141 &config, 142 - move |data: &[i16], _| process_input_i16(data, &active, &levels), 143 err_fn, 144 None, 145 )? ··· 147 SampleFormat::U16 => { 148 let active = active.clone(); 149 let levels = levels.clone(); 150 let err_fn = |err| eprintln!("cpal input stream error: {err}"); 151 device.build_input_stream( 152 &config, 153 - move |data: &[u16], _| process_input_u16(data, &active, &levels), 154 err_fn, 155 None, 156 )?
··· 5 }; 6 use std::{ 7 collections::VecDeque, 8 + sync::{ 9 + Arc, Mutex, 10 + atomic::{self, AtomicBool, AtomicU64}, 11 + }, 12 + time::{self, SystemTime, UNIX_EPOCH}, 13 }; 14 15 #[derive(Debug, Clone)] ··· 41 } 42 43 pub type SharedLevels = Arc<Mutex<VecDeque<f32>>>; 44 + type LastActiveTime = Arc<AtomicU64>; 45 46 + const HOLD_TIME_MS: u64 = 300; 47 + 48 + fn get_current_time_ms() -> u64 { 49 + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64 50 + } 51 + 52 + fn process_input_f32(data: &[f32], active: &AtomicBool, levels: &SharedLevels, last_active: &LastActiveTime) { 53 if data.is_empty() { 54 return; 55 } ··· 60 } 61 let rms = (sum / data.len() as f32).sqrt(); 62 63 + let threshold = 0.01; 64 + let now = get_current_time_ms(); 65 + 66 + if rms > threshold { 67 + last_active.store(now, atomic::Ordering::Relaxed); 68 + active.store(true, atomic::Ordering::Relaxed); 69 + } else { 70 + let last_time = last_active.load(atomic::Ordering::Relaxed); 71 + let is_active = (now - last_time) < HOLD_TIME_MS; 72 + active.store(is_active, atomic::Ordering::Relaxed); 73 + } 74 + 75 push_level(levels, rms); 76 } 77 78 + fn process_input_i16(data: &[i16], active: &AtomicBool, levels: &SharedLevels, last_active: &LastActiveTime) { 79 if data.is_empty() { 80 return; 81 } ··· 86 sum += v * v; 87 } 88 let rms = (sum / data.len() as f32).sqrt(); 89 + 90 + let threshold = 0.01; 91 + let now = get_current_time_ms(); 92 + 93 + if rms > threshold { 94 + last_active.store(now, atomic::Ordering::Relaxed); 95 + active.store(true, atomic::Ordering::Relaxed); 96 + } else { 97 + let last_time = last_active.load(atomic::Ordering::Relaxed); 98 + let is_active = (now - last_time) < HOLD_TIME_MS; 99 + active.store(is_active, atomic::Ordering::Relaxed); 100 + } 101 + 102 push_level(levels, rms); 103 } 104 105 + fn process_input_u16(data: &[u16], active: &AtomicBool, levels: &SharedLevels, last_active: &LastActiveTime) { 106 if data.is_empty() { 107 return; 108 } ··· 114 sum += v * v; 115 } 116 let rms = (sum / data.len() as f32).sqrt(); 117 + 118 + let threshold = 0.01; 119 + let now = get_current_time_ms(); 120 + 121 + if rms > threshold { 122 + last_active.store(now, atomic::Ordering::Relaxed); 123 + active.store(true, atomic::Ordering::Relaxed); 124 + } else { 125 + let last_time = last_active.load(atomic::Ordering::Relaxed); 126 + let is_active = (now - last_time) < HOLD_TIME_MS; 127 + active.store(is_active, atomic::Ordering::Relaxed); 128 + } 129 + 130 push_level(levels, rms); 131 } 132 ··· 162 .map_err(|e| anyhow::anyhow!("failed to get default input config: {e}"))?; 163 let sample_format = supported_config.sample_format(); 164 let config: StreamConfig = supported_config.into(); 165 + 166 + let last_active: LastActiveTime = Arc::new(AtomicU64::new(0)); 167 168 let stream = match sample_format { 169 SampleFormat::F32 => { 170 let active = active.clone(); 171 let levels = levels.clone(); 172 + let last_active = last_active.clone(); 173 let err_fn = |err| eprintln!("cpal input stream error: {err}"); 174 device.build_input_stream( 175 &config, 176 + move |data: &[f32], _| process_input_f32(data, &active, &levels, &last_active), 177 err_fn, 178 None, 179 )? ··· 181 SampleFormat::I16 => { 182 let active = active.clone(); 183 let levels = levels.clone(); 184 + let last_active = last_active.clone(); 185 let err_fn = |err| eprintln!("cpal input stream error: {err}"); 186 device.build_input_stream( 187 &config, 188 + move |data: &[i16], _| process_input_i16(data, &active, &levels, &last_active), 189 err_fn, 190 None, 191 )? ··· 193 SampleFormat::U16 => { 194 let active = active.clone(); 195 let levels = levels.clone(); 196 + let last_active = last_active.clone(); 197 let err_fn = |err| eprintln!("cpal input stream error: {err}"); 198 device.build_input_stream( 199 &config, 200 + move |data: &[u16], _| process_input_u16(data, &active, &levels, &last_active), 201 err_fn, 202 None, 203 )?