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
5 changes: 3 additions & 2 deletions Cargo.lock

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

12 changes: 0 additions & 12 deletions crates/app/src/audio/mod.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,2 @@
pub mod vad_adapter;
pub mod vad_processor;

// Re-export modules from coldvox-audio crate
pub use coldvox_audio::{
capture::CaptureStats,
chunker::{AudioChunker, ChunkerConfig, ResamplerQuality},
frame_reader::FrameReader,
ring_buffer::{AudioProducer, AudioRingBuffer},
};

pub use coldvox_audio::AudioFrame;
pub use vad_adapter::*;
pub use vad_processor::*;
8 changes: 4 additions & 4 deletions crates/app/src/audio/vad_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ impl VadProcessor {
timestamp_ms,
energy_db,
} => {
info!(
debug!(
"VAD: Speech started at {}ms (energy: {:.2} dB)",
timestamp_ms, energy_db
);
Expand All @@ -96,7 +96,7 @@ impl VadProcessor {
duration_ms,
energy_db,
} => {
info!(
debug!(
"VAD: Speech ended at {}ms (duration: {}ms, energy: {:.2} dB)",
timestamp_ms, duration_ms, energy_db
);
Expand Down Expand Up @@ -126,14 +126,14 @@ impl VadProcessor {

self.frames_processed += 1;

if self.frames_processed % 100 == 0 {
if self.frames_processed.is_multiple_of(100) {
tracing::debug!(
"VAD: Received {} frames, processing active",
self.frames_processed
);
}

if self.frames_processed % 1000 == 0 {
if self.frames_processed.is_multiple_of(1000) {
Comment on lines +129 to +136
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Primitive integers do not have is_multiple_of in stable Rust; this will not compile. Use a modulo check instead: self.frames_processed % 100 == 0.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue: replace with self.frames_processed % 1000 == 0 for stable Rust compatibility.

Suggested change
if self.frames_processed.is_multiple_of(1000) {
if self.frames_processed % 1000 == 0 {

Copilot uses AI. Check for mistakes.
debug!(
"VAD processor: {} frames processed, {} events generated, current state: {:?}",
self.frames_processed,
Expand Down
3 changes: 3 additions & 0 deletions crates/app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,6 @@ pub mod text_injection;
#[cfg(feature = "tui")]
pub mod tui;
pub mod vad;

#[cfg(test)]
pub mod test_utils;
6 changes: 3 additions & 3 deletions crates/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ use clap::Parser;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};

use coldvox_app::runtime::{self as app_runtime, ActivationMode as RuntimeMode, AppRuntimeOptions};
use coldvox_app::Settings;
use coldvox_app::runtime::{self as app_runtime, ActivationMode as RuntimeMode, AppRuntimeOptions};
use coldvox_audio::{DeviceManager, ResamplerQuality};
use coldvox_foundation::{AppState, HealthMonitor, ShutdownHandler, StateManager};

Expand Down Expand Up @@ -205,21 +205,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
resampler_quality,
activation_mode,
stt_selection,
enable_device_monitor: settings.enable_device_monitor,
..Default::default()
};
#[cfg(feature = "text-injection")]
{
opts.injection = if cfg!(feature = "text-injection") {
Some(coldvox_app::runtime::InjectionOptions {
enable: true, // Assuming text injection is enabled if the feature is on
allow_ydotool: false, // Default to false, can be configured later
allow_kdotool: settings.injection.allow_kdotool,
allow_enigo: settings.injection.allow_enigo,
inject_on_unknown_focus: settings.injection.inject_on_unknown_focus,
restore_clipboard: true, // Default to true for safety
max_total_latency_ms: Some(settings.injection.max_total_latency_ms),
per_method_timeout_ms: Some(settings.injection.per_method_timeout_ms),
cooldown_initial_ms: Some(settings.injection.cooldown_initial_ms),
fail_fast: settings.injection.fail_fast,
})
} else {
None
Expand Down
1 change: 1 addition & 0 deletions crates/coldvox-audio/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ tokio = { version = "1.35", features = ["sync", "rt"] }
tracing = "0.1"
anyhow = "1.0"
thiserror = "2.0"
libc = "0.2.176"

[features]
default = []
Expand Down
7 changes: 3 additions & 4 deletions crates/coldvox-audio/docs/design-user-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ COLDVOX_STT_METRICS_LOG_INTERVAL_SECS=60
```bash
# CLI
--enable-text-injection
--allow-ydotool
--allow-kdotool
--allow-enigo

Expand All @@ -125,11 +124,11 @@ COLDVOX_ALLOW_ENIGO=true
```bash
# CLI
--inject-on-unknown-focus
--restore-clipboard

# Environment
COLDVOX_INJECT_ON_UNKNOWN_FOCUS=true
COLDVOX_RESTORE_CLIPBOARD=true

Note: clipboard restoration is automatic after injection; `COLDVOX_RESTORE_CLIPBOARD` is ignored.
```

### Performance Limits
Expand Down Expand Up @@ -276,4 +275,4 @@ RUST_LOG=debug \
COLDVOX_STT_DEBUG_DUMP_EVENTS=true \
COLDVOX_STT_METRICS_LOG_INTERVAL_SECS=10 \
cargo run -- --tui
```
```
92 changes: 53 additions & 39 deletions crates/coldvox-audio/src/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ pub struct AudioCaptureThread {
impl AudioCaptureThread {
pub fn spawn(
config: AudioConfig,
audio_producer: AudioProducer,
audio_producer: Arc<Mutex<AudioProducer>>,
device_name: Option<String>,
enable_device_monitor: bool,
Comment on lines 48 to +53

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge Update AudioCaptureThread::spawn callers for new API

The spawn signature now requires an Arc<Mutex<AudioProducer>> and an enable_device_monitor flag. Callers across the workspace still invoke AudioCaptureThread::spawn with the old three-argument form and pass a bare AudioProducer (see crates/app/src/probes/mic_capture.rs, crates/app/src/probes/vad_mic.rs, crates/app/src/runtime.rs, and examples/device_hotplug_demo.rs). With this change the project no longer compiles because those call sites cannot satisfy the new parameter list or type. Either provide a backward-compatible wrapper or update all uses in the same change.

Useful? React with 👍 / 👎.

) -> Result<
Comment on lines 49 to 54
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change (new parameter and changed type), but the PR claims no breaking audio API changes. To preserve compatibility, consider keeping the original signature and adding a new constructor (e.g., spawn_with_monitor) or accept AudioProducer by value and wrap it internally (Arc<Mutex<...>>), defaulting enable_device_monitor to true.

Copilot uses AI. Check for mistakes.
(
Self,
Expand All @@ -59,7 +60,11 @@ impl AudioCaptureThread {
),
AudioError,
> {
let running = Arc::new(AtomicBool::new(false));
// Start in running state so the device monitor thread stays alive
// until we explicitly stop via `stop()`. Previously this was false,
// causing the monitor to exit immediately and the capture loop to
// detect a closed channel and terminate early.
let running = Arc::new(AtomicBool::new(true));
let shutdown = running.clone();
let device_config = Arc::new(RwLock::new(None::<DeviceConfig>));
let device_config_clone = device_config.clone();
Expand All @@ -72,14 +77,21 @@ impl AudioCaptureThread {
let (device_event_tx, device_event_rx) = tokio::sync::broadcast::channel(32);
let device_event_tx_clone = device_event_tx.clone();

// Start device monitor
let (device_monitor, mut monitor_rx) = DeviceMonitor::new(Duration::from_millis(500))?;
// Start device monitor with 2-second interval to reduce false positives from CPAL enumeration glitches
let monitor_running = running.clone();
let monitor_handle = device_monitor.start_monitoring(monitor_running);
let (monitor_rx_opt, monitor_handle) = if enable_device_monitor {
let (device_monitor, monitor_rx) = DeviceMonitor::new(Duration::from_secs(2))?;
let handle = device_monitor.start_monitoring(monitor_running);
(Some(monitor_rx), Some(handle))
} else {
tracing::debug!("Device monitor disabled; skipping hotplug polling");
(None, None)
};

let handle = thread::Builder::new()
.name("audio-capture".to_string())
.spawn(move || {
let mut monitor_rx = monitor_rx_opt;
let mut capture = match AudioCapture::new(config, audio_producer, running.clone()) {
Ok(c) => c.with_config_channel(config_tx_clone)
.with_device_event_channel(device_event_tx_clone),
Expand Down Expand Up @@ -144,40 +156,42 @@ impl AudioCaptureThread {
let mut restart_reason = "unknown";

// Check for device monitor events
match monitor_rx.try_recv() {
Ok(event) => {
tracing::debug!("Device event: {:?}", event);
let _ = capture.device_event_tx.as_ref().map(|tx| tx.send(event.clone()));

match event {
DeviceEvent::CurrentDeviceDisconnected { name } => {
if capture.current_device_name.as_ref() == Some(&name) {
tracing::warn!("Current device {} disconnected, attempting recovery", name);
if let Some(rx) = monitor_rx.as_mut() {
match rx.try_recv() {
Ok(event) => {
tracing::debug!("Device event: {:?}", event);
let _ = capture.device_event_tx.as_ref().map(|tx| tx.send(event.clone()));

match event {
DeviceEvent::CurrentDeviceDisconnected { name } => {
if capture.current_device_name.as_ref() == Some(&name) {
tracing::warn!("Current device {} disconnected, attempting recovery", name);
needs_restart = true;
restart_reason = "device disconnected";
}
}
DeviceEvent::DeviceAdded { name } => {
tracing::info!("New device available: {}", name);
// Could implement automatic switching to preferred devices here
}
DeviceEvent::DeviceSwitchRequested { target } => {
tracing::info!("Manual device switch requested to: {}", target);
needs_restart = true;
restart_reason = "device disconnected";
restart_reason = "manual device switch requested";
}
_ => {}
}
DeviceEvent::DeviceAdded { name } => {
tracing::info!("New device available: {}", name);
// Could implement automatic switching to preferred devices here
}
DeviceEvent::DeviceSwitchRequested { target } => {
tracing::info!("Manual device switch requested to: {}", target);
needs_restart = true;
restart_reason = "manual device switch requested";
}
_ => {}
}
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {
// No events, continue
}
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {
tracing::warn!("Device monitor events lagged, some events may have been missed");
}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
tracing::error!("Device monitor channel closed");
break;
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {
// No events, continue
}
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {
tracing::warn!("Device monitor events lagged, some events may have been missed");
}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
tracing::error!("Device monitor channel closed");
break;
}
}
}

Expand Down Expand Up @@ -240,7 +254,7 @@ impl AudioCaptureThread {
thread::sleep(Duration::from_millis(100));
}

tracing::info!("Audio capture thread shutting down.");
tracing::debug!("Audio capture thread shutting down.");
capture.stop();
})
.map_err(|e| AudioError::Fatal(format!("Failed to spawn audio thread: {}", e)))?;
Expand All @@ -264,7 +278,7 @@ impl AudioCaptureThread {
Self {
handle,
shutdown,
device_monitor_handle: Some(monitor_handle),
device_monitor_handle: monitor_handle,
},
cfg,
config_rx,
Expand Down Expand Up @@ -303,13 +317,13 @@ pub struct CaptureStats {
impl AudioCapture {
pub fn new(
config: AudioConfig,
audio_producer: AudioProducer,
audio_producer: Arc<Mutex<AudioProducer>>,
running: Arc<AtomicBool>,
) -> Result<Self, AudioError> {
let self_ = Self {
device_manager: DeviceManager::new()?,
stream: None,
audio_producer: Arc::new(Mutex::new(audio_producer)),
audio_producer,
watchdog: WatchdogTimer::new(Duration::from_secs(5)),
silence_detector: SilenceDetector::new(config.silence_threshold),
stats: Arc::new(CaptureStats::default()),
Expand Down
12 changes: 10 additions & 2 deletions crates/coldvox-audio/src/detector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,18 @@ impl SilenceDetector {
let sum: i64 = samples.iter().map(|&s| s as i64 * s as i64).sum();
let rms = ((sum / samples.len() as i64) as f64).sqrt() as i16;

// Log RMS every time to see actual audio levels (use trace level to avoid spam)
tracing::trace!(
"SilenceDetector: RMS={}, threshold={}, samples={}",
rms,
self.threshold,
samples.len()
);
Comment on lines +27 to +32
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Emitting a trace event per frame can add significant overhead in a 512-sample/16 kHz pipeline even when filtered. Consider sampling these logs (e.g., every N frames) or gating behind a feature flag to avoid hot-path overhead.

Copilot uses AI. Check for mistakes.

if rms < self.threshold {
if self.silence_start.is_none() {
self.silence_start = Some(Instant::now());
tracing::debug!(
tracing::info!(
"SilenceDetector: Silence started (RMS {} < threshold {})",
rms,
self.threshold
Comment on lines +37 to 40
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promoting state transition logs to info will spam logs during normal operation; prefer debug for these frequent events to keep default logs clean.

Copilot uses AI. Check for mistakes.
Expand All @@ -36,7 +44,7 @@ impl SilenceDetector {
} else {
if self.silence_start.is_some() {
let duration = self.silence_duration();
tracing::debug!(
tracing::info!(
"SilenceDetector: Silence ended after {:?} (RMS {} >= threshold {})",
duration,
rms,
Expand Down
Loading
Loading