diff --git a/crates/artcraftx/src/commands/service/app_preferences/load_custom_sound_command.rs b/crates/artcraftx/src/commands/service/app_preferences/load_custom_sound_command.rs new file mode 100644 index 00000000..a57b7a97 --- /dev/null +++ b/crates/artcraftx/src/commands/service/app_preferences/load_custom_sound_command.rs @@ -0,0 +1,38 @@ +use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::state::app_preferences::settings::app_sound_preferences::AppSoundEvent; +use log::{error, info, warn}; +use tauri::ipc::Response; +use tauri::State; + +/// The bytes of the custom `.wav` configured for an event, for the frontend +/// to play. The path comes from the saved preference — the frontend never +/// names a file — so this can only read files the user picked in Settings. +/// +/// Errors (returned as a plain message, logged at `warn`) when the event has +/// no custom sound or the file has since gone missing; the frontend treats +/// that as "play nothing". +#[tauri::command] +pub async fn load_custom_sound_command( + event: AppSoundEvent, + app_prefs: State<'_, AppPreferencesManager>, +) -> Result { + info!("load_custom_sound_command called for {:?}", event); + + let prefs = app_prefs.get().map_err(|err| { + error!("Error reading app preferences: {:?}", err); + format!("Error reading app preferences: {:?}", err) + })?; + + let path = prefs + .sounds + .get(event) + .and_then(|sound| sound.custom_wav_path()) + .ok_or_else(|| format!("No custom sound is configured for {:?}", event))?; + + let bytes = std::fs::read(path).map_err(|err| { + warn!("Custom sound for {:?} could not be read from {}: {}", event, path.display(), err); + format!("Custom sound file could not be read: {}", path.display()) + })?; + + Ok(Response::new(bytes)) +} diff --git a/crates/artcraftx/src/commands/service/app_preferences/mod.rs b/crates/artcraftx/src/commands/service/app_preferences/mod.rs index d032ba90..4c65fd34 100644 --- a/crates/artcraftx/src/commands/service/app_preferences/mod.rs +++ b/crates/artcraftx/src/commands/service/app_preferences/mod.rs @@ -1,2 +1,4 @@ pub mod get_app_preferences_command; -pub mod update_app_preference_command; \ No newline at end of file +pub mod load_custom_sound_command; +pub mod update_app_preference_command; +pub mod update_sound_preference_command; diff --git a/crates/artcraftx/src/commands/service/app_preferences/update_app_preference_command.rs b/crates/artcraftx/src/commands/service/app_preferences/update_app_preference_command.rs index b70e902b..3b02d47a 100644 --- a/crates/artcraftx/src/commands/service/app_preferences/update_app_preference_command.rs +++ b/crates/artcraftx/src/commands/service/app_preferences/update_app_preference_command.rs @@ -1,6 +1,5 @@ use crate::state::app_preferences::app_preferences::AppPreferences; use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; -use crate::state::app_preferences::settings::app_sound_file::{optional_sound, AppSoundFile}; use crate::state::app_preferences::settings::preferred_download_directory::PreferredDownloadDirectory; use crate::state::app_preferences::settings::preferred_download_filename::PreferredDownloadFilename; use anyhow::anyhow; @@ -10,19 +9,16 @@ use serde_derive::{Deserialize, Serialize}; use tauri::State; /// One preference change, typed per preference. The frontend sends -/// `{ "preference": "", "value": }`; a sound preference with a -/// missing, `null`, or `"none"` value means "silent". +/// `{ "preference": "", "value": }`. +/// +/// Per-event sounds have their own command (`update_sound_preference_command`), +/// since custom files need validating. #[derive(Deserialize, Debug)] #[serde(tag = "preference", content = "value", rename_all = "snake_case")] pub enum UpdateAppPreferencesRequest { PreferredDownloadDirectory(PreferredDownloadDirectory), PreferredDownloadFilename(PreferredDownloadFilename), PlaySounds(bool), - DeleteFileSound(#[serde(with = "optional_sound")] Option), - EnqueueSuccessSound(#[serde(with = "optional_sound")] Option), - EnqueueFailureSound(#[serde(with = "optional_sound")] Option), - GenerationSuccessSound(#[serde(with = "optional_sound")] Option), - GenerationFailureSound(#[serde(with = "optional_sound")] Option), } #[derive(Serialize)] @@ -67,11 +63,6 @@ fn apply(request: UpdateAppPreferencesRequest, prefs: &mut AppPreferences) { PreferredDownloadDirectory(directory) => prefs.downloads.preferred_download_directory = directory, PreferredDownloadFilename(filename) => prefs.downloads.preferred_download_filename = filename, PlaySounds(enabled) => prefs.sounds.play_sounds = enabled, - DeleteFileSound(sound) => prefs.sounds.delete_file = sound, - EnqueueSuccessSound(sound) => prefs.sounds.enqueue_success = sound, - EnqueueFailureSound(sound) => prefs.sounds.enqueue_failure = sound, - GenerationSuccessSound(sound) => prefs.sounds.generation_success = sound, - GenerationFailureSound(sound) => prefs.sounds.generation_failure = sound, } } @@ -106,39 +97,13 @@ mod tests { decode(r#"{"preference":"preferred_download_directory","value":{"system":"downloads"}}"#), UpdateAppPreferencesRequest::PreferredDownloadDirectory(PreferredDownloadDirectory::System(_)), )); - assert!(matches!( - decode(r#"{"preference":"enqueue_success_sound","value":"done"}"#), - UpdateAppPreferencesRequest::EnqueueSuccessSound(Some(AppSoundFile::Done)), - )); - assert!(matches!( - decode(r#"{"preference":"generation_failure_sound","value":{"custom_wav":"/tmp/x.wav"}}"#), - UpdateAppPreferencesRequest::GenerationFailureSound(Some(AppSoundFile::CustomWav(_))), - )); - } - - /// "None (Silent)" in the UI sends `value: undefined` (key absent) — and - /// `null` must work too. - #[test] - fn silent_sound_decodes_from_missing_or_null_value() { - assert!(matches!( - decode(r#"{"preference":"delete_file_sound"}"#), - UpdateAppPreferencesRequest::DeleteFileSound(None), - )); - assert!(matches!( - decode(r#"{"preference":"delete_file_sound","value":null}"#), - UpdateAppPreferencesRequest::DeleteFileSound(None), - )); - assert!(matches!( - decode(r#"{"preference":"delete_file_sound","value":"none"}"#), - UpdateAppPreferencesRequest::DeleteFileSound(None), - )); } #[test] fn wrong_value_type_is_rejected() { assert!(serde_json::from_str::(r#"{"preference":"play_sounds","value":"yes"}"#).is_err()); - assert!(serde_json::from_str::(r#"{"preference":"enqueue_success_sound","value":"not_a_sound"}"#).is_err()); - assert!(serde_json::from_str::(r#"{"preference":"generation_enqueue_sound","value":"done"}"#).is_err()); + // Per-event sounds moved to `update_sound_preference_command`. + assert!(serde_json::from_str::(r#"{"preference":"enqueue_success_sound","value":"done"}"#).is_err()); } #[test] @@ -153,10 +118,8 @@ mod tests { fn apply_writes_into_the_right_group() { let mut prefs = AppPreferences::default(); apply(decode(r#"{"preference":"play_sounds","value":false}"#), &mut prefs); - apply(decode(r#"{"preference":"generation_success_sound"}"#), &mut prefs); apply(decode(r#"{"preference":"preferred_download_directory","value":{"custom":"/tmp/out"}}"#), &mut prefs); assert!(!prefs.sounds.play_sounds); - assert_eq!(prefs.sounds.generation_success, None); assert_eq!(prefs.downloads.preferred_download_directory, PreferredDownloadDirectory::Custom("/tmp/out".into())); } } diff --git a/crates/artcraftx/src/commands/service/app_preferences/update_sound_preference_command.rs b/crates/artcraftx/src/commands/service/app_preferences/update_sound_preference_command.rs new file mode 100644 index 00000000..b87899ec --- /dev/null +++ b/crates/artcraftx/src/commands/service/app_preferences/update_sound_preference_command.rs @@ -0,0 +1,124 @@ +use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::state::app_preferences::settings::app_sound_file::{optional_sound, AppSoundFile}; +use crate::state::app_preferences::settings::app_sound_preferences::{AppSoundEvent, AppSoundPreferences}; +use log::{error, info, warn}; +use serde_derive::{Deserialize, Serialize}; +use tauri::State; + +/// Change the sound for one event. +/// +/// - `set`: `sound` is a catalog key (e.g. `"done"`), `{ "custom_wav": +/// "/abs/path.wav" }`, or `"none"` / `null` / absent for silent. A custom +/// file is checked to exist (and be a `.wav`) before it's accepted. +/// - `reset_to_default`: back to the out-of-the-box sound for the event +/// (whatever `AppSoundPreferences::default()` says). +#[derive(Deserialize, Debug)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum UpdateSoundPreferenceRequest { + Set { + event: AppSoundEvent, + #[serde(default, with = "optional_sound")] + sound: Option, + }, + ResetToDefault { + event: AppSoundEvent, + }, +} + +/// The full sound preferences after the change, so the UI can re-render +/// without a second round trip. +#[derive(Serialize)] +pub struct UpdateSoundPreferenceResponse { + pub sounds: AppSoundPreferences, +} + +#[tauri::command] +pub async fn update_sound_preference_command( + request: UpdateSoundPreferenceRequest, + app_prefs: State<'_, AppPreferencesManager>, +) -> Result { + info!("update_sound_preference_command called: {:?}", request); + + if let UpdateSoundPreferenceRequest::Set { event, sound: Some(sound) } = &request { + sound.validate().map_err(|reason| { + warn!("Rejected sound for {:?}: {}", event, reason); + reason + })?; + } + + let prefs = app_prefs + .update(|prefs| match request { + UpdateSoundPreferenceRequest::Set { event, sound } => prefs.sounds.set(event, sound), + UpdateSoundPreferenceRequest::ResetToDefault { event } => prefs.sounds.reset_to_default(event), + }) + .map_err(|err| { + error!("Error updating sound preference: {:?}", err); + format!("Error updating sound preference: {:?}", err) + })?; + + Ok(UpdateSoundPreferenceResponse { sounds: prefs.sounds }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode(json: &str) -> UpdateSoundPreferenceRequest { + serde_json::from_str(json).unwrap() + } + + fn set_sound(request: UpdateSoundPreferenceRequest) -> (AppSoundEvent, Option) { + match request { + UpdateSoundPreferenceRequest::Set { event, sound } => (event, sound), + other => panic!("expected a set action, got {other:?}"), + } + } + + /// Pin the wire format the frontend sends (`UpdateSoundPreference.ts`). + #[test] + fn decodes_catalog_custom_and_silent() { + let (event, sound) = set_sound(decode(r#"{"action":"set","event":"enqueue_success","sound":"done"}"#)); + assert_eq!(event, AppSoundEvent::EnqueueSuccess); + assert_eq!(sound, Some(AppSoundFile::Done)); + + let (event, sound) = set_sound(decode(r#"{"action":"set","event":"generation_failure","sound":{"custom_wav":"/tmp/x.wav"}}"#)); + assert_eq!(event, AppSoundEvent::GenerationFailure); + assert_eq!(sound, Some(AppSoundFile::CustomWav("/tmp/x.wav".into()))); + + assert_eq!(set_sound(decode(r#"{"action":"set","event":"delete_file","sound":"none"}"#)).1, None); + assert_eq!(set_sound(decode(r#"{"action":"set","event":"delete_file","sound":null}"#)).1, None); + assert_eq!(set_sound(decode(r#"{"action":"set","event":"delete_file"}"#)).1, None); + } + + #[test] + fn decodes_reset() { + assert!(matches!( + decode(r#"{"action":"reset_to_default","event":"generation_success"}"#), + UpdateSoundPreferenceRequest::ResetToDefault { event: AppSoundEvent::GenerationSuccess }, + )); + } + + #[test] + fn rejects_unknown_action_event_or_sound() { + assert!(serde_json::from_str::(r#"{"event":"delete_file","sound":"done"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"action":"set","event":"launch","sound":"done"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"action":"set","event":"delete_file","sound":"bogus"}"#).is_err()); + } + + /// Resetting reads the default from the `Default` impl, so changing a + /// default there is the only place it needs changing. + #[test] + fn reset_restores_the_default_impl_value() { + let mut sounds = AppSoundPreferences::default(); + sounds.set(AppSoundEvent::EnqueueFailure, Some(AppSoundFile::CustomWav("/tmp/x.wav".into()))); + sounds.set(AppSoundEvent::DeleteFile, None); + + sounds.reset_to_default(AppSoundEvent::EnqueueFailure); + sounds.reset_to_default(AppSoundEvent::DeleteFile); + + let defaults = AppSoundPreferences::default(); + assert_eq!(sounds.enqueue_failure, defaults.enqueue_failure); + assert_eq!(sounds.delete_file, defaults.delete_file); + assert_eq!(sounds, defaults); + } +} diff --git a/crates/artcraftx/src/lib.rs b/crates/artcraftx/src/lib.rs index 5e345632..ebc4280d 100644 --- a/crates/artcraftx/src/lib.rs +++ b/crates/artcraftx/src/lib.rs @@ -23,7 +23,9 @@ use crate::commands::credentials::list_credentials_command::list_credentials_com use crate::commands::credentials::open_web_login_command::open_web_login_command; use crate::commands::credentials::refresh_grok_statsig_command::refresh_grok_statsig_command; use crate::commands::service::app_preferences::get_app_preferences_command::get_app_preferences_command; +use crate::commands::service::app_preferences::load_custom_sound_command::load_custom_sound_command; use crate::commands::service::app_preferences::update_app_preference_command::update_app_preferences_command; +use crate::commands::service::app_preferences::update_sound_preference_command::update_sound_preference_command; use crate::commands::cost_estimate::estimate_audio_cost_command::estimate_audio_cost_command; use crate::commands::cost_estimate::estimate_image_cost_command::estimate_image_cost_command; use crate::commands::cost_estimate::estimate_mesh_cost_command::estimate_mesh_cost_command; @@ -220,11 +222,13 @@ pub fn run() { get_app_info_command, get_app_preferences_command, get_task_queue_command, + load_custom_sound_command, load_without_cors_command, mark_task_as_dismissed_command, platform_info_command, tasks_nuke_all_command, update_app_preferences_command, + update_sound_preference_command, ]); builder.run(tauri::generate_context!("tauri.conf.json")) diff --git a/crates/artcraftx/src/state/app_preferences/settings/app_sound_file.rs b/crates/artcraftx/src/state/app_preferences/settings/app_sound_file.rs index fe56f45f..c478a889 100644 --- a/crates/artcraftx/src/state/app_preferences/settings/app_sound_file.rs +++ b/crates/artcraftx/src/state/app_preferences/settings/app_sound_file.rs @@ -60,6 +60,37 @@ pub enum AppSoundFile { CustomWav(PathBuf), } +impl AppSoundFile { + /// Whether this sound can be saved: catalog sounds always can; a custom file + /// must be an absolute path to an existing `.wav` file. Returns a + /// user-facing reason when it can't. + pub fn validate(&self) -> Result<(), String> { + let Self::CustomWav(path) = self else { + return Ok(()); + }; + if !path.is_absolute() { + return Err(format!("Sound file path must be absolute: {}", path.display())); + } + let is_wav = path.extension() + .map(|ext| ext.eq_ignore_ascii_case("wav")) + .unwrap_or(false); + if !is_wav { + return Err(format!("Sound file must be a .wav file: {}", path.display())); + } + if !path.is_file() { + return Err(format!("Sound file does not exist: {}", path.display())); + } + Ok(()) + } + + pub fn custom_wav_path(&self) -> Option<&std::path::Path> { + match self { + Self::CustomWav(path) => Some(path), + _ => None, + } + } +} + /// Serde helper for `Option` fields: `None` (silent) is written /// as the string `"none"` — TOML has no null, and the frontend already uses /// `"none"` for "None (Silent)". Reading accepts `"none"`, `null`, or a sound. @@ -125,6 +156,28 @@ mod tests { assert!(serde_json::from_str::("{\"sound\":\"bogus\"}").is_err()); } + #[test] + fn validate_requires_an_existing_absolute_wav() { + assert!(AppSoundFile::Done.validate().is_ok()); + + let dir = tempfile::tempdir().unwrap(); + let wav = dir.path().join("ding.wav"); + std::fs::write(&wav, b"RIFF").unwrap(); + let upper = dir.path().join("DING.WAV"); + std::fs::write(&upper, b"RIFF").unwrap(); + let mp3 = dir.path().join("ding.mp3"); + std::fs::write(&mp3, b"ID3").unwrap(); + + assert!(AppSoundFile::CustomWav(wav).validate().is_ok()); + assert!(AppSoundFile::CustomWav(upper).validate().is_ok()); + assert!(AppSoundFile::CustomWav(mp3).validate().unwrap_err().contains(".wav")); + assert!(AppSoundFile::CustomWav(dir.path().join("missing.wav")).validate().unwrap_err().contains("does not exist")); + let dir_named_wav = dir.path().join("folder.wav"); + std::fs::create_dir(&dir_named_wav).unwrap(); + assert!(AppSoundFile::CustomWav(dir_named_wav).validate().unwrap_err().contains("does not exist")); + assert!(AppSoundFile::CustomWav("relative/ding.wav".into()).validate().unwrap_err().contains("absolute")); + } + #[test] fn custom_wav_serializes_as_tagged_path() { let sound = AppSoundFile::CustomWav("/tmp/ding.wav".into()); diff --git a/crates/artcraftx/src/state/app_preferences/settings/app_sound_preferences.rs b/crates/artcraftx/src/state/app_preferences/settings/app_sound_preferences.rs index 3e0e2af9..5b14fbcc 100644 --- a/crates/artcraftx/src/state/app_preferences/settings/app_sound_preferences.rs +++ b/crates/artcraftx/src/state/app_preferences/settings/app_sound_preferences.rs @@ -33,6 +33,52 @@ pub struct AppSoundPreferences { pub generation_failure: Option, } +/// The app events that can play a sound; one per field of +/// [`AppSoundPreferences`]. Serialized as the field names. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppSoundEvent { + DeleteFile, + EnqueueSuccess, + EnqueueFailure, + GenerationSuccess, + GenerationFailure, +} + +impl AppSoundPreferences { + pub fn get(&self, event: AppSoundEvent) -> Option<&AppSoundFile> { + match event { + AppSoundEvent::DeleteFile => self.delete_file.as_ref(), + AppSoundEvent::EnqueueSuccess => self.enqueue_success.as_ref(), + AppSoundEvent::EnqueueFailure => self.enqueue_failure.as_ref(), + AppSoundEvent::GenerationSuccess => self.generation_success.as_ref(), + AppSoundEvent::GenerationFailure => self.generation_failure.as_ref(), + } + } + + /// The out-of-the-box sound for an event (from the `Default` impl). + pub fn default_for(event: AppSoundEvent) -> Option { + Self::default().get(event).cloned() + } + + /// Put the event back to its out-of-the-box sound. + pub fn reset_to_default(&mut self, event: AppSoundEvent) { + self.set(event, Self::default_for(event)); + } + + /// `None` silences the event. + pub fn set(&mut self, event: AppSoundEvent, sound: Option) { + let slot = match event { + AppSoundEvent::DeleteFile => &mut self.delete_file, + AppSoundEvent::EnqueueSuccess => &mut self.enqueue_success, + AppSoundEvent::EnqueueFailure => &mut self.enqueue_failure, + AppSoundEvent::GenerationSuccess => &mut self.generation_success, + AppSoundEvent::GenerationFailure => &mut self.generation_failure, + }; + *slot = sound; + } +} + impl Default for AppSoundPreferences { fn default() -> Self { Self { diff --git a/frontend/libs/components/settings-modal/src/lib/panes/AudioSettingsPane.tsx b/frontend/libs/components/settings-modal/src/lib/panes/AudioSettingsPane.tsx index b6424c95..7832b908 100644 --- a/frontend/libs/components/settings-modal/src/lib/panes/AudioSettingsPane.tsx +++ b/frontend/libs/components/settings-modal/src/lib/panes/AudioSettingsPane.tsx @@ -1,109 +1,55 @@ import { useEffect, useState } from "react"; -import { SoundManager } from "@storyteller/soundboard"; +import { SoundManager, SoundOption } from "@storyteller/soundboard"; import { Button } from "@storyteller/ui-button"; -import { Play } from "lucide-react"; +import { FolderOpen, Play, RotateCcw } from "lucide-react"; import { - AppPreferencesPayload, + AppSoundEvent, AppSoundFile, + AppSoundPreferences, GetAppPreferences, + PreferenceName, + ResetSoundPreference, + SILENT_SOUND, + UpdateAppPreferences, + UpdateSoundPreference, + isCustomWavSound, } from "@storyteller/tauri-api"; -import { PreferenceName, UpdateAppPreferences } from "@storyteller/tauri-api"; +import { open } from "@tauri-apps/plugin-dialog"; import { Select, SelectValue } from "@storyteller/ui-select"; import { Switch } from "@storyteller/ui-switch"; import { Label } from "@storyteller/ui-label"; interface AudioSettingsPaneProps {} +// Rows in display order. +const SOUND_EVENTS: { event: AppSoundEvent; label: string }[] = [ + { event: "delete_file", label: "Delete File Sound" }, + { event: "enqueue_success", label: "Enqueue Success Sound" }, + { event: "enqueue_failure", label: "Enqueue Failure Sound" }, + { event: "generation_success", label: "Generation Success Sound" }, + { event: "generation_failure", label: "Generation Failure Sound" }, +]; + export const AudioSettingsPane = (args: AudioSettingsPaneProps) => { - const [preferences, setPreferences] = useState< - AppPreferencesPayload | undefined - >(undefined); + const [sounds, setSounds] = useState(undefined); useEffect(() => { const fetchData = async () => { const prefs = await GetAppPreferences(); - setPreferences(prefs.preferences); + setSounds(prefs.preferences.sounds); }; fetchData(); }, []); - const sounds = preferences?.sounds; const playSounds = sounds?.play_sounds || false; - const deleteFileSound = orNone(sounds?.delete_file); - const enqueueSuccessSound = orNone(sounds?.enqueue_success); - const enqueueFailureSound = orNone(sounds?.enqueue_failure); - const generationSuccessSound = orNone(sounds?.generation_success); - const generationFailureSound = orNone(sounds?.generation_failure); - - const reloadPreferences = async () => { - const prefs = await GetAppPreferences(); - setPreferences(prefs.preferences); - }; - const setPlaySounds = async (checked: boolean) => { - //const value = checked ? "true" : "false"; await UpdateAppPreferences({ preference: PreferenceName.PlaySounds, value: checked, }); - await reloadPreferences(); - }; - - const setDeleteFileSound = async (val: string) => { - let sendVal = val === "none" ? undefined : val; - await UpdateAppPreferences({ - preference: PreferenceName.DeleteFileSound, - value: sendVal, - }); - SoundManager.playPreview(val); - await reloadPreferences(); - }; - - const setEnqueueSuccessSound = async (val: string) => { - let sendVal = val === "none" ? undefined : val; - await UpdateAppPreferences({ - preference: PreferenceName.EnqueueSuccessSound, - value: sendVal, - }); - SoundManager.playPreview(val); - await reloadPreferences(); - }; - - const setEnqueueFailureSound = async (val: string) => { - let sendVal = val === "none" ? undefined : val; - await UpdateAppPreferences({ - preference: PreferenceName.EnqueueFailureSound, - value: sendVal, - }); - SoundManager.playPreview(val); - await reloadPreferences(); - }; - - const setSuccessSound = async (val: string) => { - let sendVal = val === "none" ? undefined : val; - await UpdateAppPreferences({ - preference: PreferenceName.GenerationSuccessSound, - value: sendVal, - }); - SoundManager.playPreview(val); - await reloadPreferences(); - }; - - const setFailureSound = async (val: string) => { - let sendVal = val === "none" ? undefined : val; - await UpdateAppPreferences({ - preference: PreferenceName.GenerationFailureSound, - value: sendVal, - }); - SoundManager.playPreview(val); - await reloadPreferences(); - }; - - const playSound = (val?: string) => { - if (val !== undefined && val !== "none") { - SoundManager.playPreview(val); - } + const prefs = await GetAppPreferences(); + setSounds(prefs.preferences.sounds); }; return ( @@ -116,111 +62,126 @@ export const AudioSettingsPane = (args: AudioSettingsPaneProps) => { -
- -
- setEnqueueSuccessSound(val as string)} - options={SoundManager.OPTIONS} - className="grow" - /> -
-
- -
- -
- setSuccessSound(val as string)} - options={SoundManager.OPTIONS} - className="grow" - /> -
-
- -
- -
- +
+ {error &&

{error}

} +
+ ); +}; + +// The dropdown value for a preset (or silent) sound. +const presetValue = (sound: AppSoundFile | undefined): string => { + if (!sound || typeof sound !== "string") { + return SILENT_SOUND; + } + return sound; }; diff --git a/frontend/libs/soundboard/src/lib/CustomSoundPlayer.ts b/frontend/libs/soundboard/src/lib/CustomSoundPlayer.ts new file mode 100644 index 00000000..6d8b4228 --- /dev/null +++ b/frontend/libs/soundboard/src/lib/CustomSoundPlayer.ts @@ -0,0 +1,62 @@ +// Internal to @storyteller/soundboard. Consumers go through SoundManager. +// +// Plays the user's own .wav files. The bytes come from the backend (which +// reads the path saved in preferences) and are wrapped in a blob URL for +// Howler; decoded sounds are cached per path so a file is only loaded once. +// Every failure is logged and swallowed — a missing file must never break +// the event that triggered the sound. +import { Howl } from "howler"; +import { AppSoundEvent, LoadCustomSound } from "@storyteller/tauri-api"; + +type CachedSound = { path: string; howl: Howl }; + +const DEFAULT_VOLUME = 0.5; + +export class CustomSoundPlayer { + // Keyed by event; invalidated when the configured path changes. + private static readonly cache = new Map(); + + // Play the custom sound configured for `event` (whose current path is + // `path`). Resolves once playback starts or the load failed. + static async play(event: AppSoundEvent, path: string): Promise { + const howl = await this.load(event, path); + howl?.play(); + } + + private static async load(event: AppSoundEvent, path: string): Promise { + const cached = this.cache.get(event); + if (cached && cached.path === path) { + return cached.howl; + } + if (cached) { + cached.howl.unload(); + this.cache.delete(event); + } + + let bytes: ArrayBuffer; + try { + bytes = await LoadCustomSound(event); + } catch (err) { + console.warn(`Custom sound for ${event} could not be loaded (${path}):`, err); + return undefined; + } + + const url = URL.createObjectURL(new Blob([bytes], { type: "audio/wav" })); + const howl = new Howl({ + src: [url], + // Blob URLs carry no extension, so tell Howler the codec. + format: ["wav"], + autoplay: false, + loop: false, + volume: DEFAULT_VOLUME, + onloaderror: (_id, err) => { + console.warn(`Custom sound for ${event} could not be decoded (${path}):`, err); + }, + onplayerror: (_id, err) => { + console.warn(`Custom sound for ${event} could not be played (${path}):`, err); + }, + }); + this.cache.set(event, { path, howl }); + return howl; + } +} diff --git a/frontend/libs/soundboard/src/lib/SoundManager.ts b/frontend/libs/soundboard/src/lib/SoundManager.ts index 5f4f6835..c944e3b2 100644 --- a/frontend/libs/soundboard/src/lib/SoundManager.ts +++ b/frontend/libs/soundboard/src/lib/SoundManager.ts @@ -1,4 +1,11 @@ -import { GetAppPreferences } from "@storyteller/tauri-api"; +import { + AppSoundEvent, + AppSoundFile, + GetAppPreferences, + SILENT_SOUND, + isCustomWavSound, +} from "@storyteller/tauri-api"; +import { CustomSoundPlayer } from "./CustomSoundPlayer"; import { SoundEffect } from "./SoundEffect"; import { SoundRegistry } from "./SoundRegistry"; @@ -54,18 +61,11 @@ const SOUNDS: readonly SoundDef[] = [ export type SoundOption = { value: string; label: string }; -type SoundPrefKey = - | "delete_file" - | "enqueue_success" - | "enqueue_failure" - | "generation_success" - | "generation_failure"; - export class SoundManager { // Dropdown options for AudioSettingsPane. // "None (Silent)" first, then user-selectable sounds A→Z by label. static readonly OPTIONS: SoundOption[] = [ - { value: "none", label: "None (Silent)" }, + { value: SILENT_SOUND, label: "None (Silent)" }, ...SOUNDS .filter((s): s is SoundDef & { label: string } => !!s.label) .map((s) => ({ value: s.key, label: s.label })) @@ -84,10 +84,10 @@ export class SoundManager { this.installed = true; } - // Settings preview — fires regardless of the master toggle. - static playPreview(soundName: string) { - if (!soundName || soundName === "none") return; - SoundRegistry.getInstance().playSound(soundName); + // Settings preview — fires regardless of the master toggle. `event` is + // needed to play a custom file (the backend serves it by event). + static async playPreview(sound: AppSoundFile | undefined, event: AppSoundEvent) { + await this.playSound(sound, event); } // Event-driven playback — gated on `play_sounds`. @@ -97,13 +97,35 @@ export class SoundManager { static async playGenerationSuccess() { await this.playEvent("generation_success"); } static async playGenerationFailure() { await this.playEvent("generation_failure"); } - private static async playEvent(prefKey: SoundPrefKey) { - const sounds = (await GetAppPreferences()).preferences?.sounds; + private static async playEvent(event: AppSoundEvent) { + let sounds; + try { + sounds = (await GetAppPreferences()).preferences?.sounds; + } catch (err) { + console.warn("Could not read sound preferences; not playing a sound:", err); + return; + } if (!sounds?.play_sounds) return; - const sound = sounds[prefKey]; - // Only catalog keys are playable here; "none" is silent and custom .wav - // files aren't supported by the registry yet. - if (!sound || sound === "none" || typeof sound !== "string") return; - SoundRegistry.getInstance().playSound(sound); + await this.playSound(sounds[event], event); + } + + // Never throws: a bad or missing sound is logged and skipped. + private static async playSound(sound: AppSoundFile | undefined, event: AppSoundEvent) { + try { + if (!sound || sound === SILENT_SOUND) return; + if (isCustomWavSound(sound)) { + await CustomSoundPlayer.play(event, sound.custom_wav); + return; + } + if (typeof sound !== "string") return; + const registry = SoundRegistry.getInstance(); + if (!registry.hasSound(sound)) { + console.warn(`Unknown sound key "${sound}" for ${event}; not playing.`); + return; + } + registry.playSound(sound); + } catch (err) { + console.warn(`Could not play sound for ${event}:`, err); + } } } diff --git a/frontend/libs/tauri-api/src/index.ts b/frontend/libs/tauri-api/src/index.ts index 315b6243..c0920068 100644 --- a/frontend/libs/tauri-api/src/index.ts +++ b/frontend/libs/tauri-api/src/index.ts @@ -31,6 +31,8 @@ export * from "./lib/opener/OpenUrl.js"; export * from "./lib/providers/Provider.js"; export * from "./lib/settings/GetAppPreferences.js"; export * from "./lib/settings/UpdateAppPreference.js"; +export * from "./lib/settings/UpdateSoundPreference.js"; +export * from "./lib/settings/LoadCustomSound.js"; export * from "./lib/sora/CheckSoraSession.js"; export * from "./lib/sora/LogoutSoraSession.js"; export * from "./lib/sora/SoraGetCredentialInfo.js"; diff --git a/frontend/libs/tauri-api/src/lib/settings/GetAppPreferences.ts b/frontend/libs/tauri-api/src/lib/settings/GetAppPreferences.ts index f5bb1ca5..e194807e 100644 --- a/frontend/libs/tauri-api/src/lib/settings/GetAppPreferences.ts +++ b/frontend/libs/tauri-api/src/lib/settings/GetAppPreferences.ts @@ -27,9 +27,15 @@ export interface AppSoundPreferences { export type AppSoundFile = string | CustomWavSound; export interface CustomWavSound { + // Absolute path to the user's .wav file. custom_wav: string, } +export const SILENT_SOUND = "none"; + +export const isCustomWavSound = (sound: AppSoundFile | undefined | null): sound is CustomWavSound => + typeof sound === "object" && sound !== null && typeof sound.custom_wav === "string"; + export interface AppDownloadPreferences { // Preferred download directory preferred_download_directory: PreferredDownloadDirectory, diff --git a/frontend/libs/tauri-api/src/lib/settings/LoadCustomSound.ts b/frontend/libs/tauri-api/src/lib/settings/LoadCustomSound.ts new file mode 100644 index 00000000..46d99809 --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/settings/LoadCustomSound.ts @@ -0,0 +1,11 @@ +import { invoke } from "@tauri-apps/api/core"; +import { AppSoundEvent } from "./UpdateSoundPreference"; + +// The bytes of the custom .wav configured for an event. The backend reads the +// path from the saved preference, so only files picked in Settings are +// readable. Throws when the event has no custom sound or the file is missing +// (the backend logs a warning). +export const LoadCustomSound = async (event: AppSoundEvent) : Promise => { + let result = await invoke("load_custom_sound_command", { event }); + return (result as ArrayBuffer); +} diff --git a/frontend/libs/tauri-api/src/lib/settings/UpdateAppPreference.ts b/frontend/libs/tauri-api/src/lib/settings/UpdateAppPreference.ts index 4ed8fa1d..b0cdccc1 100644 --- a/frontend/libs/tauri-api/src/lib/settings/UpdateAppPreference.ts +++ b/frontend/libs/tauri-api/src/lib/settings/UpdateAppPreference.ts @@ -1,23 +1,17 @@ import { invoke } from "@tauri-apps/api/core"; -import { AppSoundFile, PreferredDownloadDirectory, PreferredDownloadFilename } from "./GetAppPreferences"; +import { PreferredDownloadDirectory, PreferredDownloadFilename } from "./GetAppPreferences"; -// One preference change. For the sound preferences, `undefined` or "none" -// means silent. +// One preference change. Per-event sounds go through UpdateSoundPreference. export interface UpdateAppPreferencesRequest { preference: PreferenceName, - value: undefined | boolean | AppSoundFile | PreferredDownloadDirectory | PreferredDownloadFilename, + value: boolean | PreferredDownloadDirectory | PreferredDownloadFilename, } export enum PreferenceName { PreferredDownloadDirectory = "preferred_download_directory", PreferredDownloadFilename = "preferred_download_filename", - PlaySounds = "play_sounds", - DeleteFileSound = "delete_file_sound", - EnqueueSuccessSound = "enqueue_success_sound", - EnqueueFailureSound = "enqueue_failure_sound", - GenerationSuccessSound = "generation_success_sound", - GenerationFailureSound = "generation_failure_sound", + PlaySounds = "play_sounds", } export interface UpdateAppPreferencesResult { diff --git a/frontend/libs/tauri-api/src/lib/settings/UpdateSoundPreference.ts b/frontend/libs/tauri-api/src/lib/settings/UpdateSoundPreference.ts new file mode 100644 index 00000000..1b0e21cc --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/settings/UpdateSoundPreference.ts @@ -0,0 +1,45 @@ +import { invoke } from "@tauri-apps/api/core"; +import { AppSoundFile, AppSoundPreferences } from "./GetAppPreferences"; + +// The app events that can play a sound (one per field of AppSoundPreferences). +export type AppSoundEvent = + | "delete_file" + | "enqueue_success" + | "enqueue_failure" + | "generation_success" + | "generation_failure"; + +export interface UpdateSoundPreferenceRequest { + event: AppSoundEvent, + // A catalog key, a custom .wav, or undefined / "none" for silent. + sound: AppSoundFile | undefined, +} + +export interface UpdateSoundPreferenceResult { + // The full sound preferences after the change. + sounds: AppSoundPreferences, +} + +// Change the sound for one event. Rejects (throws the reason) when a custom +// file doesn't exist or isn't a .wav. +export const UpdateSoundPreference = async (request: UpdateSoundPreferenceRequest) : Promise => { + let result = await invoke("update_sound_preference_command", { + request: { + action: "set", + event: request.event, + sound: request.sound, + } + }); + return (result as UpdateSoundPreferenceResult); +} + +// Put one event's sound back to the app default (defined on the backend). +export const ResetSoundPreference = async (event: AppSoundEvent) : Promise => { + let result = await invoke("update_sound_preference_command", { + request: { + action: "reset_to_default", + event, + } + }); + return (result as UpdateSoundPreferenceResult); +}