A (planned) collection of lightweight tools for streaming.
at main 12 kB view raw
1use iced::mouse; 2use iced::widget::{self, Canvas, button, canvas, column, container, image, mouse_area, text}; 3use iced::{Color, Font, Length, Point, Rectangle, Size}; 4use iced::{Subscription, Task, Theme, time}; 5use std::collections::VecDeque; 6use std::path::PathBuf; 7use std::sync::Mutex; 8use std::sync::{ 9 Arc, 10 atomic::{AtomicBool, Ordering}, 11}; 12use std::time::{Duration, Instant}; 13use streamtools_core::{MicInfo, SharedLevels, spawn_mic_listener}; 14 15#[derive(Debug)] 16struct 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)] 35enum Message { 36 Tick, 37 WindowEvent(iced::window::Event), 38 ReplaceImagePressed, 39 ImageChosen(Option<PathBuf>), 40 ImageClicked, 41 ClosePanel, 42} 43 44const JETBRAINS_MONO: Font = Font::with_name("JetBrains Mono"); 45 46/// Waveform visualizer for audio levels 47#[derive(Debug)] 48struct Waveform { 49 levels: SharedLevels, 50} 51 52impl<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 93impl 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 315pub 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}