Skip to content

Commit 2dd59a6

Browse files
author
Yakov Tchenak
committed
feat(tray): color-coded level meter + clearer right-click menu
Tray icon now reflects the current output level into VB-Cable with 5 color buckets (grey / dim green / green / amber / red). Tooltip shows live dBFS. Set show_level_meter: false in config.json to disable. Menu layout clarifies what each entry does: - "🎙 Input microphone:" disabled header above the mic list - "🔊 System sound: <device> (Windows default)" as a read-only hint so users know which speakers loopback is capturing and that the selector is elsewhere (Windows volume mixer) Internals: - mixer::peak_i16 reports the peak amplitude of a mixed buffer - Pipeline exposes it via an Arc<AtomicU32> (lock-free tray reads) - TrayApp event loop switched from Poll to WaitUntil(166ms) so the thread actually sleeps between ticks (~6 Hz update, low CPU) - Icon repaints only on bucket transitions, not every tick, to avoid Shell_NotifyIconW rate-limit / flicker - Config::show_level_meter is optional at serde level — legacy config.json files from 0.1.0 still load and default to true README: explains menu semantics (what the mic list is vs. the system sound row) and documents the meter legend.
1 parent b8e2248 commit 2dd59a6

6 files changed

Lines changed: 301 additions & 59 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,36 @@ cargo build --release
5353
4. In your recording app (Otter, OBS, etc.), set the input device to **`CABLE Output (VB-Audio Virtual Cable)`**.
5454
5. **Left-click** again to stop.
5555

56+
### What the tray menu is for
57+
58+
The right-click menu only picks **your microphone**. The **system-audio source** (whichever speakers / headphones Windows is currently playing through) is captured automatically via WASAPI loopback — to change it, swap the Windows default playback device from the volume-mixer tray popup, not from wasamix. The menu shows the current default as a read-only hint:
59+
60+
```
61+
🎙 Input microphone:
62+
● Microphone (HD Pro Webcam C920)
63+
○ Headset (Jabra Evolve2 65)
64+
──────────────
65+
🔊 System sound: Speakers (Realtek) (Windows default)
66+
──────────────
67+
Quit
68+
```
69+
70+
So picking "Headset (Jabra Evolve2 65)" captures the Jabra's *mic* and mixes it with whatever Windows is currently playing out — it does **not** route anything else.
71+
72+
### Reading the tray icon
73+
74+
While mixing, the icon changes color to reflect the current output level into VB-Cable. Hover for an exact dBFS reading.
75+
76+
| Icon | Meaning | Peak range |
77+
|---------------|------------------------------|---------------------------|
78+
| ⚪ Grey | Idle ||
79+
| 🟢 Dim green | Running, effectively silent | below −40 dBFS |
80+
| 🟢 Green | Running, quiet | −40 to −20 dBFS |
81+
| 🟡 Amber | Running, healthy levels | −20 to −3 dBFS |
82+
| 🔴 Red | Clipping — output too hot | −3 dBFS and above |
83+
84+
Don't want the color meter? Set `"show_level_meter": false` in `config.json` and you get the classic green-on-running behavior.
85+
5686
### Behavior notes
5787

5888
- Selecting a different mic while mixing is disabled by design — stop first, switch, then start.

src/audio/devices.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,16 @@ pub fn get_default_render_device_id() -> Result<String> {
130130
Ok(id)
131131
}
132132

