+111
Cargo.lock
+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
+24
-1
README.md
···
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
+

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
+3
app/Cargo.toml
+344
-1
app/src/main.rs
+344
-1
app/src/main.rs
···
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
assets/screenshot.png
This is a binary file and will not be displayed.
+61
-14
core/src/lib.rs
+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
)?