move audio-toolkit into Handy

This commit is contained in:
CJ Pais 2025-07-10 11:26:17 -07:00
parent 7f8a908cf9
commit 8dbebb6f64
15 changed files with 8681 additions and 1 deletions

4
.gitignore vendored
View file

@ -23,4 +23,6 @@ dist-ssr
*.sln
*.sw?
src-tauri/resources/models
src-tauri/resources/models
/target/

7696
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

3
Cargo.toml Normal file
View file

@ -0,0 +1,3 @@
[workspace]
members = ["src-tauri", "crates/audio-toolkit"]
resolver = "2"

View file

@ -0,0 +1,20 @@
[package]
name = "audio-toolkit"
version = "0.1.0"
edition = "2024"
description = "Audio recording and processing toolkit for Handy"
authors = ["cjpais"]
[[bin]]
name = "cli"
path = "src/bin/cli.rs"
[dependencies]
anyhow = "1.0.98"
cpal = "0.16.0"
hound = "3.5.1"
rubato = "0.16.2"
vad-rs = "0.1.5"
[dependencies.ort-sys]
version = "=2.0.0-rc.9"

View file

@ -0,0 +1,30 @@
use cpal::traits::{DeviceTrait, HostTrait};
pub struct CpalDeviceInfo {
pub index: String,
pub name: String,
pub is_default: bool,
pub device: cpal::Device,
}
pub fn list_input_devices() -> Result<Vec<CpalDeviceInfo>, Box<dyn std::error::Error>> {
let host = cpal::default_host();
let default_name = host.default_input_device().and_then(|d| d.name().ok());
let mut out = Vec::<CpalDeviceInfo>::new();
for (index, device) in host.input_devices()?.enumerate() {
let name = device.name().unwrap_or_else(|_| "Unknown".into());
let is_default = Some(name.clone()) == default_name;
out.push(CpalDeviceInfo {
index: index.to_string(),
name,
is_default,
device,
});
}
Ok(out)
}

View file

@ -0,0 +1,8 @@
// Re-export all audio components
mod device;
mod recorder;
mod resampler;
pub use device::{CpalDeviceInfo, list_input_devices};
pub use recorder::AudioRecorder;
pub use resampler::FrameResampler;

View file

