A (planned) collection of lightweight tools for streaming.
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}