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:
Patrick Fanella
2026-03-01 10:58:49 -06:00
committed by GitHub
7 changed files with 470 additions and 2 deletions

View 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

View 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

File diff suppressed because one or more lines are too long

View File

@@ -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
View 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: () => {} };

View File

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

View File

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