@ -0,0 +1,283 @@
use std::{
io::Error,
sync::{Arc, Mutex, mpsc},
time::Duration,
};
use cpal::{
Device, Sample, SizedSample,
traits::{DeviceTrait, HostTrait, StreamTrait},
};
use crate::{
VoiceActivityDetector,
audio::FrameResampler,
constants,
vad::{self, VadFrame},
};
enum Cmd {
Start,
Stop(mpsc::Sender<Vec<f32>>),
Shutdown,
}
pub struct AudioRecorder {
stream: Option<cpal::Stream>,
device: Option<Device>,
cmd_tx: Option<mpsc::Sender<Cmd>>,
worker_handle: Option<std::thread::JoinHandle<()>>,
vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,
}
impl AudioRecorder {
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
Ok(AudioRecorder {
stream: None,
device: None,
cmd_tx: None,
worker_handle: None,
vad: None,
})
}
pub fn with_vad(mut self, vad: Box<dyn VoiceActivityDetector>) -> Self {
self.vad = Some(Arc::new(Mutex::new(vad)));
self
}
pub fn open(&mut self, device: Option<Device>) -> Result<(), Box<dyn std::error::Error>> {
// TODO should we check the device is correct?
if self.stream.is_some() {
return Ok(());
}
let (sample_tx, sample_rx) = mpsc::channel::<Vec<f32>>();
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
let host = cpal::default_host();
let device = match device {
Some(dev) => dev.clone(),
None => host
.default_input_device()
.ok_or_else(|| Error::new(std::io::ErrorKind::NotFound, "No input device found"))?,
};
let config = self.get_preferred_config(&device)?;
let sample_rate = config.sample_rate().0;
let channels = config.channels() as usize;
// info print about the device and config
println!(
"Using device: {:?}\nSample Rate: {}\nChannels: {}\nSample Format: {:?}",
device.name(),
sample_rate,
channels,
config.sample_format()
);
let stream = match config.sample_format() {
cpal::SampleFormat::I8 => {
self.build_stream::<i8>(&device, &config, sample_tx, channels)?
}
cpal::SampleFormat::I16 => {
self.build_stream::<i16>(&device, &config, sample_tx, channels)?
}
cpal::SampleFormat::I32 => {
self.build_stream::<i32>(&device, &config, sample_tx, channels)?
}
cpal::SampleFormat::F32 => {
self.build_stream::<f32>(&device, &config, sample_tx, channels)?
}
_ => {
return Err(Box::new(Error::new(
std::io::ErrorKind::InvalidInput,
"Unsupported sample format",
)));
}
};
let vad = self.vad.clone();
let worker = std::thread::spawn(move || {
run_consumer(sample_rate, vad, sample_rx, cmd_rx);
});
stream.play()?;
self.device = Some(device);
self.stream = Some(stream);
self.cmd_tx = Some(cmd_tx);
self.worker_handle = Some(worker);
Ok(())
}
pub fn start(&self) -> Result<(), Box<dyn std::error::Error>> {
if let Some(tx) = &self.cmd_tx {
tx.send(Cmd::Start)?;
}
Ok(())
}
pub fn stop(&self) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
let (resp_tx, resp_rx) = mpsc::channel();
if let Some(tx) = &self.cmd_tx {
tx.send(Cmd::Stop(resp_tx))?;
}
Ok(resp_rx.recv()?) // wait for the samples
}
pub fn close(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if let Some(tx) = self.cmd_tx.take() {
let _ = tx.send(Cmd::Shutdown);
}
self.stream.take(); // drop stream → stops cpal
if let Some(h) = self.worker_handle.take() {
let _ = h.join();
}
self.device = None;
Ok(())
}
fn build_stream<T>(
&self,
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
sample_tx: mpsc::Sender<Vec<f32>>,
channels: usize,
) -> Result<cpal::Stream, cpal::BuildStreamError>
where
T: Sample + SizedSample + Send + 'static,
f32: cpal::FromSample<T>,
{
let mut output_buffer = Vec::new();
let stream_cb = move |data: &[T], _: &cpal::InputCallbackInfo| {
output_buffer.clear();
if channels == 1 {
// Direct conversion without intermediate Vec
output_buffer.extend(data.iter().map(|&sample| sample.to_sample::<f32>()));
} else {
// Convert to mono directly
let frame_count = data.len() / channels;
output_buffer.reserve(frame_count);
for frame in data.chunks_exact(channels) {
let mono_sample = frame
.iter()
.map(|&sample| sample.to_sample::<f32>())
.sum::<f32>()
/ channels as f32;
output_buffer.push(mono_sample);
}
}
if sample_tx.send(output_buffer.clone()).is_err() {
eprintln!("Failed to send samples");
}
};
device.build_input_stream(
&config.clone().into(),
stream_cb,
|err| eprintln!("Stream error: {}", err),
None,
)
}
fn get_preferred_config(
&self,
device: &cpal::Device,
) -> Result<cpal::SupportedStreamConfig, Box<dyn std::error::Error>> {
let supported_configs = device.supported_input_configs()?;
// Try to find a config that supports 16kHz
for config_range in supported_configs {
if config_range.min_sample_rate().0 <= constants::WHISPER_SAMPLE_RATE
&& config_range.max_sample_rate().0 >= constants::WHISPER_SAMPLE_RATE
{
// Found a config that supports 16kHz, use it
return Ok(
config_range.with_sample_rate(cpal::SampleRate(constants::WHISPER_SAMPLE_RATE))
);
}
}
// If no config supports 16kHz, fall back to default
Ok(device.default_input_config()?)
}
}
fn run_consumer(
in_sample_rate: u32,
vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,
sample_rx: mpsc::Receiver<Vec<f32>>,
cmd_rx: mpsc::Receiver<Cmd>,
) {
let mut frame_resampler = FrameResampler::new(
in_sample_rate as usize,
constants::WHISPER_SAMPLE_RATE as usize,
Duration::from_millis(30),
);
let mut processed_samples = Vec::<f32>::new();
let mut recording = false;
fn handle_frame(
samples: &[f32],
recording: bool,
vad: &Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,
out_buf: &mut Vec<f32>,
) {
if !recording {
return;
}
if let Some(vad_arc) = vad {
let mut det = vad_arc.lock().unwrap();
match det.push_frame(samples).unwrap_or(VadFrame::Speech(samples)) {
VadFrame::Speech(buf) => out_buf.extend_from_slice(buf),
VadFrame::Noise => {}
}
} else {
out_buf.extend_from_slice(samples);
}
}
loop {
let raw = match sample_rx.recv() {
Ok(s) => s,
Err(_) => break, // stream closed
};
frame_resampler.push(&raw, &mut |frame: &[f32]| {
handle_frame(frame, recording, &vad, &mut processed_samples)
});
// non-blocking check for a command
while let Ok(cmd) = cmd_rx.try_recv() {
match cmd {
Cmd::Start => {
processed_samples.clear();
recording = true;
if let Some(v) = &vad {
v.lock().unwrap().reset();
}
}
Cmd::Stop(reply_tx) => {
recording = false;
frame_resampler.finish(&mut |frame: &[f32]| {
// we still want to process the last few frames
handle_frame(frame, true, &vad, &mut processed_samples)
});
let _ = reply_tx.send(std::mem::take(&mut processed_samples));
}
Cmd::Shutdown => return,
}
}
}
}

View file

@ -0,0 +1,99 @@
use rubato::{FftFixedIn, Resampler};
use std::time::Duration;
// Make this a constant you can tweak
const RESAMPLER_CHUNK_SIZE: usize = 1024;
pub struct FrameResampler {
resampler: Option<FftFixedIn<f32>>,
chunk_in: usize,
in_buf: Vec<f32>,
frame_samples: usize,
pending: Vec<f32>,
}
impl FrameResampler {
pub fn new(in_hz: usize, out_hz: usize, frame_dur: Duration) -> Self {
let frame_samples = ((out_hz as f64 * frame_dur.as_secs_f64()).round()) as usize;
assert!(frame_samples > 0, "frame duration too short");
// Use fixed chunk size instead of GCD-based
let chunk_in = RESAMPLER_CHUNK_SIZE;
let resampler = (in_hz != out_hz).then(|| {
FftFixedIn::<f32>::new(in_hz, out_hz, chunk_in, 1, 1)
.expect("Failed to create resampler")
});
Self {
resampler,
chunk_in,
in_buf: Vec::with_capacity(chunk_in),
frame_samples,
pending: Vec::with_capacity(frame_samples),
}
}
pub fn push(&mut self, mut src: &[f32], mut emit: impl FnMut(&[f32])) {
if self.resampler.is_none() {
self.emit_frames(src, &mut emit);
return;
}
while !src.is_empty() {
let space = self.chunk_in - self.in_buf.len();
let take = space.min(src.len());
self.in_buf.extend_from_slice(&src[..take]);
src = &src[take..];
if self.in_buf.len() == self.chunk_in {
// let start = std::time::Instant::now();
if let Ok(out) = self
.resampler
.as_mut()
.unwrap()
.process(&[&self.in_buf[..]], None)
{
// let duration = start.elapsed();
// println!("Resampler took: {:?}", duration);
self.emit_frames(&out[0], &mut emit);
}
self.in_buf.clear();
}
}
}
pub fn finish(&mut self, mut emit: impl FnMut(&[f32])) {
// Process any remaining input samples
if let Some(ref mut resampler) = self.resampler {
if !self.in_buf.is_empty() {
// Pad with zeros to reach chunk size
self.in_buf.resize(self.chunk_in, 0.0);
if let Ok(out) = resampler.process(&[&self.in_buf[..]], None) {
self.emit_frames(&out[0], &mut emit);
}
}
}
// Emit any remaining pending frame (padded with zeros)
if !self.pending.is_empty() {
self.pending.resize(self.frame_samples, 0.0);
emit(&self.pending);
self.pending.clear();
}
}
fn emit_frames(&mut self, mut data: &[f32], emit: &mut impl FnMut(&[f32])) {
while !data.is_empty() {
let space = self.frame_samples - self.pending.len();
let take = space.min(data.len());
self.pending.extend_from_slice(&data[..take]);
data = &data[take..];
if self.pending.len() == self.frame_samples {
emit(&self.pending);
self.pending.clear();
}
}
}
}

