Merge pull request #86 from subculture-collective/feat/sfx-audio
feat(sfx): wire Howler.js SFX audio into renderer
This commit was merged in pull request #86.
This commit is contained in:
352
docs/plans/2026-03-01-sfx-audio.md
Normal file
352
docs/plans/2026-03-01-sfx-audio.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# SFX Audio Integration Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Wire Howler.js SFX playback into the frontend renderer so `objection()`, `holdIt()`, and `takeThat()` play audio alongside their visual effects.
|
||||
|
||||
**Architecture:** All visual effects (`public/renderer/layers/effects.js`) and the directive handler (`public/renderer/index.js`) are complete. This plan adds a new `public/renderer/audio.js` module that wraps Howler.js, threads it through `initEffects`, and initialises it in `createCourtRenderer`. Audio failures are always graceful — visuals keep working even if files are missing.
|
||||
|
||||
**Tech Stack:** Howler.js 2.2.4 (CDN), PixiJS 8 (already present), vanilla ES modules (`public/renderer/*.js`)
|
||||
|
||||
---
|
||||
|
||||
### Context: project test command
|
||||
```bash
|
||||
npm test # node --import tsx --test 'src/**/*.test.ts'
|
||||
npm run lint # tsc --noEmit
|
||||
```
|
||||
Browser-side renderer code (`public/renderer/`) cannot be unit-tested in the Node suite. Audio and effects tests are verified manually in the browser. Where pure logic can be extracted, do so.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Howler CDN to `public/index.html`
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/index.html:549`
|
||||
|
||||
**Step 1: Open the file**
|
||||
|
||||
Read `public/index.html`. Line 549 currently is:
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/pixi.js@8.9.1/dist/pixi.min.js"></script>
|
||||
```
|
||||
|
||||
**Step 2: Add Howler before PixiJS**
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/howler@2.2.4/dist/howler.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pixi.js@8.9.1/dist/pixi.min.js"></script>
|
||||
```
|
||||
|
||||
Howler must load before PixiJS so `window.Howl` is available when `audio.js` runs.
|
||||
|
||||
**Step 3: Verify**
|
||||
|
||||
Open `public/index.html` in a browser (or `npm run dev`). In DevTools console:
|
||||
```js
|
||||
typeof Howl // → "function"
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add public/index.html
|
||||
git commit -m "chore: add Howler.js CDN to index.html"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create `public/renderer/audio.js`
|
||||
|
||||
**Files:**
|
||||
- Create: `public/renderer/audio.js`
|
||||
|
||||
This module wraps `window.Howl` (loaded from CDN). It loads each SFX file non-blocking and silently ignores unknown names or load errors.
|
||||
|
||||
**Step 1: Create the file**
|
||||
|
||||
```js
|
||||
/**
|
||||
* Audio module — SFX playback via Howler.js (CDN global).
|
||||
*
|
||||
* Call initAudio() to get a {loadSFX, playSfx} handle.
|
||||
* loadSFX() is fire-and-forget; the module is safe to call before loading completes.
|
||||
*/
|
||||
|
||||
export const DEFAULT_SFX_CONFIG = {
|
||||
objection: '/assets/sfx/objection.mp3',
|
||||
hold_it: '/assets/sfx/hold_it.mp3',
|
||||
gavel: '/assets/sfx/gavel.mp3',
|
||||
crowd_gasp: '/assets/sfx/crowd_gasp.mp3',
|
||||
dramatic_sting: '/assets/sfx/dramatic_sting.mp3',
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {{ loadSFX: (config?: Record<string,string>) => Promise<void>, playSfx: (name: string) => void }}
|
||||
*/
|
||||
export function initAudio() {
|
||||
/** @type {Map<string, import('howler').Howl>} */
|
||||
const sounds = new Map();
|
||||
let ready = false;
|
||||
|
||||
/**
|
||||
* Load SFX files from config.
|
||||
* Safe to call multiple times — subsequent calls no-op.
|
||||
*
|
||||
* @param {Record<string, string>} [config]
|
||||
*/
|
||||
async function loadSFX(config = DEFAULT_SFX_CONFIG) {
|
||||
if (ready) return;
|
||||
|
||||
if (typeof Howl === 'undefined') {
|
||||
console.warn('[Audio] Howler not available — SFX disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = Object.entries(config).map(
|
||||
([name, path]) =>
|
||||
new Promise(resolve => {
|
||||
const sound = new Howl({
|
||||
src: [path],
|
||||
preload: true,
|
||||
onload: resolve,
|
||||
onloaderror: () => {
|
||||
console.warn(
|
||||
`[Audio] Failed to load "${name}" (${path}) — continuing without it`,
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
sounds.set(name, sound);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
ready = true;
|
||||
console.log(`[Audio] Loaded ${sounds.size} SFX`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sound by name. Silently ignored if not loaded or unknown.
|
||||
*
|
||||
* @param {string} name
|
||||
*/
|
||||
function playSfx(name) {
|
||||
if (!ready) return;
|
||||
const sound = sounds.get(name);
|
||||
if (sound) {
|
||||
sound.play();
|
||||
}
|
||||
// unknown names are silently ignored — satisfies AC
|
||||
}
|
||||
|
||||
return { loadSFX, playSfx };
|
||||
}
|
||||
|
||||
/** Noop audio handle — used as default when audio is not initialised. */
|
||||
export const NOOP_AUDIO = { loadSFX: async () => {}, playSfx: () => {} };
|
||||
```
|
||||
|
||||
**Step 2: Verify the file exists**
|
||||
```bash
|
||||
ls public/renderer/audio.js
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add public/renderer/audio.js
|
||||
git commit -m "feat(sfx): add audio.js Howler module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire audio into `public/renderer/layers/effects.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/renderer/layers/effects.js`
|
||||
|
||||
`initEffects(stage)` becomes `initEffects(stage, audio)`. The three composite cues call `audio.playSfx`. The `audio` parameter defaults to a noop so nothing breaks if not provided.
|
||||
|
||||
**Step 1: Change the function signature**
|
||||
|
||||
Find (line 26):
|
||||
```js
|
||||
export function initEffects(stage) {
|
||||
```
|
||||
Replace with:
|
||||
```js
|
||||
/**
|
||||
* @param {import('../stage.js').RendererStage} stage
|
||||
* @param {{ playSfx: (name: string) => void }} [audio]
|
||||
*/
|
||||
export function initEffects(stage, audio = { playSfx: () => {} }) {
|
||||
```
|
||||
|
||||
**Step 2: Add SFX calls to composite cues**
|
||||
|
||||
Find `objection()`:
|
||||
```js
|
||||
function objection() {
|
||||
flash({ color: OBJECTION_COLOR, alpha: 0.35 });
|
||||
shake({ intensity: 8, durationMs: 350 });
|
||||
stamp({ text: 'OBJECTION!', color: OBJECTION_COLOR });
|
||||
}
|
||||
```
|
||||
Replace with:
|
||||
```js
|
||||
function objection() {
|
||||
audio.playSfx('objection');
|
||||
flash({ color: OBJECTION_COLOR, alpha: 0.35 });
|
||||
shake({ intensity: 8, durationMs: 350 });
|
||||
stamp({ text: 'OBJECTION!', color: OBJECTION_COLOR });
|
||||
}
|
||||
```
|
||||
|
||||
Find `holdIt()`:
|
||||
```js
|
||||
function holdIt() {
|
||||
flash({ color: HOLD_IT_COLOR, alpha: 0.3 });
|
||||
stamp({ text: 'HOLD IT!', color: HOLD_IT_COLOR });
|
||||
}
|
||||
```
|
||||
Replace with:
|
||||
```js
|
||||
function holdIt() {
|
||||
audio.playSfx('hold_it');
|
||||
flash({ color: HOLD_IT_COLOR, alpha: 0.3 });
|
||||
stamp({ text: 'HOLD IT!', color: HOLD_IT_COLOR });
|
||||
}
|
||||
```
|
||||
|
||||
Find `takeThat()`:
|
||||
```js
|
||||
function takeThat() {
|
||||
flash({ color: TAKE_THAT_COLOR, alpha: 0.3 });
|
||||
stamp({ text: 'TAKE THAT!', color: TAKE_THAT_COLOR });
|
||||
}
|
||||
```
|
||||
Replace with:
|
||||
```js
|
||||
function takeThat() {
|
||||
audio.playSfx('dramatic_sting');
|
||||
flash({ color: TAKE_THAT_COLOR, alpha: 0.3 });
|
||||
stamp({ text: 'TAKE THAT!', color: TAKE_THAT_COLOR });
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add public/renderer/layers/effects.js
|
||||
git commit -m "feat(sfx): wire audio.playSfx into objection/holdIt/takeThat cues"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Initialise audio in `public/renderer/index.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/renderer/index.js`
|
||||
|
||||
**Step 1: Import the audio module**
|
||||
|
||||
At the top of `public/renderer/index.js`, after existing imports:
|
||||
```js
|
||||
import { initAudio, DEFAULT_SFX_CONFIG } from './audio.js';
|
||||
```
|
||||
|
||||
**Step 2: Initialise audio and pass to `initEffects`**
|
||||
|
||||
Inside `createCourtRenderer`, find:
|
||||
```js
|
||||
const effects = initEffects(stage);
|
||||
```
|
||||
Replace with:
|
||||
```js
|
||||
const audio = initAudio();
|
||||
// Load SFX non-blocking — effects work even if files are absent
|
||||
audio.loadSFX(DEFAULT_SFX_CONFIG).catch(err =>
|
||||
console.warn('[Audio] SFX load error:', err),
|
||||
);
|
||||
|
||||
const effects = initEffects(stage, audio);
|
||||
```
|
||||
|
||||
**Step 3: Expose audio on the returned object (for testing in DevTools)**
|
||||
|
||||
At the end of `createCourtRenderer`, in the return object, add:
|
||||
```js
|
||||
return {
|
||||
update,
|
||||
applyDirective,
|
||||
destroy,
|
||||
ui: { speakerText: ui.speakerText, dialogueText: ui.dialogueText },
|
||||
effects,
|
||||
evidence,
|
||||
camera,
|
||||
dialogue: dialogueSM,
|
||||
audio, // ← add this line
|
||||
};
|
||||
```
|
||||
|
||||
**Step 4: Verify in browser**
|
||||
|
||||
Open DevTools → Console. After a session starts:
|
||||
```js
|
||||
// Should log: [Audio] Loaded 5 SFX (or warn about missing files)
|
||||
// Then manually trigger:
|
||||
courtRenderer.effects.objection()
|
||||
// Should see OBJECTION! stamp + hear audio (if files exist)
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
```bash
|
||||
git add public/renderer/index.js
|
||||
git commit -m "feat(sfx): initialise Howler audio in createCourtRenderer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Create `assets/sfx/` directory with placeholder notes
|
||||
|
||||
**Files:**
|
||||
- Create: `assets/sfx/README.md`
|
||||
|
||||
The audio system gracefully handles missing files (logs a warning, continues). For a working deployment, place real `.mp3` files here.
|
||||
|
||||
**Step 1: Create directory and README**
|
||||
|
||||
```markdown
|
||||
# assets/sfx — Sound Effect Files
|
||||
|
||||
Place the following MP3 files here for SFX playback:
|
||||
|
||||
| File | Used for | Suggested source |
|
||||
|--------------------|----------------------------------|-----------------------------------------|
|
||||
| `objection.mp3` | OBJECTION! stinger | Record/synthesise, or free sfx library |
|
||||
| `hold_it.mp3` | HOLD IT! stinger | Same |
|
||||
| `gavel.mp3` | Verdict gavel bang | freesound.org — search "gavel" |
|
||||
| `crowd_gasp.mp3` | Dramatic audience reaction | freesound.org — search "crowd gasp" |
|
||||
| `dramatic_sting.mp3`| TAKE THAT! / PRESENT! moment | freesound.org — search "dramatic sting" |
|
||||
|
||||
If files are absent the renderer logs a warning and continues without audio.
|
||||
All SFX must be CC0 / royalty-free for streaming use.
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add assets/sfx/README.md
|
||||
git commit -m "docs: add assets/sfx README with placeholder instructions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Manual verification checklist
|
||||
|
||||
After all tasks:
|
||||
|
||||
- [ ] `npm run lint` passes (TypeScript clean — no changes to `.ts` files)
|
||||
- [ ] Browser: DevTools console shows `[Audio] Loaded N SFX` (or graceful warnings if files absent)
|
||||
- [ ] Browser: `courtRenderer.effects.objection()` plays audio + shows OBJECTION! stamp
|
||||
- [ ] Browser: `courtRenderer.effects.holdIt()` plays audio + shows HOLD IT! stamp
|
||||
- [ ] Browser: `courtRenderer.effects.takeThat()` plays audio + shows TAKE THAT! stamp
|
||||
- [ ] Browser: `courtRenderer.effects.trigger('unknown_cue')` → no crash, no audio
|
||||
- [ ] During a live session: when server emits `render_directive` with `effect: 'objection'`, the stinger fires
|
||||
14
public/assets/sfx/README.md
Normal file
14
public/assets/sfx/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# public/assets/sfx — Sound Effect Files
|
||||
|
||||
Place the following MP3 files in `public/assets/sfx` for SFX playback in the browser:
|
||||
|
||||
| File | Used for | Suggested source |
|
||||
|--------------------|----------------------------------|-----------------------------------------|
|
||||
| `objection.mp3` | OBJECTION! stinger | Record/synthesise, or free sfx library |
|
||||
| `hold_it.mp3` | HOLD IT! stinger | Same |
|
||||
| `gavel.mp3` | Verdict gavel bang | freesound.org — search "gavel" |
|
||||
| `crowd_gasp.mp3` | Dramatic audience reaction | freesound.org — search "crowd gasp" |
|
||||
| `dramatic_sting.mp3`| TAKE THAT! / PRESENT! moment | freesound.org — search "dramatic sting" |
|
||||
|
||||
If files are absent the renderer logs a warning and continues without audio.
|
||||
All SFX must be CC0 / royalty-free for streaming use.
|
||||
4
public/howler.min.js
vendored
Normal file
4
public/howler.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -546,6 +546,7 @@
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script src="/howler.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pixi.js@8.9.1/dist/pixi.min.js"></script>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
|
||||
85
public/renderer/audio.js
Normal file
85
public/renderer/audio.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Audio module — SFX playback via Howler.js (CDN global).
|
||||
*
|
||||
* Call initAudio() to get a {loadSFX, playSfx} handle.
|
||||
* loadSFX() is fire-and-forget; the module is safe to call before loading completes.
|
||||
*/
|
||||
|
||||
export const DEFAULT_SFX_CONFIG = {
|
||||
objection: '/assets/sfx/objection.mp3',
|
||||
hold_it: '/assets/sfx/hold_it.mp3',
|
||||
gavel: '/assets/sfx/gavel.mp3',
|
||||
crowd_gasp: '/assets/sfx/crowd_gasp.mp3',
|
||||
dramatic_sting: '/assets/sfx/dramatic_sting.mp3',
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {{ loadSFX: (config?: Record<string,string>) => Promise<void>, playSfx: (name: string) => void }}
|
||||
*/
|
||||
export function initAudio() {
|
||||
/** @type {Map<string, import('howler').Howl>} */
|
||||
const sounds = new Map();
|
||||
let ready = false;
|
||||
|
||||
/**
|
||||
* Load SFX files from config.
|
||||
* Safe to call multiple times — subsequent calls no-op.
|
||||
*
|
||||
* @param {Record<string, string>} [config]
|
||||
*/
|
||||
async function loadSFX(config = DEFAULT_SFX_CONFIG) {
|
||||
if (ready) return;
|
||||
|
||||
if (typeof Howl === 'undefined') {
|
||||
console.warn('[Audio] Howler not available — SFX disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = Object.entries(config).map(
|
||||
([name, path]) =>
|
||||
new Promise(resolve => {
|
||||
try {
|
||||
const sound = new Howl({
|
||||
src: [path],
|
||||
preload: true,
|
||||
onload: () => {
|
||||
sounds.set(name, sound);
|
||||
resolve();
|
||||
},
|
||||
onloaderror: () => {
|
||||
console.warn(
|
||||
`[Audio] Failed to load "${name}" (${path}) — continuing without it`,
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[Audio] Error creating Howl for "${name}":`, err);
|
||||
resolve();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
ready = true;
|
||||
console.log(`[Audio] Loaded ${sounds.size} SFX`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sound by name. Silently ignored if not loaded or unknown.
|
||||
*
|
||||
* @param {string} name
|
||||
*/
|
||||
function playSfx(name) {
|
||||
const sound = sounds.get(name);
|
||||
if (sound) {
|
||||
sound.play();
|
||||
}
|
||||
// unknown names are silently ignored — satisfies AC
|
||||
}
|
||||
|
||||
return { loadSFX, playSfx };
|
||||
}
|
||||
|
||||
/** Noop audio handle — used as default when audio is not initialised. */
|
||||
export const NOOP_AUDIO = { loadSFX: async () => {}, playSfx: () => {} };
|
||||
@@ -22,6 +22,7 @@ import { initEffects } from './layers/effects.js';
|
||||
import { initEvidence } from './layers/evidence.js';
|
||||
import { initCamera } from './camera.js';
|
||||
import { createDialogueStateMachine } from './dialogue.js';
|
||||
import { initAudio, DEFAULT_SFX_CONFIG } from './audio.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} RendererState
|
||||
@@ -66,7 +67,13 @@ export async function createCourtRenderer(host) {
|
||||
const background = initBackground(stage);
|
||||
const characters = initCharacters(stage);
|
||||
const ui = initUI(stage);
|
||||
const effects = initEffects(stage);
|
||||
const audio = initAudio();
|
||||
// Load SFX non-blocking — effects work even if files are absent
|
||||
audio.loadSFX(DEFAULT_SFX_CONFIG).catch(err =>
|
||||
console.warn('[Audio] SFX load error:', err),
|
||||
);
|
||||
|
||||
const effects = initEffects(stage, audio);
|
||||
const evidence = initEvidence(stage);
|
||||
const camera = initCamera(stage);
|
||||
|
||||
@@ -164,5 +171,6 @@ export async function createCourtRenderer(host) {
|
||||
evidence,
|
||||
camera,
|
||||
dialogue: dialogueSM,
|
||||
audio,
|
||||
};
|
||||
}
|
||||
|
||||
6
public/renderer/layers/effects.js
vendored
6
public/renderer/layers/effects.js
vendored
@@ -22,8 +22,9 @@ const STAMP_DISPLAY_MS = 1200;
|
||||
|
||||
/**
|
||||
* @param {import('../stage.js').RendererStage} stage
|
||||
* @param {{ playSfx: (name: string) => void }} [audio]
|
||||
*/
|
||||
export function initEffects(stage) {
|
||||
export function initEffects(stage, audio = { playSfx: () => {} }) {
|
||||
const { PIXI, effectsLayer, app } = stage;
|
||||
|
||||
let shakeTimer = null;
|
||||
@@ -195,6 +196,7 @@ export function initEffects(stage) {
|
||||
* Convenience: composite "objection" cue (stamp + flash + shake).
|
||||
*/
|
||||
function objection() {
|
||||
audio.playSfx('objection');
|
||||
flash({ color: OBJECTION_COLOR, alpha: 0.35 });
|
||||
shake({ intensity: 8, durationMs: 350 });
|
||||
stamp({ text: 'OBJECTION!', color: OBJECTION_COLOR });
|
||||
@@ -204,6 +206,7 @@ export function initEffects(stage) {
|
||||
* Convenience: "hold it" cue.
|
||||
*/
|
||||
function holdIt() {
|
||||
audio.playSfx('hold_it');
|
||||
flash({ color: HOLD_IT_COLOR, alpha: 0.3 });
|
||||
stamp({ text: 'HOLD IT!', color: HOLD_IT_COLOR });
|
||||
}
|
||||
@@ -212,6 +215,7 @@ export function initEffects(stage) {
|
||||
* Convenience: "take that" cue.
|
||||
*/
|
||||
function takeThat() {
|
||||
audio.playSfx('dramatic_sting');
|
||||
flash({ color: TAKE_THAT_COLOR, alpha: 0.3 });
|
||||
stamp({ text: 'TAKE THAT!', color: TAKE_THAT_COLOR });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user