sound settings

This commit is contained in:
Hanashi 2026-08-29 22:37:20 -04:00
parent 54e23ef0f1
commit da1fdc25f8
15 changed files with 587 additions and 254 deletions

View File

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

View File

@ -1,2 +1,4 @@
pub mod get_app_preferences_command;
pub mod update_app_preference_command;
pub mod load_custom_sound_command;
pub mod update_app_preference_command;
pub mod update_sound_preference_command;

View File

@ -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": "<name>", "value": <value> }`; a sound preference with a
/// missing, `null`, or `"none"` value means "silent".
/// `{ "preference": "<name>", "value": <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<AppSoundFile>),
EnqueueSuccessSound(#[serde(with = "optional_sound")] Option<AppSoundFile>),
EnqueueFailureSound(#[serde(with = "optional_sound")] Option<AppSoundFile>),
GenerationSuccessSound(#[serde(with = "optional_sound")] Option<AppSoundFile>),
GenerationFailureSound(#[serde(with = "optional_sound")] Option<AppSoundFile>),
}
#[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::<UpdateAppPreferencesRequest>(r#"{"preference":"play_sounds","value":"yes"}"#).is_err());
assert!(serde_json::from_str::<UpdateAppPreferencesRequest>(r#"{"preference":"enqueue_success_sound","value":"not_a_sound"}"#).is_err());
assert!(serde_json::from_str::<UpdateAppPreferencesRequest>(r#"{"preference":"generation_enqueue_sound","value":"done"}"#).is_err());
// Per-event sounds moved to `update_sound_preference_command`.
assert!(serde_json::from_str::<UpdateAppPreferencesRequest>(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()));
}
}

View File

@ -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<AppSoundFile>,
},
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<UpdateSoundPreferenceResponse, String> {
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<AppSoundFile>) {
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::<UpdateSoundPreferenceRequest>(r#"{"event":"delete_file","sound":"done"}"#).is_err());
assert!(serde_json::from_str::<UpdateSoundPreferenceRequest>(r#"{"action":"set","event":"launch","sound":"done"}"#).is_err());
assert!(serde_json::from_str::<UpdateSoundPreferenceRequest>(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);
}
}

View File

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

View File

@ -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<AppSoundFile>` 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::<Holder>("{\"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());

View File

@ -33,6 +33,52 @@ pub struct AppSoundPreferences {
pub generation_failure: Option<AppSoundFile>,
}
/// 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<AppSoundFile> {
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<AppSoundFile>) {
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 {

View File

@ -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<AppSoundPreferences | undefined>(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) => {
<Switch enabled={playSounds} setEnabled={setPlaySounds} />
</div>
<div className="space-y-1">
<Label htmlFor="success-sound">Delete File Sound</Label>
<div className="flex items-center gap-2">
<Select
id="success-sound"
value={deleteFileSound}
onChange={(val: SelectValue) => setDeleteFileSound(val as string)}
options={SoundManager.OPTIONS}
className="grow"
/>
<Button
variant="primary"
className="w-[40px] h-[40px]"
icon={Play}
onClick={() => playSound(deleteFileSound)}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="success-sound">Enqueue Success Sound</Label>
<div className="flex items-center gap-2">
<Select
id="success-sound"
value={enqueueSuccessSound}
onChange={(val: SelectValue) => setEnqueueSuccessSound(val as string)}
options={SoundManager.OPTIONS}
className="grow"
/>
<Button
variant="primary"
className="w-[40px] h-[40px]"
icon={Play}
onClick={() => playSound(enqueueSuccessSound)}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="success-sound">Enqueue Failure Sound</Label>
<div className="flex items-center gap-2">
<Select
id="success-sound"
value={enqueueFailureSound}
onChange={(val: SelectValue) => setEnqueueFailureSound(val as string)}
options={SoundManager.OPTIONS}
className="grow"
/>
<Button
variant="primary"
className="w-[40px] h-[40px]"
icon={Play}
onClick={() => playSound(enqueueFailureSound)}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="success-sound">Generation Success Sound</Label>
<div className="flex items-center gap-2">
<Select
id="success-sound"
value={generationSuccessSound}
onChange={(val: SelectValue) => setSuccessSound(val as string)}
options={SoundManager.OPTIONS}
className="grow"
/>
<Button
variant="primary"
className="w-[40px] h-[40px]"
icon={Play}
onClick={() => playSound(generationSuccessSound)}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="failure-sound">Generation Failure Sound</Label>
<div className="flex items-center gap-2">
<Select
id="failure-sound"
value={generationFailureSound}
onChange={(val: SelectValue) => setFailureSound(val as string)}
options={SoundManager.OPTIONS}
className="grow"
/>
<Button
variant="primary"
className="w-[40px] h-[40px]"
icon={Play}
onClick={() => playSound(generationFailureSound)}
/>
</div>
</div>
{SOUND_EVENTS.map(({ event, label }) => (
<SoundEventRow
key={event}
event={event}
label={label}
sound={sounds?.[event]}
onSoundsChanged={setSounds}
/>
))}
</div>
</>
);
};
// The dropdown only knows catalog keys; anything else (silent, or a custom
// .wav the UI can't pick yet) shows as "None (Silent)".
const orNone = (val: AppSoundFile | undefined | null): string => {
if (!val || typeof val !== "string") {
return "none";
}
return val;
// ── One event's sound picker ──
interface SoundEventRowProps {
event: AppSoundEvent;
label: string;
sound: AppSoundFile | undefined;
onSoundsChanged: (sounds: AppSoundPreferences) => void;
}
const SoundEventRow = ({ event, label, sound, onSoundsChanged }: SoundEventRowProps) => {
const [error, setError] = useState<string | undefined>(undefined);
// The dropdown lists the presets; when a custom file is set, it also shows
// that file's full path as the selected entry.
const customPath = isCustomWavSound(sound) ? sound.custom_wav : undefined;
const selectedValue = customPath ?? presetValue(sound);
const options: SoundOption[] = customPath
? [{ value: customPath, label: customPath }, ...SoundManager.OPTIONS]
: SoundManager.OPTIONS;
const save = async (next: AppSoundFile | undefined) => {
setError(undefined);
try {
const result = await UpdateSoundPreference({ event, sound: next });
onSoundsChanged(result.sounds);
SoundManager.playPreview(next, event);
} catch (err) {
setError(String(err));
}
};
const resetToDefault = async () => {
setError(undefined);
try {
const result = await ResetSoundPreference(event);
onSoundsChanged(result.sounds);
SoundManager.playPreview(result.sounds[event], event);
} catch (err) {
setError(String(err));
}
};
// Picking a preset or "None" from the dropdown replaces a custom file too.
const onSelect = async (val: SelectValue) => {
const value = String(val);
if (value === customPath) return; // Re-selected the current custom file.
await save(value === SILENT_SOUND ? undefined : value);
};
const chooseCustomFile = async () => {
const selected = await open({
multiple: false,
directory: false,
title: `Choose a .wav file for: ${label}`,
filters: [{ name: "WAV audio", extensions: ["wav"] }],
});
if (selected === null) {
return; // User dismissed the dialog.
}
await save({ custom_wav: selected });
};
const inputId = `${event}-sound`;
return (
<div className="space-y-1">
<Label htmlFor={inputId}>{label}</Label>
<div className="flex items-center gap-2">
<Select
id={inputId}
value={selectedValue}
onChange={onSelect}
options={options}
className="grow min-w-0"
/>
<Button
variant="primary"
className="w-[40px] h-[40px]"
icon={Play}
title="Preview"
onClick={() => SoundManager.playPreview(sound, event)}
/>
<Button
variant="secondary"
className="w-[40px] h-[40px]"
icon={FolderOpen}
title="Use a custom .wav file"
onClick={chooseCustomFile}
/>
<Button
variant="secondary"
className="w-[40px] h-[40px]"
icon={RotateCcw}
title="Reset to default"
onClick={resetToDefault}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
);
};
// 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;
};

View File

@ -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<AppSoundEvent, CachedSound>();
// 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<void> {
const howl = await this.load(event, path);
howl?.play();
}
private static async load(event: AppSoundEvent, path: string): Promise<Howl | undefined> {
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;
}
}

View File

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

View File

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

View File

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

View File

@ -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<ArrayBuffer> => {
let result = await invoke("load_custom_sound_command", { event });
return (result as ArrayBuffer);
}

View File

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

View File

@ -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<UpdateSoundPreferenceResult> => {
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<UpdateSoundPreferenceResult> => {
let result = await invoke("update_sound_preference_command", {
request: {
action: "reset_to_default",
event,
}
});
return (result as UpdateSoundPreferenceResult);
}