133+
/// Friendly name of the default render device — shown in the tray menu so
134+
/// users can tell which speakers/headphones wasamix is capturing system
135+
/// audio from. Returns `None` if the device has no friendly name or we
136+
/// can't reach the enumerator.
137+
pub fn get_default_render_device_name() -> Option<String> {
138+
initialize_mta().ok().ok()?;
139+
let device = wasapi::get_default_device(&Direction::Render).ok()?;
140+
device.get_friendlyname().ok()
141+
}
142+
133143
#[cfg(test)]
134144
mod tests {
135145
use super::*;

src/audio/mixer.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,25 @@ pub fn new_shared_buffer(capacity: usize) -> Arc<Mutex<RingBuffer>> {
138138
/// Audio arrives as `&[u8]` (raw bytes) but we need to treat it as i16
139139
/// samples. We use `i16::from_le_bytes()` to convert pairs of bytes into
140140
/// signed 16-bit integers (little-endian, which is what Windows uses).
141+
/// Return the peak absolute amplitude of mono i16 audio bytes, in the
142+
/// range 0..=32768. `0` means silence; `32768` means clipped.
143+
///
144+
/// We keep the unsigned range (u16) so the tray thread can read it as an
145+
/// AtomicU32 without sign handling.
146+
pub fn peak_i16(data: &[u8]) -> u16 {
147+
let mut peak: u16 = 0;
148+
for chunk in data.chunks_exact(BYTES_PER_SAMPLE) {
149+
let s = i16::from_le_bytes([chunk[0], chunk[1]]);
150+
// i16::MIN (-32768) has |.| = 32768 which doesn't fit in i16 — use
151+
// unsigned_abs which widens to u16 and handles the edge cleanly.
152+
let abs = s.unsigned_abs();
153+
if abs > peak {
154+
peak = abs;
155+
}
156+
}
157+
peak
158+
}
159+
141160
pub fn mix_samples(mic: &[u8], loopback: &[u8]) -> Vec<u8> {
142161
if mic.is_empty() && loopback.is_empty() {
143162
return Vec::new();
@@ -291,6 +310,29 @@ mod tests {
291310
assert!(mix_samples(&[], &[]).is_empty());
292311
}
293312

313+
#[test]
314+
fn test_peak_silence() {
315+
assert_eq!(peak_i16(&[]), 0);
316+
assert_eq!(peak_i16(&[0, 0, 0, 0]), 0);
317+
}
318+
319+
#[test]
320+
fn test_peak_positive_and_negative() {
321+
let mut data = Vec::new();
322+
data.extend_from_slice(&1000i16.to_le_bytes());
323+
data.extend_from_slice(&(-5000i16).to_le_bytes());
324+
data.extend_from_slice(&100i16.to_le_bytes());
325+
assert_eq!(peak_i16(&data), 5000);
326+
}
327+
328+
#[test]
329+
fn test_peak_extreme_min_value() {
330+
// i16::MIN (-32768) — its absolute value doesn't fit in i16; must
331+
// return 32768 (u16::from(i16::MIN.unsigned_abs())).
332+
let data = i16::MIN.to_le_bytes();
333+
assert_eq!(peak_i16(&data), 32768);
334+
}
335+
294336
#[test]
295337
fn test_convert_f32_stereo_to_mono() {
296338
// Two frames of stereo: (0.5, -0.5), (1.0, 0.0)

src/audio/pipeline.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@
1010
//! `Pipeline::stop()` is called (or Pipeline is dropped), it signals all
1111
//! threads to stop and waits for them to finish. No resource leaks!
1212
13-
use std::sync::atomic::{AtomicBool, Ordering};
13+
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1414
use std::sync::{Arc, Mutex};
1515
use std::thread;
1616

1717
use anyhow::Result;
1818
use tracing::info;
1919

2020
use super::capture::{start_capture_thread, start_render_thread};
21-
use super::mixer::{BYTES_PER_SAMPLE, SAMPLE_RATE, mix_samples, new_shared_buffer};
21+
use super::mixer::{BYTES_PER_SAMPLE, SAMPLE_RATE, mix_samples, new_shared_buffer, peak_i16};
2222

2323
/// Buffer 2 seconds of audio
2424
const BUFFER_CAPACITY: usize = SAMPLE_RATE as usize * BYTES_PER_SAMPLE * 2;
@@ -31,6 +31,11 @@ const BUFFER_CAPACITY: usize = SAMPLE_RATE as usize * BYTES_PER_SAMPLE * 2;
3131
/// We start with `None` and fill them in `start()`.
3232
pub struct Pipeline {
3333
stop_flag: Arc<AtomicBool>,
34+
/// Peak absolute amplitude of the most recent mixed buffer, in 0..=32768.
35+
/// Written by the mixer closure on the render thread, read by the tray
36+
/// thread for the level meter. `AtomicU32` (not `AtomicU16`) because
37+
/// u16 atomics aren't guaranteed on all Rust targets.
38+
peak_level: Arc<AtomicU32>,
3439
mic_thread: Option<thread::JoinHandle<()>>,
3540
loopback_thread: Option<thread::JoinHandle<()>>,
3641
render_thread: Option<thread::JoinHandle<()>>,
@@ -45,6 +50,7 @@ impl Pipeline {
4550
/// It's from the `anyhow` crate — a convenient way to return errors.
4651
pub fn start(mic_device_id: &str, vbcable_device_id: &str) -> Result<Self> {
4752
let stop_flag = Arc::new(AtomicBool::new(false));
53+
let peak_level = Arc::new(AtomicU32::new(0));
4854

4955
// Create shared ring buffers for mic and loopback audio
5056
let mic_buffer = new_shared_buffer(BUFFER_CAPACITY);
@@ -74,11 +80,14 @@ impl Pipeline {
7480
// ring buffer and mix them.
7581
let mic_buf_for_mixer = Arc::clone(&mic_buffer);
7682
let loop_buf_for_mixer = Arc::clone(&loopback_buffer);
83+
let peak_for_mixer = Arc::clone(&peak_level);
7784
let mixer_fn: Arc<Mutex<dyn FnMut(usize) -> Vec<u8> + Send>> =
7885
Arc::new(Mutex::new(move |bytes_needed: usize| {
7986
let mic_data = mic_buf_for_mixer.lock().unwrap().read(bytes_needed);
8087
let loop_data = loop_buf_for_mixer.lock().unwrap().read(bytes_needed);
81-
mix_samples(&mic_data, &loop_data)
88+
let mixed = mix_samples(&mic_data, &loop_data);
89+
peak_for_mixer.store(peak_i16(&mixed) as u32, Ordering::Relaxed);
90+
mixed
8291
}));
8392

8493
// Start render thread — writes mixed audio to VB-Cable
@@ -95,12 +104,22 @@ impl Pipeline {
95104

96105
Ok(Pipeline {
97106
stop_flag,
107+
peak_level,
98108
mic_thread: Some(mic_thread),
99109
loopback_thread: Some(loopback_thread),
100110
render_thread: Some(render_thread),
101111
})
102112
}
103113

114+
/// Current peak output level, in 0..=32768.
115+
///
116+
/// Read by the tray thread on its UI timer. Cheap (single atomic load).
117+
pub fn peak_level(&self) -> u16 {
118+
// Clamp back to u16: the mixer closure only writes 0..=32768, so this
119+
// is a pure type narrowing.
120+
self.peak_level.load(Ordering::Relaxed).min(u16::MAX as u32) as u16
121+
}
122+
104123
/// Stop the pipeline — signals all threads and waits for them.
105124
///
106125
/// RUST CONCEPT: `.take()` on Option

src/config.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ pub struct Config {
2424
/// `Option<String>` means this field can be `Some("device-id")` or `None`.
2525
/// We store the device ID as a string because WASAPI device IDs are strings.
2626
pub mic_device_id: Option<String>,
27+
28+
/// Whether the tray icon color-codes the current output level.
29+
/// `#[serde(default = "...")]` lets older config.json files (missing this
30+
/// field) still deserialize — they get `default_show_level_meter()`.
31+
#[serde(default = "default_show_level_meter")]
32+
pub show_level_meter: bool,
33+
}
34+
35+
fn default_show_level_meter() -> bool {
36+
true
2737
}
2838

2939
/// `impl` blocks attach methods to a struct — similar to defining methods
@@ -76,6 +86,7 @@ impl Default for Config {
7686
fn default() -> Self {
7787
Config {
7888
mic_device_id: None,
89+
show_level_meter: default_show_level_meter(),
7990
}
8091
}
8192
}
@@ -101,10 +112,24 @@ mod tests {
101112
let path = dir.path().join("config.json");
102113
let config = Config {
103114
mic_device_id: Some("test-device-123".to_string()),
115+
show_level_meter: false,
104116
};
105117
config.save_to(&path).unwrap();
106118
let loaded = Config::load_from(&path).unwrap();
107119
assert_eq!(loaded.mic_device_id, Some("test-device-123".to_string()));
120+
assert!(!loaded.show_level_meter);
121+
}
122+
123+
#[test]
124+
fn test_load_legacy_without_level_meter_field() {
125+
// A config.json written by wasamix 0.1.0 won't have show_level_meter.
126+
// It should still load, defaulting show_level_meter to true.
127+
let dir = tempdir().unwrap();
128+
let path = dir.path().join("config.json");
129+
fs::write(&path, r#"{"mic_device_id":"abc"}"#).unwrap();
130+
let loaded = Config::load_from(&path).unwrap();
131+
assert_eq!(loaded.mic_device_id, Some("abc".to_string()));
132+
assert!(loaded.show_level_meter);
108133
}
109134

110135
#[test]

0 commit comments

Comments
 (0)