Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 3 additions & 18 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,18 @@ cargo build --release --no-default-features
```

### 🪟 Windows
1. Install **Rust**.
2. Install **BSVC** (C++ Build Tools).
1. **Install Virtual Audio**: Download [VB-Cable](https://vb-audio.com/Cable/) (free) and reboot.
2. Install **Rust** and **BSVC** (C++ Build Tools).
3. `cargo build --release`
4. Run `.\target\release\voidmic.exe`
5. In VoidMic, select "CABLE Input" as Output.

### 🍎 macOS
1. **Install Virtual Audio**: `brew install blackhole-2ch` and reboot.
2. Install **Rust** via [rustup.rs](https://rustup.rs).
3. `cargo build --release`
4. Run `./target/release/voidmic`
5. In VoidMic, select "BlackHole 2ch" as Output.

## 🎮 Usage Guide

Expand Down
88 changes: 39 additions & 49 deletions crates/app/src/gui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ impl VoidMicApp {
ui.horizontal(|ui| {
if ui
.checkbox(&mut self.config.dynamic_threshold_enabled, "Auto-Gate")
.on_hover_text("Automatically adjusts gate based on ambient noise floor")
.changed()
{
self.config.preset = "Custom".to_string();
Expand Down Expand Up @@ -607,53 +608,38 @@ impl VoidMicApp {
ui.separator();

// VAD Controls
const VAD_MODES: &[(i32, &str, &str)] = &[
(0, "Quality", "Quality (Likely Speech)"),
(1, "Low Bitrate", "Low Bitrate"),
(2, "Aggressive", "Aggressive"),
(3, "Very Aggressive", "Very Aggressive"),
];
ui.horizontal(|ui| {
ui.label("VAD Sensitivity:");
let current_label = VAD_MODES
.iter()
.find(|(v, _, _)| *v == self.config.vad_sensitivity)
.map(|(_, _, full)| *full)
.unwrap_or("Unknown");
egui::ComboBox::from_id_salt("vad_combo")
.selected_text(match self.config.vad_sensitivity {
0 => "Quality (Likely Speech)",
1 => "Low Bitrate",
2 => "Aggressive",
3 => "Very Aggressive",
_ => "Unknown",
})
.selected_text(current_label)
.show_ui(ui, |ui| {
let mut changed = false;
if ui
.selectable_value(&mut self.config.vad_sensitivity, 0, "Quality")
.clicked()
{
changed = true;
}
if ui
.selectable_value(&mut self.config.vad_sensitivity, 1, "Low Bitrate")
.clicked()
{
changed = true;
}
if ui
.selectable_value(&mut self.config.vad_sensitivity, 2, "Aggressive")
.clicked()
{
changed = true;
}
if ui
.selectable_value(&mut self.config.vad_sensitivity, 3, "Very Aggressive")
.clicked()
{
changed = true;
}

if changed {
self.mark_config_dirty();
if let Some(engine) = &self.engine {
engine
.vad_sensitivity
.store(self.config.vad_sensitivity as u32, Ordering::Relaxed);
for (value, label, _) in VAD_MODES {
if ui
.selectable_value(&mut self.config.vad_sensitivity, *value, *label)
.clicked()
{
self.mark_config_dirty();
if let Some(engine) = &self.engine {
engine
.vad_sensitivity
.store(self.config.vad_sensitivity as u32, Ordering::Relaxed);
}
}
}
});
ui.label(egui::RichText::new("ℹ️ WebRTC Voice Activity Detection").size(10.0));
ui.label(egui::RichText::new("ℹ️ WebRTC VAD").size(10.0))
.on_hover_text("Voice Activity Detection - filters non-speech sounds");
});

ui.separator();
Expand All @@ -669,7 +655,7 @@ impl VoidMicApp {
});

if self.config.eq_enabled {
ui.horizontal(|ui| {
egui::Grid::new("eq_grid").num_columns(2).show(ui, |ui| {
ui.label("Low (Bass):");
if ui
.add(egui::Slider::new(&mut self.config.eq_low_gain, -10.0..=10.0).text("dB"))
Expand All @@ -682,8 +668,8 @@ impl VoidMicApp {
.store(self.config.eq_low_gain.to_bits(), Ordering::Relaxed);
}
}
});
ui.horizontal(|ui| {
ui.end_row();

ui.label("Mid (Voice):");
if ui
.add(egui::Slider::new(&mut self.config.eq_mid_gain, -10.0..=10.0).text("dB"))
Expand All @@ -696,8 +682,8 @@ impl VoidMicApp {
.store(self.config.eq_mid_gain.to_bits(), Ordering::Relaxed);
}
}
});
ui.horizontal(|ui| {
ui.end_row();

ui.label("High (Treble):");
if ui
.add(egui::Slider::new(&mut self.config.eq_high_gain, -10.0..=10.0).text("dB"))
Expand All @@ -710,6 +696,7 @@ impl VoidMicApp {
.store(self.config.eq_high_gain.to_bits(), Ordering::Relaxed);
}
}
ui.end_row();
});
}

Expand All @@ -719,6 +706,7 @@ impl VoidMicApp {
ui.horizontal(|ui| {
if ui
.checkbox(&mut self.config.agc_enabled, "Automatic Gain Control (AGC)")
.on_hover_text("Normalizes volume to prevent clipping and boost quiet speech")
.changed()
{
self.mark_config_dirty();
Expand All @@ -728,7 +716,6 @@ impl VoidMicApp {
.store(self.config.agc_enabled, Ordering::Relaxed);
}
}
ui.label(egui::RichText::new("ℹ️ Keeps volume consistent").size(10.0));
});

ui.add_space(5.0);
Expand Down Expand Up @@ -769,6 +756,8 @@ impl VoidMicApp {
self.render_spectrum(ui);

// Jitter Monitor (Phase 6)
const JITTER_GOOD_US: u32 = 1000;
const JITTER_WARN_US: u32 = 5000;
let jitter = self
.engine
.as_ref()
Expand All @@ -778,14 +767,15 @@ impl VoidMicApp {
ui.add_space(5.0);
ui.horizontal(|ui| {
ui.label("Latency Health:");
let color = if jitter < 1000 {
let color = if jitter < JITTER_GOOD_US {
egui::Color32::GREEN
} else if jitter < 5000 {
} else if jitter < JITTER_WARN_US {
egui::Color32::YELLOW
} else {
egui::Color32::RED
};
ui.colored_label(color, format!("{} µs jitter (Max)", jitter));
ui.colored_label(color, format!("{} µs jitter", jitter))
.on_hover_text("< 1ms = excellent | 1-5ms = acceptable | > 5ms = may cause audio glitches");
});
}
}
Expand Down
7 changes: 2 additions & 5 deletions crates/app/src/virtual_device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,13 @@ pub fn create_virtual_sink() -> Result<VirtualDevice, String> {
#[cfg(target_os = "windows")]
{
// On Windows, we can't auto-create. Return instruction to install VB-Cable.
Err("Windows requires VB-Cable. Please install from vb-audio.com/Cable/".to_string())
Err("Windows requires VB-Cable. Install from: https://vb-audio.com/Cable/".to_string())
}

#[cfg(target_os = "macos")]
{
// On macOS, we can't auto-create. Return instruction to install BlackHole.
Err(
"macOS requires BlackHole. Please install from github.com/ExistentialAudio/BlackHole"
.to_string(),
)
Err("macOS requires BlackHole. Install via: brew install blackhole-2ch".to_string())
}

#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
Expand Down
25 changes: 17 additions & 8 deletions crates/core/src/echo_cancel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
//!
//! Uses the aec3 crate (Rust port of WebRTC AEC3) for acoustic echo cancellation.

use crate::constants::SAMPLE_RATE;
use crate::constants::{FRAME_SIZE, SAMPLE_RATE};
use aec3::voip::VoipAec3;

/// Echo canceller wrapper
pub struct EchoCanceller {
aec: VoipAec3,
output_buffer: [f32; FRAME_SIZE], // Pre-allocated to avoid heap allocation
}

impl EchoCanceller {
Expand All @@ -17,31 +18,38 @@ impl EchoCanceller {
let aec = VoipAec3::builder(SAMPLE_RATE as usize, 1, 1)
.build()
.expect("Failed to create AEC3");
Self { aec }
Self {
aec,
output_buffer: [0.0; FRAME_SIZE],
}
}

/// Processes a frame of audio with echo cancellation.
///
/// # Arguments
/// * `mic_input` - The microphone input (may contain echo). Expected length: 480 (10ms at 48kHz)
/// * `speaker_ref` - The reference signal from speakers. Expected length: 480
/// * `output` - Output buffer to write echo-cancelled signal to
///
/// # Returns
/// The echo-cancelled microphone signal
pub fn process_frame(&mut self, mic_input: &[f32], speaker_ref: &[f32]) -> Vec<f32> {
let mut out = vec![0.0; mic_input.len()];
/// `true` if processing succeeded, `false` if fallback to raw input was used
pub fn process_frame(&mut self, mic_input: &[f32], speaker_ref: &[f32], output: &mut [f32]) -> bool {
// Clear output buffer
self.output_buffer.fill(0.0);

// Process with AEC3
// level_change = false (we don't track volume changes yet)
if let Err(e) = self
.aec
.process(mic_input, Some(speaker_ref), false, &mut out)
.process(mic_input, Some(speaker_ref), false, &mut self.output_buffer)
{
eprintln!("AEC error: {:?}", e);
return mic_input.to_vec(); // Fallback to raw input
output.copy_from_slice(mic_input); // Fallback to raw input
return false;
}

out
output.copy_from_slice(&self.output_buffer);
true
}

/// Resets the echo canceller state.
Expand All @@ -58,3 +66,4 @@ impl Default for EchoCanceller {
Self::new()
}
}

5 changes: 3 additions & 2 deletions crates/core/src/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,8 +406,9 @@ impl VoidProcessor {
if let Some(refs) = ref_frames {
// Try to match channel, or use channel 0 if fewer refs
if let Some(ref_ch) = refs.get(i).or(refs.first()) {
let processed = aec_instance.process_frame(&temp_input, ref_ch);
temp_input.copy_from_slice(&processed);
let mut aec_output = [0.0f32; FRAME_SIZE];
aec_instance.process_frame(&temp_input, ref_ch, &mut aec_output);
temp_input.copy_from_slice(&aec_output);
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion crates/lv2/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ crate-type = ["cdylib"]
[dependencies]
lv2 = "0.6"
voidmic_core = { path = "../core" }
ringbuf = "0.2.8"
ringbuf = "0.4.7"
log = "0.4"

Loading
Loading