From 7820f0096774f64a782c3a18fbf0be798b8b05bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20S=C3=A1nchez?= Date: Tue, 28 Apr 2026 22:32:02 +0200 Subject: [PATCH] feat: add Web Audio API sound system with global click and notification sounds - Implement centralized audio manager using Web Audio API - Zero-latency playback via pre-decoded AudioBuffers - Hardware-accelerated, mobile-friendly architecture - Real-time master volume control via GainNode - Add global click sound on all interactive buttons - Add notification sound for new inbox messages - Add audio settings: master volume, SFX toggle, music toggle - Include Kenney interface sounds (CC0) for click and notification - Add contributor guide at docs/ADDING_AUDIO.md --- docs/ADDING_AUDIO.md | 196 +++++++++++++++++++ public/sounds/click.ogg | Bin 0 -> 7814 bytes public/sounds/notification.ogg | Bin 0 -> 8233 bytes src/App.tsx | 11 ++ src/components/GlobalClickSound.tsx | 56 ++++++ src/components/GlobalNotificationSound.tsx | 37 ++++ src/lib/audioManager.ts | 214 +++++++++++++++++++++ src/pages/Settings.tsx | 73 +++++++ src/store/settingsStore.test.ts | 3 + src/store/settingsStore.ts | 6 + 10 files changed, 596 insertions(+) create mode 100644 docs/ADDING_AUDIO.md create mode 100644 public/sounds/click.ogg create mode 100644 public/sounds/notification.ogg create mode 100644 src/components/GlobalClickSound.tsx create mode 100644 src/components/GlobalNotificationSound.tsx create mode 100644 src/lib/audioManager.ts diff --git a/docs/ADDING_AUDIO.md b/docs/ADDING_AUDIO.md new file mode 100644 index 000000000..5e467ecb8 --- /dev/null +++ b/docs/ADDING_AUDIO.md @@ -0,0 +1,196 @@ +# Adding Audio to OLManager + +This guide explains how to add new sound effects or music to Open League Manager. + +## Audio System Architecture + +OLManager uses the **Web Audio API** (not HTMLAudioElement) for all audio playback. This provides: + +- **Zero-latency playback** — audio buffers are decoded once and reused +- **Hardware acceleration** — especially important on Android/mobile +- **Real-time volume control** — master volume applied via a single `GainNode` +- **No memory leaks** — each sound spawns a lightweight `AudioBufferSourceNode`, not a full DOM element + +## File Locations + +| Location | Purpose | +|----------|---------| +| `public/sounds/` | Raw audio files (OGG, WAV, MP3) served as static assets | +| `src/lib/audioManager.ts` | Core audio engine — decoding, playback, volume | +| `src/components/GlobalClickSound.tsx` | Global click sound on all buttons | +| `src/components/GlobalNotificationSound.tsx` | Notification sound for new inbox messages | +| `src/App.tsx` | Pre-loads critical sounds on app startup | + +## Supported Formats + +| Format | Recommended | Notes | +|--------|-------------|-------| +| **OGG** | ✅ Yes | Best compression, fully open, patent-free | +| **WAV** | ✅ Yes | Uncompressed, instant decode, larger files | +| **MP3** | ⚠️ Okay | Patent-encumbered, slightly slower decode | + +> **License requirement**: All audio assets must be compatible with GPL-3.0. Preferred sources: CC0, CC-BY, CC-BY-SA, or public domain. See [Audio Licensing](#audio-licensing) below. + +## How to Add a New Sound Effect + +### 1. Add the audio file + +Copy your audio file into `public/sounds/`: + +```bash +cp my-sound.ogg public/sounds/my-sound.ogg +``` + +### 2. Pre-load it + +In `src/App.tsx`, add a `preloadAudio()` call so the sound is decoded before it's needed: + +```tsx +import { preloadAudio } from "./lib/audioManager"; + +useEffect(() => { + preloadAudio("/sounds/click.ogg"); + preloadAudio("/sounds/notification.ogg"); + preloadAudio("/sounds/my-sound.ogg"); // ← add this +}, []); +``` + +> Pre-loading is **optional but strongly recommended**. Without it, the first playback may be silent while the browser decodes the file. + +### 3. Play the sound from your component + +```tsx +import { playAudio } from "../lib/audioManager"; + +function MyComponent() { + const handleAction = () => { + playAudio("/sounds/my-sound.ogg", { volume: 0.5 }); + // ... rest of your logic + }; + + return ; +} +``` + +### PlayOptions + +```ts +playAudio("/sounds/file.ogg", { + volume: 0.5, // 0–1, multiplied by the user's master volume + loop: false, // set true for background music + isMusic: false // set true to respect the "Music" toggle in settings +}); +``` + +### Return value + +`playAudio()` returns a `ManagedAudioNode | null`: + +```ts +const handle = playAudio("/sounds/loop.ogg", { loop: true }); + +// Later, stop it: +handle?.stop(); +``` + +## How to Add Background Music + +Music uses the same API but with `loop: true` and `isMusic: true`: + +```tsx +import { playAudio, stopAudio } from "../lib/audioManager"; + +const musicRef = useRef(null); + +useEffect(() => { + musicRef.current = playAudio("/sounds/menu-music.ogg", { + loop: true, + isMusic: true, + volume: 0.3, + }); + + return () => { + stopAudio(musicRef.current); + }; +}, []); +``` + +Music with `isMusic: true` automatically respects the user's **Music** toggle in Settings. + +## Global Sound Effects (Built-in) + +Two global sound components are already wired into `App.tsx`: + +| Component | Trigger | Sound file | +|-----------|---------|------------| +| `GlobalClickSound` | Any ` +``` + +## Audio Settings + +The following settings are persisted in `AppSettings` (`src/store/settingsStore.ts`): + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `master_volume` | `number` (0–1) | `0.5` | Global volume multiplier | +| `sound_effects_enabled` | `boolean` | `true` | Master switch for SFX | +| `music_enabled` | `boolean` | `true` | Master switch for music | + +When `master_volume` changes, `updateMasterVolume()` in `audioManager.ts` updates the Web Audio `GainNode` in real time. + +## Audio Licensing + +**All audio assets must be GPL-3.0 compatible.** + +### Recommended licenses + +| License | Compatible? | Attribution required? | +|---------|-------------|----------------------| +| **CC0** | ✅ Yes | No | +| **CC-BY 4.0** | ✅ Yes | Yes (in `AUDIO_PROVENANCE.md` or similar) | +| **CC-BY-SA 4.0** | ✅ Yes | Yes + ShareAlike | +| **Public Domain** | ✅ Yes | No | +| **CC-BY-NC** | ❌ No | — | +| **Proprietary / All Rights Reserved** | ❌ No | — | + +### Recommended sources + +- **Kenney.nl** — `public/sounds/click.ogg` and `notification.ogg` come from here (CC0) +- **OpenGameArt.org** — Filter by CC0 / CC-BY / CC-BY-SA / OGA-BY +- **Freesound.org** — Filter by CC0 or CC-BY +- **Incompetech** (Kevin MacLeod) — CC-BY music + +### Attribution + +If you add audio that requires attribution, add an entry to `docs/AUDIO_PROVENANCE.md` (create the file if it doesn't exist): + +```markdown +## CC-BY 3.0 + +- `"Battle Theme"` by Kevin MacLeod (incompetech.com) + - Licensed under CC BY 3.0 + - File: `public/sounds/battle-theme.ogg` +``` + +## Performance Tips + +1. **Keep files small** — OGG at 44.1kHz mono is usually < 10KB for a 0.2s click sound +2. **Always pre-load** — Call `preloadAudio()` in `App.tsx` for any sound used in the first 5 seconds +3. **Don't over-use `new Audio()`** — The Web Audio API already handles this via `AudioBufferSourceNode`; you never need to create `new Audio()` manually +4. **Test on Android** — Web Audio API behaves slightly differently on mobile WebViews; verify with `npm run tauri android dev` if possible + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Sound doesn't play at all | Buffer not pre-loaded | Add `preloadAudio()` in `App.tsx` | +| Sound plays once then never again | Using a single `HTMLAudioElement` | Use `playAudio()` which creates fresh `AudioBufferSourceNode`s | +| Volume slider has no effect | Not calling `updateMasterVolume()` | Call it when `master_volume` changes | +| Sound only plays after first click | AudioContext suspended | `resumeContextIfNeeded()` is called automatically inside `playAudio()` | +| No sound on Android | Autoplay policy | First user interaction (click/tap) will unlock the context automatically | diff --git a/public/sounds/click.ogg b/public/sounds/click.ogg new file mode 100644 index 0000000000000000000000000000000000000000..799e3277c0c8826589216ec5518801293e13d5d7 GIT binary patch literal 7814 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 T34g^Ubag)Tff%TrwPpG*D^4u# literal 0 HcmV?d00001 diff --git a/src/App.tsx b/src/App.tsx index 9f45fd8f2..247162710 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,11 @@ import { useEffect, lazy, Suspense } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { useSettingsStore } from "./store/settingsStore"; +import { preloadAudio } from "./lib/audioManager"; import i18n from "./i18n"; import "./App.css"; +import GlobalNotificationSound from "./components/GlobalNotificationSound"; +import GlobalClickSound from "./components/GlobalClickSound"; const MainMenu = lazy(() => import("./pages/MainMenu")); const TeamSelection = lazy(() => import("./pages/TeamSelection")); @@ -33,6 +36,12 @@ function App() { if (!loaded) loadSettings(); }, [loaded, loadSettings]); + // Pre-load critical UI sounds so playback is instant (no load latency) + useEffect(() => { + preloadAudio("/sounds/click.ogg"); + preloadAudio("/sounds/notification.ogg"); + }, []); + useEffect(() => { const size = SCALE_MAP[settings.ui_scale] || "16px"; document.documentElement.style.fontSize = size; @@ -107,6 +116,8 @@ function App() { return ( + + }> } /> diff --git a/src/components/GlobalClickSound.tsx b/src/components/GlobalClickSound.tsx new file mode 100644 index 000000000..a821df33e --- /dev/null +++ b/src/components/GlobalClickSound.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef } from "react"; +import { playAudio, preloadAudio } from "../lib/audioManager"; + +const CLICK_SOUND_PATH = "/sounds/click.ogg"; + +/** + * Global click sound effect for all interactive buttons. + * + * Attaches a document-level click listener in capture phase and plays a + * subtle sound whenever a `