diff --git a/src-tauri/resources/rec_start.wav b/src-tauri/resources/marimba_start.wav similarity index 100% rename from src-tauri/resources/rec_start.wav rename to src-tauri/resources/marimba_start.wav diff --git a/src-tauri/resources/rec_stop.wav b/src-tauri/resources/marimba_stop.wav similarity index 100% rename from src-tauri/resources/rec_stop.wav rename to src-tauri/resources/marimba_stop.wav diff --git a/src-tauri/resources/pop_start.wav b/src-tauri/resources/pop_start.wav new file mode 100644 index 0000000..fe23de9 Binary files /dev/null and b/src-tauri/resources/pop_start.wav differ diff --git a/src-tauri/resources/pop_stop.wav b/src-tauri/resources/pop_stop.wav new file mode 100644 index 0000000..04024ad Binary files /dev/null and b/src-tauri/resources/pop_stop.wav differ diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 1d8839a..db6bc70 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -1,4 +1,4 @@ -use crate::audio_feedback::{play_recording_start_sound, play_recording_stop_sound}; +use crate::audio_feedback::{SoundType, play_feedback_sound}; use crate::managers::audio::AudioRecordingManager; use crate::managers::history::HistoryManager; use crate::managers::transcription::TranscriptionManager; @@ -46,7 +46,7 @@ impl ShortcutAction for TranscribeAction { if is_always_on { // Always-on mode: Play audio feedback immediately debug!("Always-on mode: Playing audio feedback immediately"); - play_recording_start_sound(app); + play_feedback_sound(app, SoundType::Start); let recording_started = rm.try_start_recording(&binding_id); debug!("Recording started: {}", recording_started); } else { @@ -61,7 +61,7 @@ impl ShortcutAction for TranscribeAction { std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(100)); debug!("Playing delayed audio feedback"); - play_recording_start_sound(&app_clone); + play_feedback_sound(&app_clone, SoundType::Start); }); } else { debug!("Failed to start recording"); @@ -87,7 +87,7 @@ impl ShortcutAction for TranscribeAction { show_transcribing_overlay(app); // Play audio feedback for recording stop - play_recording_stop_sound(app); + play_feedback_sound(app, SoundType::Stop); let binding_id = binding_id.to_string(); // Clone binding_id for the async task diff --git a/src-tauri/src/audio_feedback.rs b/src-tauri/src/audio_feedback.rs index eb2749f..b8179b9 100644 --- a/src-tauri/src/audio_feedback.rs +++ b/src-tauri/src/audio_feedback.rs @@ -6,25 +6,19 @@ use std::io::BufReader; use std::thread; use tauri::{AppHandle, Manager}; -/// Plays an audio resource from the resources directory. -/// Checks if audio feedback is enabled in settings before playing. -pub fn play_sound(app: &AppHandle, resource_path: &str) { - // Check if audio feedback is enabled - let settings = settings::get_settings(app); - if !settings.audio_feedback { - return; - } +pub enum SoundType { + Start, + Stop, +} +/// Plays an audio resource from the specified directory. +fn play_sound(app: &AppHandle, resource_path: &str, base_dir: tauri::path::BaseDirectory) { let app_handle = app.clone(); let resource_path = resource_path.to_string(); + let volume = settings::get_settings(app).audio_feedback_volume; - // Spawn a new thread to play the audio without blocking the main thread thread::spawn(move || { - // Get the path to the audio file in resources - let audio_path = match app_handle - .path() - .resolve(&resource_path, tauri::path::BaseDirectory::Resource) - { + let audio_path = match app_handle.path().resolve(&resource_path, base_dir) { Ok(path) => path.to_path_buf(), Err(e) => { eprintln!( @@ -35,38 +29,67 @@ 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, selected_device) { + if let Err(e) = play_audio_file(&audio_path, selected_device, volume) { eprintln!("Failed to play sound '{}': {}", resource_path, e); } }); } -/// Convenience function to play the recording start sound -pub fn play_recording_start_sound(app: &AppHandle) { - play_sound(app, "resources/rec_start.wav"); +fn get_sound_path(app: &AppHandle, sound_type: SoundType) -> String { + let settings = settings::get_settings(app); + match sound_type { + SoundType::Start => match settings.sound_theme { + crate::settings::SoundTheme::Custom => "custom_start.wav".to_string(), + _ => settings.sound_theme.to_start_path(), + }, + SoundType::Stop => match settings.sound_theme { + crate::settings::SoundTheme::Custom => "custom_stop.wav".to_string(), + _ => settings.sound_theme.to_stop_path(), + }, + } } -/// Convenience function to play the recording stop sound -pub fn play_recording_stop_sound(app: &AppHandle) { - play_sound(app, "resources/rec_stop.wav"); +pub fn play_feedback_sound(app: &AppHandle, sound_type: SoundType) { + // Only play if audio feedback is enabled + let settings = settings::get_settings(app); + if !settings.audio_feedback { + return; + } + + let sound_file = get_sound_path(app, sound_type); + let base_dir = if settings.sound_theme == crate::settings::SoundTheme::Custom { + tauri::path::BaseDirectory::AppData + } else { + tauri::path::BaseDirectory::Resource + }; + play_sound(app, &sound_file, base_dir); +} + +pub fn play_test_sound(app: &AppHandle, sound_type: SoundType) { + // Always play test sound, regardless of audio_feedback setting + let settings = settings::get_settings(app); + let sound_file = get_sound_path(app, sound_type); + let base_dir = if settings.sound_theme == crate::settings::SoundTheme::Custom { + tauri::path::BaseDirectory::AppData + } else { + tauri::path::BaseDirectory::Resource + }; + play_sound(app, &sound_file, base_dir); } fn play_audio_file( path: &std::path::Path, selected_device: Option, + volume: f32, ) -> 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 = crate::audio_toolkit::get_cpal_host(); let devices = host.output_devices()?; @@ -88,18 +111,17 @@ fn play_audio_file( } } 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 sink = rodio::play(mixer, buf_reader)?; + sink.set_volume(volume); sink.sleep_until_end(); Ok(()) diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 00d6cc3..d39cc54 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -1,3 +1,4 @@ +use crate::audio_feedback; use crate::audio_toolkit::audio::{list_input_devices, list_output_devices}; use crate::managers::audio::{AudioRecordingManager, MicrophoneMode}; use crate::settings::{get_settings, write_settings}; @@ -5,6 +6,29 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::{AppHandle, Manager}; +#[derive(Serialize)] +pub struct CustomSounds { + start: bool, + stop: bool, +} + +fn custom_sound_exists(app: &AppHandle, sound_type: &str) -> bool { + app.path() + .resolve( + format!("custom_{}.wav", sound_type), + tauri::path::BaseDirectory::AppData, + ) + .map_or(false, |path| path.exists()) +} + +#[tauri::command] +pub fn check_custom_sounds(app: AppHandle) -> CustomSounds { + CustomSounds { + start: custom_sound_exists(&app, "start"), + stop: custom_sound_exists(&app, "stop"), + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct AudioDevice { pub index: String, @@ -122,3 +146,16 @@ pub fn get_selected_output_device(app: AppHandle) -> Result { .selected_output_device .unwrap_or_else(|| "default".to_string())) } + +#[tauri::command] +pub fn play_test_sound(app: AppHandle, sound_type: String) { + let sound = match sound_type.as_str() { + "start" => audio_feedback::SoundType::Start, + "stop" => audio_feedback::SoundType::Stop, + _ => { + eprintln!("Unknown sound type: {}", sound_type); + return; + } + }; + audio_feedback::play_test_sound(&app, sound); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 441782d..634a4cc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -211,6 +211,8 @@ pub fn run() { shortcut::reset_binding, shortcut::change_ptt_setting, shortcut::change_audio_feedback_setting, + shortcut::change_audio_feedback_volume_setting, + shortcut::change_sound_theme_setting, shortcut::change_start_hidden_setting, shortcut::change_autostart_setting, shortcut::change_translate_to_english_setting, @@ -245,6 +247,8 @@ pub fn run() { commands::audio::get_available_output_devices, commands::audio::set_selected_output_device, commands::audio::get_selected_output_device, + commands::audio::play_test_sound, + commands::audio::check_custom_sounds, commands::transcription::set_model_unload_timeout, commands::transcription::get_model_load_status, commands::transcription::unload_model_manually, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 1bc1760..b7a4c88 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -80,12 +80,42 @@ impl ModelUnloadTimeout { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SoundTheme { + Marimba, + Pop, + Custom, +} + +impl SoundTheme { + fn as_str(&self) -> &'static str { + match self { + SoundTheme::Marimba => "marimba", + SoundTheme::Pop => "pop", + SoundTheme::Custom => "custom", + } + } + + pub fn to_start_path(&self) -> String { + format!("resources/{}_start.wav", self.as_str()) + } + + pub fn to_stop_path(&self) -> String { + format!("resources/{}_stop.wav", self.as_str()) + } +} + /* still handy for composing the initial JSON in the store ------------- */ #[derive(Serialize, Deserialize, Debug, Clone)] pub struct AppSettings { pub bindings: HashMap, pub push_to_talk: bool, pub audio_feedback: bool, + #[serde(default = "default_audio_feedback_volume")] + pub audio_feedback_volume: f32, + #[serde(default = "default_sound_theme")] + pub sound_theme: SoundTheme, #[serde(default = "default_start_hidden")] pub start_hidden: bool, #[serde(default = "default_autostart_enabled")] @@ -161,6 +191,14 @@ fn default_history_limit() -> usize { 5 } +fn default_audio_feedback_volume() -> f32 { + 1.0 +} + +fn default_sound_theme() -> SoundTheme { + SoundTheme::Marimba +} + pub const SETTINGS_STORE_PATH: &str = "settings_store.json"; pub fn get_default_settings() -> AppSettings { @@ -189,6 +227,8 @@ pub fn get_default_settings() -> AppSettings { bindings, push_to_talk: true, audio_feedback: false, + audio_feedback_volume: default_audio_feedback_volume(), + sound_theme: default_sound_theme(), start_hidden: default_start_hidden(), autostart_enabled: default_autostart_enabled(), selected_model: "".to_string(), diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index 8b84ba8..a5ce7d3 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -6,7 +6,7 @@ use tauri_plugin_global_shortcut::{Shortcut, ShortcutState}; use crate::actions::ACTION_MAP; use crate::settings::ShortcutBinding; -use crate::settings::{self, get_settings, OverlayPosition, PasteMethod}; +use crate::settings::{self, get_settings, OverlayPosition, PasteMethod, SoundTheme}; use crate::ManagedToggleState; pub fn init_shortcuts(app: &App) { @@ -119,6 +119,31 @@ pub fn change_audio_feedback_setting(app: AppHandle, enabled: bool) -> Result<() Ok(()) } +#[tauri::command] +pub fn change_audio_feedback_volume_setting(app: AppHandle, volume: f32) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.audio_feedback_volume = volume; + settings::write_settings(&app, settings); + Ok(()) +} + +#[tauri::command] +pub fn change_sound_theme_setting(app: AppHandle, theme: String) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + let parsed = match theme.as_str() { + "marimba" => SoundTheme::Marimba, + "pop" => SoundTheme::Pop, + "custom" => SoundTheme::Custom, + other => { + eprintln!("Invalid sound theme '{}', defaulting to marimba", other); + SoundTheme::Marimba + } + }; + settings.sound_theme = parsed; + settings::write_settings(&app, settings); + Ok(()) +} + #[tauri::command] pub fn change_translate_to_english_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); diff --git a/src/components/settings/AudioFeedback.tsx b/src/components/settings/AudioFeedback.tsx index 7aeb53d..3889aa9 100644 --- a/src/components/settings/AudioFeedback.tsx +++ b/src/components/settings/AudioFeedback.tsx @@ -1,30 +1,31 @@ import React from "react"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; +import { VolumeSlider } from "./VolumeSlider"; +import { SoundPicker } from "./SoundPicker"; interface AudioFeedbackProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } -export const AudioFeedback: React.FC = React.memo(({ - descriptionMode = "tooltip", - grouped = false, -}) => { - const { getSetting, updateSetting, isUpdating } = useSettings(); - - const audioFeedbackEnabled = getSetting("audio_feedback") || false; +export const AudioFeedback: React.FC = React.memo( + ({ descriptionMode = "tooltip", grouped = false }) => { + const { getSetting, updateSetting, isUpdating } = useSettings(); + const audioFeedbackEnabled = getSetting("audio_feedback") || false; return ( - updateSetting("audio_feedback", enabled)} - isUpdating={isUpdating("audio_feedback")} - label="Audio Feedback" - description="Play sound when recording starts and stops" - descriptionMode={descriptionMode} - grouped={grouped} - /> +
+ updateSetting("audio_feedback", enabled)} + isUpdating={isUpdating("audio_feedback")} + label="Audio Feedback" + description="Play sound when recording starts and stops" + descriptionMode={descriptionMode} + grouped={grouped} + /> +
); }, ); diff --git a/src/components/settings/DebugSettings.tsx b/src/components/settings/DebugSettings.tsx index ee73608..fc1ca27 100644 --- a/src/components/settings/DebugSettings.tsx +++ b/src/components/settings/DebugSettings.tsx @@ -5,12 +5,17 @@ import { SettingsGroup } from "../ui/SettingsGroup"; import { HistoryLimit } from "./HistoryLimit"; import { PasteMethodSetting } from "./PasteMethod"; import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; +import { SoundPicker } from "./SoundPicker"; export const DebugSettings: React.FC = () => { return (
+ diff --git a/src/components/settings/GeneralSettings.tsx b/src/components/settings/GeneralSettings.tsx index 5ff6af6..4f9d7a1 100644 --- a/src/components/settings/GeneralSettings.tsx +++ b/src/components/settings/GeneralSettings.tsx @@ -7,6 +7,7 @@ import { OutputDeviceSelector } from "./OutputDeviceSelector"; import { PushToTalk } from "./PushToTalk"; import { AudioFeedback } from "./AudioFeedback"; import { useSettings } from "../../hooks/useSettings"; +import { VolumeSlider } from "./VolumeSlider"; export const GeneralSettings: React.FC = () => { const { audioFeedbackEnabled } = useSettings(); @@ -25,6 +26,7 @@ export const GeneralSettings: React.FC = () => { grouped={true} disabled={!audioFeedbackEnabled} /> +
); diff --git a/src/components/settings/HistorySettings.tsx b/src/components/settings/HistorySettings.tsx index b78aec3..b6d5014 100644 --- a/src/components/settings/HistorySettings.tsx +++ b/src/components/settings/HistorySettings.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { SettingsGroup } from "../ui/SettingsGroup"; import { AudioPlayer } from "../ui/AudioPlayer"; -import { ClipboardCopy, Star, Check, Trash2, Loader2 } from "lucide-react"; +import { Copy, Star, Check, Trash2 } from "lucide-react"; import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; @@ -86,12 +86,12 @@ export const HistorySettings: React.FC = () => { const deleteAudioEntry = async (id: number) => { try { - await invoke("delete_history_entry", {id}); + await invoke("delete_history_entry", { id }); } catch (error) { console.error("Failed to delete audio entry:", error); throw error; } - } + }; if (loading) { return ( @@ -189,7 +189,7 @@ const HistoryEntryComponent: React.FC = ({ {showCopied ? ( ) : ( - + )} diff --git a/src/components/settings/SoundPicker.tsx b/src/components/settings/SoundPicker.tsx new file mode 100644 index 0000000..629f737 --- /dev/null +++ b/src/components/settings/SoundPicker.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { Button } from "../ui/Button"; +import { Dropdown, DropdownOption } from "../ui/Dropdown"; +import { PlayIcon } from "lucide-react"; +import { SettingContainer } from "../ui/SettingContainer"; +import { useSettingsStore } from "../../stores/settingsStore"; +import { useSettings } from "../../hooks/useSettings"; + +interface SoundPickerProps { + label: string; + description: string; +} + +export const SoundPicker: React.FC = ({ + label, + description, +}) => { + const { getSetting, updateSetting } = useSettings(); + const playTestSound = useSettingsStore((state) => state.playTestSound); + const customSounds = useSettingsStore((state) => state.customSounds); + + const selectedTheme = getSetting("sound_theme") ?? "marimba"; + + const options: DropdownOption[] = [ + { value: "marimba", label: "Marimba" }, + { value: "pop", label: "Pop" }, + ]; + + // Only add Custom option if both custom sound files exist + if (customSounds.start && customSounds.stop) { + options.push({ value: "custom", label: "Custom" }); + } + + const handlePlayBothSounds = async () => { + await playTestSound("start"); + // Wait before playing stop sound + await new Promise((resolve) => setTimeout(resolve, 800)); + await playTestSound("stop"); + }; + + return ( + +
+ + updateSetting("sound_theme", value as "marimba" | "pop" | "custom") + } + options={options} + /> + +
+
+ ); +}; diff --git a/src/components/settings/VolumeSlider.tsx b/src/components/settings/VolumeSlider.tsx new file mode 100644 index 0000000..7a2cd3e --- /dev/null +++ b/src/components/settings/VolumeSlider.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Slider } from "../ui/Slider"; +import { useSettings } from "../../hooks/useSettings"; + +export const VolumeSlider: React.FC<{ disabled?: boolean }> = ({ + disabled = false, +}) => { + const { getSetting, updateSetting } = useSettings(); + const audioFeedbackVolume = getSetting("audio_feedback_volume") ?? 0.5; + + return ( + + updateSetting("audio_feedback_volume", value) + } + min={0} + max={1} + step={0.1} + label="Volume" + description="Adjust the volume of audio feedback sounds" + descriptionMode="tooltip" + grouped + formatValue={(value) => `${Math.round(value * 100)}%`} + disabled={disabled} + /> + ); +}; diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx index 8e1cf5c..724861f 100644 --- a/src/components/ui/Dropdown.tsx +++ b/src/components/ui/Dropdown.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from "react"; export interface DropdownOption { value: string; label: string; + disabled?: boolean; } interface DropdownProps { @@ -97,8 +98,9 @@ export const Dropdown: React.FC = ({ selectedValue === option.value ? "bg-logo-primary/20 font-semibold" : "" - }`} + } ${option.disabled ? "opacity-50 cursor-not-allowed" : ""}`} onClick={() => handleSelect(option.value)} + disabled={option.disabled} > {option.label} diff --git a/src/components/ui/Slider.tsx b/src/components/ui/Slider.tsx index 005bd18..2aabb81 100644 --- a/src/components/ui/Slider.tsx +++ b/src/components/ui/Slider.tsx @@ -41,6 +41,7 @@ export const Slider: React.FC = ({ descriptionMode={descriptionMode} grouped={grouped} layout="horizontal" + disabled={disabled} >
@@ -54,11 +55,11 @@ export const Slider: React.FC = ({ disabled={disabled} className="flex-grow h-2 rounded-lg appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-logo-primary disabled:opacity-50 disabled:cursor-not-allowed" style={{ - background: `linear-gradient(to right, #FAA2CA 0%, #FAA2CA ${ + background: `linear-gradient(to right, var(--color-background-ui) ${ ((value - min) / (max - min)) * 100 }%, rgba(128, 128, 128, 0.2) ${ ((value - min) / (max - min)) * 100 - }%, rgba(128, 128, 128, 0.2) 100%)`, + }%)`, }} /> {showValue && ( diff --git a/src/lib/types.ts b/src/lib/types.ts index 70a4424..52740ab 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -41,6 +41,11 @@ export const SettingsSchema = z.object({ bindings: ShortcutBindingsMapSchema, push_to_talk: z.boolean(), audio_feedback: z.boolean(), + audio_feedback_volume: z.number().optional().default(1.0), + sound_theme: z + .enum(["marimba", "pop", "custom"]) + .optional() + .default("marimba"), start_hidden: z.boolean().optional().default(false), autostart_enabled: z.boolean().optional().default(false), selected_model: z.string(), diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 1e35265..86bc00c 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -9,6 +9,7 @@ interface SettingsStore { isUpdating: Record; audioDevices: AudioDevice[]; outputDevices: AudioDevice[]; + customSounds: { start: boolean; stop: boolean }; // Actions initialize: () => Promise; @@ -24,6 +25,8 @@ interface SettingsStore { resetBinding: (id: string) => Promise; getSetting: (key: K) => Settings[K] | undefined; isUpdatingKey: (key: string) => boolean; + playTestSound: (soundType: "start" | "stop") => Promise; + checkCustomSounds: () => Promise; // Internal state setters setSettings: (settings: Settings | null) => void; @@ -31,11 +34,14 @@ interface SettingsStore { setUpdating: (key: string, updating: boolean) => void; setAudioDevices: (devices: AudioDevice[]) => void; setOutputDevices: (devices: AudioDevice[]) => void; + setCustomSounds: (sounds: { start: boolean; stop: boolean }) => void; } const DEFAULT_SETTINGS: Partial = { always_on_microphone: false, audio_feedback: true, + audio_feedback_volume: 1.0, + sound_theme: "marimba", start_hidden: false, autostart_enabled: false, push_to_talk: false, @@ -55,6 +61,46 @@ const DEFAULT_AUDIO_DEVICE: AudioDevice = { is_default: true, }; +const settingUpdaters: { + [K in keyof Settings]?: (value: Settings[K]) => Promise; +} = { + always_on_microphone: (value) => + invoke("update_microphone_mode", { alwaysOn: value }), + audio_feedback: (value) => + invoke("change_audio_feedback_setting", { enabled: value }), + audio_feedback_volume: (value) => + invoke("change_audio_feedback_volume_setting", { volume: value }), + sound_theme: (value) => + invoke("change_sound_theme_setting", { theme: value }), + start_hidden: (value) => + invoke("change_start_hidden_setting", { enabled: value }), + autostart_enabled: (value) => + invoke("change_autostart_setting", { enabled: value }), + push_to_talk: (value) => invoke("change_ptt_setting", { enabled: value }), + selected_microphone: (value) => + invoke("set_selected_microphone", { + deviceName: value === "Default" ? "default" : value, + }), + selected_output_device: (value) => + invoke("set_selected_output_device", { + deviceName: value === "Default" ? "default" : value, + }), + translate_to_english: (value) => + invoke("change_translate_to_english_setting", { enabled: value }), + selected_language: (value) => + invoke("change_selected_language_setting", { language: value }), + overlay_position: (value) => + invoke("change_overlay_position_setting", { position: value }), + debug_mode: (value) => + invoke("change_debug_mode_setting", { enabled: value }), + custom_words: (value) => invoke("update_custom_words", { words: value }), + word_correction_threshold: (value) => + invoke("change_word_correction_threshold_setting", { threshold: value }), + paste_method: (value) => + invoke("change_paste_method_setting", { method: value }), + history_limit: (value) => invoke("update_history_limit", { limit: value }), +}; + export const useSettingsStore = create()( subscribeWithSelector((set, get) => ({ settings: null, @@ -62,6 +108,7 @@ export const useSettingsStore = create()( isUpdating: {}, audioDevices: [], outputDevices: [], + customSounds: { start: false, stop: false }, // Internal setters setSettings: (settings) => set({ settings }), @@ -72,6 +119,7 @@ export const useSettingsStore = create()( })), setAudioDevices: (audioDevices) => set({ audioDevices }), setOutputDevices: (outputDevices) => set({ outputDevices }), + setCustomSounds: (customSounds) => set({ customSounds }), // Getters getSetting: (key) => get().settings?.[key], @@ -154,99 +202,52 @@ export const useSettingsStore = create()( } }, + // Play a test sound + playTestSound: async (soundType: "start" | "stop") => { + try { + await invoke("play_test_sound", { soundType }); + } catch (error) { + console.error(`Failed to play test sound (${soundType}):`, error); + } + }, + + + checkCustomSounds: async () => { + try { + const sounds = await invoke("check_custom_sounds"); + get().setCustomSounds(sounds as { start: boolean; stop: boolean }); + } catch (error) { + console.error("Failed to check custom sounds:", error); + } + }, + // Update a specific setting updateSetting: async ( key: K, value: Settings[K], ) => { - const { settings, setUpdating, refreshSettings } = get(); + const { settings, setUpdating } = get(); const updateKey = String(key); const originalValue = settings?.[key]; setUpdating(updateKey, true); try { - // Optimistic update set((state) => ({ settings: state.settings ? { ...state.settings, [key]: value } : null, })); - // Invoke the appropriate backend method - switch (key) { - case "always_on_microphone": - await invoke("update_microphone_mode", { alwaysOn: value }); - break; - case "audio_feedback": - await invoke("change_audio_feedback_setting", { enabled: value }); - break; - case "start_hidden": - await invoke("change_start_hidden_setting", { enabled: value }); - break; - case "autostart_enabled": - await invoke("change_autostart_setting", { enabled: value }); - break; - case "push_to_talk": - await invoke("change_ptt_setting", { enabled: value }); - break; - case "selected_microphone": - const micDeviceName = value === "Default" ? "default" : value; - await invoke("set_selected_microphone", { - deviceName: micDeviceName, - }); - break; - case "selected_output_device": - const outputDeviceName = value === "Default" ? "default" : value; - await invoke("set_selected_output_device", { - deviceName: outputDeviceName, - }); - break; - case "translate_to_english": - await invoke("change_translate_to_english_setting", { - enabled: value, - }); - break; - case "selected_language": - await invoke("change_selected_language_setting", { - language: value, - }); - break; - case "overlay_position": - await invoke("change_overlay_position_setting", { - position: value, - }); - break; - case "debug_mode": - await invoke("change_debug_mode_setting", { enabled: value }); - break; - case "custom_words": - await invoke("update_custom_words", { words: value }); - break; - case "word_correction_threshold": - await invoke("change_word_correction_threshold_setting", { - threshold: value, - }); - break; - case "paste_method": - await invoke("change_paste_method_setting", { method: value }); - break; - case "history_limit": - await invoke("update_history_limit", { limit: value }); - break; - case "bindings": - case "selected_model": - break; - default: - console.warn(`No handler for setting: ${String(key)}`); + const updater = settingUpdaters[key]; + if (updater) { + await updater(value); + } else if (key !== "bindings" && key !== "selected_model") { + console.warn(`No handler for setting: ${String(key)}`); } } catch (error) { console.error(`Failed to update setting ${String(key)}:`, error); - - // Rollback on error - set((state) => ({ - settings: state.settings - ? { ...state.settings, [key]: originalValue } - : null, - })); + if (settings) { + set({ settings: { ...settings, [key]: originalValue } }); + } } finally { setUpdating(updateKey, false); } @@ -273,15 +274,15 @@ export const useSettingsStore = create()( set((state) => ({ settings: state.settings ? { - ...state.settings, - bindings: { - ...state.settings.bindings, - [id]: { - ...state.settings.bindings[id], - current_binding: binding, + ...state.settings, + bindings: { + ...state.settings.bindings, + [id]: { + ...state.settings.bindings[id], + current_binding: binding, + }, }, - }, - } + } : null, })); @@ -294,15 +295,15 @@ export const useSettingsStore = create()( set((state) => ({ settings: state.settings ? { - ...state.settings, - bindings: { - ...state.settings.bindings, - [id]: { - ...state.settings.bindings[id], - current_binding: originalBinding, + ...state.settings, + bindings: { + ...state.settings.bindings, + [id]: { + ...state.settings.bindings[id], + current_binding: originalBinding, + }, }, - }, - } + } : null, })); } @@ -330,12 +331,17 @@ export const useSettingsStore = create()( // Initialize everything initialize: async () => { - const { refreshSettings, refreshAudioDevices, refreshOutputDevices } = - get(); + const { + refreshSettings, + refreshAudioDevices, + refreshOutputDevices, + checkCustomSounds, + } = get(); await Promise.all([ refreshSettings(), refreshAudioDevices(), refreshOutputDevices(), + checkCustomSounds(), ]); }, })),