diff --git a/.gitignore b/.gitignore index 50fdfaa..eab3332 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ dist-ssr *.sln *.sw? -src-tauri/resources/models \ No newline at end of file +/target/ +recording_* diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 249da72..94ff6a6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f3a3063..d082e4f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/resources/models/silero_vad_v4.onnx b/src-tauri/resources/models/silero_vad_v4.onnx new file mode 100644 index 0000000..e6db48d Binary files /dev/null and b/src-tauri/resources/models/silero_vad_v4.onnx differ diff --git a/src-tauri/src/audio_toolkit/audio/device.rs b/src-tauri/src/audio_toolkit/audio/device.rs new file mode 100644 index 0000000..fbe2dec --- /dev/null +++ b/src-tauri/src/audio_toolkit/audio/device.rs @@ -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, Box> { + let host = cpal::default_host(); + let default_name = host.default_input_device().and_then(|d| d.name().ok()); + + let mut out = Vec::::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, Box> { + let host = cpal::default_host(); + let default_name = host.default_output_device().and_then(|d| d.name().ok()); + + let mut out = Vec::::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) +} diff --git a/src-tauri/src/audio_toolkit/audio/mod.rs b/src-tauri/src/audio_toolkit/audio/mod.rs new file mode 100644 index 0000000..e837b8e --- /dev/null +++ b/src-tauri/src/audio_toolkit/audio/mod.rs @@ -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; diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs new file mode 100644 index 0000000..a0ca572 --- /dev/null +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -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>), + Shutdown, +} + +pub struct AudioRecorder { + device: Option, + cmd_tx: Option>, + worker_handle: Option>, + vad: Option>>>, +} + +impl AudioRecorder { + pub fn new() -> Result> { + Ok(AudioRecorder { + device: None, + cmd_tx: None, + worker_handle: None, + vad: None, + }) + } + + pub fn with_vad(mut self, vad: Box) -> Self { + self.vad = Some(Arc::new(Mutex::new(vad))); + self + } + + pub fn open(&mut self, device: Option) -> Result<(), Box> { + if self.worker_handle.is_some() { + return Ok(()); // already open + } + + let (sample_tx, sample_rx) = mpsc::channel::>(); + let (cmd_tx, cmd_rx) = mpsc::channel::(); + + 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::(&thread_device, &config, sample_tx, channels) + .unwrap() + } + cpal::SampleFormat::I16 => { + AudioRecorder::build_stream::(&thread_device, &config, sample_tx, channels) + .unwrap() + } + cpal::SampleFormat::I32 => { + AudioRecorder::build_stream::(&thread_device, &config, sample_tx, channels) + .unwrap() + } + cpal::SampleFormat::F32 => { + AudioRecorder::build_stream::(&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> { + if let Some(tx) = &self.cmd_tx { + tx.send(Cmd::Start)?; + } + Ok(()) + } + + pub fn stop(&self) -> Result, Box> { + 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> { + 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( + device: &cpal::Device, + config: &cpal::SupportedStreamConfig, + sample_tx: mpsc::Sender>, + channels: usize, + ) -> Result + where + T: Sample + SizedSample + Send + 'static, + f32: cpal::FromSample, + { + 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::())); + } 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::()) + .sum::() + / 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> { + 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>>>, + sample_rx: mpsc::Receiver>, + cmd_rx: mpsc::Receiver, +) { + 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::::new(); + let mut recording = false; + + fn handle_frame( + samples: &[f32], + recording: bool, + vad: &Option>>>, + out_buf: &mut Vec, + ) { + 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, + } + } + } +} diff --git a/src-tauri/src/audio_toolkit/audio/resampler.rs b/src-tauri/src/audio_toolkit/audio/resampler.rs new file mode 100644 index 0000000..2ba2215 --- /dev/null +++ b/src-tauri/src/audio_toolkit/audio/resampler.rs @@ -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>, + chunk_in: usize, + in_buf: Vec, + frame_samples: usize, + pending: Vec, +} + +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::::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(); + } + } + } +} diff --git a/src-tauri/src/audio_toolkit/bin/cli.rs b/src-tauri/src/audio_toolkit/bin/cli.rs new file mode 100644 index 0000000..a99e5a6 --- /dev/null +++ b/src-tauri/src/audio_toolkit/bin/cli.rs @@ -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, + 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> { + 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, + devices: &[CpalDeviceInfo], + ) -> Result<(), Box> { + 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, Box> { + 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> { + if self.is_recording { + self.stop_recording()?; + } + if self.is_open { + self.recorder.close()?; + self.is_open = false; + } + Ok(()) + } +} + +fn main() -> Result<(), Box> { + 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::() { + 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> { + 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(()) +} diff --git a/src-tauri/src/audio_toolkit/constants.rs b/src-tauri/src/audio_toolkit/constants.rs new file mode 100644 index 0000000..e261ac5 --- /dev/null +++ b/src-tauri/src/audio_toolkit/constants.rs @@ -0,0 +1 @@ +pub const WHISPER_SAMPLE_RATE: u32 = 16000; diff --git a/src-tauri/src/audio_toolkit/mod.rs b/src-tauri/src/audio_toolkit/mod.rs new file mode 100644 index 0000000..b9455a0 --- /dev/null +++ b/src-tauri/src/audio_toolkit/mod.rs @@ -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}; diff --git a/src-tauri/src/audio_toolkit/vad/mod.rs b/src-tauri/src/audio_toolkit/vad/mod.rs new file mode 100644 index 0000000..7283e0d --- /dev/null +++ b/src-tauri/src/audio_toolkit/vad/mod.rs @@ -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>; + + fn is_voice(&mut self, frame: &[f32]) -> Result { + Ok(self.push_frame(frame)?.is_speech()) + } + + fn reset(&mut self) {} +} + +mod silero; +mod smoothed; + +pub use silero::SileroVad; +pub use smoothed::SmoothedVad; diff --git a/src-tauri/src/audio_toolkit/vad/silero.rs b/src-tauri/src/audio_toolkit/vad/silero.rs new file mode 100644 index 0000000..4b30983 --- /dev/null +++ b/src-tauri/src/audio_toolkit/vad/silero.rs @@ -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>(model_path: P, threshold: f32) -> Result { + 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> { + 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) + } + } +} diff --git a/src-tauri/src/audio_toolkit/vad/smoothed.rs b/src-tauri/src/audio_toolkit/vad/smoothed.rs new file mode 100644 index 0000000..50ef19a --- /dev/null +++ b/src-tauri/src/audio_toolkit/vad/smoothed.rs @@ -0,0 +1,106 @@ +use super::{VadFrame, VoiceActivityDetector}; +use anyhow::Result; +use std::collections::VecDeque; + +pub struct SmoothedVad { + inner_vad: Box, + prefill_frames: usize, + hangover_frames: usize, + onset_frames: usize, + + frame_buffer: VecDeque>, + hangover_counter: usize, + onset_counter: usize, + in_speech: bool, + + temp_out: Vec, +} + +impl SmoothedVad { + pub fn new( + inner_vad: Box, + 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> { + // 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(); + } +} diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 1842b38..00d6cc3 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -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 { let settings = get_settings(&app); Ok(settings.always_on_microphone) } + +#[tauri::command] +pub fn get_available_microphones() -> Result, 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::>(); + 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 { + let settings = get_settings(&app); + Ok(settings + .selected_microphone + .unwrap_or_else(|| "default".to_string())) +} + +#[tauri::command] +pub fn get_available_output_devices() -> Result, 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 { + let settings = get_settings(&app); + Ok(settings + .selected_output_device + .unwrap_or_else(|| "default".to_string())) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bbcd9af..e6f7d25 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index d0d2f27..e5c8994 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -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 { + 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>, - buffer: Arc>>, mode: Arc>, app_handle: tauri::AppHandle, - // For on-demand mode - stream_handle: Arc>>>, - stream_active: Arc>, -} -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>>, + is_open: Arc>, + is_recording: Arc>, } impl AudioRecordingManager { + /* ---------- construction ------------------------------------------------ */ + pub fn new(app: &App) -> Result { 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( - data: &[T], - channels: usize, - state_clone: Arc>, - temp_buffer_clone: Arc>>, - resampler_clone: Arc>>, - vad_buffer_clone: Arc>>, - buffer_clone: Arc>>, - vad_clone: Arc>, - stream_active_clone: Arc>, - ) { - // 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::() / channels as f32; - temp_buffer.push(mono_sample); - } - } else { - // Single channel processing - let f32_data: Vec = 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 = 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::>(); - - // 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> { 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 = 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, } } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 9adfbd1..aeb96a8 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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, + #[serde(default)] + pub selected_output_device: Option, } 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, } } diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 8917317..fc7b20c 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -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> { - // 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, +) -> Result<(), Box> { + 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(()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d3a2557..ffc77be 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,9 +14,9 @@ { "title": "Handy", "width": 540, - "height": 510, + "height": 590, "minWidth": 540, - "minHeight": 510, + "minHeight": 590, "resizable": true, "maximizable": false } diff --git a/src/components/settings/AlwaysOnMicrophone.tsx b/src/components/settings/AlwaysOnMicrophone.tsx new file mode 100644 index 0000000..1acabb6 --- /dev/null +++ b/src/components/settings/AlwaysOnMicrophone.tsx @@ -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 = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const [alwaysOnMode, setAlwaysOnMode] = useState(false); + const [isUpdating, setIsUpdating] = useState(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 ( + + ); +}; diff --git a/src/components/settings/AudioFeedback.tsx b/src/components/settings/AudioFeedback.tsx new file mode 100644 index 0000000..de768ce --- /dev/null +++ b/src/components/settings/AudioFeedback.tsx @@ -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 = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const [audioFeedbackEnabled, setAudioFeedbackEnabled] = + useState(false); + const [isUpdating, setIsUpdating] = useState(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 ( + + ); +}; diff --git a/src/components/settings/KeyboardShortcuts.tsx b/src/components/settings/HandyShortcut.tsx similarity index 60% rename from src/components/settings/KeyboardShortcuts.tsx rename to src/components/settings/HandyShortcut.tsx index c1f6a45..6bae54e 100644 --- a/src/components/settings/KeyboardShortcuts.tsx +++ b/src/components/settings/HandyShortcut.tsx @@ -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 = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { const [bindings, setBindings] = React.useState({}); - const [pttEnabled, setPttEnabled] = React.useState(false); - const [audioFeedbackEnabled, setAudioFeedbackEnabled] = - React.useState(false); const [keyPressed, setKeyPressed] = useState([]); const [recordedKeys, setRecordedKeys] = useState([]); const [editingShortcutId, setEditingShortcutId] = useState( @@ -23,13 +27,14 @@ export const KeyboardShortcuts: React.FC = () => { ); const [originalBinding, setOriginalBinding] = useState(""); const [isMacOS, setIsMacOS] = useState(false); + const [isLoading, setIsLoading] = useState(true); const shortcutRefs = useRef>(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 ( + +
Loading shortcuts...
+
+ ); + } + + // If no bindings are loaded, show empty state + if (Object.keys(bindings).length === 0) { + return ( + +
No shortcuts configured
+
+ ); + } + return ( -
-
-
-

Push To Talk

-

Hold to record, release to stop

-
- -
-
-
-

Audio Feedback

-

Play sound when recording starts and stops

-
- -
- {Object.entries(bindings).map(([id, binding]) => ( -
-
-

{binding.name}

-

{binding.description}

-
+ return (
- {editingShortcutId === id ? ( + {editingShortcutId === primaryId ? (
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 = () => { ) : (
startRecording(id)} + onClick={() => startRecording(primaryId)} > - {formatKeyCombination(binding.current_binding)} + {formatKeyCombination(primaryBinding.current_binding)}
)}
-
- ))} -
+ ); + })()} + ); }; diff --git a/src/components/settings/MicrophoneSelector.tsx b/src/components/settings/MicrophoneSelector.tsx new file mode 100644 index 0000000..7a36510 --- /dev/null +++ b/src/components/settings/MicrophoneSelector.tsx @@ -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 = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const [audioDevices, setAudioDevices] = useState([]); + const [selectedMicrophone, setSelectedMicrophone] = + useState("default"); + const [isLoading, setIsLoading] = useState(true); + const [isUpdating, setIsUpdating] = useState(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 ( + +
+ + +
+ {isUpdating && ( +
+
+
+ )} +
+ ); +}; diff --git a/src/components/settings/OutputDeviceSelector.tsx b/src/components/settings/OutputDeviceSelector.tsx new file mode 100644 index 0000000..e66c6db --- /dev/null +++ b/src/components/settings/OutputDeviceSelector.tsx @@ -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 = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const [outputDevices, setOutputDevices] = useState([]); + const [selectedOutputDevice, setSelectedOutputDevice] = + useState("default"); + const [isLoading, setIsLoading] = useState(true); + const [isUpdating, setIsUpdating] = useState(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 ( + +
+ + +
+ {isUpdating && ( +
+
+
+ )} +
+ ); +}; diff --git a/src/components/settings/PushToTalk.tsx b/src/components/settings/PushToTalk.tsx new file mode 100644 index 0000000..8c04d7a --- /dev/null +++ b/src/components/settings/PushToTalk.tsx @@ -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 = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const [pttEnabled, setPttEnabled] = useState(false); + const [isUpdating, setIsUpdating] = useState(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 ( + + ); +}; diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index 9255041..423174d 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -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 ( -
- +
+ + + + + + + + + + +
); }; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts new file mode 100644 index 0000000..81c1d65 --- /dev/null +++ b/src/components/settings/index.ts @@ -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"; diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx new file mode 100644 index 0000000..2ea9a9f --- /dev/null +++ b/src/components/ui/Dropdown.tsx @@ -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 = ({ + devices, + selectedDevice, + onSelect, + placeholder = "Select a microphone...", + disabled = false, +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(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 ( +
+ + + {isOpen && !disabled && ( +
+ {devices.length === 0 ? ( +
+ No microphones found +
+ ) : ( + devices.map((device) => ( + + )) + )} +
+ )} +
+ ); +}; diff --git a/src/components/ui/SettingContainer.tsx b/src/components/ui/SettingContainer.tsx new file mode 100644 index 0000000..d3d7bb1 --- /dev/null +++ b/src/components/ui/SettingContainer.tsx @@ -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 = ({ + title, + description, + children, + descriptionMode = "tooltip", + grouped = false, +}) => { + const [showTooltip, setShowTooltip] = useState(false); + const tooltipRef = useRef(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 ( +
+
+
+

{title}

+
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + onClick={toggleTooltip} + > + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleTooltip(); + } + }} + > + + + {showTooltip && ( +
+

+ {description} +

+
+
+ )} +
+
+
+
{children}
+
+ ); + } + + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ); +}; diff --git a/src/components/ui/SettingsGroup.tsx b/src/components/ui/SettingsGroup.tsx new file mode 100644 index 0000000..f9266e7 --- /dev/null +++ b/src/components/ui/SettingsGroup.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +interface SettingsGroupProps { + title?: string; + description?: string; + children: React.ReactNode; +} + +export const SettingsGroup: React.FC = ({ + title, + description, + children, +}) => { + return ( +
+ {title && ( +
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ )} +
+
{children}
+
+
+ ); +}; diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx new file mode 100644 index 0000000..f081d78 --- /dev/null +++ b/src/components/ui/ToggleSwitch.tsx @@ -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 = ({ + checked, + onChange, + disabled = false, + isUpdating = false, + label, + description, + descriptionMode = "tooltip", + grouped = false, +}) => { + return ( + + + {isUpdating && ( +
+
+
+ )} +
+ ); +}; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..4abd84c --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,4 @@ +export { Dropdown } from "./Dropdown"; +export { ToggleSwitch } from "./ToggleSwitch"; +export { SettingContainer } from "./SettingContainer"; +export { SettingsGroup } from "./SettingsGroup"; diff --git a/src/lib/types.ts b/src/lib/types.ts index 892f719..fd97768 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; export type BindingResponse = z.infer; export type ShortcutBinding = z.infer; export type ShortcutBindingsMap = z.infer;