Skip to content

feat(audio)!: replace the Howler wrapper with a Web Audio sound mixer - #716

Open
stormmuller wants to merge 7 commits into
devfrom
claude/quirky-fermat-d9z7gp
Open

stormmuller wants to merge 7 commits into
devfrom
claude/quirky-fermat-d9z7gp

Conversation

@stormmuller

@stormmuller stormmuller commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Implements Phase 1 of the sound mixer design (design/audio-mixer.md, tasks 1.1-1.11). The design doc is removed in this PR now that it has shipped, following the convention from #720, and its finding is marked implemented in design/demo-findings.md.

Forge's audio module was a thin wrapper around Howler. It had no buses, no volume or mute per group, no way to play a sound without an entity, and no report of when a sound ends. Removing an entity didn't stop its sound, and stopping the world unloaded shared Howls. This PR replaces it with a mixer built directly on Web Audio.

New API (@forge-game-engine/forge/audio)

  • createSoundMixer(context?): owns the AudioContext and a master bus. It also provides:
    • createBus(name, parent?) and getBus
    • state
    • suspend/resume
    • stop(), which stops every sound, closes the context and removes its listeners
  • Unlocking: the mixer listens for pointerup/touchend/click/keydown (Escape ignored) until the context reports 'running'. It listens again if audio drops to suspended or interrupted without the game asking.
  • MixerBus: volume (linear) and muted. Changes ramp with setTargetAtTime, so they don't click. Buses nest.
  • SoundAsset, createSoundAsset({ sampleRate, channels }), and SoundAssetCache (an AssetCache). Concurrent loads of one URL share a single fetch and decode. HTTP and decode failures reject with an error naming the URL.
  • playSound(bus, sound, { volume, rate, loop }) returns a PlayingSound with volume, isPlaying and stop(). Stopping fades out with a linear ramp before the source stops.
  • SoundEcsComponent (addSoundComponent, soundId) and createSoundEcsSystem(). The system runs every tick and:
    • starts sounds;
    • pauses and resumes at the playback position (accounting for rate changes);
    • applies volume, rate, loop and bus changes live, and restarts when sound changes;
    • sets hasFinished on natural end (written only by the system; addSoundComponent doesn't accept it);
    • stops sounds whose component or entity was removed;
    • stops everything in cleanup.

Removed: AudioEcsComponent, audioId, addAudioComponent, createAudioEcsSystem, and howler/@types/howler from both package.jsons and lockfiles.

Docs site

  • Space shooter: music is now a looping SoundEcsComponent on the music bus, and laser and explosion are playSound calls on the sfx bus. The explosion's second entity with its guessed 6-second lifetime is gone, and so is the Howl created per gun system.
  • useGame teardown: useGame/Demo now pass createGame a stopWithGame(resource) callback, so a demo's mixer is stopped after its game on unmount, including when it unmounts mid-creation. Every other demo is unchanged, since they ignore the argument.
  • Audio guides: rewritten as an overview plus Mixer and Buses, Loading Sounds and Playing Sounds.
  • Other pages: ecs/system.md (its cleanup example was the old two-writer audio system), asset-loading/index.md and ui/controls.md.

Other: README, AGENTS.md (audio line, peer deps, test-helpers/ convention, the e2e audio-activation gotcha) and the create-component skill.

Where this differs from the design draft

  • Gesture rule: a non-looping sound is dropped only if no gesture has happened and the context isn't running. A browser that already allows audio creates the context 'running', for example after client-side navigation within the docs site. There, the literal §5.4 rule would drop every sound effect until the next click. The burst of stale sounds that DL-5 avoids can only happen while the context can't play.
  • hasFinished isn't readonly in the type, because the system has to write it without a cast. addSoundComponent doesn't accept it, so game code can't set it.
  • Undefined edge cases, now settled:
    • Changing a component's bus to one from another mixer throws, because Web Audio can't connect nodes across contexts.
    • Stopping the mixer while its world keeps running marks the component's sound hasFinished, instead of retrying every tick.
  • Open questions: all took the proposed answer (no streaming, no dB helpers, no voice limiting, no automatic visibility suspend; the audio guide shows the visibilitychange snippet).

solution-reviewer verdict

REVISE, with six points, all adopted before implementation:

  1. No MixerBus.output. It would have exposed the graph to a second writer. The e2e test meters through an injected AudioContext subclass instead.
  2. No union type in useGame. It uses one callback signature instead of Game | { game, mixer }.
  3. Edge cases defined: a bus change across mixers, and a mixer stopped while the world keeps running (above).
  4. e2e asserts the suspended precondition. Two Playwright behaviours unlock audio early: every page.evaluate runs as a user gesture, and trace recording snapshots the page. So the spec turns tracing off and waits for a console signal from the scene before evaluating anything. With tracing on, the context started 'running'.
  5. The gesture-rule refinement is documented (above).
  6. Missed callers updated: ecs/system.md, the skill, asset-loading/index.md, ui/controls.md and AGENTS.md.

Reviewer suggestion not taken in this PR: the space shooter's KeyboardInputSource is never stopped either. That's a separate fix.

Testing

  • Unit: 64 tests against a small fake AudioContext in src/audio/test-helpers/, excluded from the build and coverage. They cover graph wiring, gains, gesture rule and re-arming, stop, the cache's shared loads and errors, createSoundAsset validation, and the component reconciliation (pause position, rate-change tracking, loop wrap, bus/sound/loop changes, finish, removal, mixer stop).
  • e2e (audio-mixer.spec.ts):
    • Before the click, audio is 'suspended' and a one-shot is dropped.
    • A real click unlocks audio, and the sound played by that click plays.
    • Measured as RMS on the master output: half volume is 0.5× full (±0.05), and the muted bus is silent.
    • Removing a sound entity silences it.
    • Passes repeatedly. Breaking either bus muting or stop-on-removal makes it fail.
  • Docs site:
    • npm run build from the root, then typecheck and build in documentation-site/.
    • Loaded /demos/space-shooter in Chromium: it renders, music starts, shooting plays a laser per volley, no page errors, and the AudioContext is closed after navigating to another page.

All of the above re-run after each merge of dev into this branch, most recently with the storage, states, physics, render-target and text changes.

Related issue(s)

Implements design/audio-mixer.md (finding 2 in design/demo-findings.md). The Galactic Journey game repo uses the published package, so it can migrate once this is released.

Verification checklist

  • npm run check-types passes with 0 errors
  • npm test passes (2160 tests after the latest merge of dev)
  • npm run lint passes with 0 errors (2 existing TODO warnings)
  • npm run cspell passes with 0 errors
  • npm run check-exports passes
  • Any new/changed public API is exported from the module's index.ts
  • Documentation under /documentation-site/docs/docs is updated
  • The space-shooter demo has been updated and verified

Changelog

  • Bullets added under ## [Unreleased] in CHANGELOG.md (#### Added and #### Removed, with migration steps)

🤖 Generated with Claude Code

https://claude.ai/code/session_01GR7HgBEY8CatWd78XsJkag

claude added 2 commits October 6, 2026 16:21
Implements Phase 1 of design/audio-mixer.md. Adds createSoundMixer with
nestable buses (volume, mute), gesture unlocking and teardown;
SoundAssetCache and createSoundAsset; entity-free playSound; and
SoundEcsComponent with createSoundEcsSystem, which applies live changes,
reports hasFinished and stops sounds whose entity or component is
removed. Removes AudioEcsComponent, createAudioEcsSystem and the howler
peer dependency, and migrates the space-shooter demo, the audio guides
and the docs site's useGame teardown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR7HgBEY8CatWd78XsJkag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR7HgBEY8CatWd78XsJkag
@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.41176% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/audio/sound-mixer.ts 97.33% 0 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

claude added 5 commits October 6, 2026 17:15
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR7HgBEY8CatWd78XsJkag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR7HgBEY8CatWd78XsJkag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR7HgBEY8CatWd78XsJkag
Removes design/audio-mixer.md and marks the sound mixer finding as
implemented in design/demo-findings.md, following #720's convention of
deleting design documents once they ship.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR7HgBEY8CatWd78XsJkag
…stances

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR7HgBEY8CatWd78XsJkag

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants