move audio-toolkit into Handy
This commit is contained in:
parent
7f8a908cf9
commit
8dbebb6f64
15 changed files with 8681 additions and 1 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -23,4 +23,6 @@ dist-ssr
|
|||
*.sln
|
||||
*.sw?
|
||||
|
||||
src-tauri/resources/models
|
||||
src-tauri/resources/models
|
||||
|
||||
/target/
|
||||
|
|
|
|||
7696
Cargo.lock
generated
Normal file
7696
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[workspace]
|
||||
members = ["src-tauri", "crates/audio-toolkit"]
|
||||
resolver = "2"
|
||||
20
crates/audio-toolkit/Cargo.toml
Normal file
20
crates/audio-toolkit/Cargo.toml
Normal 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"
|
||||
30
crates/audio-toolkit/src/audio/device.rs
Normal file
30
crates/audio-toolkit/src/audio/device.rs
Normal 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)
|
||||
}
|
||||
8
crates/audio-toolkit/src/audio/mod.rs
Normal file
8
crates/audio-toolkit/src/audio/mod.rs
Normal 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;
|
||||
283
crates/audio-toolkit/src/audio/recorder.rs
Normal file
283
crates/audio-toolkit/src/audio/recorder.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
crates/audio-toolkit/src/audio/resampler.rs
Normal file
99
crates/audio-toolkit/src/audio/resampler.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
352
crates/audio-toolkit/src/bin/cli.rs
Normal file
352
crates/audio-toolkit/src/bin/cli.rs
Normal 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(())
|
||||
}
|
||||
1
crates/audio-toolkit/src/constants.rs
Normal file
1
crates/audio-toolkit/src/constants.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub const WHISPER_SAMPLE_RATE: u32 = 16000;
|
||||
6
crates/audio-toolkit/src/lib.rs
Normal file
6
crates/audio-toolkit/src/lib.rs
Normal 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};
|
||||
32
crates/audio-toolkit/src/vad/mod.rs
Normal file
32
crates/audio-toolkit/src/vad/mod.rs
Normal 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;
|
||||
54
crates/audio-toolkit/src/vad/silero.rs
Normal file
54
crates/audio-toolkit/src/vad/silero.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
88
crates/audio-toolkit/src/vad/smoothed.rs
Normal file
88
crates/audio-toolkit/src/vad/smoothed.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue