Add custom sound and control notif volume (#214)

* Add custom sound and control notif volume

* Modularize the picker and slider, consolidate the playback and loading

* Move custom sounds to advanced

* pairs of audio

* custom sound handling

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
Jackson 2025-10-17 01:08:27 +09:00 committed by GitHub
parent d5a970042a
commit a286fe5143
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 394 additions and 149 deletions

Binary file not shown.

Binary file not shown.

View file

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

View file

@ -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<String>,
volume: f32,
) -> 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 = 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(())

View file

@ -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<String, String> {
.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);
}

View file

@ -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,

View file

@ -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<String, ShortcutBinding>,
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(),

View file

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

View file

@ -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<AudioFeedbackProps> = React.memo(({
descriptionMode = "tooltip",
grouped = false,
}) => {
const { getSetting, updateSetting, isUpdating } = useSettings();
const audioFeedbackEnabled = getSetting("audio_feedback") || false;
export const AudioFeedback: React.FC<AudioFeedbackProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { getSetting, updateSetting, isUpdating } = useSettings();
const audioFeedbackEnabled = getSetting("audio_feedback") || false;
return (
<ToggleSwitch
checked={audioFeedbackEnabled}
onChange={(enabled) => updateSetting("audio_feedback", enabled)}
isUpdating={isUpdating("audio_feedback")}
label="Audio Feedback"
description="Play sound when recording starts and stops"
descriptionMode={descriptionMode}
grouped={grouped}
/>
<div className="flex flex-col">
<ToggleSwitch
checked={audioFeedbackEnabled}
onChange={(enabled) => updateSetting("audio_feedback", enabled)}
isUpdating={isUpdating("audio_feedback")}
label="Audio Feedback"
description="Play sound when recording starts and stops"
descriptionMode={descriptionMode}
grouped={grouped}
/>
</div>
);
},
);

View file

@ -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 (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="Debug">
<PasteMethodSetting descriptionMode="tooltip" grouped={true} />
<SoundPicker
label="Sound Theme"
description="Choose a sound theme for recording start and stop feedback"
/>
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
<AppDataDirectory descriptionMode="tooltip" grouped={true} />
<HistoryLimit descriptionMode="tooltip" grouped={true} />

View file

@ -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}
/>
<VolumeSlider disabled={!audioFeedbackEnabled} />
</SettingsGroup>
</div>
);

View file

@ -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<HistoryEntryProps> = ({
{showCopied ? (
<Check width={16} height={16} />
) : (
<ClipboardCopy width={16} height={16} />
<Copy width={16} height={16} />
)}
</button>
<button
@ -212,7 +212,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
className="text-text/50 hover:text-logo-primary transition-colors cursor-pointer"
title="Delete entry"
>
<Trash2 width={16} height={16}/>
<Trash2 width={16} height={16} />
</button>
</div>
</div>

View file

@ -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<SoundPickerProps> = ({
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 (
<SettingContainer
title={label}
description={description}
grouped
layout="horizontal"
>
<div className="flex items-center gap-2">
<Dropdown
selectedValue={selectedTheme}
onSelect={(value) =>
updateSetting("sound_theme", value as "marimba" | "pop" | "custom")
}
options={options}
/>
<Button
variant="ghost"
size="sm"
onClick={handlePlayBothSounds}
title="Preview sound theme (plays start then stop)"
>
<PlayIcon className="h-4 w-4" />
</Button>
</div>
</SettingContainer>
);
};

View file

@ -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 (
<Slider
value={audioFeedbackVolume}
onChange={(value: number) =>
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}
/>
);
};

View file

@ -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<DropdownProps> = ({
selectedValue === option.value
? "bg-logo-primary/20 font-semibold"
: ""
}`}
} ${option.disabled ? "opacity-50 cursor-not-allowed" : ""}`}
onClick={() => handleSelect(option.value)}
disabled={option.disabled}
>
<span className="truncate">{option.label}</span>
</button>

View file

@ -41,6 +41,7 @@ export const Slider: React.FC<SliderProps> = ({
descriptionMode={descriptionMode}
grouped={grouped}
layout="horizontal"
disabled={disabled}
>
<div className="w-full">
<div className="flex items-center space-x-1 h-6">
@ -54,11 +55,11 @@ export const Slider: React.FC<SliderProps> = ({
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 && (

View file

@ -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(),

View file

@ -9,6 +9,7 @@ interface SettingsStore {
isUpdating: Record<string, boolean>;
audioDevices: AudioDevice[];
outputDevices: AudioDevice[];
customSounds: { start: boolean; stop: boolean };
// Actions
initialize: () => Promise<void>;
@ -24,6 +25,8 @@ interface SettingsStore {
resetBinding: (id: string) => Promise<void>;
getSetting: <K extends keyof Settings>(key: K) => Settings[K] | undefined;
isUpdatingKey: (key: string) => boolean;
playTestSound: (soundType: "start" | "stop") => Promise<void>;
checkCustomSounds: () => Promise<void>;
// 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<Settings> = {
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<unknown>;
} = {
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<SettingsStore>()(
subscribeWithSelector((set, get) => ({
settings: null,
@ -62,6 +108,7 @@ export const useSettingsStore = create<SettingsStore>()(
isUpdating: {},
audioDevices: [],
outputDevices: [],
customSounds: { start: false, stop: false },
// Internal setters
setSettings: (settings) => set({ settings }),
@ -72,6 +119,7 @@ export const useSettingsStore = create<SettingsStore>()(
})),
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<SettingsStore>()(
}
},
// 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 <K extends keyof Settings>(
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<SettingsStore>()(
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<SettingsStore>()(
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<SettingsStore>()(
// Initialize everything
initialize: async () => {
const { refreshSettings, refreshAudioDevices, refreshOutputDevices } =
get();
const {
refreshSettings,
refreshAudioDevices,
refreshOutputDevices,
checkCustomSounds,
} = get();
await Promise.all([
refreshSettings(),
refreshAudioDevices(),
refreshOutputDevices(),
checkCustomSounds(),
]);
},
})),