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
|
*.sln
|
||||||
*.sw?
|
*.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"
|
rodio = "0.20.1"
|
||||||
reqwest = { version = "0.11", features = ["json", "stream"] }
|
reqwest = { version = "0.11", features = ["json", "stream"] }
|
||||||
futures-util = "0.3"
|
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]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
whisper-rs = { version = "0.13.2", features = ["metal"] }
|
whisper-rs = { version = "0.13.2", features = ["metal"] }
|
||||||
|
|
@ -60,3 +64,5 @@ tauri-plugin-autostart = "2"
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
|
||||||
|
once_cell = "1"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue