Merge pull request #34 from cjpais/audio-backend-refactor

Refactor the Audio Backend
This commit is contained in:
CJ Pais 2025-07-11 16:53:08 -07:00 committed by GitHub
commit 57f07c180c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2399 additions and 647 deletions

3
.gitignore vendored
View file

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

335
src-tauri/Cargo.lock generated
View file

@ -392,7 +392,7 @@ dependencies = [
"bitflags 2.8.0",
"cexpr",
"clang-sys",
"itertools 0.12.1",
"itertools",
"lazy_static",
"lazycell",
"log",
@ -406,24 +406,6 @@ dependencies = [
"which",
]
[[package]]
name = "bindgen"
version = "0.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f"
dependencies = [
"bitflags 2.8.0",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"proc-macro2",
"quote",
"regex",
"rustc-hash 1.1.0",
"shlex",
"syn 2.0.96",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@ -612,8 +594,6 @@ version = "1.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4730490333d58093109dc02c23174c3f4d490998c3fed3cc8e82d57afedb9cf"
dependencies = [
"jobserver",
"libc",
"shlex",
]
@ -655,9 +635,9 @@ dependencies = [
[[package]]
name = "cfg-if"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
[[package]]
name = "cfg_aliases"
@ -689,12 +669,6 @@ dependencies = [
"libloading 0.8.6",
]
[[package]]
name = "claxon"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
[[package]]
name = "clipboard-win"
version = "5.4.0"
@ -886,41 +860,38 @@ dependencies = [
[[package]]
name = "coreaudio-rs"
version = "0.11.3"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
dependencies = [
"bitflags 1.3.2",
"core-foundation-sys",
"coreaudio-sys",
]
[[package]]
name = "coreaudio-sys"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ce857aa0b77d77287acc1ac3e37a05a8c95a2af3647d23b15f263bdaeb7562b"
dependencies = [
"bindgen 0.70.1",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "cpal"
version = "0.15.3"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f"
dependencies = [
"alsa",
"core-foundation-sys",
"coreaudio-rs",
"dasp_sample",
"jni",
"js-sys",
"libc",
"mach2",
"ndk 0.8.0",
"ndk",
"ndk-context",
"oboe",
"num-derive",
"num-traits",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
@ -1435,6 +1406,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "extended"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "eyre"
version = "0.6.12"
@ -2033,8 +2010,10 @@ dependencies = [
"enigo",
"env_logger",
"futures-util",
"hound",
"log",
"once_cell",
"ort-sys",
"rdev",
"reqwest 0.11.27",
"rodio",
@ -2576,15 +2555,6 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.14"
@ -2636,15 +2606,6 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
dependencies = [
"libc",
]
[[package]]
name = "jpeg-decoder"
version = "0.3.1"
@ -2718,17 +2679,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "lewton"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030"
dependencies = [
"byteorder",
"ogg",
"tinyvec",
]
[[package]]
name = "libappindicator"
version = "0.9.0"
@ -3036,20 +2986,6 @@ dependencies = [
"rawpointer",
]
[[package]]
name = "ndk"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.8.0",
"jni-sys",
"log",
"ndk-sys 0.5.0+25.2.9519653",
"num_enum",
"thiserror 1.0.69",
]
[[package]]
name = "ndk"
version = "0.9.0"
@ -3059,7 +2995,7 @@ dependencies = [
"bitflags 2.8.0",
"jni-sys",
"log",
"ndk-sys 0.6.0+11769913",
"ndk-sys",
"num_enum",
"raw-window-handle",
"thiserror 1.0.69",
@ -3071,15 +3007,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.5.0+25.2.9519653"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
dependencies = [
"jni-sys",
]
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
@ -3133,6 +3060,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
@ -3168,6 +3105,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@ -3268,6 +3216,21 @@ dependencies = [
"objc2-quartz-core 0.3.1",
]
[[package]]
name = "objc2-audio-toolbox"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cbe18d879e20a4aea544f8befe38bcf52255eb63d3f23eca2842f3319e4c07"
dependencies = [
"bitflags 2.8.0",
"libc",
"objc2 0.6.1",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.1"
@ -3279,6 +3242,28 @@ dependencies = [
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-core-audio"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82"
dependencies = [
"dispatch2",
"objc2 0.6.1",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "objc2-core-audio-types"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1"
dependencies = [
"bitflags 2.8.0",
"objc2 0.6.1",
]
[[package]]
name = "objc2-core-data"
version = "0.2.2"
@ -3482,38 +3467,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "oboe"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni",
"ndk 0.8.0",
"ndk-context",
"num-derive",
"num-traits",
"oboe-sys",
]
[[package]]
name = "oboe-sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
dependencies = [
"cc",
]
[[package]]
name = "ogg"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.20.2"
@ -4401,21 +4354,19 @@ checksum = "3df6368f71f205ff9c33c076d170dd56ebf68e8161c733c0caa07a7a5509ed53"
[[package]]
name = "rodio"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
source = "git+https://github.com/cjpais/rodio.git#fed30292db417cb95305c118c0e1d804fb74cbff"
dependencies = [
"claxon",
"cpal",
"hound",
"lewton",
"dasp_sample",
"num-rational",
"symphonia",
]
[[package]]
name = "rubato"
version = "0.16.1"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd96992d7e24b3d7f35fdfe02af037a356ac90d41b466945cf3333525a86eea"
checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1"
dependencies = [
"num-complex",
"num-integer",
@ -5041,9 +4992,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
"symphonia-bundle-mp3",
"symphonia-codec-aac",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-isomp4",
"symphonia-format-ogg",
"symphonia-format-riff",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-flac"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72e34f34298a7308d4397a6c7fbf5b84c5d491231ce3dd379707ba673ab3bd97"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
@ -5058,6 +5028,38 @@ dependencies = [
"symphonia-metadata",
]
[[package]]
name = "symphonia-codec-aac"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdbf25b545ad0d3ee3e891ea643ad115aff4ca92f6aec472086b957a58522f70"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f395a67057c2ebc5e84d7bb1be71cce1a7ba99f64e0f0f0e303a03f79116f89b"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-vorbis"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30"
dependencies = [
"log",
"symphonia-core",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-core"
version = "0.5.4"
@ -5071,6 +5073,43 @@ dependencies = [
"log",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abfdf178d697e50ce1e5d9b982ba1b94c47218e03ec35022d9f0e071a16dc844"
dependencies = [
"encoding_rs",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-ogg"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ada3505789516bcf00fc1157c67729eded428b455c27ca370e41f4d785bfa931"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-riff"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f7be232f962f937f4b7115cbe62c330929345434c834359425e043bfd15f50"
dependencies = [
"extended",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.4"
@ -5083,6 +5122,16 @@ dependencies = [
"symphonia-core",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "484472580fa49991afda5f6550ece662237b00c6f562c7d9638d1b086ed010fe"
dependencies = [
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "syn"
version = "1.0.109"
@ -5194,9 +5243,9 @@ dependencies = [
"lazy_static",
"libc",
"log",
"ndk 0.9.0",
"ndk",
"ndk-context",
"ndk-sys 0.6.0+11769913",
"ndk-sys",
"objc2 0.6.1",
"objc2-app-kit 0.3.1",
"objc2-foundation 0.3.1",
@ -6459,7 +6508,7 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76bab42b2c319e3a1e0280137c59368072348d3277873c7588b6466a127dca58"
dependencies = [
"bindgen 0.69.5",
"bindgen",
"cfg-if",
"cmake",
"fs_extra",
@ -7125,7 +7174,7 @@ dependencies = [
"jni",
"kuchikiki",
"libc",
"ndk 0.9.0",
"ndk",
"objc2 0.6.1",
"objc2-app-kit 0.3.1",
"objc2-core-foundation",

View file

@ -5,6 +5,7 @@ description = "Handy"
authors = ["cjpais"]
edition = "2021"
license = "MIT"
default-run = "handy"
[profile.dev]
incremental = true # Compile your binary in smaller steps.
@ -18,6 +19,10 @@ incremental = true # Compile your binary in smaller steps.
name = "handy_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
# [[bin]]
# name = "cli"
# path = "src/audio_toolkit/bin/cli.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
@ -30,10 +35,11 @@ serde_json = "1"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-macos-permissions = "2.0.4"
rdev = { git = "https://github.com/rustdesk-org/rdev" }
cpal = "0.15.3"
cpal = "0.16.0"
whisper-rs = { version = "0.13.2", features = ["whisper-cpp-log"] }
anyhow = "1.0.95"
rubato = "0.16.1"
rubato = "0.16.2"
hound = "3.5.1"
env_logger = "0.11.6"
log = "0.4.25"
tokio = "1.43.0"
@ -42,10 +48,13 @@ tauri-plugin-store = "2"
tauri-plugin-os = "2"
enigo = "0.5.0"
tauri-plugin-process = "2"
rodio = "0.20.1"
rodio = { git = "https://github.com/cjpais/rodio.git" }
reqwest = { version = "0.11", features = ["json", "stream"] }
futures-util = "0.3"
[dependencies.ort-sys]
version = "=2.0.0-rc.9"
[target.'cfg(target_os = "macos")'.dependencies]
whisper-rs = { version = "0.13.2", features = ["metal"] }

Binary file not shown.

View file

@ -0,0 +1,52 @@
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)
}
pub fn list_output_devices() -> Result<Vec<CpalDeviceInfo>, Box<dyn std::error::Error>> {
let host = cpal::default_host();
let default_name = host.default_output_device().and_then(|d| d.name().ok());
let mut out = Vec::<CpalDeviceInfo>::new();
for (index, device) in host.output_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::{list_input_devices, list_output_devices, CpalDeviceInfo};
pub use recorder::AudioRecorder;
pub use resampler::FrameResampler;

View file

@ -0,0 +1,279 @@
use std::{
io::Error,
sync::{mpsc, Arc, Mutex},
time::Duration,
};
use cpal::{
traits::{DeviceTrait, HostTrait, StreamTrait},
Device, Sample, SizedSample,
};
use crate::audio_toolkit::{
audio::FrameResampler,
constants,
vad::{self, VadFrame},
VoiceActivityDetector,
};
enum Cmd {
Start,
Stop(mpsc::Sender<Vec<f32>>),
Shutdown,
}
pub struct AudioRecorder {
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 {
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>> {
if self.worker_handle.is_some() {
return Ok(()); // already open
}
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,
None => host
.default_input_device()
.ok_or_else(|| Error::new(std::io::ErrorKind::NotFound, "No input device found"))?,
};
let thread_device = device.clone();
let vad = self.vad.clone();
let worker = std::thread::spawn(move || {
let config = AudioRecorder::get_preferred_config(&thread_device)
.expect("failed to fetch preferred config");
let sample_rate = config.sample_rate().0;
let channels = config.channels() as usize;
println!(
"Using device: {:?}\nSample rate: {}\nChannels: {}\nFormat: {:?}",
thread_device.name(),
sample_rate,
channels,
config.sample_format()
);
let stream = match config.sample_format() {
cpal::SampleFormat::I8 => {
AudioRecorder::build_stream::<i8>(&thread_device, &config, sample_tx, channels)
.unwrap()
}
cpal::SampleFormat::I16 => {
AudioRecorder::build_stream::<i16>(&thread_device, &config, sample_tx, channels)
.unwrap()
}
cpal::SampleFormat::I32 => {
AudioRecorder::build_stream::<i32>(&thread_device, &config, sample_tx, channels)
.unwrap()
}
cpal::SampleFormat::F32 => {
AudioRecorder::build_stream::<f32>(&thread_device, &config, sample_tx, channels)
.unwrap()
}
_ => panic!("unsupported sample format"),
};
stream.play().expect("failed to start stream");
// keep the stream alive while we process samples
run_consumer(sample_rate, vad, sample_rx, cmd_rx);
// stream is dropped here, after run_consumer returns
});
self.device = Some(device);
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);
}
if let Some(h) = self.worker_handle.take() {
let _ = h.join();
}
self.device = None;
Ok(())
}
fn build_stream<T>(
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(
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,357 @@
use hound::WavWriter;
use std::io::{self, Write};
use handy_app_lib::audio_toolkit::{
audio::{list_input_devices, CpalDeviceInfo},
vad::SmoothedVad,
AudioRecorder, SileroVad,
};
#[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: &[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("./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: &[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::{list_input_devices, list_output_devices, AudioRecorder, CpalDeviceInfo};
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::audio_toolkit::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,106 @@
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,
onset_frames: usize,
frame_buffer: VecDeque<Vec<f32>>,
hangover_counter: usize,
onset_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,
onset_frames: usize,
) -> Self {
Self {
inner_vad,
prefill_frames,
hangover_frames,
onset_frames,
frame_buffer: VecDeque::new(),
hangover_counter: 0,
onset_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)?;
// println!("Is Voice: {}", is_voice);
match (self.in_speech, is_voice) {
// Potential start of speech - need to accumulate onset frames
(false, true) => {
self.onset_counter += 1;
if self.onset_counter >= self.onset_frames {
// We have enough consecutive voice frames to trigger speech
self.in_speech = true;
self.hangover_counter = self.hangover_frames;
self.onset_counter = 0; // Reset for next time
// 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))
} else {
// Not enough frames yet, still silence
Ok(VadFrame::Noise)
}
}
// Ongoing Speech
(true, true) => {
self.hangover_counter = self.hangover_frames;
Ok(VadFrame::Speech(frame))
}
// End of Speech or interruption during onset phase
(true, false) => {
if self.hangover_counter > 0 {
self.hangover_counter -= 1;
Ok(VadFrame::Speech(frame))
} else {
self.in_speech = false;
Ok(VadFrame::Noise)
}
}
// Silence or broken onset sequence
(false, false) => {
self.onset_counter = 0; // Reset onset counter on silence
Ok(VadFrame::Noise)
}
}
}
fn reset(&mut self) {
self.frame_buffer.clear();
self.hangover_counter = 0;
self.onset_counter = 0;
self.in_speech = false;
self.temp_out.clear();
}
}

View file

@ -1,8 +1,17 @@
use crate::audio_toolkit::audio::{list_input_devices, list_output_devices};
use crate::managers::audio::{AudioRecordingManager, MicrophoneMode};
use crate::settings::{get_settings, write_settings};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Manager};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AudioDevice {
pub index: String,
pub name: String,
pub is_default: bool,
}
#[tauri::command]
pub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> {
// Update settings
@ -27,3 +36,89 @@ pub fn get_microphone_mode(app: AppHandle) -> Result<bool, String> {
let settings = get_settings(&app);
Ok(settings.always_on_microphone)
}
#[tauri::command]
pub fn get_available_microphones() -> Result<Vec<AudioDevice>, String> {
let devices =
list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?;
let mut result = vec![AudioDevice {
index: "default".to_string(),
name: "Default".to_string(),
is_default: true,
}];
result.extend(devices.into_iter().map(|d| AudioDevice {
index: d.index,
name: d.name,
is_default: false, // The explicit default is handled separately
}));
Ok(result)
}
#[tauri::command]
pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> {
let mut settings = get_settings(&app);
settings.selected_microphone = if device_name == "default" {
None
} else {
Some(device_name)
};
write_settings(&app, settings);
// Update the audio manager to use the new device
let rm = app.state::<Arc<AudioRecordingManager>>();
rm.update_selected_device()
.map_err(|e| format!("Failed to update selected device: {}", e))?;
Ok(())
}
#[tauri::command]
pub fn get_selected_microphone(app: AppHandle) -> Result<String, String> {
let settings = get_settings(&app);
Ok(settings
.selected_microphone
.unwrap_or_else(|| "default".to_string()))
}
#[tauri::command]
pub fn get_available_output_devices() -> Result<Vec<AudioDevice>, String> {
let devices =
list_output_devices().map_err(|e| format!("Failed to list output devices: {}", e))?;
let mut result = vec![AudioDevice {
index: "default".to_string(),
name: "Default".to_string(),
is_default: true,
}];
result.extend(devices.into_iter().map(|d| AudioDevice {
index: d.index,
name: d.name,
is_default: false, // The explicit default is handled separately
}));
Ok(result)
}
#[tauri::command]
pub fn set_selected_output_device(app: AppHandle, device_name: String) -> Result<(), String> {
let mut settings = get_settings(&app);
settings.selected_output_device = if device_name == "default" {
None
} else {
Some(device_name)
};
write_settings(&app, settings);
Ok(())
}
#[tauri::command]
pub fn get_selected_output_device(app: AppHandle) -> Result<String, String> {
let settings = get_settings(&app);
Ok(settings
.selected_output_device
.unwrap_or_else(|| "default".to_string()))
}

View file

@ -1,4 +1,5 @@
mod actions;
pub mod audio_toolkit;
mod commands;
mod managers;
mod settings;
@ -187,7 +188,13 @@ pub fn run() {
commands::models::has_any_models_or_downloads,
commands::models::get_recommended_first_model,
commands::audio::update_microphone_mode,
commands::audio::get_microphone_mode
commands::audio::get_microphone_mode,
commands::audio::get_available_microphones,
commands::audio::set_selected_microphone,
commands::audio::get_selected_microphone,
commands::audio::get_available_output_devices,
commands::audio::set_selected_output_device,
commands::audio::get_selected_output_device
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View file

@ -1,16 +1,14 @@
use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad};
use crate::settings::get_settings;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::SampleFormat;
use log::{debug, info};
use rubato::{FftFixedIn, Resampler};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use std::vec::Vec;
use tauri::{App, Manager};
use vad_rs::Vad;
const WHISPER_SAMPLE_RATE: usize = 16000;
/* ──────────────────────────────────────────────────────────────── */
#[derive(Clone, Debug)]
pub enum RecordingState {
Idle,
@ -23,46 +21,34 @@ pub enum MicrophoneMode {
OnDemand,
}
/* ──────────────────────────────────────────────────────────────── */
fn create_audio_recorder(vad_path: &str) -> Result<AudioRecorder, anyhow::Error> {
let silero = SileroVad::new(vad_path, 0.3)
.map_err(|e| anyhow::anyhow!("Failed to create SileroVad: {}", e))?;
let smoothed_vad = SmoothedVad::new(Box::new(silero), 15, 15, 2);
let recorder = AudioRecorder::new()
.map_err(|e| anyhow::anyhow!("Failed to create AudioRecorder: {}", e))?
.with_vad(Box::new(smoothed_vad));
Ok(recorder)
}
/* ──────────────────────────────────────────────────────────────── */
#[derive(Clone)]
pub struct AudioRecordingManager {
state: Arc<Mutex<RecordingState>>,
buffer: Arc<Mutex<Vec<f32>>>,
mode: Arc<Mutex<MicrophoneMode>>,
app_handle: tauri::AppHandle,
// For on-demand mode
stream_handle: Arc<Mutex<Option<std::thread::JoinHandle<()>>>>,
stream_active: Arc<Mutex<bool>>,
}
trait SampleToF32 {
fn to_f32(&self) -> f32;
}
impl SampleToF32 for i8 {
fn to_f32(&self) -> f32 {
*self as f32 / 128.0
}
}
impl SampleToF32 for i16 {
fn to_f32(&self) -> f32 {
*self as f32 / 32768.0
}
}
impl SampleToF32 for i32 {
fn to_f32(&self) -> f32 {
*self as f32 / 2147483648.0
}
}
impl SampleToF32 for f32 {
fn to_f32(&self) -> f32 {
*self
}
recorder: Arc<Mutex<Option<AudioRecorder>>>,
is_open: Arc<Mutex<bool>>,
is_recording: Arc<Mutex<bool>>,
}
impl AudioRecordingManager {
/* ---------- construction ------------------------------------------------ */
pub fn new(app: &App) -> Result<Self, anyhow::Error> {
let settings = get_settings(&app.handle());
let mode = if settings.always_on_microphone {
@ -71,23 +57,17 @@ impl AudioRecordingManager {
MicrophoneMode::OnDemand
};
let state = Arc::new(Mutex::new(RecordingState::Idle));
let buffer = Arc::new(Mutex::new(Vec::new()));
let mode_arc = Arc::new(Mutex::new(mode.clone()));
let app_handle = app.handle().clone();
let stream_handle = Arc::new(Mutex::new(None));
let stream_active = Arc::new(Mutex::new(false));
let manager = Self {
state: state.clone(),
buffer: buffer.clone(),
mode: mode_arc,
app_handle,
stream_handle,
stream_active,
state: Arc::new(Mutex::new(RecordingState::Idle)),
mode: Arc::new(Mutex::new(mode.clone())),
app_handle: app.handle().clone(),
recorder: Arc::new(Mutex::new(None)),
is_open: Arc::new(Mutex::new(false)),
is_recording: Arc::new(Mutex::new(false)),
};
// If always-on mode, start the stream immediately
// Always-on? Open immediately.
if matches!(mode, MicrophoneMode::AlwaysOn) {
manager.start_microphone_stream()?;
}
@ -95,393 +75,187 @@ impl AudioRecordingManager {
Ok(manager)
}
/* ---------- microphone life-cycle -------------------------------------- */
pub fn start_microphone_stream(&self) -> Result<(), anyhow::Error> {
let mut open_flag = self.is_open.lock().unwrap();
if *open_flag {
debug!("Microphone stream already active");
return Ok(());
}
let start_time = Instant::now();
debug!("Starting microphone stream initialization...");
let mut stream_active = self.stream_active.lock().unwrap();
if *stream_active {
debug!("Microphone stream already active, skipping initialization");
return Ok(()); // Stream already active
let vad_path = self
.app_handle
.path()
.resolve(
"resources/models/silero_vad_v4.onnx",
tauri::path::BaseDirectory::Resource,
)
.map_err(|e| anyhow::anyhow!("Failed to resolve VAD path: {}", e))?;
let mut recorder_opt = self.recorder.lock().unwrap();
if recorder_opt.is_none() {
*recorder_opt = Some(create_audio_recorder(vad_path.to_str().unwrap())?);
}
let vad_path = self.app_handle.path().resolve(
"resources/models/silero_vad_v4.onnx",
tauri::path::BaseDirectory::Resource,
)?;
debug!("VAD model path resolved: {:?}", vad_path);
let host = cpal::default_host();
debug!("Audio host initialized");
let device = host
.default_input_device()
.ok_or_else(|| anyhow::Error::msg("No input device available"))?;
debug!("Default input device acquired: {:?}", device.name());
let config = device.default_input_config()?;
let sample_rate = config.sample_rate().0;
debug!(
"Audio config - Sample rate: {}, Channels: {}, Format: {:?}",
sample_rate,
config.channels(),
config.sample_format()
);
// Configure the resampler - keeping 1024 as input size for FFT efficiency
let resampler_start = Instant::now();
let resampler = FftFixedIn::new(sample_rate as usize, WHISPER_SAMPLE_RATE, 1024, 2, 1)?;
debug!("Resampler initialized in {:?}", resampler_start.elapsed());
let vad_start = Instant::now();
let vad = Arc::new(Mutex::new(Vad::new(vad_path, WHISPER_SAMPLE_RATE).unwrap()));
debug!("VAD initialized in {:?}", vad_start.elapsed());
let vad_clone = Arc::clone(&vad);
let state_clone = Arc::clone(&self.state);
let buffer_clone = Arc::clone(&self.buffer);
let resampler = Arc::new(Mutex::new(resampler));
let resampler_clone = Arc::clone(&resampler);
let stream_active_clone = Arc::clone(&self.stream_active);
// Create a temporary buffer to accumulate samples
let temp_buffer = Arc::new(Mutex::new(Vec::new()));
let temp_buffer_clone = Arc::clone(&temp_buffer);
// Create a buffer for resampled chunks waiting for VAD processing
let vad_buffer = Arc::new(Mutex::new(Vec::new()));
let vad_buffer_clone = Arc::clone(&vad_buffer);
// Generic function to process audio data
fn process_audio<T: SampleToF32 + Send + 'static>(
data: &[T],
channels: usize,
state_clone: Arc<Mutex<RecordingState>>,
temp_buffer_clone: Arc<Mutex<Vec<f32>>>,
resampler_clone: Arc<Mutex<FftFixedIn<f32>>>,
vad_buffer_clone: Arc<Mutex<Vec<f32>>>,
buffer_clone: Arc<Mutex<Vec<f32>>>,
vad_clone: Arc<Mutex<Vad>>,
stream_active_clone: Arc<Mutex<bool>>,
) {
// Check if stream should still be active
let stream_active = stream_active_clone.lock().unwrap();
if !*stream_active {
return;
}
drop(stream_active);
let state_guard = state_clone.lock().unwrap();
if let RecordingState::Recording { .. } = *state_guard {
let mut temp_buffer = temp_buffer_clone.lock().unwrap();
// Handle multichannel audio by mixing down to mono
if channels > 1 {
// Process chunks of `channels` samples at a time (each chunk is one audio frame)
for chunk in data.chunks(channels) {
// Average the channels to create a mono sample
let mono_sample: f32 =
chunk.iter().map(|s| s.to_f32()).sum::<f32>() / channels as f32;
temp_buffer.push(mono_sample);
}
} else {
// Single channel processing
let f32_data: Vec<f32> = data.iter().map(|sample| sample.to_f32()).collect();
temp_buffer.extend_from_slice(&f32_data);
}
// Process when we have enough samples
while temp_buffer.len() >= 1024 {
// Take the first 1024 samples for processing
let chunk: Vec<f32> = temp_buffer.drain(..1024).collect();
// Convert input data to the format expected by Rubato
let input_frames = vec![chunk];
// Process the audio chunk through the resampler
let mut resampler = resampler_clone.lock().unwrap();
if let Ok(resampled) = resampler.process(&input_frames, None) {
// Add resampled data to VAD buffer
let mut vad_buffer = vad_buffer_clone.lock().unwrap();
vad_buffer.extend_from_slice(&resampled[0]);
// Process 30ms chunks (480 samples) for VAD
while vad_buffer.len() >= 480 {
let chunk = vad_buffer.drain(..480).collect::<Vec<f32>>();
// Use VAD to detect speech
if let Ok(mut vad) = vad_clone.lock() {
match vad.compute(&chunk) {
Ok(result) => {
if result.prob > 0.3 {
let mut buffer = buffer_clone.lock().unwrap();
buffer.extend_from_slice(&chunk);
}
}
Err(error) => {
eprintln!("Error computing VAD: {:?}", error)
}
}
}
}
}
// Get the selected device from settings
let settings = get_settings(&self.app_handle);
let selected_device = if let Some(device_name) = settings.selected_microphone {
// Find the device by name
match list_input_devices() {
Ok(devices) => devices
.into_iter()
.find(|d| d.name == device_name)
.map(|d| d.device),
Err(e) => {
debug!("Failed to list devices, using default: {}", e);
None
}
}
} else {
None
};
if let Some(rec) = recorder_opt.as_mut() {
rec.open(selected_device)
.map_err(|e| anyhow::anyhow!("Failed to open recorder: {}", e))?;
}
let handle = std::thread::spawn(move || {
let err_fn = |err| eprintln!("Error in stream: {}", err);
// Build the appropriate stream based on the sample format
// Store the number of channels for use in the closure
let channels = config.channels() as usize;
debug!(
"Audio stream thread started - Using {} channel(s) for recording",
channels
);
let stream = match config.sample_format() {
SampleFormat::I8 => {
let stream_active_i8 = Arc::clone(&stream_active_clone);
device.build_input_stream(
&config.into(),
move |data: &[i8], _| {
process_audio(
data,
channels,
Arc::clone(&state_clone),
Arc::clone(&temp_buffer_clone),
Arc::clone(&resampler_clone),
Arc::clone(&vad_buffer_clone),
Arc::clone(&buffer_clone),
Arc::clone(&vad_clone),
Arc::clone(&stream_active_i8),
)
},
err_fn,
None,
)
}
SampleFormat::I16 => {
let stream_active_i16 = Arc::clone(&stream_active_clone);
device.build_input_stream(
&config.into(),
move |data: &[i16], _| {
process_audio(
data,
channels,
Arc::clone(&state_clone),
Arc::clone(&temp_buffer_clone),
Arc::clone(&resampler_clone),
Arc::clone(&vad_buffer_clone),
Arc::clone(&buffer_clone),
Arc::clone(&vad_clone),
Arc::clone(&stream_active_i16),
)
},
err_fn,
None,
)
}
SampleFormat::I32 => {
let stream_active_i32 = Arc::clone(&stream_active_clone);
device.build_input_stream(
&config.into(),
move |data: &[i32], _| {
process_audio(
data,
channels,
Arc::clone(&state_clone),
Arc::clone(&temp_buffer_clone),
Arc::clone(&resampler_clone),
Arc::clone(&vad_buffer_clone),
Arc::clone(&buffer_clone),
Arc::clone(&vad_clone),
Arc::clone(&stream_active_i32),
)
},
err_fn,
None,
)
}
SampleFormat::F32 => {
let stream_active_f32 = Arc::clone(&stream_active_clone);
device.build_input_stream(
&config.into(),
move |data: &[f32], _| {
process_audio(
data,
channels,
Arc::clone(&state_clone),
Arc::clone(&temp_buffer_clone),
Arc::clone(&resampler_clone),
Arc::clone(&vad_buffer_clone),
Arc::clone(&buffer_clone),
Arc::clone(&vad_clone),
Arc::clone(&stream_active_f32),
)
},
err_fn,
None,
)
}
sample_format => {
panic!("Unsupported sample format: {:?}", sample_format);
}
}
.expect("Failed to build input stream");
debug!("Audio input stream built successfully");
stream.play().expect("Failed to play stream");
debug!("Audio stream started playing");
// Keep the thread alive until stream_active becomes false
let stream_active_for_loop = Arc::clone(&stream_active_clone);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let active = {
let guard = stream_active_for_loop.lock().unwrap();
*guard
};
if !active {
break;
}
}
});
*self.stream_handle.lock().unwrap() = Some(handle);
*stream_active = true;
let total_time = start_time.elapsed();
*open_flag = true;
info!(
"Microphone stream initialization completed in {:?}",
total_time
"Microphone stream initialized in {:?}",
start_time.elapsed()
);
Ok(())
}
pub fn stop_microphone_stream(&self) {
let mut stream_active = self.stream_active.lock().unwrap();
if !*stream_active {
return; // Stream already stopped
let mut open_flag = self.is_open.lock().unwrap();
if !*open_flag {
return;
}
*stream_active = false;
drop(stream_active);
// Wait for the stream thread to finish
if let Some(handle) = self.stream_handle.lock().unwrap().take() {
let _ = handle.join();
if let Some(rec) = self.recorder.lock().unwrap().as_mut() {
// If still recording, stop first.
if *self.is_recording.lock().unwrap() {
let _ = rec.stop();
*self.is_recording.lock().unwrap() = false;
}
let _ = rec.close();
}
*open_flag = false;
debug!("Microphone stream stopped");
}
pub fn update_mode(&self, new_mode: MicrophoneMode) -> Result<(), anyhow::Error> {
let mut mode = self.mode.lock().unwrap();
let current_mode = mode.clone();
/* ---------- mode switching --------------------------------------------- */
if matches!(current_mode, MicrophoneMode::AlwaysOn)
&& matches!(new_mode, MicrophoneMode::OnDemand)
{
// Switching from always-on to on-demand
// Stop the stream if not currently recording
let state = self.state.lock().unwrap();
if matches!(*state, RecordingState::Idle) {
drop(state);
drop(mode);
self.stop_microphone_stream();
mode = self.mode.lock().unwrap();
pub fn update_mode(&self, new_mode: MicrophoneMode) -> Result<(), anyhow::Error> {
let mode_guard = self.mode.lock().unwrap();
let cur_mode = mode_guard.clone();
match (cur_mode, &new_mode) {
(MicrophoneMode::AlwaysOn, MicrophoneMode::OnDemand) => {
if matches!(*self.state.lock().unwrap(), RecordingState::Idle) {
drop(mode_guard);
self.stop_microphone_stream();
}
}
} else if matches!(current_mode, MicrophoneMode::OnDemand)
&& matches!(new_mode, MicrophoneMode::AlwaysOn)
{
// Switching from on-demand to always-on
drop(mode);
self.start_microphone_stream()?;
mode = self.mode.lock().unwrap();
(MicrophoneMode::OnDemand, MicrophoneMode::AlwaysOn) => {
drop(mode_guard);
self.start_microphone_stream()?;
}
_ => {}
}
*mode = new_mode;
*self.mode.lock().unwrap() = new_mode;
Ok(())
}
/* ---------- recording --------------------------------------------------- */
pub fn try_start_recording(&self, binding_id: &str) -> bool {
let mut state = self.state.lock().unwrap();
match *state {
RecordingState::Idle => {
// For on-demand mode, start the microphone stream now
let mode = self.mode.lock().unwrap();
if matches!(*mode, MicrophoneMode::OnDemand) {
debug!("On-demand mode: Starting microphone stream for recording");
drop(mode);
drop(state);
if let Err(e) = self.start_microphone_stream() {
eprintln!("Failed to start microphone stream: {}", e);
return false;
}
state = self.state.lock().unwrap();
}
// Clear the buffer before starting new recording
self.buffer.lock().unwrap().clear();
*state = RecordingState::Recording {
binding_id: binding_id.to_string(),
};
println!("Started recording for binding {}", binding_id);
true
if let RecordingState::Idle = *state {
// Ensure microphone is open in on-demand mode
if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {
if let Err(e) = self.start_microphone_stream() {
eprintln!("Failed to open microphone stream: {e}");
return false;
}
}
RecordingState::Recording {
binding_id: ref active_id,
} => {
println!(
"Cannot start recording: already recording for binding {}",
active_id
);
false
if let Some(rec) = self.recorder.lock().unwrap().as_ref() {
if rec.start().is_ok() {
*self.is_recording.lock().unwrap() = true;
*state = RecordingState::Recording {
binding_id: binding_id.to_string(),
};
debug!("Recording started for binding {binding_id}");
return true;
}
}
eprintln!("Recorder not available");
false
} else {
false
}
}
pub fn update_selected_device(&self) -> Result<(), anyhow::Error> {
// If currently open, restart the microphone stream to use the new device
if *self.is_open.lock().unwrap() {
self.stop_microphone_stream();
self.start_microphone_stream()?;
}
Ok(())
}
pub fn stop_recording(&self, binding_id: &str) -> Option<Vec<f32>> {
let mut state = self.state.lock().unwrap();
println!("Stop recording called from binding {}", binding_id);
match *state {
RecordingState::Recording {
binding_id: ref active_id,
} if active_id == binding_id => {
binding_id: ref active,
} if active == binding_id => {
*state = RecordingState::Idle;
println!("Stopped recording for binding {}", binding_id);
drop(state);
// For on-demand mode, stop the microphone stream
let mode = self.mode.lock().unwrap();
if matches!(*mode, MicrophoneMode::OnDemand) {
debug!("On-demand mode: Stopping microphone stream after recording");
drop(mode);
drop(state);
let samples = if let Some(rec) = self.recorder.lock().unwrap().as_ref() {
match rec.stop() {
Ok(buf) => buf,
Err(e) => {
eprintln!("stop() failed: {e}");
Vec::new()
}
}
} else {
eprintln!("Recorder not available");
Vec::new()
};
*self.is_recording.lock().unwrap() = false;
// In on-demand mode turn the mic off again
if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {
self.stop_microphone_stream();
}
let mut buffer = self.buffer.lock().unwrap();
let audio_data: Vec<f32> = buffer.drain(..).collect();
let samples = audio_data.len();
if samples < WHISPER_SAMPLE_RATE && samples > 1000 {
let target_samples = WHISPER_SAMPLE_RATE * 5 / 4; // sample rate * 1.25
let mut padded_audio = audio_data;
padded_audio.resize(target_samples, 0.0); // Pad with silence (zeros)
Some(padded_audio)
// Pad if very short
let s_len = samples.len();
// println!("Got {} samples", { s_len });
if s_len < WHISPER_SAMPLE_RATE && s_len > 0 {
let mut padded = samples;
padded.resize(WHISPER_SAMPLE_RATE * 5 / 4, 0.0);
Some(padded)
} else {
Some(audio_data)
Some(samples)
}
}
_ => {
// println!("Cannot stop recording: not recording or wrong binding");
None
}
_ => None,
}
}
}

View file

@ -22,6 +22,10 @@ pub struct AppSettings {
pub selected_model: String,
#[serde(default = "default_always_on_microphone")]
pub always_on_microphone: bool,
#[serde(default)]
pub selected_microphone: Option<String>,
#[serde(default)]
pub selected_output_device: Option<String>,
}
fn default_model() -> String {
@ -77,6 +81,8 @@ pub fn get_default_settings() -> AppSettings {
audio_feedback: false,
selected_model: "".to_string(),
always_on_microphone: false,
selected_microphone: None,
selected_output_device: None,
}
}

View file

@ -5,7 +5,8 @@ use enigo::Key;
use enigo::Keyboard;
use enigo::Settings;
use rodio::{Decoder, OutputStream, Sink};
use cpal::traits::{DeviceTrait, HostTrait};
use rodio::OutputStreamBuilder;
use std::fs::File;
use std::io::BufReader;
use std::thread;
@ -121,8 +122,12 @@ pub fn play_sound(app: &AppHandle, resource_path: &str) {
}
};
// Get the selected output device from settings
let settings = settings::get_settings(&app_handle);
let selected_device = settings.selected_output_device.clone();
// Try to play the audio file
if let Err(e) = play_audio_file(&audio_path) {
if let Err(e) = play_audio_file(&audio_path, selected_device) {
eprintln!("Failed to play sound '{}': {}", resource_path, e);
}
});
@ -138,20 +143,50 @@ pub fn play_recording_stop_sound(app: &AppHandle) {
play_sound(app, "resources/rec_stop.wav");
}
fn play_audio_file(path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
// Get a output stream handle to the default physical sound device
let (_stream, stream_handle) = OutputStream::try_default()?;
fn play_audio_file(
path: &std::path::Path,
selected_device: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let stream_builder = if let Some(device_name) = selected_device {
if device_name == "Default" {
println!("Using default device");
// Use default device
OutputStreamBuilder::from_default_device()?
} else {
// Try to find the device by name
let host = cpal::default_host();
let devices = host.output_devices()?;
let mut found_device = None;
for device in devices {
if device.name()? == device_name {
found_device = Some(device);
break;
}
}
match found_device {
Some(device) => OutputStreamBuilder::from_device(device)?,
None => {
eprintln!("Device '{}' not found, using default device", device_name);
OutputStreamBuilder::from_default_device()?
}
}
}
} else {
println!("Using default device");
// Use default device
OutputStreamBuilder::from_default_device()?
};
let stream_handle = stream_builder.open_stream()?;
let mixer = stream_handle.mixer();
// Load the audio file
let file = File::open(path)?;
let buf_reader = BufReader::new(file);
let source = Decoder::new(buf_reader)?;
// Create a sink to play the audio
let sink = Sink::try_new(&stream_handle)?;
sink.append(source);
// Wait for the audio to finish playing
let sink = rodio::play(mixer, buf_reader)?;
sink.sleep_until_end();
Ok(())

View file

@ -14,9 +14,9 @@
{
"title": "Handy",
"width": 540,
"height": 510,
"height": 590,
"minWidth": 540,
"minHeight": 510,
"minHeight": 590,
"resizable": true,
"maximizable": false
}

View file

@ -0,0 +1,60 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { ToggleSwitch } from "../ui/ToggleSwitch";
interface AlwaysOnMicrophoneProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const [alwaysOnMode, setAlwaysOnMode] = useState<boolean>(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const alwaysOn: boolean = await invoke("get_microphone_mode");
setAlwaysOnMode(alwaysOn);
} catch (error) {
console.error("Failed to load always-on microphone setting:", error);
}
};
const handleAlwaysOnToggle = async (enabled: boolean) => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("update_microphone_mode", { alwaysOn: enabled });
setAlwaysOnMode(enabled);
// Provide user feedback about the change
console.log(`Always-on microphone ${enabled ? "enabled" : "disabled"}`);
} catch (error) {
console.error("Failed to update microphone mode:", error);
// Revert the toggle if the update failed
setAlwaysOnMode(!enabled);
} finally {
setIsUpdating(false);
}
};
return (
<ToggleSwitch
checked={alwaysOnMode}
onChange={handleAlwaysOnToggle}
isUpdating={isUpdating}
label="Always-On Microphone"
description="Keep microphone active for low latency recording. This may prevent your computer from sleeping."
descriptionMode={descriptionMode}
grouped={grouped}
/>
);
};

View file

@ -0,0 +1,69 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { ToggleSwitch } from "../ui/ToggleSwitch";
interface AudioFeedbackProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const AudioFeedback: React.FC<AudioFeedbackProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const [audioFeedbackEnabled, setAudioFeedbackEnabled] =
useState<boolean>(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
// Load from the store since this setting is handled differently
const { load } = await import("@tauri-apps/plugin-store");
const store = await load("settings_store.json", { autoSave: false });
const settings = await store.get("settings");
if (
settings &&
typeof settings === "object" &&
"audio_feedback" in settings
) {
setAudioFeedbackEnabled(settings.audio_feedback as boolean);
}
} catch (error) {
console.error("Failed to load audio feedback setting:", error);
}
};
const handleAudioFeedbackToggle = async (enabled: boolean) => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("change_audio_feedback_setting", { enabled });
setAudioFeedbackEnabled(enabled);
console.log(`Audio feedback ${enabled ? "enabled" : "disabled"}`);
} catch (error) {
console.error("Failed to update audio feedback setting:", error);
// Revert the toggle if the update failed
setAudioFeedbackEnabled(!enabled);
} finally {
setIsUpdating(false);
}
};
return (
<ToggleSwitch
checked={audioFeedbackEnabled}
onChange={handleAudioFeedbackToggle}
isUpdating={isUpdating}
label="Audio Feedback"
description="Play sound when recording starts and stops"
descriptionMode={descriptionMode}
grouped={grouped}
/>
);
};

View file

@ -1,21 +1,25 @@
import React, { useEffect, useState, useRef } from "react";
import { load } from "@tauri-apps/plugin-store";
import {
BindingResponseSchema,
SettingsSchema,
ShortcutBindingSchema,
ShortcutBindingsMap,
} from "../../lib/types";
import { invoke } from "@tauri-apps/api/core";
import { type } from "@tauri-apps/plugin-os";
import { getKeyName } from "../../lib/utils/keyboard";
import ResetIcon from "../icons/ResetIcon";
import { SettingContainer } from "../ui/SettingContainer";
export const KeyboardShortcuts: React.FC = () => {
interface HandyShortcutProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const HandyShortcut: React.FC<HandyShortcutProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const [bindings, setBindings] = React.useState<ShortcutBindingsMap>({});
const [pttEnabled, setPttEnabled] = React.useState<boolean>(false);
const [audioFeedbackEnabled, setAudioFeedbackEnabled] =
React.useState<boolean>(false);
const [keyPressed, setKeyPressed] = useState<string[]>([]);
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
const [editingShortcutId, setEditingShortcutId] = useState<string | null>(
@ -23,13 +27,14 @@ export const KeyboardShortcuts: React.FC = () => {
);
const [originalBinding, setOriginalBinding] = useState<string>("");
const [isMacOS, setIsMacOS] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const shortcutRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
// Check if running on macOS
useEffect(() => {
const checkOsType = async () => {
try {
const osType = type();
const osType = await type();
setIsMacOS(osType === "macos");
} catch (error) {
console.error("Error detecting OS type:", error);
@ -72,18 +77,27 @@ export const KeyboardShortcuts: React.FC = () => {
};
useEffect(() => {
load("settings_store.json", { autoSave: false }).then((r) => {
console.log("loaded store", r);
r.get("settings").then((s) => {
const settings = SettingsSchema.parse(s);
setBindings(settings.bindings);
setPttEnabled(settings.push_to_talk);
setAudioFeedbackEnabled(settings.audio_feedback);
});
});
loadSettings();
}, []);
const loadSettings = async () => {
try {
setIsLoading(true);
const { load } = await import("@tauri-apps/plugin-store");
const store = await load("settings_store.json", { autoSave: false });
const settings = await store.get("settings");
if (settings) {
const parsedSettings = SettingsSchema.parse(settings);
setBindings(parsedSettings.bindings);
}
} catch (error) {
console.error("Failed to load settings:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
// Only add event listeners when we're in editing mode
if (editingShortcutId === null) return;
@ -91,7 +105,7 @@ export const KeyboardShortcuts: React.FC = () => {
console.log("keyPressed", keyPressed);
// Keyboard event listeners
const handleKeyDown = (e: KeyboardEvent) => {
const handleKeyDown = async (e: KeyboardEvent) => {
e.preventDefault();
// Get the key and normalize it (unify left/right modifiers)
@ -109,7 +123,7 @@ export const KeyboardShortcuts: React.FC = () => {
}
};
const handleKeyUp = (e: KeyboardEvent) => {
const handleKeyUp = async (e: KeyboardEvent) => {
e.preventDefault();
// Get the key and normalize it
@ -120,7 +134,8 @@ export const KeyboardShortcuts: React.FC = () => {
setKeyPressed((prev) => prev.filter((k) => k !== key));
// If no keys are pressed anymore, commit the shortcut
if (keyPressed.length === 1 && keyPressed[0] === key) {
const updatedKeyPressed = keyPressed.filter((k) => k !== key);
if (updatedKeyPressed.length === 0 && recordedKeys.length > 0) {
// Create the shortcut string from all recorded keys
const newShortcut = recordedKeys.join("+");
@ -135,10 +150,22 @@ export const KeyboardShortcuts: React.FC = () => {
[editingShortcutId]: updatedBinding,
}));
invoke("change_binding", {
id: editingShortcutId,
binding: newShortcut,
});
try {
await invoke("change_binding", {
id: editingShortcutId,
binding: newShortcut,
});
} catch (error) {
console.error("Failed to change binding:", error);
// Restore original binding on error
setBindings((prev) => ({
...prev,
[editingShortcutId]: {
...prev[editingShortcutId],
current_binding: originalBinding,
},
}));
}
// Exit editing mode and reset states
setEditingShortcutId(null);
@ -174,17 +201,19 @@ export const KeyboardShortcuts: React.FC = () => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("mousedown", handleClickOutside);
window.addEventListener("click", handleClickOutside);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("mousedown", handleClickOutside);
window.removeEventListener("click", handleClickOutside);
};
}, [keyPressed, recordedKeys, editingShortcutId, bindings, originalBinding]);
// Start recording a new shortcut
const startRecording = (id: string) => {
if (editingShortcutId === id) return; // Already editing this shortcut
// Store the original binding to restore if canceled
setOriginalBinding(bindings[id]?.current_binding || "");
setEditingShortcutId(id);
@ -209,69 +238,56 @@ export const KeyboardShortcuts: React.FC = () => {
shortcutRefs.current.set(id, ref);
};
// If still loading, show loading state
if (isLoading) {
return (
<SettingContainer
title="Handy Shortcuts"
description="Configure keyboard shortcuts to trigger speech-to-text recording"
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">Loading shortcuts...</div>
</SettingContainer>
);
}
// If no bindings are loaded, show empty state
if (Object.keys(bindings).length === 0) {
return (
<SettingContainer
title="Handy Shortcuts"
description="Configure keyboard shortcuts to trigger speech-to-text recording"
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">No shortcuts configured</div>
</SettingContainer>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg border border-mid-gray/20 ">
<div className="max-w-2/3">
<h3 className="text-sm font-medium ">Push To Talk</h3>
<p className="text-sm">Hold to record, release to stop</p>
</div>
<label className="inline-flex items-center cursor-pointer">
<input
type="checkbox"
value=""
className="sr-only peer"
checked={pttEnabled}
onChange={(e) => {
console.log("change ptt setting", e.target.checked);
const newValue = e.target.checked;
setPttEnabled(newValue);
<SettingContainer
title="Handy Shortcut"
description="Set the keyboard shortcut to start and stop speech-to-text recording"
descriptionMode={descriptionMode}
grouped={grouped}
>
{(() => {
const primaryBinding = Object.values(bindings)[0];
const primaryId = Object.keys(bindings)[0];
invoke("change_ptt_setting", {
enabled: newValue,
});
}}
/>
<div className="relative w-11 h-6 bg-mid-gray/20 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-logo-primary rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-logo-primary"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 rounded-lg border border-mid-gray/20 ">
<div className="max-w-2/3">
<h3 className="text-sm font-medium ">Audio Feedback</h3>
<p className="text-sm">Play sound when recording starts and stops</p>
</div>
<label className="inline-flex items-center cursor-pointer">
<input
type="checkbox"
value=""
className="sr-only peer"
checked={audioFeedbackEnabled}
onChange={(e) => {
console.log("change audio feedback setting", e.target.checked);
const newValue = e.target.checked;
setAudioFeedbackEnabled(newValue);
if (!primaryBinding) {
return (
<div className="text-sm text-mid-gray">No shortcuts configured</div>
);
}
invoke("change_audio_feedback_setting", {
enabled: newValue,
});
}}
/>
<div className="relative w-11 h-6 bg-mid-gray/20 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-logo-primary rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-logo-primary"></div>
</label>
</div>
{Object.entries(bindings).map(([id, binding]) => (
<div
key={id}
className="flex items-center justify-between p-4 rounded-lg border border-mid-gray/20 "
>
<div>
<h3 className="text-sm font-medium ">{binding.name}</h3>
<p className="text-sm">{binding.description}</p>
</div>
return (
<div className="flex items-center space-x-1">
{editingShortcutId === id ? (
{editingShortcutId === primaryId ? (
<div
ref={(ref) => setShortcutRef(id, ref)}
ref={(ref) => setShortcutRef(primaryId, ref)}
className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded min-w-[120px] text-center"
>
{formatCurrentKeys()}
@ -279,15 +295,16 @@ export const KeyboardShortcuts: React.FC = () => {
) : (
<div
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary"
onClick={() => startRecording(id)}
onClick={() => startRecording(primaryId)}
>
{formatKeyCombination(binding.current_binding)}
{formatKeyCombination(primaryBinding.current_binding)}
</div>
)}
<button
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
onClick={() => {
invoke("reset_binding", { id }).then((b) => {
onClick={async () => {
try {
const b = await invoke("reset_binding", { id: primaryId });
console.log("reset");
const newBinding = BindingResponseSchema.parse(b);
@ -299,14 +316,16 @@ export const KeyboardShortcuts: React.FC = () => {
const binding = newBinding.binding!;
setBindings({ ...bindings, [binding.id]: binding });
});
} catch (error) {
console.error("Failed to reset binding:", error);
}
}}
>
<ResetIcon className="" />
</button>
</div>
</div>
))}
</div>
);
})()}
</SettingContainer>
);
};

View file

@ -0,0 +1,116 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { AudioDevice } from "../../lib/types";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import ResetIcon from "../icons/ResetIcon";
interface MicrophoneSelectorProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([]);
const [selectedMicrophone, setSelectedMicrophone] =
useState<string>("default");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
useEffect(() => {
loadAudioDevices();
loadSettings();
}, []);
const loadAudioDevices = async () => {
try {
const devices: AudioDevice[] = await invoke("get_available_microphones");
setAudioDevices(devices);
} catch (error) {
console.error("Failed to load audio devices:", error);
setAudioDevices([]);
}
};
const loadSettings = async () => {
try {
setIsLoading(true);
const selectedMic: string = await invoke("get_selected_microphone");
setSelectedMicrophone(selectedMic);
} catch (error) {
console.error("Failed to load microphone settings:", error);
} finally {
setIsLoading(false);
}
};
const handleMicrophoneSelect = async (deviceName: string) => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("set_selected_microphone", { deviceName });
setSelectedMicrophone(deviceName);
console.log(
`Microphone changed to: ${audioDevices.find((d) => d.name === deviceName)?.name}`,
);
} catch (error) {
console.error("Failed to set microphone:", error);
// Revert selection if update failed
loadSettings();
} finally {
setIsUpdating(false);
}
};
const handleReset = async () => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("set_selected_microphone", { deviceName: "default" });
setSelectedMicrophone("default");
console.log("Microphone reset to default");
} catch (error) {
console.error("Failed to reset microphone:", error);
// Revert selection if reset failed
loadSettings();
} finally {
setIsUpdating(false);
}
};
return (
<SettingContainer
title="Microphone"
description="Select your preferred microphone device"
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="flex items-center space-x-1">
<Dropdown
devices={audioDevices}
selectedDevice={selectedMicrophone}
onSelect={handleMicrophoneSelect}
placeholder={isLoading ? "Loading..." : "Select microphone..."}
disabled={isUpdating || isLoading}
/>
<button
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
onClick={handleReset}
disabled={isUpdating || isLoading}
>
<ResetIcon className="" />
</button>
</div>
{isUpdating && (
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
<div className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</SettingContainer>
);
};

View file

@ -0,0 +1,118 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { AudioDevice } from "../../lib/types";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import ResetIcon from "../icons/ResetIcon";
interface OutputDeviceSelectorProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const [outputDevices, setOutputDevices] = useState<AudioDevice[]>([]);
const [selectedOutputDevice, setSelectedOutputDevice] =
useState<string>("default");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
useEffect(() => {
loadOutputDevices();
loadSettings();
}, []);
const loadOutputDevices = async () => {
try {
const devices: AudioDevice[] = await invoke(
"get_available_output_devices",
);
setOutputDevices(devices);
} catch (error) {
console.error("Failed to load output devices:", error);
setOutputDevices([]);
}
};
const loadSettings = async () => {
try {
setIsLoading(true);
const selectedDevice: string = await invoke("get_selected_output_device");
setSelectedOutputDevice(selectedDevice);
} catch (error) {
console.error("Failed to load output device settings:", error);
} finally {
setIsLoading(false);
}
};
const handleOutputDeviceSelect = async (deviceName: string) => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("set_selected_output_device", { deviceName });
setSelectedOutputDevice(deviceName);
console.log(
`Output device changed to: ${outputDevices.find((d) => d.name === deviceName)?.name}`,
);
} catch (error) {
console.error("Failed to set output device:", error);
// Revert selection if update failed
loadSettings();
} finally {
setIsUpdating(false);
}
};
const handleReset = async () => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("set_selected_output_device", { deviceName: "default" });
setSelectedOutputDevice("default");
console.log("Output device reset to default");
} catch (error) {
console.error("Failed to reset output device:", error);
// Revert selection if reset failed
loadSettings();
} finally {
setIsUpdating(false);
}
};
return (
<SettingContainer
title="Output Device"
description="Select your preferred audio output device for feedback sounds"
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="flex items-center space-x-1">
<Dropdown
devices={outputDevices}
selectedDevice={selectedOutputDevice}
onSelect={handleOutputDeviceSelect}
placeholder={isLoading ? "Loading..." : "Select output device..."}
disabled={isUpdating || isLoading}
/>
<button
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
onClick={handleReset}
disabled={isUpdating || isLoading}
>
<ResetIcon className="" />
</button>
</div>
{isUpdating && (
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
<div className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</SettingContainer>
);
};

View file

@ -0,0 +1,68 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { ToggleSwitch } from "../ui/ToggleSwitch";
interface PushToTalkProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const PushToTalk: React.FC<PushToTalkProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const [pttEnabled, setPttEnabled] = useState<boolean>(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
// Load from the store since this setting is handled differently
const { load } = await import("@tauri-apps/plugin-store");
const store = await load("settings_store.json", { autoSave: false });
const settings = await store.get("settings");
if (
settings &&
typeof settings === "object" &&
"push_to_talk" in settings
) {
setPttEnabled(settings.push_to_talk as boolean);
}
} catch (error) {
console.error("Failed to load push-to-talk setting:", error);
}
};
const handlePttToggle = async (enabled: boolean) => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("change_ptt_setting", { enabled });
setPttEnabled(enabled);
console.log(`Push-to-talk ${enabled ? "enabled" : "disabled"}`);
} catch (error) {
console.error("Failed to update push-to-talk setting:", error);
// Revert the toggle if the update failed
setPttEnabled(!enabled);
} finally {
setIsUpdating(false);
}
};
return (
<ToggleSwitch
checked={pttEnabled}
onChange={handlePttToggle}
isUpdating={isUpdating}
label="Push To Talk"
description="Hold to record, release to stop"
descriptionMode={descriptionMode}
grouped={grouped}
/>
);
};

View file

@ -1,11 +1,26 @@
import React from "react";
// import { AIConfig } from "./AIConfig";
import { KeyboardShortcuts } from "./KeyboardShortcuts";
import { MicrophoneSelector } from "./MicrophoneSelector";
import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
import { PushToTalk } from "./PushToTalk";
import { AudioFeedback } from "./AudioFeedback";
import { OutputDeviceSelector } from "./OutputDeviceSelector";
import { HandyShortcut } from "./HandyShortcut";
import { SettingsGroup } from "../ui/SettingsGroup";
export const Settings: React.FC = () => {
return (
<div className="max-w-3xl w-full mx-auto space-y-4">
<KeyboardShortcuts />
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup>
<HandyShortcut descriptionMode="tooltip" grouped={true} />
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
</SettingsGroup>
<SettingsGroup title="Advanced">
<PushToTalk descriptionMode="tooltip" grouped={true} />
<AudioFeedback descriptionMode="tooltip" grouped={true} />
<OutputDeviceSelector descriptionMode="tooltip" grouped={true} />
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
</SettingsGroup>
</div>
);
};

View file

@ -0,0 +1,7 @@
export { Settings } from "./Settings";
export { MicrophoneSelector } from "./MicrophoneSelector";
export { OutputDeviceSelector } from "./OutputDeviceSelector";
export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
export { PushToTalk } from "./PushToTalk";
export { AudioFeedback } from "./AudioFeedback";
export { HandyShortcut } from "./HandyShortcut";

View file

@ -0,0 +1,107 @@
import React, { useState, useRef, useEffect } from "react";
import { AudioDevice } from "../../lib/types";
interface DropdownProps {
devices: AudioDevice[];
selectedDevice: string | null;
onSelect: (deviceName: string) => void;
placeholder?: string;
disabled?: boolean;
}
export const Dropdown: React.FC<DropdownProps> = ({
devices,
selectedDevice,
onSelect,
placeholder = "Select a microphone...",
disabled = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
// Find the selected device name
const selectedDeviceName = selectedDevice
? devices.find((d) => d.name === selectedDevice)?.name || "Unknown Device"
: null;
const handleSelect = (deviceName: string) => {
onSelect(deviceName);
setIsOpen(false);
};
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
className={`px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded min-w-[200px] text-left flex items-center justify-between transition-all duration-150 ${
disabled
? "opacity-50 cursor-not-allowed"
: "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
}`}
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
>
<span className="truncate">{selectedDeviceName || placeholder}</span>
<svg
className={`w-4 h-4 ml-2 transition-transform duration-200 ${
isOpen ? "transform rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{isOpen && !disabled && (
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-mid-gray/80 rounded shadow-lg z-50 max-h-60 overflow-y-auto">
{devices.length === 0 ? (
<div className="px-2 py-1 text-sm text-mid-gray">
No microphones found
</div>
) : (
devices.map((device) => (
<button
key={device.index}
type="button"
className={`w-full px-2 py-1 text-sm text-left hover:bg-logo-primary/10 transition-colors duration-150 ${
selectedDevice === device.name
? "bg-logo-primary/20 text-logo-primary font-semibold"
: ""
}`}
onClick={() => handleSelect(device.name)}
>
<div className="flex items-center justify-between">
<span className="truncate">{device.name}</span>
</div>
</button>
))
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,107 @@
import React, { useState, useEffect, useRef } from "react";
interface SettingContainerProps {
title: string;
description: string;
children: React.ReactNode;
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const SettingContainer: React.FC<SettingContainerProps> = ({
title,
description,
children,
descriptionMode = "tooltip",
grouped = false,
}) => {
const [showTooltip, setShowTooltip] = useState(false);
const tooltipRef = useRef<HTMLDivElement>(null);
// Handle click outside to close tooltip
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
tooltipRef.current &&
!tooltipRef.current.contains(event.target as Node)
) {
setShowTooltip(false);
}
};
if (showTooltip) {
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}
}, [showTooltip]);
const toggleTooltip = () => {
setShowTooltip(!showTooltip);
};
const containerClasses = grouped
? "flex items-center justify-between px-4 p-2"
: "flex items-center justify-between px-4 p-2 rounded-lg border border-mid-gray/20";
if (descriptionMode === "tooltip") {
return (
<div className={containerClasses}>
<div className="max-w-2/3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium ">{title}</h3>
<div
ref={tooltipRef}
className="relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onClick={toggleTooltip}
>
<svg
className="w-4 h-4 text-mid-gray cursor-help hover:text-logo-primary transition-colors duration-200 select-none"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-label="More information"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleTooltip();
}
}}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{showTooltip && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-background border border-mid-gray/80 rounded-lg shadow-lg z-50 max-w-xs min-w-[200px] whitespace-normal animate-in fade-in-0 zoom-in-95 duration-200">
<p className="text-sm text-center leading-relaxed">
{description}
</p>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-[6px] border-r-[6px] border-t-[6px] border-l-transparent border-r-transparent border-t-mid-gray/80"></div>
</div>
)}
</div>
</div>
</div>
<div className="relative">{children}</div>
</div>
);
}
return (
<div className={containerClasses}>
<div className="max-w-2/3">
<h3 className="text-sm font-medium ">{title}</h3>
<p className="text-sm">{description}</p>
</div>
<div className="relative">{children}</div>
</div>
);
};

View file

@ -0,0 +1,31 @@
import React from "react";
interface SettingsGroupProps {
title?: string;
description?: string;
children: React.ReactNode;
}
export const SettingsGroup: React.FC<SettingsGroupProps> = ({
title,
description,
children,
}) => {
return (
<div className="space-y-2">
{title && (
<div className="px-4">
<h2 className="text-sm font-medium text-mid-gray uppercase tracking-wide">
{title}
</h2>
{description && (
<p className="text-xs text-mid-gray mt-1">{description}</p>
)}
</div>
)}
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="divide-y divide-mid-gray/20">{children}</div>
</div>
</div>
);
};

View file

@ -0,0 +1,50 @@
import React from "react";
import { SettingContainer } from "./SettingContainer";
interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
isUpdating?: boolean;
label: string;
description: string;
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
checked,
onChange,
disabled = false,
isUpdating = false,
label,
description,
descriptionMode = "tooltip",
grouped = false,
}) => {
return (
<SettingContainer
title={label}
description={description}
descriptionMode={descriptionMode}
grouped={grouped}
>
<label className="inline-flex items-center cursor-pointer">
<input
type="checkbox"
value=""
className="sr-only peer"
checked={checked}
disabled={disabled || isUpdating}
onChange={(e) => onChange(e.target.checked)}
/>
<div className="relative w-11 h-6 bg-mid-gray/20 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-logo-primary rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-logo-primary peer-disabled:opacity-50"></div>
</label>
{isUpdating && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</SettingContainer>
);
};

View file

@ -0,0 +1,4 @@
export { Dropdown } from "./Dropdown";
export { ToggleSwitch } from "./ToggleSwitch";
export { SettingContainer } from "./SettingContainer";
export { SettingsGroup } from "./SettingsGroup";

View file

@ -13,10 +13,20 @@ export const ShortcutBindingsMapSchema = z.record(
ShortcutBindingSchema,
);
export const AudioDeviceSchema = z.object({
index: z.string(),
name: z.string(),
is_default: z.boolean(),
});
export const SettingsSchema = z.object({
bindings: ShortcutBindingsMapSchema,
push_to_talk: z.boolean(),
audio_feedback: z.boolean(),
selected_model: z.string(),
always_on_microphone: z.boolean(),
selected_microphone: z.string().nullable().optional(),
selected_output_device: z.string().nullable().optional(),
});
export const BindingResponseSchema = z.object({
@ -25,6 +35,7 @@ export const BindingResponseSchema = z.object({
error: z.string().nullable(),
});
export type AudioDevice = z.infer<typeof AudioDeviceSchema>;
export type BindingResponse = z.infer<typeof BindingResponseSchema>;
export type ShortcutBinding = z.infer<typeof ShortcutBindingSchema>;
export type ShortcutBindingsMap = z.infer<typeof ShortcutBindingsMapSchema>;