big refactor of settings + switch audio devices

This commit is contained in:
CJ Pais 2025-07-10 21:10:46 -07:00
parent 6ef95d9298
commit c23befd98a
26 changed files with 1231 additions and 288 deletions

310
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"
@ -884,17 +858,6 @@ dependencies = [
"libc",
]
[[package]]
name = "coreaudio-rs"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
dependencies = [
"bitflags 1.3.2",
"core-foundation-sys",
"coreaudio-sys",
]
[[package]]
name = "coreaudio-rs"
version = "0.13.0"
@ -909,38 +872,6 @@ dependencies = [
"objc2-core-foundation",
]
[[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",
]
[[package]]
name = "cpal"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"core-foundation-sys",
"coreaudio-rs 0.11.3",
"dasp_sample",
"jni",
"js-sys",
"libc",
"mach2",
"ndk 0.8.0",
"ndk-context",
"oboe",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.54.0",
]
[[package]]
name = "cpal"
version = "0.16.0"
@ -948,13 +879,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f"
dependencies = [
"alsa",
"coreaudio-rs 0.13.0",
"coreaudio-rs",
"dasp_sample",
"jni",
"js-sys",
"libc",
"mach2",
"ndk 0.9.0",
"ndk",
"ndk-context",
"num-derive",
"num-traits",
@ -1475,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"
@ -2069,7 +2006,7 @@ name = "handy"
version = "0.2.3"
dependencies = [
"anyhow",
"cpal 0.16.0",
"cpal",
"enigo",
"env_logger",
"futures-util",
@ -2618,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"
@ -2678,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"
@ -2760,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"
@ -3078,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"
@ -3101,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",
@ -3113,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"
@ -3175,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"
@ -3210,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"
@ -3561,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"
@ -4480,13 +4354,11 @@ 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 0.15.3",
"hound",
"lewton",
"cpal",
"dasp_sample",
"num-rational",
"symphonia",
]
@ -5120,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]]
@ -5137,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"
@ -5150,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"
@ -5162,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"
@ -5273,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",
@ -6538,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",
@ -7204,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

@ -48,7 +48,7 @@ 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"

View file

@ -28,3 +28,25 @@ pub fn list_input_devices() -> Result<Vec<CpalDeviceInfo>, Box<dyn std::error::E
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

@ -3,6 +3,6 @@ mod device;
mod recorder;
mod resampler;
pub use device::{CpalDeviceInfo, list_input_devices};
pub use device::{list_input_devices, list_output_devices, CpalDeviceInfo};
pub use recorder::AudioRecorder;
pub use resampler::FrameResampler;

View file

@ -1,19 +1,19 @@
use std::{
io::Error,
sync::{Arc, Mutex, mpsc},
sync::{mpsc, Arc, Mutex},
time::Duration,
};
use cpal::{
Device, Sample, SizedSample,
traits::{DeviceTrait, HostTrait, StreamTrait},
Device, Sample, SizedSample,
};
use crate::audio_toolkit::{
VoiceActivityDetector,
audio::FrameResampler,
constants,
vad::{self, VadFrame},
VoiceActivityDetector,
};
enum Cmd {

View file

@ -2,5 +2,5 @@ pub mod audio;
pub mod constants;
pub mod vad;
pub use audio::{AudioRecorder, CpalDeviceInfo, list_input_devices};
pub use audio::{list_input_devices, list_output_devices, AudioRecorder, CpalDeviceInfo};
pub use vad::{SileroVad, VoiceActivityDetector};

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

@ -188,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,5 +1,5 @@
use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad};
use crate::settings::get_settings;
use crate::audio_toolkit::{vad::SmoothedVad, AudioRecorder, SileroVad};
use log::{debug, info};
use std::sync::{Arc, Mutex};
use std::time::Instant;
@ -100,8 +100,26 @@ impl AudioRecordingManager {
*recorder_opt = Some(create_audio_recorder(vad_path.to_str().unwrap())?);
}
// 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(None)
rec.open(selected_device)
.map_err(|e| anyhow::anyhow!("Failed to open recorder: {}", e))?;
}
@ -187,6 +205,15 @@ impl AudioRecordingManager {
}
}
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();

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>;