diff --git a/Cargo.lock b/Cargo.lock index a6d9823729..8e95a33541 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -704,6 +704,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-global-shortcut", "tauri-plugin-single-instance", "tokio", @@ -3009,6 +3010,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -3970,6 +3972,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4857,6 +4883,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-global-shortcut" version = "2.3.1" @@ -5227,6 +5295,21 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", +] + [[package]] name = "toml_datetime" version = "0.6.3" diff --git a/apps/desktop-tauri/package.json b/apps/desktop-tauri/package.json index 02be55aff8..19ffd8ebb8 100644 --- a/apps/desktop-tauri/package.json +++ b/apps/desktop-tauri/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@tauri-apps/api": "2.10.1", + "@tauri-apps/plugin-dialog": "2.7.2", "react": "18.3.1", "react-dom": "18.3.1" }, diff --git a/apps/desktop-tauri/pnpm-lock.yaml b/apps/desktop-tauri/pnpm-lock.yaml index 214e0573b0..29854f4e1a 100644 --- a/apps/desktop-tauri/pnpm-lock.yaml +++ b/apps/desktop-tauri/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@tauri-apps/api': specifier: 2.10.1 version: 2.10.1 + '@tauri-apps/plugin-dialog': + specifier: 2.7.2 + version: 2.7.2 react: specifier: 18.3.1 version: 18.3.1 @@ -488,6 +491,9 @@ packages: '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + '@tauri-apps/cli-darwin-arm64@2.10.1': resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} engines: {node: '>= 10'} @@ -564,6 +570,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-dialog@2.7.2': + resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1564,6 +1573,8 @@ snapshots: '@tauri-apps/api@2.10.1': {} + '@tauri-apps/api@2.11.1': {} + '@tauri-apps/cli-darwin-arm64@2.10.1': optional: true @@ -1611,6 +1622,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1 + '@tauri-apps/plugin-dialog@2.7.2': + dependencies: + '@tauri-apps/api': 2.11.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 diff --git a/apps/desktop-tauri/src-tauri/Cargo.toml b/apps/desktop-tauri/src-tauri/Cargo.toml index 02030cfc8c..9fa3d88fcf 100644 --- a/apps/desktop-tauri/src-tauri/Cargo.toml +++ b/apps/desktop-tauri/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tauri = { version = "2", features = ["image-png", "tray-icon"] } tauri-plugin-global-shortcut = "2" +tauri-plugin-dialog = "2.7.2" tauri-plugin-single-instance = "2" tokio = { version = "1", features = ["rt", "time", "sync", "net", "io-util"] } tracing = "0.1" diff --git a/apps/desktop-tauri/src-tauri/capabilities/notification-sound-dialog.json b/apps/desktop-tauri/src-tauri/capabilities/notification-sound-dialog.json new file mode 100644 index 0000000000..d29910eb58 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/capabilities/notification-sound-dialog.json @@ -0,0 +1,6 @@ +{ + "identifier": "notification-sound-dialog", + "description": "Allows the settings window to select custom notification WAV files.", + "windows": ["main", "settings"], + "permissions": ["dialog:allow-open"] +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index c1734c38e8..e15a195804 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -462,7 +462,8 @@ pub struct SettingsSnapshot { start_minimized: bool, show_notifications: bool, sound_enabled: bool, - sound_volume: u8, + notification_sound_theme: codexbar::settings::NotificationSoundTheme, + notification_sound_paths: codexbar::settings::NotificationSoundPaths, high_usage_threshold: f64, critical_usage_threshold: f64, provider_usage_thresholds: @@ -566,7 +567,8 @@ impl From for SettingsSnapshot { start_minimized: settings.start_minimized, show_notifications: settings.show_notifications, sound_enabled: settings.sound_enabled, - sound_volume: settings.sound_volume, + notification_sound_theme: settings.notification_sound_theme, + notification_sound_paths: settings.notification_sound_paths, high_usage_threshold: settings.high_usage_threshold, critical_usage_threshold: settings.critical_usage_threshold, provider_usage_thresholds: settings.provider_usage_thresholds, diff --git a/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs b/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs index 09cc76fb47..4d895b445e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs @@ -178,12 +178,21 @@ mod locale_tests { "chinesetraditional", "japanese", "korean", - "spanish" + "spanish", + "russian" ] ); assert_eq!( displays, - vec!["English", "中文", "繁體中文", "日本語", "한국어", "Español"] + vec![ + "English", + "中文", + "繁體中文", + "日本語", + "한국어", + "Español", + "Русский" + ] ); } diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 070059c67b..2f4979af1b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -15,7 +15,8 @@ pub struct SettingsUpdate { pub start_minimized: Option, pub show_notifications: Option, pub sound_enabled: Option, - pub sound_volume: Option, + pub notification_sound_theme: Option, + pub notification_sound_paths: Option, pub high_usage_threshold: Option, pub critical_usage_threshold: Option, pub provider_usage_thresholds: @@ -212,15 +213,23 @@ impl SettingsUpdate { self } - fn apply_notification_settings(self, settings: &mut Settings) -> Self { + fn apply_notification_settings(self, settings: &mut Settings) -> Result { if let Some(v) = self.show_notifications { settings.show_notifications = v; } if let Some(v) = self.sound_enabled { settings.sound_enabled = v; } - if let Some(v) = self.sound_volume { - settings.sound_volume = v; + if let Some(v) = self.notification_sound_theme { + settings.notification_sound_theme = v; + } + if let Some(v) = self.notification_sound_paths.clone() { + codexbar::sound::validate_custom_sound_path_updates( + &settings.notification_sound_paths, + &v, + ) + .map_err(|error| error.to_string())?; + settings.notification_sound_paths = v; } if let Some(v) = self.high_usage_threshold { settings.high_usage_threshold = v.clamp(0.0, 100.0); @@ -235,7 +244,7 @@ impl SettingsUpdate { if let Some(v) = self.predictive_pace_warning_enabled { settings.predictive_pace_warning_enabled = v; } - self + Ok(self) } fn apply_advanced_settings(self, settings: &mut Settings) -> Self { @@ -334,7 +343,7 @@ impl SettingsUpdate { self.apply_provider_settings(settings) .apply_general_settings(settings)? .apply_display_settings(settings) - .apply_notification_settings(settings) + .apply_notification_settings(settings)? .apply_advanced_settings(settings); float_bar_patch.apply(settings); Ok(float_bar_patch) @@ -545,4 +554,40 @@ mod tests { .apply_display_settings(&mut settings); assert_eq!(settings.tray_scale_percent, 100); } + + #[test] + fn apply_notification_settings_updates_sound() { + let mut settings = Settings::default(); + + SettingsUpdate { + notification_sound_theme: Some(codexbar::settings::NotificationSoundTheme::CodexBar), + ..Default::default() + } + .apply_notification_settings(&mut settings) + .expect("apply sound theme"); + + assert_eq!( + settings.notification_sound_theme, + codexbar::settings::NotificationSoundTheme::CodexBar + ); + } + + #[test] + fn apply_notification_settings_rejects_invalid_custom_sound() { + let mut settings = Settings::default(); + let result = SettingsUpdate { + notification_sound_paths: Some(codexbar::settings::NotificationSoundPaths { + high_usage: Some("relative.wav".to_string()), + ..Default::default() + }), + ..Default::default() + } + .apply_notification_settings(&mut settings); + + assert!(result.is_err()); + assert_eq!( + settings.notification_sound_paths, + codexbar::settings::NotificationSoundPaths::default() + ); + } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index d9188aac37..5df7c39da7 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -171,11 +171,12 @@ pub fn get_work_area_rect(app: tauri::AppHandle) -> Result // ── Misc UX ──────────────────────────────────────────────────────────── #[tauri::command] -pub fn play_notification_sound() -> Result<(), String> { - // Use the shared sound helper, honouring the user's `sound_enabled` flag. +pub fn play_notification_sound( + event: codexbar::sound::NotificationSoundEvent, +) -> Result<(), String> { + // Preview through the same settings resolution path used by real notifications. let settings = Settings::load(); - codexbar::sound::play_alert(codexbar::sound::AlertSound::Success, &settings); - Ok(()) + codexbar::sound::play_alert(event, &settings).map_err(|error| error.to_string()) } /// Reposition the flyout window so its bottom-right corner stays anchored to diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index aa5d09fd22..3ec981376e 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -127,6 +127,7 @@ fn main() { tauri::Builder::default() .manage(Mutex::new(initial_state)) .plugin(shortcut_bridge::plugin()) + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { if should_reopen_primary_window_from_instance_args(args.iter().skip(1)) { let request = primary_window_request(); diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 2c8e3e8eed..b72c7eb405 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -69,7 +69,16 @@ function settings(overrides: Partial = {}): SettingsSnapshot { startMinimized: false, showNotifications: true, soundEnabled: true, - soundVolume: 100, + notificationSoundTheme: "windows", + notificationSoundPaths: { + predictiveWarning: null, + highUsage: null, + criticalUsage: null, + exhausted: null, + statusIssue: null, + sessionDepleted: null, + sessionRestored: null, + }, highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, diff --git a/apps/desktop-tauri/src/components/FormControls.tsx b/apps/desktop-tauri/src/components/FormControls.tsx index bf016a8bf5..cf1df7a469 100644 --- a/apps/desktop-tauri/src/components/FormControls.tsx +++ b/apps/desktop-tauri/src/components/FormControls.tsx @@ -42,15 +42,21 @@ export function Select({ onChange, disabled, ariaLabel, + minWidth, }: { value: string; options: { value: string; label: string }[]; onChange: (v: string) => void; disabled?: boolean; ariaLabel?: string; + minWidth?: number; }) { const selectedLabel = options.find((option) => option.value === value)?.label ?? value; - const width = Math.min(128, Math.max(48, Math.ceil((selectedLabel ?? "").length * 6.8) + 18)); + const calculatedWidth = Math.min( + 128, + Math.max(48, Math.ceil((selectedLabel ?? "").length * 6.8) + 18), + ); + const width = Math.max(calculatedWidth, minWidth ?? 0); return ( ({ + value: option.value, + label: t(option.labelKey), + }))} + onChange={(value) => { + if (isNotificationSoundTheme(value)) { + set({ notificationSoundTheme: value }); + } + }} + /> + + {NOTIFICATION_SOUND_EVENTS.map((sound) => { + const path = settings.notificationSoundPaths[sound.pathKey]; + const label = t(sound.labelKey); + return ( + +
+ + + {path && ( + + )} +
+
+ ); + })} + {soundError && ( +

+ {soundError} +

+ )} + )}
diff --git a/apps/desktop-tauri/src/types/bridge.test.ts b/apps/desktop-tauri/src/types/bridge.test.ts index e3a8ec9815..3845d01b25 100644 --- a/apps/desktop-tauri/src/types/bridge.test.ts +++ b/apps/desktop-tauri/src/types/bridge.test.ts @@ -56,7 +56,16 @@ describe("Language type", () => { startMinimized: false, showNotifications: true, soundEnabled: true, - soundVolume: 100, + notificationSoundTheme: "windows", + notificationSoundPaths: { + predictiveWarning: null, + highUsage: null, + criticalUsage: null, + exhausted: null, + statusIssue: null, + sessionDepleted: null, + sessionRestored: null, + }, highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 5b9ac4fc4e..b3fd3d5d64 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -14,6 +14,27 @@ export type SettingsTabId = export type TrayIconMode = "single" | "perProvider"; +export type NotificationSoundTheme = "windows" | "codexBar"; + +export type NotificationSoundEvent = + | "predictiveWarning" + | "highUsage" + | "criticalUsage" + | "exhausted" + | "statusIssue" + | "sessionDepleted" + | "sessionRestored"; + +export interface NotificationSoundPaths { + predictiveWarning: string | null; + highUsage: string | null; + criticalUsage: string | null; + exhausted: string | null; + statusIssue: string | null; + sessionDepleted: string | null; + sessionRestored: string | null; +} + export type MetricPreference = | "automatic" | "session" @@ -140,7 +161,8 @@ export interface SettingsSnapshot { startMinimized: boolean; showNotifications: boolean; soundEnabled: boolean; - soundVolume: number; + notificationSoundTheme: NotificationSoundTheme; + notificationSoundPaths: NotificationSoundPaths; highUsageThreshold: number; criticalUsageThreshold: number; providerUsageThresholds?: Record; @@ -219,7 +241,8 @@ export interface SettingsUpdate { startMinimized?: boolean; showNotifications?: boolean; soundEnabled?: boolean; - soundVolume?: number; + notificationSoundTheme?: NotificationSoundTheme; + notificationSoundPaths?: NotificationSoundPaths; highUsageThreshold?: number; criticalUsageThreshold?: number; providerUsageThresholds?: Record; diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 1b5299c47a..c1cf426077 100755 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -88,6 +88,7 @@ windows = { version = "0.58", features = [ "Win32_Foundation", "Win32_Security_Credentials", "Win32_Security_Cryptography", + "Win32_Media_Audio", "Win32_UI_WindowsAndMessaging", ] } diff --git a/rust/assets/sounds/README.md b/rust/assets/sounds/README.md new file mode 100644 index 0000000000..743f51645e --- /dev/null +++ b/rust/assets/sounds/README.md @@ -0,0 +1,21 @@ +# CodexBar notification sounds + +These original notification sounds were created for CodexBar with deterministic +procedural DSP. They contain no sampled or third-party audio and are distributed +under the repository's MIT License. + +The source synthesis used a fixed random seed of 7291 with NumPy and SciPy. +Otoforge performed the final EBU R128 loudness normalization and true-peak +validation. All files are 48 kHz, stereo, 16-bit PCM WAV files. Critical usage is +intentionally louder than ordinary notifications, while the exhausted cue is +compensated for its lower-frequency content. + +| File | Intended meaning | Duration | Integrated loudness | SHA-256 | +| --- | --- | ---: | ---: | --- | +| `predictive-warning.wav` | Clock-like onset and two rising tones for an early forecast | 1.00 s | -18.0 LUFS | `10301f4def1113f26d8b1366d64f62b6ee5ed33ed6d001e6e32e3a8667e1ec91` | +| `high-usage.wav` | Two equal meter-warning pulses | 0.82 s | -18.0 LUFS | `6629ec0ce14e531b70b900af78495c3c071044fea2bbf1ec09aca31b86d7ba29` | +| `critical-usage.wav` | Four rapid alternating alarm pulses and a firm final pulse | 0.84 s | -15.0 LUFS | `75aa34ac08ccb2d430cd778452e4f376f8953f134b3200fed4793ad367af3469` | +| `exhausted.wav` | A restrained cutoff followed by a low terminal chord | 1.10 s | -16.0 LUFS | `ebeb98efada1f8e61446811887b4679e950e3fa9c4e2498f2514ed3a5db3c2b3` | +| `status-issue.wav` | A data glitch and dissonant interval for a provider fault | 0.90 s | -18.0 LUFS | `6b9d4340a791d441d10977907e3d8010cd59a15d7a34369ab8a01852bbdf0c46` | +| `session-depleted.wav` | A low-battery pattern for temporary session depletion | 1.06 s | -18.0 LUFS | `10ef21a5c1f5dca9245669c640e5b49cd628abfca3dc1e1c836853ce26383ade` | +| `session-restored.wav` | A bright ascending completion arpeggio | 1.05 s | -18.0 LUFS | `ac653f8096ce87ad2405c1491f7ea20a62464ab2638575d2d6c5c6a68d3f04d6` | diff --git a/rust/assets/sounds/critical-usage.wav b/rust/assets/sounds/critical-usage.wav new file mode 100644 index 0000000000..ac6d9c1ba1 Binary files /dev/null and b/rust/assets/sounds/critical-usage.wav differ diff --git a/rust/assets/sounds/exhausted.wav b/rust/assets/sounds/exhausted.wav new file mode 100644 index 0000000000..f287b00517 Binary files /dev/null and b/rust/assets/sounds/exhausted.wav differ diff --git a/rust/assets/sounds/high-usage.wav b/rust/assets/sounds/high-usage.wav new file mode 100644 index 0000000000..e31fb19198 Binary files /dev/null and b/rust/assets/sounds/high-usage.wav differ diff --git a/rust/assets/sounds/predictive-warning.wav b/rust/assets/sounds/predictive-warning.wav new file mode 100644 index 0000000000..a4c7ba7633 Binary files /dev/null and b/rust/assets/sounds/predictive-warning.wav differ diff --git a/rust/assets/sounds/session-depleted.wav b/rust/assets/sounds/session-depleted.wav new file mode 100644 index 0000000000..ae1e1c9767 Binary files /dev/null and b/rust/assets/sounds/session-depleted.wav differ diff --git a/rust/assets/sounds/session-restored.wav b/rust/assets/sounds/session-restored.wav new file mode 100644 index 0000000000..5a49d81f83 Binary files /dev/null and b/rust/assets/sounds/session-restored.wav differ diff --git a/rust/assets/sounds/status-issue.wav b/rust/assets/sounds/status-issue.wav new file mode 100644 index 0000000000..9c7a67d662 Binary files /dev/null and b/rust/assets/sounds/status-issue.wav differ diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 7cd0602393..28f9a7356c 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -188,7 +188,28 @@ locale_keys! { // Notification settings (Preferences) ShowNotifications, SoundEnabled, - SoundVolume, + NotificationSoundTheme, + NotificationSoundThemeHelper, + NotificationSoundThemeWindows, + NotificationSoundThemeCodexBar, + NotificationSoundChooseFile, + NotificationSoundClearFile, + NotificationSoundUsesTheme, + NotificationSoundWaveFile, + NotificationSoundEventPredictiveWarning, + NotificationSoundEventPredictiveWarningHelper, + NotificationSoundEventHighUsage, + NotificationSoundEventHighUsageHelper, + NotificationSoundEventCriticalUsage, + NotificationSoundEventCriticalUsageHelper, + NotificationSoundEventExhausted, + NotificationSoundEventExhaustedHelper, + NotificationSoundEventStatusIssue, + NotificationSoundEventStatusIssueHelper, + NotificationSoundEventSessionDepleted, + NotificationSoundEventSessionDepletedHelper, + NotificationSoundEventSessionRestored, + NotificationSoundEventSessionRestoredHelper, HighUsageThreshold, HighUsageAlert, CriticalUsageThreshold, @@ -635,7 +656,6 @@ locale_keys! { RefreshIntervalHelper, RefreshAllProvidersOnMenuOpen, RefreshAllProvidersOnMenuOpenHelper, - SoundVolumeHelper, HighUsageWarningHelper, CriticalUsageWarningHelper, GlobalShortcutFieldLabel, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index cea035ab8b..360fb1467a 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -21,7 +21,28 @@ HighUsageThresholdHelper = Show warning at this usage level CriticalUsageThresholdHelper = Show critical alert at this level ShowNotifications = Show Notifications SoundEnabled = Sound Alerts -SoundVolume = Alert Volume +NotificationSoundTheme = Default sound set +NotificationSoundThemeHelper = Used when no custom WAV file is assigned. +NotificationSoundThemeWindows = Windows system sounds +NotificationSoundThemeCodexBar = CodexBar distinct sounds +NotificationSoundChooseFile = Choose WAV +NotificationSoundClearFile = Clear +NotificationSoundUsesTheme = Uses the selected sound set +NotificationSoundWaveFile = WAV audio +NotificationSoundEventPredictiveWarning = Predictive pace warning +NotificationSoundEventPredictiveWarningHelper = Quota may run out before reset. +NotificationSoundEventHighUsage = High usage +NotificationSoundEventHighUsageHelper = The high usage threshold is reached. +NotificationSoundEventCriticalUsage = Critical usage +NotificationSoundEventCriticalUsageHelper = The critical usage threshold is reached. +NotificationSoundEventExhausted = Usage exhausted +NotificationSoundEventExhaustedHelper = Usage reaches 100%. +NotificationSoundEventStatusIssue = Provider status issue +NotificationSoundEventStatusIssueHelper = A provider reports an outage or error. +NotificationSoundEventSessionDepleted = Session depleted +NotificationSoundEventSessionDepletedHelper = A session quota reaches 100%. +NotificationSoundEventSessionRestored = Session restored +NotificationSoundEventSessionRestoredHelper = A depleted session becomes available again. HighUsageThreshold = High Usage Threshold HighUsageAlert = High Usage Alert CriticalUsageThreshold = Critical Usage Threshold @@ -379,7 +400,6 @@ RefreshIntervalLabel = Refresh interval RefreshIntervalHelper = Seconds between automatic provider refreshes (0 = manual). RefreshAllProvidersOnMenuOpen = Refresh on menu open RefreshAllProvidersOnMenuOpenHelper = Force-refresh enabled providers whenever the tray menu opens. -SoundVolumeHelper = Volume for threshold alert sounds (0–100). HighUsageWarningHelper = Show a warning when usage exceeds this percentage. CriticalUsageWarningHelper = Show a critical alert when usage exceeds this percentage. GlobalShortcutFieldLabel = Global shortcut diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index fc6e81096e..08b4e6e0c9 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -21,7 +21,28 @@ HighUsageThresholdHelper = Mostrar advertencia en este nivel de uso CriticalUsageThresholdHelper = Mostrar alerta crítica en este nivel ShowNotifications = Mostrar notificaciones SoundEnabled = Alertas sonoras -SoundVolume = Volumen de alerta +NotificationSoundTheme = Conjunto de sonidos predeterminado +NotificationSoundThemeHelper = Se usa cuando no se asigna un archivo WAV personalizado. +NotificationSoundThemeWindows = Sonidos del sistema de Windows +NotificationSoundThemeCodexBar = Sonidos distintivos de CodexBar +NotificationSoundChooseFile = Elegir WAV +NotificationSoundClearFile = Quitar +NotificationSoundUsesTheme = Usa el conjunto de sonidos seleccionado +NotificationSoundWaveFile = Audio WAV +NotificationSoundEventPredictiveWarning = Alerta de ritmo predictivo +NotificationSoundEventPredictiveWarningHelper = La cuota podría agotarse antes del reinicio. +NotificationSoundEventHighUsage = Uso alto +NotificationSoundEventHighUsageHelper = Se alcanzó el umbral de uso alto. +NotificationSoundEventCriticalUsage = Uso crítico +NotificationSoundEventCriticalUsageHelper = Se alcanzó el umbral de uso crítico. +NotificationSoundEventExhausted = Uso agotado +NotificationSoundEventExhaustedHelper = El uso llegó al 100%. +NotificationSoundEventStatusIssue = Problema del proveedor +NotificationSoundEventStatusIssueHelper = Un proveedor informa una interrupción o un error. +NotificationSoundEventSessionDepleted = Sesión agotada +NotificationSoundEventSessionDepletedHelper = La cuota de una sesión llegó al 100%. +NotificationSoundEventSessionRestored = Sesión restaurada +NotificationSoundEventSessionRestoredHelper = Una sesión agotada vuelve a estar disponible. HighUsageThreshold = Umbral de uso alto HighUsageAlert = Alerta de uso alto CriticalUsageThreshold = Umbral de uso crítico @@ -378,7 +399,6 @@ RefreshInterval30Min = 30 minutos RefreshInterval1Hour = 1 hora RefreshAllProvidersOnMenuOpen = Actualizar al abrir el menú RefreshAllProvidersOnMenuOpenHelper = Fuerza la actualización de proveedores habilitados cada vez que se abre el menú de bandeja. -SoundVolumeHelper = Volumen para sonidos de alerta de umbral (0–100). HighUsageWarningHelper = Mostrar una advertencia cuando el uso exceda este porcentaje. CriticalUsageWarningHelper = Mostrar una alerta crítica cuando el uso exceda este porcentaje. GlobalShortcutFieldLabel = Atajo global diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 76dc028e9d..440e4d9da7 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -21,7 +21,28 @@ HighUsageThresholdHelper = この使用量で警告を表示 CriticalUsageThresholdHelper = このレベルで重大なアラートを表示 ShowNotifications = 通知を表示 SoundEnabled = サウンド通知 -SoundVolume = 通知音量 +NotificationSoundTheme = 既定の通知音セット +NotificationSoundThemeHelper = 独自の WAV ファイルを指定していない通知に使用します。 +NotificationSoundThemeWindows = Windows のシステム音 +NotificationSoundThemeCodexBar = CodexBar の識別音 +NotificationSoundChooseFile = WAV を選択 +NotificationSoundClearFile = 解除 +NotificationSoundUsesTheme = 選択中の通知音セットを使用 +NotificationSoundWaveFile = WAV 音声 +NotificationSoundEventPredictiveWarning = 予測ペース警告 +NotificationSoundEventPredictiveWarningHelper = リセット前に使用枠がなくなる見込みを通知します。 +NotificationSoundEventHighUsage = 使用量警告 +NotificationSoundEventHighUsageHelper = 使用量が警告しきい値に達したときに通知します。 +NotificationSoundEventCriticalUsage = 重大な使用量 +NotificationSoundEventCriticalUsageHelper = 使用量が重大通知のしきい値に達したときに通知します。 +NotificationSoundEventExhausted = 使用量上限到達 +NotificationSoundEventExhaustedHelper = 使用量が 100% に達したときに通知します。 +NotificationSoundEventStatusIssue = プロバイダー障害 +NotificationSoundEventStatusIssueHelper = プロバイダーで停止やエラーが発生したときに通知します。 +NotificationSoundEventSessionDepleted = セッション枯渇 +NotificationSoundEventSessionDepletedHelper = セッション使用量が 100% に達したときに通知します。 +NotificationSoundEventSessionRestored = セッション復旧 +NotificationSoundEventSessionRestoredHelper = 枯渇したセッションが再び利用可能になったときに通知します。 HighUsageThreshold = 高使用量しきい値 HighUsageAlert = 高使用量アラート CriticalUsageThreshold = 重大使用量しきい値 @@ -373,7 +394,6 @@ RefreshIntervalLabel = 更新間隔 RefreshIntervalHelper = プロバイダーを自動更新する間隔(秒)(0 = 手動)。 RefreshAllProvidersOnMenuOpen = メニュー表示時に更新 RefreshAllProvidersOnMenuOpenHelper = トレイメニューを開くたびに有効なプロバイダーを強制更新。 -SoundVolumeHelper = しきい値通知音の音量(0〜100)。 HighUsageWarningHelper = 使用量がこの割合を超えたら警告を表示。 CriticalUsageWarningHelper = 使用量がこの割合を超えたら重大アラートを表示。 GlobalShortcutFieldLabel = グローバルショートカット diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 7ea587819e..ee07730725 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -21,7 +21,28 @@ HighUsageThresholdHelper = 이 사용량 수준에서 경고 표시 CriticalUsageThresholdHelper = 이 수준에서 위험 경고 표시 ShowNotifications = 알림 표시 SoundEnabled = 소리 알림 -SoundVolume = 알림 볼륨 +NotificationSoundTheme = 기본 알림 소리 모음 +NotificationSoundThemeHelper = 사용자 지정 WAV 파일이 없는 알림에 사용합니다. +NotificationSoundThemeWindows = Windows 시스템 소리 +NotificationSoundThemeCodexBar = CodexBar 구분 소리 +NotificationSoundChooseFile = WAV 선택 +NotificationSoundClearFile = 해제 +NotificationSoundUsesTheme = 선택한 소리 모음 사용 +NotificationSoundWaveFile = WAV 오디오 +NotificationSoundEventPredictiveWarning = 예측 속도 경고 +NotificationSoundEventPredictiveWarningHelper = 재설정 전에 할당량이 소진될 수 있을 때 알립니다. +NotificationSoundEventHighUsage = 높은 사용량 +NotificationSoundEventHighUsageHelper = 높은 사용량 임계값에 도달했을 때 알립니다. +NotificationSoundEventCriticalUsage = 위험 사용량 +NotificationSoundEventCriticalUsageHelper = 위험 사용량 임계값에 도달했을 때 알립니다. +NotificationSoundEventExhausted = 사용량 소진 +NotificationSoundEventExhaustedHelper = 사용량이 100%에 도달했을 때 알립니다. +NotificationSoundEventStatusIssue = 공급자 상태 문제 +NotificationSoundEventStatusIssueHelper = 공급자 중단 또는 오류가 발생했을 때 알립니다. +NotificationSoundEventSessionDepleted = 세션 소진 +NotificationSoundEventSessionDepletedHelper = 세션 할당량이 100%에 도달했을 때 알립니다. +NotificationSoundEventSessionRestored = 세션 복구 +NotificationSoundEventSessionRestoredHelper = 소진된 세션을 다시 사용할 수 있을 때 알립니다. HighUsageThreshold = 높은 사용량 임계값 HighUsageAlert = 높은 사용량 경고 CriticalUsageThreshold = 위험 사용량 임계값 @@ -378,7 +399,6 @@ RefreshInterval30Min = 30분 RefreshInterval1Hour = 1시간 RefreshAllProvidersOnMenuOpen = 메뉴를 열 때 새로고침 RefreshAllProvidersOnMenuOpenHelper = 트레이 메뉴를 열 때마다 활성화된 제공업체를 강제로 새로고침합니다. -SoundVolumeHelper = 임계값 경고 소리 볼륨 (0~100). HighUsageWarningHelper = 사용량이 이 백분율을 초과하면 경고를 표시합니다. CriticalUsageWarningHelper = 사용량이 이 백분율을 초과하면 위험 경고를 표시합니다. GlobalShortcutFieldLabel = 글로벌 단축키 diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index c75bf098ed..d2d4dfa562 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -21,7 +21,6 @@ HighUsageThresholdHelper = Показывать предупреждение н CriticalUsageThresholdHelper = Показывать критическое оповещение на этом уровне ShowNotifications = Показать уведомления SoundEnabled = Звуковые оповещения -SoundVolume = Громкость оповещения HighUsageThreshold = Высокий порог использования HighUsageAlert = Оповещение о высокой загрузке CriticalUsageThreshold = Критический порог использования @@ -379,7 +378,6 @@ RefreshIntervalLabel = Интервал обновления RefreshIntervalHelper = Секунды между автоматическими обновлениями провайдера (0 = вручную). RefreshAllProvidersOnMenuOpen = Обновить при открытии меню RefreshAllProvidersOnMenuOpenHelper = Провайдеры с включенным принудительным обновлением всякий раз, когда открывается меню на панели задач. -SoundVolumeHelper = Громкость звуков порогового оповещения (0–100). HighUsageWarningHelper = Показывать предупреждение, когда использование превышает этот процент. CriticalUsageWarningHelper = Показывать критическое предупреждение, когда использование превышает этот процент. GlobalShortcutFieldLabel = Глобальный ярлык diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index a5c94dc82e..2459738d7c 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -21,7 +21,28 @@ HighUsageThresholdHelper = 在该用量水平显示预警 CriticalUsageThresholdHelper = 在该水平显示严重告警 ShowNotifications = 显示通知 SoundEnabled = 声音提示 -SoundVolume = 提示音音量 +NotificationSoundTheme = 默认通知声音组 +NotificationSoundThemeHelper = 用于未指定自定义 WAV 文件的通知。 +NotificationSoundThemeWindows = Windows 系统声音 +NotificationSoundThemeCodexBar = CodexBar 区分声音 +NotificationSoundChooseFile = 选择 WAV +NotificationSoundClearFile = 清除 +NotificationSoundUsesTheme = 使用所选声音组 +NotificationSoundWaveFile = WAV 音频 +NotificationSoundEventPredictiveWarning = 预测进度警告 +NotificationSoundEventPredictiveWarningHelper = 配额可能在重置前耗尽时通知。 +NotificationSoundEventHighUsage = 高使用量 +NotificationSoundEventHighUsageHelper = 达到高使用量阈值时通知。 +NotificationSoundEventCriticalUsage = 严重使用量 +NotificationSoundEventCriticalUsageHelper = 达到严重使用量阈值时通知。 +NotificationSoundEventExhausted = 使用量耗尽 +NotificationSoundEventExhaustedHelper = 使用量达到 100% 时通知。 +NotificationSoundEventStatusIssue = 提供商状态问题 +NotificationSoundEventStatusIssueHelper = 提供商发生中断或错误时通知。 +NotificationSoundEventSessionDepleted = 会话配额耗尽 +NotificationSoundEventSessionDepletedHelper = 会话配额达到 100% 时通知。 +NotificationSoundEventSessionRestored = 会话恢复 +NotificationSoundEventSessionRestoredHelper = 已耗尽的会话恢复可用时通知。 HighUsageThreshold = 高用量阈值 HighUsageAlert = 高位预警 CriticalUsageThreshold = 紧急用量阈值 @@ -373,7 +394,6 @@ RefreshIntervalLabel = 刷新间隔 RefreshIntervalHelper = 两次自动刷新之间的秒数(0 = 手动)。 RefreshAllProvidersOnMenuOpen = 打开菜单时刷新 RefreshAllProvidersOnMenuOpenHelper = 每次打开托盘菜单时强制刷新已启用的提供商。 -SoundVolumeHelper = 阈值告警音量(0–100)。 HighUsageWarningHelper = 当用量超过该百分比时显示预警。 CriticalUsageWarningHelper = 当用量超过该百分比时显示严重告警。 GlobalShortcutFieldLabel = 全局快捷键 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index ecd54db806..abcc7bef62 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -21,7 +21,28 @@ HighUsageThresholdHelper = 在該用量水平顯示預警 CriticalUsageThresholdHelper = 在該水平顯示嚴重告警 ShowNotifications = 顯示通知 SoundEnabled = 聲音提示 -SoundVolume = 提示音音量 +NotificationSoundTheme = 預設通知聲音組 +NotificationSoundThemeHelper = 用於未指定自訂 WAV 檔案的通知。 +NotificationSoundThemeWindows = Windows 系統聲音 +NotificationSoundThemeCodexBar = CodexBar 識別聲音 +NotificationSoundChooseFile = 選擇 WAV +NotificationSoundClearFile = 清除 +NotificationSoundUsesTheme = 使用所選聲音組 +NotificationSoundWaveFile = WAV 音訊 +NotificationSoundEventPredictiveWarning = 預測進度警告 +NotificationSoundEventPredictiveWarningHelper = 配額可能在重設前用盡時通知。 +NotificationSoundEventHighUsage = 高使用量 +NotificationSoundEventHighUsageHelper = 達到高使用量門檻時通知。 +NotificationSoundEventCriticalUsage = 嚴重使用量 +NotificationSoundEventCriticalUsageHelper = 達到嚴重使用量門檻時通知。 +NotificationSoundEventExhausted = 使用量用盡 +NotificationSoundEventExhaustedHelper = 使用量達到 100% 時通知。 +NotificationSoundEventStatusIssue = 供應商狀態問題 +NotificationSoundEventStatusIssueHelper = 供應商發生中斷或錯誤時通知。 +NotificationSoundEventSessionDepleted = 工作階段配額用盡 +NotificationSoundEventSessionDepletedHelper = 工作階段配額達到 100% 時通知。 +NotificationSoundEventSessionRestored = 工作階段恢復 +NotificationSoundEventSessionRestoredHelper = 已用盡的工作階段恢復可用時通知。 HighUsageThreshold = 高用量臨界值 HighUsageAlert = 高位預警 CriticalUsageThreshold = 緊急用量臨界值 @@ -373,7 +394,6 @@ RefreshIntervalLabel = 重新整理間隔 RefreshIntervalHelper = 兩次自動重新整理之間的秒數(0 = 手動)。 RefreshAllProvidersOnMenuOpen = 開啟選單時重新整理 RefreshAllProvidersOnMenuOpenHelper = 每次開啟系統匣選單時強制重新整理已啟用的提供商。 -SoundVolumeHelper = 臨界值告警音量(0–100)。 HighUsageWarningHelper = 當用量超過該百分比時顯示預警。 CriticalUsageWarningHelper = 當用量超過該百分比時顯示嚴重告警。 GlobalShortcutFieldLabel = 全域性快捷鍵 diff --git a/rust/src/notifications.rs b/rust/src/notifications.rs index 1c4520c25b..cae6dbd301 100755 --- a/rust/src/notifications.rs +++ b/rust/src/notifications.rs @@ -8,7 +8,7 @@ use crate::core::ProviderId; use crate::core::{RateWindow, UsagePace}; use crate::locale::{self, LocaleKey}; use crate::settings::Settings; -use crate::sound::{AlertSound, play_alert}; +use crate::sound::{NotificationSoundEvent, play_alert}; use chrono::{DateTime, Utc}; /// Notification types @@ -236,7 +236,7 @@ impl NotificationManager { &[&eta], ); self.show_toast(&title, &body); - play_alert(AlertSound::Warning, settings); + Self::play_notification_sound(NotificationSoundEvent::PredictiveWarning, settings); } /// Check usage and send notifications if thresholds are crossed. @@ -348,7 +348,7 @@ impl NotificationManager { provider.display_name() ); self.show_toast(title, &body); - play_alert(AlertSound::Error, settings); + Self::play_notification_sound(NotificationSoundEvent::SessionDepleted, settings); self.sent_notifications.insert(( provider, account.to_string(), @@ -372,7 +372,7 @@ impl NotificationManager { provider.display_name() ); self.show_toast(title, &body); - play_alert(AlertSound::Success, settings); + Self::play_notification_sound(NotificationSoundEvent::SessionRestored, settings); self.sent_notifications.remove(&depleted_key); } } @@ -394,7 +394,7 @@ impl NotificationManager { let title = notif_type.title(); let body = Self::notification_body(provider, window, used_percent, notif_type); self.show_toast(title, &body); - play_alert(Self::alert_sound_for(notif_type), settings); + Self::play_notification_sound(Self::sound_event_for(notif_type), settings); } fn window_label(window: &str) -> &str { @@ -438,14 +438,20 @@ impl NotificationManager { } } - fn alert_sound_for(notif_type: NotificationType) -> AlertSound { + fn sound_event_for(notif_type: NotificationType) -> NotificationSoundEvent { match notif_type { - NotificationType::HighUsage => AlertSound::Warning, - NotificationType::CriticalUsage => AlertSound::Critical, - NotificationType::Exhausted - | NotificationType::StatusIssue - | NotificationType::SessionDepleted => AlertSound::Error, - NotificationType::SessionRestored => AlertSound::Success, + NotificationType::HighUsage => NotificationSoundEvent::HighUsage, + NotificationType::CriticalUsage => NotificationSoundEvent::CriticalUsage, + NotificationType::Exhausted => NotificationSoundEvent::Exhausted, + NotificationType::StatusIssue => NotificationSoundEvent::StatusIssue, + NotificationType::SessionDepleted => NotificationSoundEvent::SessionDepleted, + NotificationType::SessionRestored => NotificationSoundEvent::SessionRestored, + } + } + + fn play_notification_sound(event: NotificationSoundEvent, settings: &Settings) { + if let Err(error) = play_alert(event, settings) { + tracing::warn!(?event, %error, "notification sound failed to play"); } } @@ -458,7 +464,7 @@ impl NotificationManager { let title = NotificationType::StatusIssue.title(); let body = format!("{}: {}", provider.display_name(), description); self.show_toast(title, &body); - play_alert(AlertSound::Error, settings); + Self::play_notification_sound(NotificationSoundEvent::StatusIssue, settings); } #[cfg(target_os = "windows")] @@ -494,7 +500,7 @@ impl NotificationManager { [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null $template = @' -{}{} +{}{} '@ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument $xml.LoadXml($template) @@ -602,6 +608,43 @@ mod tests { use crate::core::{PaceStage, RateWindow, UsagePace}; use chrono::{DateTime, Duration, Utc}; + #[test] + fn notification_types_map_to_their_sound_events() { + let mappings = [ + ( + NotificationType::HighUsage, + NotificationSoundEvent::HighUsage, + ), + ( + NotificationType::CriticalUsage, + NotificationSoundEvent::CriticalUsage, + ), + ( + NotificationType::Exhausted, + NotificationSoundEvent::Exhausted, + ), + ( + NotificationType::StatusIssue, + NotificationSoundEvent::StatusIssue, + ), + ( + NotificationType::SessionDepleted, + NotificationSoundEvent::SessionDepleted, + ), + ( + NotificationType::SessionRestored, + NotificationSoundEvent::SessionRestored, + ), + ]; + + for (notification_type, sound_event) in mappings { + assert_eq!( + NotificationManager::sound_event_for(notification_type), + sound_event + ); + } + } + fn pace(will_last_to_reset: bool, eta_seconds: Option) -> UsagePace { UsagePace { stage: PaceStage::Ahead, diff --git a/rust/src/settings.rs b/rust/src/settings.rs index b80d1ba4fd..62163381cc 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -62,8 +62,13 @@ pub struct Settings { /// Whether to play sound effects for threshold alerts pub sound_enabled: bool, - /// Sound volume for alerts (0-100) - pub sound_volume: u8, + /// Per-notification WAV files. Unassigned events use the selected sound theme. + #[serde(default)] + pub notification_sound_paths: NotificationSoundPaths, + + /// Sound theme used when an event has no custom WAV file. + #[serde(default)] + pub notification_sound_theme: NotificationSoundTheme, /// High usage threshold for warnings (percentage) pub high_usage_threshold: f64, @@ -419,7 +424,8 @@ impl Default for Settings { start_at_login: false, show_notifications: true, sound_enabled: true, - sound_volume: 100, + notification_sound_paths: NotificationSoundPaths::default(), + notification_sound_theme: NotificationSoundTheme::default(), high_usage_threshold: 70.0, critical_usage_threshold: 90.0, provider_usage_thresholds: HashMap::new(), diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index dce98ea538..5730142fad 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -23,7 +23,8 @@ pub(super) struct RawSettings { start_at_login: bool, show_notifications: bool, sound_enabled: bool, - sound_volume: u8, + notification_sound_paths: NotificationSoundPaths, + notification_sound_theme: NotificationSoundTheme, high_usage_threshold: f64, critical_usage_threshold: f64, provider_usage_thresholds: HashMap, @@ -175,7 +176,8 @@ impl Default for RawSettings { start_at_login: s.start_at_login, show_notifications: s.show_notifications, sound_enabled: s.sound_enabled, - sound_volume: s.sound_volume, + notification_sound_paths: s.notification_sound_paths, + notification_sound_theme: s.notification_sound_theme, high_usage_threshold: s.high_usage_threshold, critical_usage_threshold: s.critical_usage_threshold, provider_usage_thresholds: HashMap::new(), @@ -479,7 +481,8 @@ impl From for Settings { start_at_login: raw.start_at_login, show_notifications: raw.show_notifications, sound_enabled: raw.sound_enabled, - sound_volume: raw.sound_volume, + notification_sound_paths: raw.notification_sound_paths, + notification_sound_theme: raw.notification_sound_theme, high_usage_threshold: raw.high_usage_threshold, critical_usage_threshold: raw.critical_usage_threshold, provider_usage_thresholds: normalize_usage_threshold_overrides( diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index ae6b389f1f..11195edfb6 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -7,6 +7,14 @@ fn test_settings_default() { assert!(settings.enabled_providers.contains("codex")); assert_eq!(settings.refresh_interval_secs, 300); assert!(settings.show_notifications); + assert_eq!( + settings.notification_sound_paths, + NotificationSoundPaths::default() + ); + assert_eq!( + settings.notification_sound_theme, + NotificationSoundTheme::Windows + ); assert_eq!(settings.high_usage_threshold, 70.0); assert_eq!(settings.critical_usage_threshold, 90.0); assert!(!settings.show_reset_when_exhausted); @@ -16,6 +24,42 @@ fn test_settings_default() { assert!(settings.claude_daily_routines_usage_visible); } +#[test] +fn notification_sound_paths_round_trip_and_default_for_existing_settings() { + let settings = Settings { + notification_sound_theme: NotificationSoundTheme::CodexBar, + notification_sound_paths: NotificationSoundPaths { + critical_usage: Some(r"C:\sounds\critical.wav".to_string()), + ..NotificationSoundPaths::default() + }, + ..Settings::default() + }; + let json = serde_json::to_string(&settings).expect("serialize notification sound paths"); + assert!(json.contains("\"criticalUsage\":\"C:\\\\sounds\\\\critical.wav\"")); + + let loaded: Settings = + serde_json::from_str(&json).expect("deserialize notification sound paths"); + assert_eq!( + loaded.notification_sound_paths, + settings.notification_sound_paths + ); + assert_eq!( + loaded.notification_sound_theme, + NotificationSoundTheme::CodexBar + ); + + let legacy: Settings = serde_json::from_str(r#"{ "enabled_providers": [] }"#) + .expect("deserialize settings without notification sound paths"); + assert_eq!( + legacy.notification_sound_paths, + NotificationSoundPaths::default() + ); + assert_eq!( + legacy.notification_sound_theme, + NotificationSoundTheme::Windows + ); +} + #[test] fn promote_tray_icon_defaults_on_when_missing_from_disk() { let loaded: Settings = serde_json::from_str( @@ -261,7 +305,6 @@ fn float_bar_raw_clamps_out_of_range_opacity_on_load() { "start_at_login": false, "show_notifications": true, "sound_enabled": true, - "sound_volume": 100, "high_usage_threshold": 70.0, "critical_usage_threshold": 90.0, "merge_tray_icons": false, diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index 15eb3d9033..9d17beb07f 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -12,6 +12,30 @@ pub struct UsageThresholds { pub critical: f64, } +/// WAV files assigned to individual notification events. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct NotificationSoundPaths { + pub predictive_warning: Option, + pub high_usage: Option, + pub critical_usage: Option, + pub exhausted: Option, + pub status_issue: Option, + pub session_depleted: Option, + pub session_restored: Option, +} + +/// Sound theme used when an event has no custom WAV file. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NotificationSoundTheme { + /// Use the existing Windows system-sound mapping. + #[default] + Windows, + /// Use a distinct built-in CodexBar sound for each event. + CodexBar, +} + pub fn normalize_usage_threshold_overrides( values: HashMap, ) -> HashMap { diff --git a/rust/src/sound.rs b/rust/src/sound.rs index b5223ecff4..958303f470 100755 --- a/rust/src/sound.rs +++ b/rust/src/sound.rs @@ -1,105 +1,552 @@ -//! Sound effects for CodexBar -//! -//! Plays Windows system sounds for usage threshold alerts - -#![allow(dead_code)] - -use crate::settings::Settings; - -/// Sound types for different alerts -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AlertSound { - /// High usage warning (approaching limit) - Warning, - /// Critical usage alert - Critical, - /// Usage exhausted / error - Error, - /// Status restored / success - Success, -} - -impl AlertSound { - /// Get the Windows system sound type for this alert - #[cfg(target_os = "windows")] - fn system_sound_type(&self) -> u32 { - // Windows MessageBeep types - // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-messagebeep - const MB_OK: u32 = 0x00000000; - const MB_ICONWARNING: u32 = 0x00000030; - const MB_ICONERROR: u32 = 0x00000010; - const MB_ICONINFORMATION: u32 = 0x00000040; - - match self { - AlertSound::Warning => MB_ICONWARNING, - AlertSound::Critical => MB_ICONERROR, - AlertSound::Error => MB_ICONERROR, - AlertSound::Success => MB_ICONINFORMATION, - } - } -} - -/// Play an alert sound if sound is enabled in settings -pub fn play_alert(sound: AlertSound, settings: &Settings) { - if !settings.sound_enabled { - return; - } - - // Volume is 0-100, but Windows MessageBeep doesn't support volume control - // For more advanced volume control, we'd need to use a different API - // For now, we just play at system volume if enabled - play_system_sound(sound); -} - -/// Play a Windows system sound -#[cfg(target_os = "windows")] -fn play_system_sound(sound: AlertSound) { - use std::ffi::c_uint; - - #[link(name = "user32")] - unsafe extern "system" { - fn MessageBeep(uType: c_uint) -> i32; - } - - unsafe { - MessageBeep(sound.system_sound_type()); - } -} - -#[cfg(not(target_os = "windows"))] -fn play_system_sound(_sound: AlertSound) { - // No-op on non-Windows platforms - tracing::debug!("Sound playback not supported on this platform"); -} - -/// Play a warning sound (high usage threshold) -pub fn play_warning(settings: &Settings) { - play_alert(AlertSound::Warning, settings); -} - -/// Play a critical alert sound -pub fn play_critical(settings: &Settings) { - play_alert(AlertSound::Critical, settings); -} - -/// Play an error sound (exhausted/error states) -pub fn play_error(settings: &Settings) { - play_alert(AlertSound::Error, settings); -} - -/// Play a success sound (restored states) -pub fn play_success(settings: &Settings) { - play_alert(AlertSound::Success, settings); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_alert_sound_types() { - // Just verify the enum works - assert_eq!(AlertSound::Warning, AlertSound::Warning); - assert_ne!(AlertSound::Warning, AlertSound::Critical); - } -} +//! Notification sound playback for CodexBar. +//! +//! Handles per-event custom WAV files, built-in CodexBar sounds, and Windows system sounds. + +#![allow(dead_code)] + +use crate::settings::{NotificationSoundPaths, NotificationSoundTheme, Settings}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[cfg(target_os = "windows")] +use std::sync::{OnceLock, mpsc}; + +const MINIMUM_WAV_SIZE: usize = 44; +const TAG_SIZE: usize = 4; +const RIFF_TAG_OFFSET: usize = 0; +const RIFF_SIZE_OFFSET: usize = 4; +const RIFF_SIZE_BASE: usize = 8; +const WAVE_TAG_OFFSET: usize = 8; +const RIFF_HEADER_SIZE: usize = 12; +const CHUNK_HEADER_SIZE: usize = 8; +const CHUNK_SIZE_OFFSET: usize = 4; +const MINIMUM_FORMAT_CHUNK_SIZE: usize = 16; + +const PREDICTIVE_WARNING_WAV: &[u8] = include_bytes!("../assets/sounds/predictive-warning.wav"); +const HIGH_USAGE_WAV: &[u8] = include_bytes!("../assets/sounds/high-usage.wav"); +const CRITICAL_USAGE_WAV: &[u8] = include_bytes!("../assets/sounds/critical-usage.wav"); +const EXHAUSTED_WAV: &[u8] = include_bytes!("../assets/sounds/exhausted.wav"); +const STATUS_ISSUE_WAV: &[u8] = include_bytes!("../assets/sounds/status-issue.wav"); +const SESSION_DEPLETED_WAV: &[u8] = include_bytes!("../assets/sounds/session-depleted.wav"); +const SESSION_RESTORED_WAV: &[u8] = include_bytes!("../assets/sounds/session-restored.wav"); + +/// Notification events that can have individual sounds assigned. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NotificationSoundEvent { + PredictiveWarning, + HighUsage, + CriticalUsage, + Exhausted, + StatusIssue, + SessionDepleted, + SessionRestored, +} + +impl NotificationSoundEvent { + fn custom_path(self, paths: &NotificationSoundPaths) -> Option<&str> { + match self { + Self::PredictiveWarning => paths.predictive_warning.as_deref(), + Self::HighUsage => paths.high_usage.as_deref(), + Self::CriticalUsage => paths.critical_usage.as_deref(), + Self::Exhausted => paths.exhausted.as_deref(), + Self::StatusIssue => paths.status_issue.as_deref(), + Self::SessionDepleted => paths.session_depleted.as_deref(), + Self::SessionRestored => paths.session_restored.as_deref(), + } + } + + fn windows_sound_alias(self) -> &'static str { + match self { + Self::PredictiveWarning | Self::HighUsage => "SystemExclamation", + Self::CriticalUsage | Self::Exhausted | Self::StatusIssue | Self::SessionDepleted => { + "SystemHand" + } + Self::SessionRestored => "SystemAsterisk", + } + } + + fn built_in_wav(self) -> &'static [u8] { + match self { + Self::PredictiveWarning => PREDICTIVE_WARNING_WAV, + Self::HighUsage => HIGH_USAGE_WAV, + Self::CriticalUsage => CRITICAL_USAGE_WAV, + Self::Exhausted => EXHAUSTED_WAV, + Self::StatusIssue => STATUS_ISSUE_WAV, + Self::SessionDepleted => SESSION_DEPLETED_WAV, + Self::SessionRestored => SESSION_RESTORED_WAV, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SoundError { + #[error("notification sound path must be absolute: {0}")] + RelativePath(String), + #[error("notification sound file does not exist: {0}")] + MissingFile(String), + #[error("notification sound must be a WAV file: {0}")] + UnsupportedFormat(String), + #[error("could not read notification sound file {path}: {source}")] + ReadFailed { + path: String, + #[source] + source: std::io::Error, + }, + #[error("notification sound is not a valid WAV file ({reason}): {path}")] + InvalidWav { path: String, reason: &'static str }, + #[error("Windows could not play the notification sound: {0}")] + PlaybackFailed(String), + #[error("could not start notification sound playback: {0}")] + PlaybackThread(String), + #[error("notification sound playback is not supported on this platform")] + UnsupportedPlatform, +} + +/// Play the sound configured for one notification event. +pub fn play_alert(event: NotificationSoundEvent, settings: &Settings) -> Result<(), SoundError> { + if !settings.sound_enabled { + return Ok(()); + } + + if let Some(path) = event.custom_path(&settings.notification_sound_paths) { + return play_custom_wav(path); + } + + match settings.notification_sound_theme { + NotificationSoundTheme::Windows => play_windows_system_sound(event), + NotificationSoundTheme::CodexBar => play_built_in_sound(event), + } +} + +/// Validate a file path before saving it as a notification sound. +pub fn validate_custom_sound_path(path: &str) -> Result<(), SoundError> { + let candidate = Path::new(path); + if !candidate.is_absolute() { + return Err(SoundError::RelativePath(path.to_string())); + } + if !candidate.is_file() { + return Err(SoundError::MissingFile(path.to_string())); + } + let is_wav = candidate + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("wav")); + if !is_wav { + return Err(SoundError::UnsupportedFormat(path.to_string())); + } + let wav = std::fs::read(candidate).map_err(|source| SoundError::ReadFailed { + path: path.to_string(), + source, + })?; + validate_wav_bytes(&wav).map_err(|reason| SoundError::InvalidWav { + path: path.to_string(), + reason, + })?; + Ok(()) +} + +fn validate_wav_bytes(wav: &[u8]) -> Result<(), &'static str> { + if wav.len() < RIFF_HEADER_SIZE { + return Err("file is shorter than the RIFF header"); + } + if &wav[RIFF_TAG_OFFSET..RIFF_TAG_OFFSET + TAG_SIZE] != b"RIFF" + || &wav[WAVE_TAG_OFFSET..WAVE_TAG_OFFSET + TAG_SIZE] != b"WAVE" + { + return Err("RIFF/WAVE signature is missing"); + } + + let riff_size = u32::from_le_bytes( + wav[RIFF_SIZE_OFFSET..RIFF_SIZE_OFFSET + TAG_SIZE] + .try_into() + .expect("RIFF size field"), + ) as usize; + let riff_end = RIFF_SIZE_BASE + .checked_add(riff_size) + .ok_or("RIFF size overflows")?; + if riff_end < RIFF_HEADER_SIZE || riff_end > wav.len() { + return Err("RIFF size exceeds the file bounds"); + } + + let mut offset = RIFF_HEADER_SIZE; + let mut has_format = false; + let mut has_audio_data = false; + while offset + CHUNK_HEADER_SIZE <= riff_end { + let chunk_size = u32::from_le_bytes( + wav[offset + CHUNK_SIZE_OFFSET..offset + CHUNK_HEADER_SIZE] + .try_into() + .expect("WAV chunk size field"), + ) as usize; + let data_start = offset + CHUNK_HEADER_SIZE; + let data_end = data_start + .checked_add(chunk_size) + .ok_or("WAV chunk size overflows")?; + if data_end > riff_end { + return Err("WAV chunk exceeds the RIFF bounds"); + } + + match &wav[offset..offset + TAG_SIZE] { + b"fmt " if chunk_size >= MINIMUM_FORMAT_CHUNK_SIZE => has_format = true, + b"fmt " => return Err("format chunk is too short"), + b"data" if chunk_size > 0 => has_audio_data = true, + _ => {} + } + + let padding = chunk_size % 2; + offset = data_end + .checked_add(padding) + .ok_or("WAV chunk padding overflows")?; + if offset > riff_end { + return Err("WAV chunk padding exceeds the RIFF bounds"); + } + } + + if !has_format { + return Err("format chunk is missing"); + } + if !has_audio_data { + return Err("audio data chunk is missing or empty"); + } + Ok(()) +} + +/// Validate custom notification sounds added or changed by a settings update. +pub fn validate_custom_sound_path_updates( + current: &NotificationSoundPaths, + updated: &NotificationSoundPaths, +) -> Result<(), SoundError> { + let path_pairs = [ + ( + current.predictive_warning.as_deref(), + updated.predictive_warning.as_deref(), + ), + (current.high_usage.as_deref(), updated.high_usage.as_deref()), + ( + current.critical_usage.as_deref(), + updated.critical_usage.as_deref(), + ), + (current.exhausted.as_deref(), updated.exhausted.as_deref()), + ( + current.status_issue.as_deref(), + updated.status_issue.as_deref(), + ), + ( + current.session_depleted.as_deref(), + updated.session_depleted.as_deref(), + ), + ( + current.session_restored.as_deref(), + updated.session_restored.as_deref(), + ), + ]; + + for (current_path, updated_path) in path_pairs { + if updated_path != current_path + && let Some(path) = updated_path + { + validate_custom_sound_path(path)?; + } + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn play_windows_system_sound(event: NotificationSoundEvent) -> Result<(), SoundError> { + enqueue_playback(PlaybackRequest::WindowsSystem(event)) +} + +#[cfg(target_os = "windows")] +fn perform_windows_system_sound(event: NotificationSoundEvent) -> Result<(), SoundError> { + use windows::Win32::Foundation::HMODULE; + use windows::Win32::Media::Audio::{PlaySoundW, SND_ALIAS, SND_NODEFAULT}; + use windows::core::PCWSTR; + + let wide_alias: Vec = event + .windows_sound_alias() + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let played = unsafe { + PlaySoundW( + PCWSTR(wide_alias.as_ptr()), + HMODULE::default(), + SND_ALIAS | SND_NODEFAULT, + ) + }; + if played.as_bool() { + Ok(()) + } else { + Err(SoundError::PlaybackFailed(format!("{event:?}"))) + } +} + +#[cfg(not(target_os = "windows"))] +fn play_windows_system_sound(_event: NotificationSoundEvent) -> Result<(), SoundError> { + Err(SoundError::UnsupportedPlatform) +} + +#[cfg(target_os = "windows")] +fn play_custom_wav(path: &str) -> Result<(), SoundError> { + enqueue_playback(PlaybackRequest::CustomWav(path.to_string())) +} + +#[cfg(target_os = "windows")] +fn perform_custom_wav(path: &str) -> Result<(), SoundError> { + use std::os::windows::ffi::OsStrExt; + use windows::Win32::Foundation::HMODULE; + use windows::Win32::Media::Audio::{PlaySoundW, SND_FILENAME, SND_NODEFAULT}; + use windows::core::PCWSTR; + + let wide_path: Vec = Path::new(path) + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let played = unsafe { + PlaySoundW( + PCWSTR(wide_path.as_ptr()), + HMODULE::default(), + SND_FILENAME | SND_NODEFAULT, + ) + }; + if played.as_bool() { + Ok(()) + } else { + Err(SoundError::PlaybackFailed(path.to_string())) + } +} + +#[cfg(not(target_os = "windows"))] +fn play_custom_wav(_path: &str) -> Result<(), SoundError> { + Err(SoundError::UnsupportedPlatform) +} + +#[cfg(target_os = "windows")] +fn play_built_in_sound(event: NotificationSoundEvent) -> Result<(), SoundError> { + enqueue_playback(PlaybackRequest::BuiltIn(event)) +} + +#[cfg(target_os = "windows")] +fn perform_built_in_sound(event: NotificationSoundEvent) -> Result<(), SoundError> { + let wav = event.built_in_wav(); + use windows::Win32::Foundation::HMODULE; + use windows::Win32::Media::Audio::{PlaySoundA, SND_MEMORY, SND_NODEFAULT}; + use windows::core::PCSTR; + + let played = unsafe { + PlaySoundA( + PCSTR(wav.as_ptr()), + HMODULE::default(), + SND_MEMORY | SND_NODEFAULT, + ) + }; + if played.as_bool() { + Ok(()) + } else { + Err(SoundError::PlaybackFailed(format!("{event:?}"))) + } +} + +#[cfg(not(target_os = "windows"))] +fn play_built_in_sound(_event: NotificationSoundEvent) -> Result<(), SoundError> { + Err(SoundError::UnsupportedPlatform) +} + +#[cfg(target_os = "windows")] +enum PlaybackRequest { + WindowsSystem(NotificationSoundEvent), + BuiltIn(NotificationSoundEvent), + CustomWav(String), +} + +#[cfg(target_os = "windows")] +static PLAYBACK_QUEUE: OnceLock, String>> = OnceLock::new(); + +#[cfg(target_os = "windows")] +fn enqueue_playback(request: PlaybackRequest) -> Result<(), SoundError> { + let sender = PLAYBACK_QUEUE.get_or_init(|| { + let (sender, receiver) = mpsc::channel(); + std::thread::Builder::new() + .name("codexbar-notification-sound".to_string()) + .spawn(move || playback_worker(receiver)) + .map(|_| sender) + .map_err(|error| error.to_string()) + }); + match sender { + Ok(sender) => sender + .send(request) + .map_err(|error| SoundError::PlaybackThread(error.to_string())), + Err(error) => Err(SoundError::PlaybackThread(error.clone())), + } +} + +#[cfg(target_os = "windows")] +fn playback_worker(receiver: mpsc::Receiver) { + for request in receiver { + let result = match request { + PlaybackRequest::WindowsSystem(event) => perform_windows_system_sound(event), + PlaybackRequest::BuiltIn(event) => perform_built_in_sound(event), + PlaybackRequest::CustomWav(path) => perform_custom_wav(&path), + }; + if let Err(error) = result { + tracing::warn!(%error, "notification sound failed to play"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn find_chunk<'a>(wav: &'a [u8], tag: &[u8; TAG_SIZE]) -> Option<&'a [u8]> { + let mut offset = RIFF_HEADER_SIZE; + while offset + CHUNK_HEADER_SIZE <= wav.len() { + let chunk_size = u32::from_le_bytes( + wav[offset + CHUNK_SIZE_OFFSET..offset + CHUNK_HEADER_SIZE] + .try_into() + .expect("WAV chunk size"), + ) as usize; + let data_start = offset + CHUNK_HEADER_SIZE; + let data_end = data_start.saturating_add(chunk_size); + if &wav[offset..offset + TAG_SIZE] == tag && data_end <= wav.len() { + return Some(&wav[data_start..data_end]); + } + let padding = chunk_size % 2; + offset = data_end.saturating_add(padding); + } + None + } + + const ALL_EVENTS: [NotificationSoundEvent; 7] = [ + NotificationSoundEvent::PredictiveWarning, + NotificationSoundEvent::HighUsage, + NotificationSoundEvent::CriticalUsage, + NotificationSoundEvent::Exhausted, + NotificationSoundEvent::StatusIssue, + NotificationSoundEvent::SessionDepleted, + NotificationSoundEvent::SessionRestored, + ]; + + #[test] + fn windows_defaults_preserve_existing_alert_mapping() { + assert_eq!( + NotificationSoundEvent::PredictiveWarning.windows_sound_alias(), + "SystemExclamation" + ); + assert_eq!( + NotificationSoundEvent::HighUsage.windows_sound_alias(), + "SystemExclamation" + ); + for event in [ + NotificationSoundEvent::CriticalUsage, + NotificationSoundEvent::Exhausted, + NotificationSoundEvent::StatusIssue, + NotificationSoundEvent::SessionDepleted, + ] { + assert_eq!(event.windows_sound_alias(), "SystemHand"); + } + assert_eq!( + NotificationSoundEvent::SessionRestored.windows_sound_alias(), + "SystemAsterisk" + ); + } + + #[test] + fn built_in_events_have_distinct_valid_wav_data() { + let mut wav_data = Vec::new(); + for event in ALL_EVENTS { + let wav = event.built_in_wav(); + assert_eq!(&wav[RIFF_TAG_OFFSET..RIFF_TAG_OFFSET + TAG_SIZE], b"RIFF"); + assert_eq!(&wav[WAVE_TAG_OFFSET..WAVE_TAG_OFFSET + TAG_SIZE], b"WAVE"); + assert!(validate_wav_bytes(wav).is_ok()); + assert!(wav.len() > MINIMUM_WAV_SIZE); + let format = find_chunk(wav, b"fmt ").expect("format chunk"); + assert_eq!( + u16::from_le_bytes(format[0..2].try_into().expect("audio format")), + 1 + ); + assert_eq!( + u16::from_le_bytes(format[2..4].try_into().expect("channel count")), + 2 + ); + assert_eq!( + u32::from_le_bytes(format[4..8].try_into().expect("sample rate")), + 48_000 + ); + assert_eq!( + u16::from_le_bytes(format[14..16].try_into().expect("bit depth")), + 16 + ); + wav_data.push(wav); + } + + for first in 0..wav_data.len() { + for second in (first + 1)..wav_data.len() { + assert_ne!(wav_data[first], wav_data[second]); + } + } + } + + #[test] + fn each_event_reads_only_its_custom_path() { + let paths = NotificationSoundPaths { + predictive_warning: Some("predictive.wav".to_string()), + high_usage: Some("high.wav".to_string()), + critical_usage: Some("critical.wav".to_string()), + exhausted: Some("exhausted.wav".to_string()), + status_issue: Some("status.wav".to_string()), + session_depleted: Some("depleted.wav".to_string()), + session_restored: Some("restored.wav".to_string()), + }; + let expected = [ + "predictive.wav", + "high.wav", + "critical.wav", + "exhausted.wav", + "status.wav", + "depleted.wav", + "restored.wav", + ]; + for (event, expected_path) in ALL_EVENTS.into_iter().zip(expected) { + assert_eq!(event.custom_path(&paths), Some(expected_path)); + } + } + + #[test] + fn custom_sound_validation_rejects_relative_and_non_wav_paths() { + assert!(matches!( + validate_custom_sound_path("relative.wav"), + Err(SoundError::RelativePath(_)) + )); + + let temp = tempfile::tempdir().expect("create temp directory"); + let mp3 = temp.path().join("sound.mp3"); + std::fs::write(&mp3, b"not audio").expect("write test file"); + assert!(matches!( + validate_custom_sound_path(mp3.to_str().expect("UTF-8 test path")), + Err(SoundError::UnsupportedFormat(_)) + )); + + let fake_wav = temp.path().join("renamed.wav"); + std::fs::write(&fake_wav, b"not audio").expect("write test file"); + assert!(matches!( + validate_custom_sound_path(fake_wav.to_str().expect("UTF-8 test path")), + Err(SoundError::InvalidWav { .. }) + )); + } + + #[test] + fn path_update_validation_allows_clearing_one_of_multiple_missing_files() { + let current = NotificationSoundPaths { + high_usage: Some(r"C:\missing\high.wav".to_string()), + critical_usage: Some(r"C:\missing\critical.wav".to_string()), + ..NotificationSoundPaths::default() + }; + let updated = NotificationSoundPaths { + high_usage: None, + ..current.clone() + }; + + assert!(validate_custom_sound_path_updates(¤t, &updated).is_ok()); + } +}