View file

@ -0,0 +1,352 @@
use audio_toolkit::{AudioRecorder, SileroVad, list_input_devices, vad::SmoothedVad};
use hound::WavWriter;
use std::io::{self, Write};
#[derive(Debug, Clone, PartialEq)]
enum RecorderMode {
AlwaysOn,
OnDemand,
}
impl std::fmt::Display for RecorderMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RecorderMode::AlwaysOn => write!(f, "Always-On"),
RecorderMode::OnDemand => write!(f, "On-Demand"),
}
}
}
struct RecorderState {
recorder: AudioRecorder,
mode: RecorderMode,
is_recording: bool,
is_open: bool,
current_device_index: Option<usize>,
recording_index: u32,
}
impl RecorderState {
fn new(recorder: AudioRecorder) -> Self {
Self {
recorder,
mode: RecorderMode::AlwaysOn,
is_recording: false,
is_open: false,
current_device_index: None,
recording_index: 1,
}
}
fn switch_mode(&mut self, new_mode: RecorderMode) -> Result<(), Box<dyn std::error::Error>> {
if self.mode == new_mode {
return Ok(());
}
// If we're currently recording, stop first
if self.is_recording {
println!("Stopping current recording to switch modes...");
self.stop_recording()?;
}
// Close if open and switching to on-demand, or if switching from on-demand to always-on
if self.is_open {
match (&self.mode, &new_mode) {
(RecorderMode::AlwaysOn, RecorderMode::OnDemand) => {
self.recorder.close()?;
self.is_open = false;
println!("Closed recorder for On-Demand mode");
}
(RecorderMode::OnDemand, RecorderMode::AlwaysOn) => {
// For switching from on-demand to always-on, we need to reopen
// This will be handled when the user starts recording
}
_ => {}
}
}
self.mode = new_mode;
println!("Switched to {} mode", self.mode);
Ok(())
}
fn start_recording(
&mut self,
device_index: Option<usize>,
devices: &[audio_toolkit::CpalDeviceInfo],
) -> Result<(), Box<dyn std::error::Error>> {
if self.is_recording {
return Err("Already recording! Stop the current recording first.".into());
}
let device = if let Some(idx) = device_index {
if idx >= devices.len() {
return Err(format!(
"Invalid device index: {}. Available devices: 0-{}",
idx,
devices.len() - 1
)
.into());
}
Some(devices[idx].device.clone())
} else {
None
};
match self.mode {
RecorderMode::AlwaysOn => {
// In always-on mode, open once and keep open
if !self.is_open || self.current_device_index != device_index {
if self.is_open {
self.recorder.close()?;
}
self.recorder.open(device)?;
self.is_open = true;
self.current_device_index = device_index;
println!("Opened recorder in Always-On mode");
}
self.recorder.start()?;
}
RecorderMode::OnDemand => {
// In on-demand mode, open for each recording
if self.is_open {
self.recorder.close()?;
}
self.recorder.open(device)?;
self.is_open = true;
self.current_device_index = device_index;
self.recorder.start()?;
println!("Opened and started recorder in On-Demand mode");
}
}
self.is_recording = true;
println!(
"Recording started with device: {}",
device_index.map_or("default".to_string(), |i| i.to_string())
);
Ok(())
}
fn stop_recording(&mut self) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
if !self.is_recording {
return Err("No recording in progress.".into());
}
let samples = self.recorder.stop()?;
self.is_recording = false;
match self.mode {
RecorderMode::AlwaysOn => {
// Keep the recorder open for next recording
println!("Recording stopped. Recorder remains open for next recording.");
}
RecorderMode::OnDemand => {
// Close the recorder after each recording
self.recorder.close()?;
self.is_open = false;
self.current_device_index = None;
println!("Recording stopped and recorder closed.");
}
}
Ok(samples)
}
fn close(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if self.is_recording {
self.stop_recording()?;
}
if self.is_open {
self.recorder.close()?;
self.is_open = false;
}
Ok(())
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Advanced Audio Recorder CLI");
println!("=========================");
print_help();
let silero = SileroVad::new("./src-tauri/resources/models/silero_vad_v4.onnx", 0.5)?;
let smoothed_vad = SmoothedVad::new(Box::new(silero), 15, 15);
let recorder = AudioRecorder::new()?.with_vad(Box::new(smoothed_vad));
let mut state = RecorderState::new(recorder);
let mut devices = list_input_devices()?;
print_devices(&devices);
loop {
print!("[{}] > ", state.mode);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let parts: Vec<&str> = input.trim().split_whitespace().collect();
if parts.is_empty() {
continue;
}
let command = parts[0].to_lowercase();
match command.as_str() {
"start" | "s" => {
let device_index = if parts.len() > 1 {
match parts[1].parse::<usize>() {
Ok(idx) => Some(idx),
Err(_) => {
println!("Invalid device index format. Usage: start [device_index]");
continue;
}
}
} else {
None
};
match state.start_recording(device_index, &devices) {
Ok(_) => println!("Recording started successfully!"),
Err(e) => println!("Error starting recording: {}", e),
}
}
"stop" => match state.stop_recording() {
Ok(samples) => {
if !samples.is_empty() {
let filename = format!("recording_{}.wav", state.recording_index);
match save_audio(&samples, &filename) {
Ok(_) => {
println!("Recording saved as: {}", filename);
state.recording_index += 1;
}
Err(e) => println!("Error saving recording: {}", e),
}
} else {
println!("No audio data captured.");
}
}
Err(e) => println!("Error stopping recording: {}", e),
},
"mode" => {
if parts.len() > 1 {
let new_mode = match parts[1].to_lowercase().as_str() {
"always" | "alwayson" | "always-on" | "a" => RecorderMode::AlwaysOn,
"demand" | "ondemand" | "on-demand" | "d" => RecorderMode::OnDemand,
_ => {
println!("Invalid mode. Use 'always' or 'demand'");
continue;
}
};
match state.switch_mode(new_mode) {
Ok(_) => {}
Err(e) => println!("Error switching modes: {}", e),
}
} else {
println!("Current mode: {}", state.mode);
println!("Usage: mode [always|demand]");
}
}
"devices" | "dev" => {
devices = list_input_devices()?;
print_devices(&devices);
}
"status" => {
println!("Status:");
println!(" Mode: {}", state.mode);
println!(
" Recording: {}",
if state.is_recording { "Yes" } else { "No" }
);
println!(
" Recorder Open: {}",
if state.is_open { "Yes" } else { "No" }
);
println!(
" Current Device: {}",
state
.current_device_index
.map_or("None".to_string(), |i| i.to_string())
);
println!(" Next Recording: recording_{}.wav", state.recording_index);
}
"help" | "h" => {
print_help();
}
"quit" | "exit" | "q" => {
println!("Shutting down...");
match state.close() {
Ok(_) => {
if state.is_recording {
println!(
"Final recording saved as: recording_{}.wav",
state.recording_index
);
}
}
Err(e) => println!("Error during shutdown: {}", e),
}
println!("Goodbye!");
break;
}
"" => {
// Empty input, continue
}
_ => {
println!(
"Unknown command: '{}'. Type 'help' for available commands.",
command
);
}
}
}
Ok(())
}
fn print_help() {
println!("Commands:");
println!(
" start [device_index] | s [device_index] - Start recording (optionally with device)"
);
println!(" stop - Stop recording and save");
println!(
" mode [always|demand] - Switch recording mode or show current mode"
);
println!(" devices | dev - List available audio devices");
println!(" status - Show current recorder status");
println!(" help | h - Show this help message");
println!(" quit | exit | q - Exit the program");
println!();
println!("Modes:");
println!(" Always-On: Keeps recorder open for quick start/stop cycles");
println!(" On-Demand: Opens/closes recorder for each recording session");
println!();
}
fn print_devices(devices: &[audio_toolkit::CpalDeviceInfo]) {
println!("Available audio devices:");
for (index, device) in devices.iter().enumerate() {
println!(" {}: {}", index, device.name);
}
println!();
}
fn save_audio(samples: &[f32], filename: &str) -> Result<(), Box<dyn std::error::Error>> {
let spec = hound::WavSpec {
channels: 1,
sample_rate: 16000,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut writer = WavWriter::create(filename, spec)?;
for &sample in samples {
let sample_i16 = (sample * i16::MAX as f32) as i16;
writer.write_sample(sample_i16)?;
}
writer.finalize()?;
Ok(())
}

View file

@ -0,0 +1 @@
pub const WHISPER_SAMPLE_RATE: u32 = 16000;

View file

@ -0,0 +1,6 @@
pub mod audio;
pub mod constants;
pub mod vad;
pub use audio::{AudioRecorder, CpalDeviceInfo, list_input_devices};
pub use vad::{SileroVad, VoiceActivityDetector};

View file

@ -0,0 +1,32 @@
use anyhow::Result;
pub enum VadFrame<'a> {
/// Speech may aggregate several frames (prefill + current + hangover)
Speech(&'a [f32]),
/// Non-speech (silence, noise). Down-stream code can ignore it.
Noise,
}
impl<'a> VadFrame<'a> {
#[inline]
pub fn is_speech(&self) -> bool {
matches!(self, VadFrame::Speech(_))
}
}
pub trait VoiceActivityDetector: Send + Sync {
/// Primary streaming API: feed one 30-ms frame, get keep/drop decision.
fn push_frame<'a>(&'a mut self, frame: &'a [f32]) -> Result<VadFrame<'a>>;
fn is_voice(&mut self, frame: &[f32]) -> Result<bool> {
Ok(self.push_frame(frame)?.is_speech())
}
fn reset(&mut self) {}
}
mod silero;
mod smoothed;
pub use silero::SileroVad;
pub use smoothed::SmoothedVad;

View file

@ -0,0 +1,54 @@
use anyhow::Result;
use std::path::Path;
use vad_rs::Vad;
use super::{VadFrame, VoiceActivityDetector};
use crate::constants;
const SILERO_FRAME_MS: u32 = 30;
const SILERO_FRAME_SAMPLES: usize =
(constants::WHISPER_SAMPLE_RATE * SILERO_FRAME_MS / 1000) as usize;
pub struct SileroVad {
engine: Vad,
threshold: f32,
}
impl SileroVad {
pub fn new<P: AsRef<Path>>(model_path: P, threshold: f32) -> Result<Self> {
if !(0.0..=1.0).contains(&threshold) {
anyhow::bail!("threshold must be between 0.0 and 1.0");
}
Ok(Self {
engine: Vad::new(&model_path, constants::WHISPER_SAMPLE_RATE as usize)
.map_err(|e| anyhow::anyhow!("Failed to create VAD: {e}"))?,
threshold,
})
}
}
impl VoiceActivityDetector for SileroVad {
fn push_frame<'a>(&'a mut self, frame: &'a [f32]) -> Result<VadFrame<'a>> {
if frame.len() != SILERO_FRAME_SAMPLES {
anyhow::bail!(
"expected {SILERO_FRAME_SAMPLES} samples, got {}",
frame.len()
);
}
let result = self
.engine
.compute(frame)
.map_err(|e| anyhow::anyhow!("Silero VAD error: {e}"))?;
// println!("Silero VAD result: prob = {}", result.prob);
if result.prob > self.threshold {
Ok(VadFrame::Speech(frame))
} else {
Ok(VadFrame::Noise)
}
}
}

View file

@ -0,0 +1,88 @@
use super::{VadFrame, VoiceActivityDetector};
use anyhow::Result;
use std::collections::VecDeque;
pub struct SmoothedVad {
inner_vad: Box<dyn VoiceActivityDetector>,
prefill_frames: usize,
hangover_frames: usize,
frame_buffer: VecDeque<Vec<f32>>,
hangover_counter: usize,
in_speech: bool,
temp_out: Vec<f32>,
}
impl SmoothedVad {
pub fn new(
inner_vad: Box<dyn VoiceActivityDetector>,
prefill_frames: usize,
hangover_frames: usize,
) -> Self {
Self {
inner_vad,
prefill_frames,
hangover_frames,
frame_buffer: VecDeque::new(),
hangover_counter: 0,
in_speech: false,
temp_out: Vec::new(),
}
}
}
impl VoiceActivityDetector for SmoothedVad {
fn push_frame<'a>(&'a mut self, frame: &'a [f32]) -> Result<VadFrame<'a>> {
// 1. Buffer every incoming frame for possible pre-roll
self.frame_buffer.push_back(frame.to_vec());
while self.frame_buffer.len() > self.prefill_frames + 1 {
self.frame_buffer.pop_front();
}
// 2. Delegate to the wrapped boolean VAD
let is_voice = self.inner_vad.is_voice(frame)?;
match (self.in_speech, is_voice) {
// Start of Speech
(false, true) => {
self.in_speech = true;
self.hangover_counter = self.hangover_frames;
// Collect prefill + current frame
self.temp_out.clear();
for buf in &self.frame_buffer {
self.temp_out.extend(buf);
}
Ok(VadFrame::Speech(&self.temp_out))
}
// Ongoing Speech
(true, true) => {
self.hangover_counter = self.hangover_frames;
Ok(VadFrame::Speech(frame))
}
// End of Speech
(true, false) => {
if self.hangover_counter > 0 {
self.hangover_counter -= 1;
Ok(VadFrame::Speech(frame))
} else {
self.in_speech = false;
Ok(VadFrame::Noise)
}
}
// Silence
(false, false) => Ok(VadFrame::Noise),
}
}
fn reset(&mut self) {
self.frame_buffer.clear();
self.hangover_counter = 0;
self.in_speech = false;
self.temp_out.clear();
}
}

View file

@ -45,6 +45,10 @@ tauri-plugin-process = "2"
rodio = "0.20.1"
reqwest = { version = "0.11", features = ["json", "stream"] }
futures-util = "0.3"
audio-toolkit = { path = "../crates/audio-toolkit" }
[dependencies.ort-sys]
version = "=2.0.0-rc.9"
[target.'cfg(target_os = "macos")'.dependencies]
whisper-rs = { version = "0.13.2", features = ["metal"] }
@ -60,3 +64,5 @@ tauri-plugin-autostart = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
once_cell = "1"