From 28706f101f4a1a985cb64f8a1d8c3647e68ef92b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:21:23 +0000 Subject: [PATCH 1/2] feat(audio)!: replace the Howler wrapper with a Web Audio sound mixer 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 Claude-Session: https://claude.ai/code/session_01GR7HgBEY8CatWd78XsJkag --- .claude/skills/create-component/SKILL.md | 12 +- .cspell/project-words.txt | 2 + AGENTS.md | 18 +- CHANGELOG.md | 12 + README.md | 1 - design/audio-mixer.md | 29 +- .../docs/docs/asset-loading/index.md | 16 +- documentation-site/docs/docs/audio/index.md | 60 ++-- .../docs/docs/audio/loading-sounds.md | 74 +++++ .../docs/docs/audio/mixer-and-buses.md | 112 +++++++ .../docs/docs/audio/playing-sounds.md | 162 ++++++---- documentation-site/docs/docs/ecs/system.md | 49 +-- documentation-site/docs/docs/ui/controls.md | 2 +- documentation-site/package-lock.json | 19 +- documentation-site/package.json | 2 - documentation-site/src/components/Demo.tsx | 5 +- documentation-site/src/hooks/useGame.ts | 34 +- .../demos/space-shooter/_create-explosions.ts | 31 +- .../pages/demos/space-shooter/_create-game.ts | 35 +- .../demos/space-shooter/_create-music.ts | 28 +- .../pages/demos/space-shooter/_gun.system.ts | 49 ++- .../src/pages/demos/space-shooter/index.tsx | 6 +- e2e/fixtures/scenes/audio-mixer.ts | 170 ++++++++++ e2e/specs/audio-mixer.spec.ts | 230 +++++++++++++ package-lock.json | 16 - package.json | 2 - src/audio/components/audio-component.test.ts | 44 --- src/audio/components/audio-component.ts | 61 ---- src/audio/components/index.ts | 2 +- src/audio/components/sound-component.test.ts | 72 +++++ src/audio/components/sound-component.ts | 86 +++++ src/audio/index.ts | 5 + src/audio/internal/audio-internals.ts | 161 ++++++++++ src/audio/internal/create-mixer-bus.ts | 56 ++++ src/audio/internal/sound-instance.ts | 256 +++++++++++++++ src/audio/mixer-bus.ts | 23 ++ src/audio/play-sound.test.ts | 183 +++++++++++ src/audio/play-sound.ts | 125 ++++++++ src/audio/sound-asset-cache.test.ts | 91 ++++++ src/audio/sound-asset-cache.ts | 118 +++++++ src/audio/sound-asset.test.ts | 47 +++ src/audio/sound-asset.ts | 65 ++++ src/audio/sound-mixer.test.ts | 217 +++++++++++++ src/audio/sound-mixer.ts | 244 ++++++++++++++ src/audio/systems/audio-system.test.ts | 132 -------- src/audio/systems/audio-system.ts | 35 -- src/audio/systems/index.ts | 2 +- src/audio/systems/sound-system.test.ts | 303 ++++++++++++++++++ src/audio/systems/sound-system.ts | 178 ++++++++++ src/audio/test-helpers/fake-audio-context.ts | 183 +++++++++++ tsconfig.build.json | 8 +- vite.config.base.js | 6 +- 52 files changed, 3372 insertions(+), 507 deletions(-) create mode 100644 documentation-site/docs/docs/audio/loading-sounds.md create mode 100644 documentation-site/docs/docs/audio/mixer-and-buses.md create mode 100644 e2e/fixtures/scenes/audio-mixer.ts create mode 100644 e2e/specs/audio-mixer.spec.ts delete mode 100644 src/audio/components/audio-component.test.ts delete mode 100644 src/audio/components/audio-component.ts create mode 100644 src/audio/components/sound-component.test.ts create mode 100644 src/audio/components/sound-component.ts create mode 100644 src/audio/internal/audio-internals.ts create mode 100644 src/audio/internal/create-mixer-bus.ts create mode 100644 src/audio/internal/sound-instance.ts create mode 100644 src/audio/mixer-bus.ts create mode 100644 src/audio/play-sound.test.ts create mode 100644 src/audio/play-sound.ts create mode 100644 src/audio/sound-asset-cache.test.ts create mode 100644 src/audio/sound-asset-cache.ts create mode 100644 src/audio/sound-asset.test.ts create mode 100644 src/audio/sound-asset.ts create mode 100644 src/audio/sound-mixer.test.ts create mode 100644 src/audio/sound-mixer.ts delete mode 100644 src/audio/systems/audio-system.test.ts delete mode 100644 src/audio/systems/audio-system.ts create mode 100644 src/audio/systems/sound-system.test.ts create mode 100644 src/audio/systems/sound-system.ts create mode 100644 src/audio/test-helpers/fake-audio-context.ts diff --git a/.claude/skills/create-component/SKILL.md b/.claude/skills/create-component/SKILL.md index 586448d28..d294758dd 100644 --- a/.claude/skills/create-component/SKILL.md +++ b/.claude/skills/create-component/SKILL.md @@ -134,9 +134,17 @@ Notes: defaulted ones into a `DefaultedOptions` interface so `defaultOptions` can be typed as that (not the whole component, which would wrongly force the required fields into the - defaults object too) — see `audio-component.ts`, `lifetime-component.ts`, - or `sprite-animation-component.ts`. `sprite-component.ts` shows the same + defaults object too) — see `lifetime-component.ts` or + `sprite-animation-component.ts`. `sprite-component.ts` shows the same shape with more fields. + - **Has an output field that a system writes** (e.g. `hasFinished` on + `SoundEcsComponent`, which only `createSoundEcsSystem` writes): leave it + out of the factory's options, so a caller can't set it and become its + second writer. Type `options` as + `RequiredOptions & Partial<DefaultedOptions>` + and set the output field's starting value in the factory after + spreading the options (see `sound-component.ts`). Document the field as + output, written only by its system. - **Has no required fields, only defaulted fields**: no interface split needed at all — type `defaultOptions` as the full `EcsComponent` directly, as in the template above (see diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt index dbd1070ae..b5fb0d8db 100644 --- a/.cspell/project-words.txt +++ b/.cspell/project-words.txt @@ -195,6 +195,7 @@ undersamples unedit unflipped unmarks +unmuting unnegated unrepresentable unrotated @@ -212,6 +213,7 @@ viewports Viktor visibilitychange Vleugels +Vorbis WASD webgl webglcontextlost diff --git a/AGENTS.md b/AGENTS.md index c7da64531..23c2113a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Forge is a browser-based, code-only game engine built with TypeScript. It provid - **ECS (Entity-Component-System)**: Core architecture pattern - **Rendering**: WebGL2-based rendering system - **Physics**: Native 2D physics engine (rigid bodies, collision detection/resolution, gravity) -- **Audio**: Sound management via Howler.js +- **Audio**: Web Audio mixer with buses, sound assets, one-shot playback and entity-bound sounds - **Animations**: Robust animation system - **Input**: Keyboard, mouse, and gamepad input handling - **Particles**: Particle system @@ -488,6 +488,11 @@ describe('MyClass', () => { `Material.setUniform` picks the upload from the declared type and throws for a value that doesn't fit it (or for an unknown type such as `0`), and `bind` calls the matching `uniform*` method, so mock that method too +- Fakes shared by several test files go in a `test-helpers/` folder inside + the module (e.g. `src/audio/test-helpers/fake-audio-context.ts`, a + stand-in for the Web Audio API, which jsdom lacks). `tsconfig.build.json` + and the coverage config exclude `**/test-helpers/**`, so they never ship + in `/dist` ### Coverage @@ -552,6 +557,15 @@ vite.config.e2e.js # dev server for fixtures/, rooted like vite.config. not `Game.run()`'s `requestAnimationFrame` loop), instead of waiting on real time. This is what keeps the suite flake-free. +**Audio scenes and user activation**: Playwright runs every `page.evaluate` +(including `waitForFunction`'s polling) as a user gesture, and recording a +trace does the same when it snapshots the page. Either one before a scene +creates its `AudioContext` lets the browser start audio unlocked, so a test +of the first-gesture behavior passes for the wrong reason or fails. +`audio-mixer.spec.ts` turns tracing off with `test.use({ trace: 'off' })` +and waits for a console message from the scene, not for +`window.__forgeTestHooks`, before it evaluates anything. + **Node vs. browser split**: `e2e/specs/*.spec.ts` files run under Node (Playwright's own TS loader), not through Vite - they can `import type` from a scene module freely (erased at compile time), but a _value_ import that @@ -953,7 +967,7 @@ export class Entity { ### Dependencies -- Peer dependencies: `howler` +- Peer dependencies: `msdf-bmfont-xml` (optional, only for generating font atlases) - Keep dependencies minimal and well-maintained ## Additional Resources diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2f7e2ce..83e4a903a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +#### Added + +- **audio:** A sound mixer built on the Web Audio API. `createSoundMixer()` owns the game's audio output and starts it on the player's first click, tap or key press (and again after Safari interrupts it). `mixer.createBus(name, parent?)` creates nestable buses whose `volume` and `muted` apply to every sound played through them, including sounds already playing. `mixer.suspend()`/`resume()` pause all audio, and `mixer.stop()` releases it when the game is torn down +- **audio:** `SoundAssetCache` loads and decodes each sound file once and shares concurrent loads, and `createSoundAsset({ sampleRate, channels })` makes a sound from samples, for audio generated at runtime. Sounds stay usable after the world that played them stops +- **audio:** `playSound(bus, sound, { volume, rate, loop })` plays a sound without an entity and returns a handle to change its volume or stop it. Any number of sounds, including copies of the same one, can play at once +- **audio:** `SoundEcsComponent` (`addSoundComponent`, `soundId`) with `createSoundEcsSystem()` plays a sound that belongs to an entity. Changes to its `volume`, `rate`, `loop`, `bus`, `sound` and `paused` apply while it plays, removing the component or the entity stops it, and `hasFinished` reports when a non-looping sound has played to its end +- **audio:** A non-looping sound requested before the player's first interaction with the page is dropped rather than played late on the first click; looping sounds start and are heard once audio runs + +#### Removed + +- **audio:** `AudioEcsComponent`, `audioId`, `addAudioComponent` and `createAudioEcsSystem`, along with the Howler.js peer dependency, so you can uninstall `howler` and `@types/howler`. Create one mixer with `createSoundMixer()` and buses for your volume settings, load sounds with `new SoundAssetCache(mixer).getOrLoad(url)` instead of creating `Howl`s, replace `playSound: true` one-shots with `playSound(bus, sound, options)`, and replace sounds that belong to an entity (music, loops) with `addSoundComponent(world, entity, { sound, bus, loop })` and `createSoundEcsSystem()`. Removing an entity now stops its sound, so drop any code that stopped or unloaded sounds before removing entities + ## [0.25.8] - 2026-10-03 #### Fixed diff --git a/README.md b/README.md index 8852dcaa7..a89bab73c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,5 @@ You can make a separate folder or package outside of the engine. ## Acknowledgements -- [Howler.js](https://howlerjs.com/) - [Vite](https://vite.dev/) - [Kenny](https://www.kenney.nl/) diff --git a/design/audio-mixer.md b/design/audio-mixer.md index d69a860ae..d6c4cb6eb 100644 --- a/design/audio-mixer.md +++ b/design/audio-mixer.md @@ -2,7 +2,7 @@ | | | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Status** | Draft, for review | +| **Status** | Phase 1 implemented; the engine behaves as documented in `documentation-site/docs/docs/audio` (see §11 for where it differs from this draft) | | **Kind** | Missing feature | | **Found in** | Galactic Journey demo: `src/audio/audio-mixer.ts`, `src/speed/create-speed-sounds.ts`, `src/explosions/create-explosions.ts`, `src/gun/gun.system.ts`, `src/enemy/enemy.system.ts`, `src/music/create-music.ts`, `src/main-menu/create-settings-panel.ts` | | **Engine version at time of writing** | `0.25.8` | @@ -671,3 +671,30 @@ writer per field, like `LifetimeEcsComponent.hasExpired`. dependency, so games can uninstall it. - The demo's own `audio-mixer.ts` keeps only its saved-settings code; `create-speed-sounds.ts` drops its WAV encoder. + +## 11. Implementation notes + +Phase 1 shipped with these differences from the draft above, each settled +during review of the implementation plan: + +- **The gesture rule also checks the context's state.** A non-looping + sound is dropped only if no gesture has happened _and_ the context isn't + running. A browser that already allows the page to play audio (for + example after client-side navigation within a site the player has + interacted with, as on the docs site) creates the context `'running'`, + and the literal §5.4 rule would have dropped every sound effect until the + next click there. The stale-burst problem DL-5 avoids only exists 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. +- **Edge cases the draft left open:** changing a component's `bus` to a bus + of a different mixer throws (Web Audio can't connect nodes across + contexts), and a sound whose mixer is stopped while its world keeps + running reports `hasFinished` instead of being restarted. +- **The docs site's `useGame`** passes each demo's `createGame` a + `stopWithGame(resource)` callback; the space shooter registers its mixer + with it. +- **Open questions** all took the proposed answer: no streaming, no + decibel helpers, no voice limiting, and no automatic suspend while the + page is hidden (the audio guide shows the `visibilitychange` snippet). diff --git a/documentation-site/docs/docs/asset-loading/index.md b/documentation-site/docs/docs/asset-loading/index.md index a4f3cede4..729647f81 100644 --- a/documentation-site/docs/docs/asset-loading/index.md +++ b/documentation-site/docs/docs/asset-loading/index.md @@ -6,16 +6,18 @@ sidebar_position: 5 Asset loading covers fetching external files (images, sprite sheets, sounds, data) and turning them into objects your game can use, then caching -the results so the same file is never fetched twice. Forge ships two -concrete caches today, [`ImageCache`](/Forge/docs/api/classes/ImageCache) -and [`FontAtlasCache`](/Forge/docs/api/classes/FontAtlasCache) (see -[Text](../text/index.md)), plus two supporting building blocks: +the results so the same file is never fetched twice. Forge ships three +concrete caches today, [`ImageCache`](/Forge/docs/api/classes/ImageCache), +[`FontAtlasCache`](/Forge/docs/api/classes/FontAtlasCache) (see +[Text](../text/index.md)) and +[`SoundAssetCache`](/Forge/docs/api/classes/SoundAssetCache) (see +[Audio](../audio/index.md)), plus two supporting building blocks: - [`AssetCache`](/Forge/docs/api/interfaces/AssetCache): the common `get` / `load` / `getOrLoad` contract that asset caches implement. - `ImageCache` and `FontAtlasCache` both implement it; if you add a cache - for another asset type (audio buffers, arbitrary JSON data), implement - this interface so it behaves consistently with the rest of the engine. + All three caches implement it; if you add a cache for another asset type + (arbitrary JSON data, for example), implement this interface so it + behaves consistently with the rest of the engine. - [`AssetRegistry`](/Forge/docs/api/classes/AssetRegistry): maps human-readable string IDs to compact numeric IDs, so hot-path code (like a per-frame animation system) can look up an asset by index instead of by diff --git a/documentation-site/docs/docs/audio/index.md b/documentation-site/docs/docs/audio/index.md index 39dfdb322..28262b40a 100644 --- a/documentation-site/docs/docs/audio/index.md +++ b/documentation-site/docs/docs/audio/index.md @@ -1,28 +1,48 @@ # Audio -Forge's audio integration is a thin ECS wrapper around -[Howler.js](https://github.com/goldfire/howler.js): an -[`AudioEcsComponent`](/Forge/docs/api/interfaces/AudioEcsComponent) pairs a -Howler `Howl` instance with a `playSound` flag, and -[`createAudioEcsSystem`](/Forge/docs/api/functions/createAudioEcsSystem) -plays queued sounds each tick. +Forge plays audio through the browser's Web Audio API. A game creates one +[`SoundMixer`](/Forge/docs/api/interfaces/SoundMixer), arranges +[buses](/Forge/docs/api/interfaces/MixerBus) under its `master` bus (for +example `music` and `sfx`), loads sounds once, and plays them through a +bus: -`howler` is a peer dependency. Install it alongside Forge: +```ts +import { + createSoundMixer, + playSound, + SoundAssetCache, +} from '@forge-game-engine/forge/audio'; -```bash -npm install howler -``` +const mixer = createSoundMixer(); +const music = mixer.createBus('music'); +const sfx = mixer.createBus('sfx'); + +const sounds = new SoundAssetCache(mixer); +const laser = await sounds.getOrLoad('audio/laser.mp3'); -Core concepts: +playSound(sfx, laser, { volume: 0.5 }); +``` -- [`AudioEcsComponent`](/Forge/docs/api/interfaces/AudioEcsComponent): a - `Howl` instance to play, plus a `playSound` flag that triggers playback. -- [`audioId`](/Forge/docs/api/variables/audioId): the component key used to - add an `AudioEcsComponent` to an entity. -- [`createAudioEcsSystem`](/Forge/docs/api/functions/createAudioEcsSystem): - plays queued sounds every tick and unloads them when the world stops. +The pieces: -Guides in this section: +- [`createSoundMixer`](/Forge/docs/api/functions/createSoundMixer) owns the + game's audio output and unlocks it on the player's first click, tap or + key press. See [Mixer and Buses](./mixer-and-buses.md). +- A [`MixerBus`](/Forge/docs/api/interfaces/MixerBus) has a `volume` and a + `muted` flag that apply to every sound played through it, including + sounds already playing. +- A [`SoundAsset`](/Forge/docs/api/interfaces/SoundAsset) is decoded audio, + loaded once with + [`SoundAssetCache`](/Forge/docs/api/classes/SoundAssetCache) or made from + samples with + [`createSoundAsset`](/Forge/docs/api/functions/createSoundAsset). See + [Loading Sounds](./loading-sounds.md). +- [`playSound`](/Forge/docs/api/functions/playSound) plays a sound without + an entity. A + [`SoundEcsComponent`](/Forge/docs/api/interfaces/SoundEcsComponent), + played by + [`createSoundEcsSystem`](/Forge/docs/api/functions/createSoundEcsSystem), + plays a sound that belongs to an entity and stops with it. See + [Playing Sounds](./playing-sounds.md). -- [Playing Sounds](./playing-sounds.md): triggering one-shot and looping - sounds, and cleaning up audio resources. +Forge has no audio dependencies to install. diff --git a/documentation-site/docs/docs/audio/loading-sounds.md b/documentation-site/docs/docs/audio/loading-sounds.md new file mode 100644 index 000000000..539c26dc7 --- /dev/null +++ b/documentation-site/docs/docs/audio/loading-sounds.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 2 +--- + +# Loading Sounds + +A [`SoundAsset`](/Forge/docs/api/interfaces/SoundAsset) is decoded audio. +It can play any number of times at once, on any bus, and stays usable +after the world that played it stops, so load each sound once and share +it. + +## From files + +[`SoundAssetCache`](/Forge/docs/api/classes/SoundAssetCache) is an +[asset cache](../asset-loading/index.md): `getOrLoad` fetches and decodes a +file the first time and returns the cached sound afterwards. Requests for +a file that's still loading share that load. + +```ts +import { SoundAssetCache } from '@forge-game-engine/forge/audio'; + +const sounds = new SoundAssetCache(mixer); + +const [music, laser, explosion] = await Promise.all([ + sounds.getOrLoad('audio/theme.mp3'), + sounds.getOrLoad('audio/laser.mp3'), + sounds.getOrLoad('audio/explosion.mp3'), +]); +``` + +Load sounds before gameplay starts. Decoding a long file takes noticeable +time, and a sound effect loaded when it's first needed plays late. + +### Formats + +There is one file per sound, so use a format every browser decodes: MP3, +AAC (`.m4a`) or WAV. Older Safari versions can't decode Ogg Vorbis or +Opus. `getOrLoad` rejects for a file that can't be fetched or decoded, +naming its URL. + +### Memory + +A decoded sound holds every sample in memory: a three-minute stereo track +takes around 60 MB. Keep music tracks to what the game plays, and drop +references to sounds a level no longer needs (`sounds.assets.delete(url)` +removes one from the cache). + +## From samples + +[`createSoundAsset`](/Forge/docs/api/functions/createSoundAsset) makes a +sound from samples, for audio synthesized at runtime. Each channel is a +`Float32Array` of samples between -1 and 1: + +```ts +import { createSoundAsset } from '@forge-game-engine/forge/audio'; + +const sampleRate = 44100; +const samples = new Float32Array(sampleRate * 0.5); + +// A half-second rising "whoosh" of filtered noise, fading out. +let filtered = 0; + +for (let i = 0; i < samples.length; i++) { + const progress = i / samples.length; + const smoothing = 0.02 + progress * 0.2; + + filtered += (Math.random() * 2 - 1 - filtered) * smoothing; + samples[i] = filtered * (1 - progress); +} + +const whoosh = createSoundAsset({ sampleRate, channels: [samples] }); +``` + +Create it once, like a loaded sound, and play it as often as needed. diff --git a/documentation-site/docs/docs/audio/mixer-and-buses.md b/documentation-site/docs/docs/audio/mixer-and-buses.md new file mode 100644 index 000000000..a5404d906 --- /dev/null +++ b/documentation-site/docs/docs/audio/mixer-and-buses.md @@ -0,0 +1,112 @@ +--- +sidebar_position: 1 +--- + +# Mixer and Buses + +A game has one [`SoundMixer`](/Forge/docs/api/interfaces/SoundMixer). +Every sound plays through one of its +[buses](/Forge/docs/api/interfaces/MixerBus), and every bus feeds into +another bus, up to `master`. A sound's loudness is its own volume times +the volume of each bus on the way to `master`. + +## Buses for a settings screen + +Create a bus for each group of sounds the player can adjust separately. +Most games need two: + +```ts +import { createSoundMixer } from '@forge-game-engine/forge/audio'; + +const mixer = createSoundMixer(); +const music = mixer.createBus('music'); +const sfx = mixer.createBus('sfx'); +``` + +A volume slider or mute toggle writes the bus directly. The change applies +to sounds that are already playing: + +```ts +musicSlider.onValueChanged.registerListener((value) => { + music.volume = value; +}); + +muteToggle.onValueChanged.registerListener((isOn) => { + mixer.master.muted = isOn; +}); +``` + +Muting keeps the bus's `volume`, so unmuting restores it. + +Buses can nest: `mixer.createBus('footsteps', sfx)` creates a bus whose +sounds also follow the `sfx` volume. Bus names are unique within a mixer, +and [`getBus`](/Forge/docs/api/interfaces/SoundMixer#getbus) looks one up by +name. + +### Volume is linear + +`volume` is a linear gain from 0 (silent) to 1 (unchanged). Ears hear +loudness roughly logarithmically, so a slider assigned straight to +`volume` changes little over its top half and a lot near the bottom. For a +slider that sounds even, map its position before assigning it, for example +`bus.volume = position * position`. + +### Saving settings + +The mixer doesn't save anything. Store the volumes and mute flag with the +rest of your game's settings and assign them to the buses when the game +starts. + +## The first click + +Browsers don't play audio until the player has interacted with the page. +The mixer listens for the first click, tap or key press (other than +Escape) and starts audio then. Until that happens: + +- A looping sound (music, ambience) starts, and is heard as soon as audio + runs. +- A non-looping sound is dropped. A sound effect from before the player + touched anything, such as an explosion in an attract loop, would + otherwise play late, together with every other one, on the first click. +- A sound played by the first click itself (a menu button, the first shot) + plays. + +If the browser already lets the page play audio (for example after the +player navigated to it from another page of the same site), nothing is +dropped. + +The mixer also starts audio again after it stops without the game asking, +for example when Safari pauses it for a phone call. +[`state`](/Forge/docs/api/interfaces/SoundMixer#state) reports +`'suspended'`, `'running'`, `'interrupted'` (Safari) or `'closed'`. + +## Pausing audio + +[`suspend`](/Forge/docs/api/interfaces/SoundMixer#suspend) pauses all +audio and [`resume`](/Forge/docs/api/interfaces/SoundMixer#resume) +continues it from the same place. Browsers slow down a hidden tab's game +loop but keep its audio playing, so music keeps going over a paused game. +To silence a game whose tab is hidden, suspend on `visibilitychange`: + +```ts +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + void mixer.suspend(); + } else { + void mixer.resume(); + } +}); +``` + +## Tearing down + +Call [`stop`](/Forge/docs/api/interfaces/SoundMixer#stop) when the game is +torn down, for example when a single-page app navigates away from it. It +stops every sound and releases the browser's audio resources; a mixer that +isn't stopped keeps playing its sounds. Stop the game's worlds before the +mixer, since `createSoundEcsSystem` stops its sounds when its world stops. +A stopped mixer can't play sounds again; create a new one. + +## iOS silent switch + +On iPhones and iPads, the hardware silent switch mutes the page's audio. diff --git a/documentation-site/docs/docs/audio/playing-sounds.md b/documentation-site/docs/docs/audio/playing-sounds.md index 10e619ad4..a598ed37c 100644 --- a/documentation-site/docs/docs/audio/playing-sounds.md +++ b/documentation-site/docs/docs/audio/playing-sounds.md @@ -1,95 +1,127 @@ --- -sidebar_position: 1 +sidebar_position: 3 --- # Playing Sounds -[`AudioEcsComponent`](/Forge/docs/api/interfaces/AudioEcsComponent) holds a -Howler [`Howl`](https://github.com/goldfire/howler.js#documentation) and a -`playSound` flag. -[`createAudioEcsSystem`](/Forge/docs/api/functions/createAudioEcsSystem) -checks that flag every tick: when it's `true`, it calls `sound.play()` and -resets `playSound` back to `false`. +There are two ways to play a sound: -## Quick start +- [`playSound`](/Forge/docs/api/functions/playSound) for a sound that's + an event: a shot, an explosion, a button click. It needs no entity. +- A [`SoundEcsComponent`](/Forge/docs/api/interfaces/SoundEcsComponent) + for a sound that belongs to an entity: an engine's hum, an alarm on a + pickup, the music of a level. It stops when its entity (or the + component) is removed. -Create the `Howl` once, store it in the component, and register the system: +Both take the bus to play through. There is no default bus, so every +sound follows the volume setting it belongs under. + +## One-shots with playSound ```ts -import { - addAudioComponent, - audioId, - createAudioEcsSystem, -} from '@forge-game-engine/forge/audio'; -import { createGame } from '@forge-game-engine/forge/utilities'; -import { Howl } from 'howler'; +import { playSound } from '@forge-game-engine/forge/audio'; -const { world } = createGame('game-container'); +playSound(sfx, explosion, { volume: 0.6 }); -world.addSystem(createAudioEcsSystem()); +// The same sound, lower and quieter, for enemy fire. +playSound(sfx, laser, { volume: 0.3, rate: 0.6 }); +``` -const player = world.createEntity(); +Sounds overlap freely, including several copies of the same sound. +`rate` changes the speed and the pitch together: 2 is twice as fast and an +octave higher. -addAudioComponent(world, player, { - sound: new Howl({ src: ['jump.mp3'] }), -}); +`playSound` returns a +[`PlayingSound`](/Forge/docs/api/interfaces/PlayingSound) for sounds the +game controls itself: + +```ts +const siren = playSound(sfx, sirenSound, { loop: true }); + +siren.volume = 0.3; +siren.stop(); ``` -## Triggering playback +`isPlaying` is `false` once the sound has ended or was stopped. A looping +sound started with `playSound` plays until `stop()` is called, even after +the world stops, so keep its handle. + +## Sounds that belong to an entity -Flip `playSound` to `true` from any other system or event handler when the -sound should play, for example on a rising edge of a jump input: +[`addSoundComponent`](/Forge/docs/api/functions/addSoundComponent) attaches +a sound to an entity, and +[`createSoundEcsSystem`](/Forge/docs/api/functions/createSoundEcsSystem) +plays it: ```ts -const audio = world.getComponent(player, audioId); +import { + addSoundComponent, + createSoundEcsSystem, +} from '@forge-game-engine/forge/audio'; + +world.addSystem(createSoundEcsSystem()); -if (audio && justPressedJump) { - audio.playSound = true; +const hum = addSoundComponent(world, ship, { + sound: engineHum, + bus: sfx, + loop: true, + volume: 0.4, +}); +``` + +The system starts the sound on its next update and keeps it in step with +the component: + +- `volume`, `rate` and `loop` changes apply to the playing sound, so the + hum can follow the ship's speed: `hum.rate = 0.8 + speed / maxSpeed`. +- `paused` pauses the sound where it is; clearing it resumes from there. +- Changing `bus` moves the playing sound to another bus of the same mixer. +- Changing `sound` starts the new sound from the beginning. +- Removing the component or the entity stops the sound. +- Stopping the world stops every sound the system started. + +### Knowing when a sound has finished + +`hasFinished` becomes `true` once a non-looping sound has played to its +end. Use it instead of guessing the sound's length with a timer, for +example to remove an entity once its sound is over: + +```ts +for (let i = 0; i < entities.length; i++) { + if (sounds[i].hasFinished) { + world.removeEntity(entities[i]); + } } ``` -The next `world.update()` plays the sound and resets `playSound` back to -`false` for you, so this is a one-shot trigger; you don't need to reset it -yourself. +Only the system writes `hasFinished`. A finished component plays nothing +more; to play the sound again, remove the component and add a new one. + +## Before the first click -:::caution -Setting `playSound = true` on every tick that a condition holds (for example -"the player is moving") re-triggers playback every frame, stacking -overlapping copies of the same sound. Trigger it on the transition into the -condition (the rising edge), not while it remains true. -::: +Until the player has clicked, tapped or pressed a key, a non-looping sound +is dropped: `playSound` returns a handle whose `isPlaying` is `false`, and +a component reports `hasFinished`. Looping sounds start and are heard once +audio runs. See [The first click](./mixer-and-buses.md#the-first-click). -## Background music and looping sounds +## Common mistakes -For music or ambience, configure looping on the `Howl` itself and trigger -playback once: +**Loading a sound every time it plays.** A new cache per shot fetches +and decodes the file every time: ```ts -const music = world.createEntity(); +// Don't +playSound(sfx, await new SoundAssetCache(mixer).getOrLoad('audio/laser.mp3')); -addAudioComponent(world, music, { - sound: new Howl({ src: ['theme.mp3'], loop: true, volume: 0.4 }), - playSound: true, -}); +// Do: load once, up front, and reuse the asset +const laser = await sounds.getOrLoad('audio/laser.mp3'); +playSound(sfx, laser); ``` -`createAudioEcsSystem` resets `playSound` to `false` after the first -`update()`, but `loop: true` keeps Howler playing the sound, so no further -flag changes are needed. To stop it, call the `Howl` API directly (for -example `music.sound.stop()`); the component doesn't expose a "stop" flag. - -## Cleanup - -`createAudioEcsSystem`'s cleanup hook stops and unloads the `Howl` for any -matching entity whose sound is still playing, but it only runs when the -whole [`world.stop()`](/Forge/docs/api/classes/EcsWorld#stop) (for example -via [`Game.stop()`](/Forge/docs/api/classes/Game#stop)) runs, not when an -individual entity or component is removed. - -:::caution -If you remove an entity with an `AudioEcsComponent` while the game keeps -running (for example a temporary "explosion" entity), this cleanup never -runs for it. Call `sound.stop()` and `sound.unload()` yourself before -removing the entity or component, otherwise the loaded audio buffer stays in -memory for the rest of the session. -::: +**An entity just to play a one-shot.** An entity that only carries a sound +and a lifetime long enough for it to finish is what `playSound` replaces. + +**Playing a sound every frame a condition holds.** `playSound` in an +`update` loop while "the player is moving" starts a new copy every frame. +Play on the change into the condition, or use a looping +`SoundEcsComponent` and set `paused` from the condition. diff --git a/documentation-site/docs/docs/ecs/system.md b/documentation-site/docs/docs/ecs/system.md index 784c2dc8d..4e7ce447a 100644 --- a/documentation-site/docs/docs/ecs/system.md +++ b/documentation-site/docs/docs/ecs/system.md @@ -99,28 +99,37 @@ Systems may implement an optional `cleanup(world)` method. It runs once - not pe Since `cleanup` doesn't receive a query result, a system that needs to release a resource per matched entity should track what it acquired itself (for example in a `Map` keyed by entity id) rather than re-querying the world: ```ts -const audioSystem: EcsSystem<[AudioComponent]> = { - query: [Audio], - update(world, { components: [audioComponents] }) { - for (const audio of audioComponents) { - if (audio.playSound) { - audio.sound.play(); - audio.playSound = false; +import { EcsSystem } from '@forge-game-engine/forge/ecs'; + +// Shows each player's name in an HTML label over the game. +const createNameplateEcsSystem = ( + container: HTMLElement, +): EcsSystem<[NameplateEcsComponent]> => { + const labels = new Map(); + + return { + query: [nameplateId], + update(_world, { entities, components: [nameplates] }) { + for (let i = 0; i < entities.length; i++) { + let label = labels.get(entities[i]); + + if (!label) { + label = document.createElement('div'); + container.appendChild(label); + labels.set(entities[i], label); + } + + label.textContent = nameplates[i].name; } - } - }, - cleanup(world) { - const { - components: [audioComponents], - } = world.query<[AudioComponent]>([Audio]); - - for (const audio of audioComponents) { - if (audio.sound.playing()) { - audio.sound.stop(); - audio.sound.unload(); + }, + cleanup() { + for (const label of labels.values()) { + label.remove(); } - } - }, + + labels.clear(); + }, + }; }; ``` diff --git a/documentation-site/docs/docs/ui/controls.md b/documentation-site/docs/docs/ui/controls.md index 89fe88282..f75f2c5ec 100644 --- a/documentation-site/docs/docs/ui/controls.md +++ b/documentation-site/docs/docs/ui/controls.md @@ -84,7 +84,7 @@ const volume = createSlider(world, canvas, { }); volume.onValueChanged.registerListener((value) => { - audio.volume = value / 100; + musicBus.volume = value / 100; }); ``` diff --git a/documentation-site/package-lock.json b/documentation-site/package-lock.json index 964fd4c6e..c5ee2dc0a 100644 --- a/documentation-site/package-lock.json +++ b/documentation-site/package-lock.json @@ -15,7 +15,6 @@ "@forge-game-engine/forge": "file:..", "@mdx-js/react": "^3.1.1", "clsx": "^2.0.0", - "howler": "^2.2.4", "prism-react-renderer": "^2.3.0", "raw-loader": "^4.0.2", "react": "^19.2.7", @@ -25,7 +24,6 @@ "@docusaurus/module-type-aliases": "^3.10.1", "@docusaurus/tsconfig": "^3.10.2", "@docusaurus/types": "^3.10.2", - "@types/howler": "^2.2.12", "@types/webpack-env": "^1.18.8", "docusaurus-plugin-typedoc": "^1.4.2", "typedoc": "^0.28.20", @@ -38,7 +36,7 @@ }, "..": { "name": "@forge-game-engine/forge", - "version": "0.25.3", + "version": "0.25.8", "license": "MIT", "dependencies": { "@types/imurmurhash": "^0.1.4", @@ -54,7 +52,6 @@ "@commitlint/config-conventional": "^21.2.0", "@eslint/js": "^10.0.1", "@playwright/test": "1.62.1", - "@types/howler": "^2.2.13", "@types/node": "^26.2.0", "@types/seedrandom": "^3.0.8", "@vitest/coverage-v8": "^4.1.10", @@ -79,7 +76,6 @@ "vitest": "^4.1.10" }, "peerDependencies": { - "howler": "^2.2.4", "msdf-bmfont-xml": "^2.8.0" }, "peerDependenciesMeta": { @@ -6324,13 +6320,6 @@ "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", "license": "MIT" }, - "node_modules/@types/howler": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.12.tgz", - "integrity": "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -10969,12 +10958,6 @@ "react-is": "^16.7.0" } }, - "node_modules/howler": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", - "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==", - "license": "MIT" - }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", diff --git a/documentation-site/package.json b/documentation-site/package.json index a04e7cfff..eecf2b174 100644 --- a/documentation-site/package.json +++ b/documentation-site/package.json @@ -25,7 +25,6 @@ "@forge-game-engine/forge": "file:..", "@mdx-js/react": "^3.1.1", "clsx": "^2.0.0", - "howler": "^2.2.4", "prism-react-renderer": "^2.3.0", "raw-loader": "^4.0.2", "react": "^19.2.7", @@ -35,7 +34,6 @@ "@docusaurus/module-type-aliases": "^3.10.1", "@docusaurus/tsconfig": "^3.10.2", "@docusaurus/types": "^3.10.2", - "@types/howler": "^2.2.12", "@types/webpack-env": "^1.18.8", "docusaurus-plugin-typedoc": "^1.4.2", "typedoc": "^0.28.20", diff --git a/documentation-site/src/components/Demo.tsx b/documentation-site/src/components/Demo.tsx index 11d19e009..304264526 100644 --- a/documentation-site/src/components/Demo.tsx +++ b/documentation-site/src/components/Demo.tsx @@ -3,11 +3,10 @@ import Layout from '@theme/Layout'; import Link from '@docusaurus/Link'; import { useLocation } from '@docusaurus/router'; import clsx from 'clsx'; -import { useGame } from '@site/src/hooks/useGame'; +import { CreateDemoGame, useGame } from '@site/src/hooks/useGame'; import { useFullscreen } from '@site/src/hooks/useFullscreen'; import { demoCategories } from '@site/src/data/demo-categories'; import styles from './_Demo.module.css'; -import { Game } from '@forge-game-engine/forge/utilities'; import { CodeSelector } from './_CodeSelector'; interface CodeFile { @@ -23,7 +22,7 @@ interface DemoProps { interactions?: ReactNode; header: string; blurb: string; - createGame: () => Promise; + createGame: CreateDemoGame; codeFiles: CodeFile[]; } diff --git a/documentation-site/src/hooks/useGame.ts b/documentation-site/src/hooks/useGame.ts index 67d9f844d..156f7e008 100644 --- a/documentation-site/src/hooks/useGame.ts +++ b/documentation-site/src/hooks/useGame.ts @@ -1,20 +1,49 @@ import { Game } from '@forge-game-engine/forge/utilities'; import { useEffect, useRef } from 'react'; -type UseGameHook = (createGame: () => Promise) => Game | undefined; +/** + * Something a demo creates alongside its game that has to be stopped when + * the demo unmounts, such as a sound mixer. + */ +export interface DemoResource { + stop(): void | Promise; +} + +/** + * Creates a demo's game. Anything passed to `stopWithGame` is stopped + * after the game when the demo unmounts, including when it unmounts before + * the game has finished being created. + */ +export type CreateDemoGame = ( + stopWithGame: (resource: DemoResource) => void, +) => Promise; + +type UseGameHook = (createGame: CreateDemoGame) => Game | undefined; + +const stopResources = (resources: readonly DemoResource[]): void => { + for (const resource of resources) { + Promise.resolve(resource.stop()).catch((error: unknown) => { + console.error('Failed to stop a demo resource:', error); + }); + } +}; export const useGame: UseGameHook = (createGame) => { const gameRef = useRef(undefined); useEffect(() => { let cancelled = false; + const resources: DemoResource[] = []; const startGame = async () => { - const game = await createGame(); + const game = await createGame((resource) => { + resources.push(resource); + }); if (cancelled) { game.stop(); game.container.querySelector('canvas')?.remove(); + stopResources(resources); return; } @@ -32,6 +61,7 @@ export const useGame: UseGameHook = (createGame) => { gameRef.current.stop(); gameRef.current.container.querySelector('canvas')?.remove(); gameRef.current = undefined; + stopResources(resources); } }; }, [createGame]); diff --git a/documentation-site/src/pages/demos/space-shooter/_create-explosions.ts b/documentation-site/src/pages/demos/space-shooter/_create-explosions.ts index 0963586db..b6ff92969 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-explosions.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-explosions.ts @@ -1,4 +1,3 @@ -import { Howl } from 'howler'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { @@ -8,7 +7,11 @@ import { selectAnimationFrames, } from '@forge-game-engine/forge/animations'; import { AssetRegistry } from '@forge-game-engine/forge/asset-loading'; -import { addAudioComponent } from '@forge-game-engine/forge/audio'; +import { + MixerBus, + playSound, + SoundAsset, +} from '@forge-game-engine/forge/audio'; import { addPositionComponent, addScaleComponent, @@ -31,10 +34,6 @@ const explosionFrameCount = 26; const explosionFrameDurationMilliseconds = 20; const explosionScale = 0.4; -// explosion.mp3 runs ~5.5s, much longer than the sprite animation, so its -// playback is tracked on its own entity instead of the short-lived visual one. -const explosionSoundDurationSeconds = 6; - export interface ExplosionSpawner { animationRegistry: AssetRegistry; spawn: ( @@ -48,6 +47,8 @@ export async function createExplosionSpawner( renderContext: RenderContext, renderLayer: number, triggerCameraShake: () => void, + sfxBus: MixerBus, + explosionSound: SoundAsset, ): Promise { const image = await renderContext.imageCache.getOrLoad( getAssetUrl('img/space-shooter/Effect_Explosion_1_517x517.png'), @@ -115,21 +116,9 @@ export async function createExplosionSpawner( world.addTag(explosionEntity, RemoveFromWorldLifetimeStrategyId); - const explosionSoundEntity = world.createEntity(); - - addAudioComponent(world, explosionSoundEntity, { - sound: new Howl({ - src: getAssetUrl('audio/explosion.mp3'), - volume: 0.6, - }), - playSound: true, - }); - - addLifetimeComponent(world, explosionSoundEntity, { - durationSeconds: explosionSoundDurationSeconds, - }); - - world.addTag(explosionSoundEntity, RemoveFromWorldLifetimeStrategyId); + // The sound outlasts the sprite animation, and needs no entity of its + // own: it plays to its end on the sfx bus. + playSound(sfxBus, explosionSound, { volume: 0.6 }); }, }; } diff --git a/documentation-site/src/pages/demos/space-shooter/_create-game.ts b/documentation-site/src/pages/demos/space-shooter/_create-game.ts index 604194acd..97740f4d6 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-game.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-game.ts @@ -16,7 +16,11 @@ import { RENDER_TARGET_FORMAT, } from '@forge-game-engine/forge/rendering'; import { createGame, Game } from '@forge-game-engine/forge/utilities'; -import { createAudioEcsSystem } from '@forge-game-engine/forge/audio'; +import { + createSoundEcsSystem, + createSoundMixer, + SoundAssetCache, +} from '@forge-game-engine/forge/audio'; import { createTransformEcsSystem } from '@forge-game-engine/forge/common'; import { createLifetimeTrackingEcsSystem, @@ -30,6 +34,8 @@ import { createNarrowPhaseEcsSystem, } from '@forge-game-engine/forge/physics'; import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; +import type { DemoResource } from '@site/src/hooks/useGame'; import { createMovementEcsSystem } from './_movement.system'; import { createBackground } from './_create-background'; import { createBackgroundEcsSystem } from './_background.system'; @@ -74,11 +80,28 @@ export const blurDefaults: GaussianBlurEcsComponent = { }; export const createSpaceShooterGame = async ( + stopWithGame: (resource: DemoResource) => void, onBloomReady?: (bloom: BloomEcsComponent) => void, onBlurReady?: (blur: GaussianBlurEcsComponent) => void, ): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); + // One mixer for the whole game, stopped when the demo page closes. Music + // and sound effects get their own buses, so each could get its own + // volume slider. + const mixer = createSoundMixer(); + + stopWithGame(mixer); + + const musicBus = mixer.createBus('music'); + const sfxBus = mixer.createBus('sfx'); + const sounds = new SoundAssetCache(mixer); + const [musicSound, laserSound, explosionSound] = await Promise.all([ + sounds.getOrLoad(getAssetUrl('audio/background-space-music.mp3')), + sounds.getOrLoad(getAssetUrl('audio/laser.mp3')), + sounds.getOrLoad(getAssetUrl('audio/explosion.mp3')), + ]); + // Background and foreground each get their own off-screen target, so the // blur post-process pass can affect the background only: the present // pass then layers the sharp foreground back on top of the blurred @@ -178,8 +201,10 @@ export const createSpaceShooterGame = async ( renderContext, renderLayers.foreground, triggerCameraShake, + sfxBus, + explosionSound, ); - createMusic(world); + createMusic(world, musicBus, musicSound); const gameOverEntity = world.createEntity(); const gameOverMessageElement = document.createElement('div'); @@ -230,10 +255,12 @@ export const createSpaceShooterGame = async ( world.addSystem(createCameraShakeEcsSystem(time, random)); world.addSystem(createMovementEcsSystem(moveInput, time)); world.addSystem(createBackgroundEcsSystem(time, renderContext)); - world.addSystem(createAudioEcsSystem()); + world.addSystem(createSoundEcsSystem()); world.addSystem(createLifetimeTrackingEcsSystem(time)); world.addSystem(createRemoveFromWorldEcsSystem()); - world.addSystem(createGunEcsSystem(time, world, shootInput)); + world.addSystem( + createGunEcsSystem(time, world, shootInput, sfxBus, laserSound), + ); world.addSystem(createBulletEcsSystem(time)); world.addSystem(createAsteroidSpawnerEcsSystem(time, random)); world.addSystem(createAsteroidEcsSystem(time)); diff --git a/documentation-site/src/pages/demos/space-shooter/_create-music.ts b/documentation-site/src/pages/demos/space-shooter/_create-music.ts index ac20f9108..b64aac25b 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-music.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-music.ts @@ -1,17 +1,23 @@ -import { Howl } from 'howler'; import { EcsWorld } from '@forge-game-engine/forge/ecs'; -import { addAudioComponent } from '@forge-game-engine/forge/audio'; -import { getAssetUrl } from '@site/src/utils/get-asset-url'; +import { + addSoundComponent, + MixerBus, + SoundAsset, +} from '@forge-game-engine/forge/audio'; -export function createMusic(world: EcsWorld): void { +// The music belongs to its own entity, so it plays for as long as the +// entity has its sound component and stops when the world does. +export function createMusic( + world: EcsWorld, + musicBus: MixerBus, + music: SoundAsset, +): void { const musicEntity = world.createEntity(); - addAudioComponent(world, musicEntity, { - sound: new Howl({ - src: getAssetUrl('audio/background-space-music.mp3'), - loop: true, - volume: 0.3, - }), - playSound: true, + addSoundComponent(world, musicEntity, { + sound: music, + bus: musicBus, + loop: true, + volume: 0.3, }); } diff --git a/documentation-site/src/pages/demos/space-shooter/_gun.system.ts b/documentation-site/src/pages/demos/space-shooter/_gun.system.ts index 3e485513e..2e8391180 100644 --- a/documentation-site/src/pages/demos/space-shooter/_gun.system.ts +++ b/documentation-site/src/pages/demos/space-shooter/_gun.system.ts @@ -1,4 +1,3 @@ -import { Howl } from 'howler'; import { EcsSystem, EcsWorld } from '@forge-game-engine/forge/ecs'; import { addPositionComponent, @@ -15,7 +14,11 @@ import { addLifetimeComponent, RemoveFromWorldLifetimeStrategyId, } from '@forge-game-engine/forge/lifecycle'; -import { addAudioComponent } from '@forge-game-engine/forge/audio'; +import { + MixerBus, + playSound, + SoundAsset, +} from '@forge-game-engine/forge/audio'; import { addAabbComponent, addColliderComponent, @@ -23,21 +26,14 @@ import { } from '@forge-game-engine/forge/physics'; import { bulletId } from './_bullet.component'; import { GunEcsComponent, gunId } from './_gun.component'; -import { getAssetUrl } from '@site/src/utils/get-asset-url'; export const createGunEcsSystem = ( time: Time, world: EcsWorld, shootAction: HoldAction, + sfxBus: MixerBus, + laserSound: SoundAsset, ): EcsSystem<[GunEcsComponent, PositionEcsComponent]> => { - // Created per system instance (rather than at module scope) so each game - // restart gets its own Howl, since the audio system unloads any sound - // still playing when the world stops. - const sound = new Howl({ - src: getAssetUrl('audio/laser.mp3'), - volume: 0.2, - }); - return { query: [gunId, positionId], update: (_world, { components: [gunComponents, positionComponents] }) => { @@ -53,20 +49,17 @@ export const createGunEcsSystem = ( continue; } - createBulletWithOffset( - world, - gunComponent, - positionComponent, - { x: 20, y: 20 }, - sound, - ); - createBulletWithOffset( - world, - gunComponent, - positionComponent, - { x: -20, y: 20 }, - sound, - ); + createBulletWithOffset(world, gunComponent, positionComponent, { + x: 20, + y: 20, + }); + createBulletWithOffset(world, gunComponent, positionComponent, { + x: -20, + y: 20, + }); + + // One sound per volley; both bullets fire together. + playSound(sfxBus, laserSound, { volume: 0.4 }); gunComponent.nextAllowedShotTime = time.timeInSeconds + gunComponent.timeBetweenShots; @@ -80,7 +73,6 @@ function createBulletWithOffset( gunComponent: GunEcsComponent, positionComponent: PositionEcsComponent, offset: Vector2, - sound: Howl, ) { const bullet = world.createEntity(); const bulletScale = 0.15; @@ -112,11 +104,6 @@ function createBulletWithOffset( world.addTag(bullet, RemoveFromWorldLifetimeStrategyId); - addAudioComponent(world, bullet, { - playSound: true, - sound, - }); - const bulletRadius = (gunComponent.bulletSprite.width * bulletScale + gunComponent.bulletSprite.height * bulletScale) / diff --git a/documentation-site/src/pages/demos/space-shooter/index.tsx b/documentation-site/src/pages/demos/space-shooter/index.tsx index 6b10eddac..46ebac291 100644 --- a/documentation-site/src/pages/demos/space-shooter/index.tsx +++ b/documentation-site/src/pages/demos/space-shooter/index.tsx @@ -37,6 +37,7 @@ import gameOverComponentCode from '!!raw-loader!./_game-over.component'; import gameOverSystemCode from '!!raw-loader!./_game-over.system'; import { Demo } from '@site/src/components/Demo'; +import type { CreateDemoGame } from '@site/src/hooks/useGame'; import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; import { KeyboardKey } from '@site/src/components/_KeyboardKey'; @@ -52,9 +53,10 @@ export default function Rendering(): JSX.Element { const [blurIntensity, setBlurIntensity] = useState(blurDefaults.intensity); const [blurEnabled, setBlurEnabled] = useState(true); - const createGame = useCallback( - () => + const createGame = useCallback( + (stopWithGame) => createSpaceShooterGame( + stopWithGame, (bloom) => { bloomRef.current = bloom; }, diff --git a/e2e/fixtures/scenes/audio-mixer.ts b/e2e/fixtures/scenes/audio-mixer.ts new file mode 100644 index 000000000..961280e42 --- /dev/null +++ b/e2e/fixtures/scenes/audio-mixer.ts @@ -0,0 +1,170 @@ +import { + addSoundComponent, + createSoundAsset, + createSoundEcsSystem, + createSoundMixer, + MixerBus, + PlayingSound, + playSound, + SoundAsset, + SoundMixerState, +} from '../../../src/audio/index.js'; +import { EcsWorld } from '../../../src/ecs/index.js'; +import { CreateScene, SceneHandle } from './scene.js'; + +const sampleRate = 44100; +const toneFrequency = 440; +const toneAmplitude = 0.5; + +/** Logged once the scene's audio context exists. */ +export const audioMixerSceneReadyMessage = + '[audio-mixer] audio context created'; + +// The buses the spec compares, by name. +export const busNames = ['full', 'half', 'muted'] as const; + +export type BusName = (typeof busNames)[number]; + +/** + * An `AudioContext` whose `destination` is an `AnalyserNode` in front of the + * real one, so everything the mixer's master bus outputs can be measured + * without the mixer exposing its nodes. + */ +class MeteredAudioContext extends AudioContext { + private _meter: AnalyserNode | null = null; + + /** The analyser everything the context plays passes through. */ + get meter(): AnalyserNode { + if (!this._meter) { + const meter = new AnalyserNode(this, { fftSize: 2048 }); + // The real destination, read through the base class's getter since + // this class overrides it. + const speakers: AudioDestinationNode = Reflect.get( + BaseAudioContext.prototype, + 'destination', + this, + ); + + meter.connect(speakers); + this._meter = meter; + } + + return this._meter; + } + + get destination(): AudioDestinationNode { + // Only ever used as a node to connect to, which an analyser is too. + return this.meter as unknown as AudioDestinationNode; + } +} + +/** One second of a sine tone, which loops seamlessly at 440Hz. */ +const createTone = (): SoundAsset => { + const samples = new Float32Array(sampleRate); + + for (let i = 0; i < samples.length; i++) { + samples[i] = + toneAmplitude * Math.sin((2 * Math.PI * toneFrequency * i) / sampleRate); + } + + return createSoundAsset({ sampleRate, channels: [samples] }); +}; + +export interface AudioMixerSceneHandle extends SceneHandle { + /** The mixer's state, e.g. `'suspended'` before the first click. */ + state(): SoundMixerState; + /** Plays the tone once on the `full` bus; returns whether it's playing. */ + playOneShot(): boolean; + /** Whether the tone the first `pointerup` played is still playing. */ + isClickSoundPlaying(): boolean | null; + /** Starts the tone looping on the named bus. */ + startLoop(bus: BusName): void; + /** Stops the looping tone. */ + stopLoop(): void; + /** Adds an entity with a looping sound component on the `full` bus. */ + addSoundEntity(): void; + /** Removes that entity. */ + removeSoundEntity(): void; + /** The root mean square of what the master bus outputs right now. */ + measureLevel(): number; +} + +export const createScene: CreateScene = (): AudioMixerSceneHandle => { + const context = new MeteredAudioContext(); + const mixer = createSoundMixer(context); + const buses: Record = { + full: mixer.createBus('full'), + half: mixer.createBus('half'), + muted: mixer.createBus('muted'), + }; + + buses.half.volume = 0.5; + buses.muted.muted = true; + + const tone = createTone(); + const world = new EcsWorld(); + + world.addSystem(createSoundEcsSystem()); + + let loop: PlayingSound | null = null; + let clickSound: PlayingSound | null = null; + let soundEntity: number | null = null; + + // Plays a sound from the same gesture that unlocks audio, the way a game's + // first click (a menu button, a first shot) does. + window.addEventListener( + 'pointerup', + () => { + clickSound = playSound(buses.full, tone); + }, + { once: true }, + ); + + const samples = new Float32Array(context.meter.fftSize); + + // The spec waits for this instead of polling the page, since Playwright + // runs every `page.evaluate` as a user gesture: one before the context + // exists would let the browser start it unlocked. + console.info(audioMixerSceneReadyMessage); + + return { + step: (): void => { + world.update(); + }, + state: () => mixer.state, + playOneShot: () => playSound(buses.full, tone).isPlaying, + isClickSoundPlaying: () => clickSound?.isPlaying ?? null, + startLoop: (bus) => { + loop = playSound(buses[bus], tone, { loop: true }); + }, + stopLoop: () => { + loop?.stop(); + loop = null; + }, + addSoundEntity: () => { + soundEntity = world.createEntity(); + addSoundComponent(world, soundEntity, { + sound: tone, + bus: buses.full, + loop: true, + }); + }, + removeSoundEntity: () => { + if (soundEntity !== null) { + world.removeEntity(soundEntity); + soundEntity = null; + } + }, + measureLevel: () => { + context.meter.getFloatTimeDomainData(samples); + + let sumOfSquares = 0; + + for (const sample of samples) { + sumOfSquares += sample * sample; + } + + return Math.sqrt(sumOfSquares / samples.length); + }, + }; +}; diff --git a/e2e/specs/audio-mixer.spec.ts b/e2e/specs/audio-mixer.spec.ts new file mode 100644 index 000000000..761ab4e95 --- /dev/null +++ b/e2e/specs/audio-mixer.spec.ts @@ -0,0 +1,230 @@ +import { expect, Page, test } from '@playwright/test'; +import type { + AudioMixerSceneHandle, + BusName, +} from '../fixtures/scenes/audio-mixer.js'; + +// See `translucent-ui-compositing.spec.ts` for why the hooks are cast inline +// rather than declared per scene. +type Hooks = AudioMixerSceneHandle; + +// Matches `audioMixerSceneReadyMessage` in the scene, which can't be imported +// here as a value: it would pull `/src` into Node (see AGENTS.md). +const sceneReadyMessage = '[audio-mixer] audio context created'; + +// How often to re-measure while waiting for the level to settle. +const pollIntervalMilliseconds = 50; + +// How many measurements must be taken, and how little the last two may +// differ, before a level counts as settled. The first measurements after a +// sound starts or stops can catch audio from before the change. +const minimumMeasurements = 4; +const steadyLevelTolerance = 0.002; + +// A level at or below this is silence: a quiet tone at the scene's +// amplitude measures around 0.35. +const silenceLevel = 0.001; + +// How far half a bus's volume may measure from half the full level. +// Amplitude scales exactly with gain, so this only absorbs the analyser +// catching slightly different parts of the waveform. +const halfVolumeTolerance = 0.05; + +// Each call is its own `page.evaluate`, which runs in the page and can't +// close over anything here. +const scene = (page: Page) => ({ + state: () => + page.evaluate(() => (window.__forgeTestHooks as unknown as Hooks).state()), + playOneShot: () => + page.evaluate(() => + (window.__forgeTestHooks as unknown as Hooks).playOneShot(), + ), + isClickSoundPlaying: () => + page.evaluate(() => + (window.__forgeTestHooks as unknown as Hooks).isClickSoundPlaying(), + ), + startLoop: (bus: BusName) => + page.evaluate((name) => { + (window.__forgeTestHooks as unknown as Hooks).startLoop(name); + }, bus), + stopLoop: () => + page.evaluate(() => { + (window.__forgeTestHooks as unknown as Hooks).stopLoop(); + }), + addSoundEntity: () => + page.evaluate(() => { + const hooks = window.__forgeTestHooks as unknown as Hooks; + + hooks.addSoundEntity(); + hooks.step(); + }), + removeSoundEntity: () => + page.evaluate(() => { + const hooks = window.__forgeTestHooks as unknown as Hooks; + + hooks.removeSoundEntity(); + hooks.step(); + }), + measureLevel: () => + page.evaluate(() => + (window.__forgeTestHooks as unknown as Hooks).measureLevel(), + ), +}); + +/** Measures the master bus's level until it stops changing. */ +const measureSteadyLevel = async (page: Page): Promise => { + let measurements = 0; + let previous = 0; + let level = 0; + + await expect + .poll( + async () => { + previous = level; + level = await scene(page).measureLevel(); + measurements++; + + return ( + measurements >= minimumMeasurements && + Math.abs(level - previous) < steadyLevelTolerance + ); + }, + { intervals: [pollIntervalMilliseconds] }, + ) + .toBe(true); + + return level; +}; + +/** Waits until nothing is audible on the master bus. */ +const waitForSilence = (page: Page): Promise => + expect + .poll(() => scene(page).measureLevel(), { + intervals: [pollIntervalMilliseconds], + }) + .toBeLessThan(silenceLevel); + +/** Plays the tone looping on `bus` and measures the master bus's level. */ +const measureBus = async (page: Page, bus: BusName): Promise => { + await scene(page).startLoop(bus); + + const level = await measureSteadyLevel(page); + + await scene(page).stopLoop(); + await waitForSilence(page); + + return level; +}; + +// Recording a trace snapshots the page in a way that counts as a user +// gesture, so the browser would start audio unlocked and the spec couldn't +// test unlocking. The video is still recorded. +test.use({ trace: 'off' }); + +test.describe('sound mixer', () => { + test.beforeEach(async ({ page }) => { + await test.step('load the audio-mixer scene', async () => { + // See `translucent-ui-compositing.spec.ts` for why page errors are + // captured here. + let pageError: Error | undefined; + + page.once('pageerror', (error) => { + pageError = error; + }); + + // Waits on the scene's console message rather than polling the page + // first; see `audioMixerSceneReadyMessage`. + const contextCreated = page.waitForEvent('console', { + predicate: (message) => message.text() === sceneReadyMessage, + }); + + await page.goto('/?scene=audio-mixer'); + await contextCreated; + + try { + await page.waitForFunction(() => Boolean(window.__forgeTestHooks)); + } catch (timeoutError) { + throw pageError ?? timeoutError; + } + }); + }); + + test('unlocks on a click, and buses scale and mute what plays through them', async ({ + page, + }) => { + await test.step('audio starts suspended and drops one-shots', async () => { + // Everything below about the first click depends on this: a browser + // that let audio run without a gesture would play the one-shot. + expect(await scene(page).state()).toBe('suspended'); + expect(await scene(page).playOneShot()).toBe(false); + }); + + await test.step('a real click unlocks audio and plays its own sound', async () => { + await page.mouse.click(100, 100); + + expect(await scene(page).isClickSoundPlaying()).toBe(true); + await expect.poll(() => scene(page).state()).toBe('running'); + await expect + .poll(() => scene(page).measureLevel()) + .toBeGreaterThan(silenceLevel); + }); + + await test.step('wait for the click sound to end', async () => { + await expect + .poll(() => scene(page).isClickSoundPlaying(), { + timeout: 5000, + }) + .toBe(false); + await waitForSilence(page); + }); + + const full = await test.step('measure the full-volume bus', () => + measureBus(page, 'full')); + const half = await test.step('measure the half-volume bus', () => + measureBus(page, 'half')); + const muted = await test.step('measure the muted bus', () => + measureBus(page, 'muted')); + + await test.step('compare the levels', () => { + expect(full, 'the full bus should be audible').toBeGreaterThan(0.1); + expect( + half / full, + `half volume should measure half the full level (full ${full}, half ${half})`, + ).toBeGreaterThan(0.5 - halfVolumeTolerance); + expect(half / full).toBeLessThan(0.5 + halfVolumeTolerance); + expect(muted, 'the muted bus should be silent').toBeLessThan( + silenceLevel, + ); + }); + }); + + test("an entity's sound stops when the entity is removed", async ({ + page, + }) => { + await page.mouse.click(100, 100); + await expect.poll(() => scene(page).state()).toBe('running'); + await expect + .poll(() => scene(page).isClickSoundPlaying(), { + timeout: 5000, + }) + .toBe(false); + await waitForSilence(page); + + const playing = await test.step('add the entity and step', async () => { + await scene(page).addSoundEntity(); + + return measureSteadyLevel(page); + }); + + const removed = await test.step('remove the entity and step', async () => { + await scene(page).removeSoundEntity(); + + return measureSteadyLevel(page); + }); + + expect(playing, 'the entity sound should be audible').toBeGreaterThan(0.1); + expect(removed, 'removing the entity should silence it').toBeLessThan( + silenceLevel, + ); + }); +}); diff --git a/package-lock.json b/package-lock.json index c30bca2ee..49a671540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "@commitlint/config-conventional": "^21.2.0", "@eslint/js": "^10.0.1", "@playwright/test": "1.62.1", - "@types/howler": "^2.2.13", "@types/node": "^26.2.0", "@types/seedrandom": "^3.0.8", "@vitest/coverage-v8": "^4.1.10", @@ -47,7 +46,6 @@ "vitest": "^4.1.10" }, "peerDependencies": { - "howler": "^2.2.4", "msdf-bmfont-xml": "^2.8.0" }, "peerDependenciesMeta": { @@ -2696,13 +2694,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/howler": { - "version": "2.2.13", - "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.13.tgz", - "integrity": "sha512-40+EBjqIHHrC4VShlz/7i0lBUsE3QkgzZinQQji74Hd8sBkJZUBaT7LWFLK6rcabsDOOQpoMbEJvtaFQwxOu/g==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@types/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -5243,13 +5234,6 @@ "dev": true, "license": "MIT" }, - "node_modules/howler": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", - "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==", - "license": "MIT", - "peer": true - }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", diff --git a/package.json b/package.json index 572858bc3..5a1bc81b0 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,6 @@ "@commitlint/config-conventional": "^21.2.0", "@eslint/js": "^10.0.1", "@playwright/test": "1.62.1", - "@types/howler": "^2.2.13", "@types/node": "^26.2.0", "@types/seedrandom": "^3.0.8", "@vitest/coverage-v8": "^4.1.10", @@ -177,7 +176,6 @@ "vitest": "^4.1.10" }, "peerDependencies": { - "howler": "^2.2.4", "msdf-bmfont-xml": "^2.8.0" }, "peerDependenciesMeta": { diff --git a/src/audio/components/audio-component.test.ts b/src/audio/components/audio-component.test.ts deleted file mode 100644 index 9ba135231..000000000 --- a/src/audio/components/audio-component.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Howl } from 'howler'; -import { describe, expect, it, vi } from 'vitest'; -import { addAudioComponent, audioId } from './audio-component.js'; -import { EcsWorld } from '../../ecs/index.js'; - -vi.mock(import('howler'), { spy: true }); - -describe('addAudioComponent', () => { - it('attaches a component with default playSound', () => { - const world = new EcsWorld(); - const entity = world.createEntity(); - const sound = new Howl({ src: ['sound.mp3'] }); - - addAudioComponent(world, entity, { sound }); - - expect(world.getComponent(entity, audioId)).toEqual({ - sound, - playSound: false, - }); - }); - - it('overrides only the provided options', () => { - const world = new EcsWorld(); - const entity = world.createEntity(); - const sound = new Howl({ src: ['sound.mp3'] }); - - addAudioComponent(world, entity, { sound, playSound: true }); - - expect(world.getComponent(entity, audioId)).toEqual({ - sound, - playSound: true, - }); - }); - - it('returns the attached component', () => { - const world = new EcsWorld(); - const entity = world.createEntity(); - const sound = new Howl({ src: ['sound.mp3'] }); - - const component = addAudioComponent(world, entity, { sound }); - - expect(world.getComponent(entity, audioId)).toBe(component); - }); -}); diff --git a/src/audio/components/audio-component.ts b/src/audio/components/audio-component.ts deleted file mode 100644 index fd15f4d87..000000000 --- a/src/audio/components/audio-component.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Howl } from 'howler'; -import { createComponentId } from '../../ecs/ecs-component.js'; -import { EcsWorld } from '../../ecs/ecs-world.js'; - -/** - * Fields of {@link AudioEcsComponent} with no sensible default; callers must - * always provide these. - */ -export interface AudioRequiredOptions { - /** - * The Howler.js sound to play. - * - * @see {@link https://github.com/goldfire/howler.js#documentation | Howler.js Documentation} - */ - sound: Howl; -} - -/** - * Fields of {@link AudioEcsComponent} with a sensible default; callers may - * omit these. - */ -export interface AudioDefaultedOptions { - /** - * Set to `true` to play `sound` on the next `createAudioEcsSystem` update. - * The system resets this back to `false` once playback has started. - */ - playSound: boolean; -} - -/** - * ECS-style component interface for audio. - */ -export interface AudioEcsComponent - extends AudioRequiredOptions, AudioDefaultedOptions {} - -export const audioId = createComponentId('audio'); - -const defaultAudioOptions: AudioDefaultedOptions = { - playSound: false, -}; - -/** - * Attaches a {@link AudioEcsComponent} to `entity`. - * @param world - The ECS world `entity` belongs to. - * @param entity - The entity to attach the component to. - * @param options - Options for configuring the audio. `sound` has no - * sensible default and must always be provided. - * @returns The attached component, for further tuning or runtime changes. - */ -export function addAudioComponent( - world: EcsWorld, - entity: number, - options: AudioRequiredOptions & Partial, -): AudioEcsComponent { - const component: AudioEcsComponent = { - ...defaultAudioOptions, - ...options, - }; - - return world.addComponent(entity, audioId, component); -} diff --git a/src/audio/components/index.ts b/src/audio/components/index.ts index 02a30b9c8..c5459f1a8 100644 --- a/src/audio/components/index.ts +++ b/src/audio/components/index.ts @@ -1 +1 @@ -export * from './audio-component.js'; +export * from './sound-component.js'; diff --git a/src/audio/components/sound-component.test.ts b/src/audio/components/sound-component.test.ts new file mode 100644 index 000000000..d21ee1eae --- /dev/null +++ b/src/audio/components/sound-component.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { EcsWorld } from '../../ecs/index.js'; +import { createSoundMixer, SoundMixer } from '../sound-mixer.js'; +import { + createFakeSoundAsset, + FakeAudioContext, +} from '../test-helpers/fake-audio-context.js'; +import { addSoundComponent, soundId } from './sound-component.js'; + +describe('addSoundComponent', () => { + let world: EcsWorld; + let mixer: SoundMixer; + + beforeEach(() => { + world = new EcsWorld(); + mixer = createSoundMixer(new FakeAudioContext().asAudioContext()); + }); + + afterEach(async () => { + await mixer.stop(); + }); + + it('attaches a component with defaults applied', () => { + const entity = world.createEntity(); + const sound = createFakeSoundAsset(); + + addSoundComponent(world, entity, { sound, bus: mixer.master }); + + expect(world.getComponent(entity, soundId)).toEqual({ + sound, + bus: mixer.master, + volume: 1, + rate: 1, + loop: false, + paused: false, + hasFinished: false, + }); + }); + + it('overrides only the provided options', () => { + const entity = world.createEntity(); + const sound = createFakeSoundAsset(); + + addSoundComponent(world, entity, { + sound, + bus: mixer.master, + volume: 0.3, + loop: true, + }); + + expect(world.getComponent(entity, soundId)).toEqual({ + sound, + bus: mixer.master, + volume: 0.3, + rate: 1, + loop: true, + paused: false, + hasFinished: false, + }); + }); + + it('returns the attached component', () => { + const entity = world.createEntity(); + + const component = addSoundComponent(world, entity, { + sound: createFakeSoundAsset(), + bus: mixer.master, + }); + + expect(world.getComponent(entity, soundId)).toBe(component); + }); +}); diff --git a/src/audio/components/sound-component.ts b/src/audio/components/sound-component.ts new file mode 100644 index 000000000..2c17237d9 --- /dev/null +++ b/src/audio/components/sound-component.ts @@ -0,0 +1,86 @@ +import { createComponentId } from '../../ecs/ecs-component.js'; +import { EcsWorld } from '../../ecs/ecs-world.js'; +import type { MixerBus } from '../mixer-bus.js'; +import type { SoundAsset } from '../sound-asset.js'; + +/** + * Fields of {@link SoundEcsComponent} with no sensible default; callers must + * always provide these. + */ +export interface SoundRequiredOptions { + /** The sound to play. Changing it starts the new sound from the beginning. */ + sound: SoundAsset; + + /** + * The bus to play through. Changing it moves the playing sound to the new + * bus without restarting it. Must be a bus of the same mixer. + */ + bus: MixerBus; +} + +/** + * Fields of {@link SoundEcsComponent} with a sensible default; callers may + * omit these. + */ +export interface SoundDefaultedOptions { + /** The sound's own gain, multiplied by its bus's. Changes ramp smoothly. */ + volume: number; + + /** The playback rate. Also shifts the pitch: 2 is an octave up. */ + rate: number; + + /** Whether the sound repeats. Can be changed while it plays. */ + loop: boolean; + + /** + * Pauses the sound where it is. Clearing it resumes from the same place. + */ + paused: boolean; +} + +/** + * A sound that belongs to an entity: it plays while the entity has this + * component, and stops when the component or the entity is removed. + * `createSoundEcsSystem` plays it. + */ +export interface SoundEcsComponent + extends SoundRequiredOptions, SoundDefaultedOptions { + /** + * Output, written only by `createSoundEcsSystem`: `true` once a + * non-looping sound has played to its end, was dropped because the player + * hadn't interacted with the page yet, or its mixer was stopped. Nothing + * more plays after that; to play the sound again, add a new component. + */ + hasFinished: boolean; +} + +export const soundId = createComponentId('sound'); + +const defaultSoundOptions: SoundDefaultedOptions = { + volume: 1, + rate: 1, + loop: false, + paused: false, +}; + +/** + * Attaches a {@link SoundEcsComponent} to `entity`. + * @param world - The ECS world `entity` belongs to. + * @param entity - The entity to attach the component to. + * @param options - The sound, its bus, and how to play it. `sound` and + * `bus` have no sensible default and must always be provided. + * @returns The attached component, for runtime changes. + */ +export function addSoundComponent( + world: EcsWorld, + entity: number, + options: SoundRequiredOptions & Partial, +): SoundEcsComponent { + const component: SoundEcsComponent = { + ...defaultSoundOptions, + ...options, + hasFinished: false, + }; + + return world.addComponent(entity, soundId, component); +} diff --git a/src/audio/index.ts b/src/audio/index.ts index d315851ef..a1a65ff17 100644 --- a/src/audio/index.ts +++ b/src/audio/index.ts @@ -1,2 +1,7 @@ export * from './components/index.js'; +export * from './mixer-bus.js'; +export * from './play-sound.js'; +export * from './sound-asset.js'; +export * from './sound-asset-cache.js'; +export * from './sound-mixer.js'; export * from './systems/index.js'; diff --git a/src/audio/internal/audio-internals.ts b/src/audio/internal/audio-internals.ts new file mode 100644 index 000000000..6ae403b47 --- /dev/null +++ b/src/audio/internal/audio-internals.ts @@ -0,0 +1,161 @@ +import type { MixerBus } from '../mixer-bus.js'; +import type { SoundMixer } from '../sound-mixer.js'; + +/** + * The state of a sound instance the mixer needs, to stop every sound when + * it's stopped. + */ +export interface StoppableInstance { + stopImmediately(): void; +} + +/** + * The parts of a {@link SoundMixer} that playback needs but games don't. + */ +export interface MixerInternals { + readonly context: AudioContext; + /** Every instance currently playing (or fading out) through this mixer. */ + readonly instances: Set; + /** Set on the first user gesture, and never cleared. */ + hasHadGesture: boolean; + isStopped: boolean; +} + +/** + * The parts of a {@link MixerBus} that playback needs but games don't. + */ +export interface BusInternals { + readonly mixer: MixerInternals; + /** The node sounds and child buses connect to. */ + readonly gain: GainNode; +} + +// Keyed by the public objects, so the internals stay out of the public API +// and a bus or mixer that wasn't made by `createSoundMixer` is detected. +const mixerInternals = new WeakMap(); +const busInternals = new WeakMap(); + +export const registerMixerInternals = ( + mixer: SoundMixer, + internals: MixerInternals, +): void => { + mixerInternals.set(mixer, internals); +}; + +export const registerBusInternals = ( + bus: MixerBus, + internals: BusInternals, +): void => { + busInternals.set(bus, internals); +}; + +/** + * Gets the internals of a mixer made by `createSoundMixer`. + * @param mixer - The mixer. + * @returns Its internals. + * @throws If `mixer` wasn't made by `createSoundMixer`. + */ +export const getMixerInternals = (mixer: SoundMixer): MixerInternals => { + const internals = mixerInternals.get(mixer); + + if (!internals) { + throw new Error( + 'The sound mixer was not made by `createSoundMixer`. Create mixers with `createSoundMixer()`.', + ); + } + + return internals; +}; + +/** + * Gets the internals of a bus made by `SoundMixer.createBus`. + * @param bus - The bus. + * @returns Its internals. + * @throws If `bus` wasn't made by a `SoundMixer`. + */ +export const getBusInternals = (bus: MixerBus): BusInternals => { + const internals = busInternals.get(bus); + + if (!internals) { + throw new Error( + `The bus "${bus.name}" was not made by a sound mixer. Create buses with \`mixer.createBus(name)\`.`, + ); + } + + return internals; +}; + +/** + * Whether a non-looping sound requested now should play. Until the player + * has interacted with the page, a context that isn't running can't play + * anything, and a sound effect queued until the first click would play + * late, together with every other one queued, so it's dropped instead. A + * context that's already running (the browser allowed autoplay, e.g. after + * navigating within a site the player already interacted with) plays it. + * @param mixer - The mixer the sound would play through. + * @returns `true` if the sound should be started. + */ +export const canStartOneShot = (mixer: MixerInternals): boolean => + mixer.hasHadGesture || mixer.context.state === 'running'; + +/** + * Throws if `mixer` has been stopped. + * @param mixer - The mixer a sound would play through. + * @throws If the mixer has been stopped. + */ +export const assertMixerNotStopped = (mixer: MixerInternals): void => { + if (mixer.isStopped) { + throw new Error( + 'Unable to play a sound on a stopped sound mixer. Create a new mixer with `createSoundMixer()`.', + ); + } +}; + +/** + * Throws unless `volume` is a finite gain of at least 0. + * @param volume - The volume to check. + * @param owner - What the volume belongs to, for the error message. + * @throws If `volume` is negative or not finite. + */ +export const assertValidVolume = (volume: number, owner: string): void => { + if (!Number.isFinite(volume) || volume < 0) { + throw new Error( + `The volume of ${owner} must be a finite number of at least 0, got ${volume}.`, + ); + } +}; + +/** + * Throws unless `rate` is a finite playback rate above 0. + * @param rate - The rate to check. + * @throws If `rate` is 0 or less, or not finite. + */ +export const assertValidRate = (rate: number): void => { + if (!Number.isFinite(rate) || rate <= 0) { + throw new Error( + `A sound's playback rate must be a finite number above 0, got ${rate}.`, + ); + } +}; + +/** + * How quickly a gain follows a new volume, in seconds: the time constant of + * `setTargetAtTime`, so the change is about 99% done after five of these. + * Fast enough to sound immediate, slow enough not to click. + */ +export const gainSmoothingSeconds = 0.01; + +/** + * Moves `param` smoothly to `value`, replacing any change still scheduled. + * @param param - The parameter to change. + * @param value - The value to move to. + * @param now - The context's current time. + */ +export const smoothParamTo = ( + param: AudioParam, + value: number, + now: number, +): void => { + param.cancelScheduledValues(now); + param.setTargetAtTime(value, now, gainSmoothingSeconds); +}; diff --git a/src/audio/internal/create-mixer-bus.ts b/src/audio/internal/create-mixer-bus.ts new file mode 100644 index 000000000..a4f455792 --- /dev/null +++ b/src/audio/internal/create-mixer-bus.ts @@ -0,0 +1,56 @@ +import type { MixerBus } from '../mixer-bus.js'; +import { + assertValidVolume, + MixerInternals, + registerBusInternals, + smoothParamTo, +} from './audio-internals.js'; + +/** + * Creates a bus whose gain node feeds `destination`. + * @param mixer - The mixer the bus belongs to. + * @param name - The bus's name. + * @param parent - The bus it feeds into, or `null` for the master bus. + * @param destination - The node its gain connects to. + * @returns The bus. + */ +export const createMixerBus = ( + mixer: MixerInternals, + name: string, + parent: MixerBus | null, + destination: AudioNode, +): MixerBus => { + const gain = mixer.context.createGain(); + let volume = 1; + let muted = false; + + gain.connect(destination); + + const applyGain = (): void => { + smoothParamTo(gain.gain, muted ? 0 : volume, mixer.context.currentTime); + }; + + const bus: MixerBus = { + name, + parent, + get volume(): number { + return volume; + }, + set volume(value: number) { + assertValidVolume(value, `the bus "${name}"`); + volume = value; + applyGain(); + }, + get muted(): boolean { + return muted; + }, + set muted(value: boolean) { + muted = value; + applyGain(); + }, + }; + + registerBusInternals(bus, { mixer, gain }); + + return bus; +}; diff --git a/src/audio/internal/sound-instance.ts b/src/audio/internal/sound-instance.ts new file mode 100644 index 000000000..eb5112038 --- /dev/null +++ b/src/audio/internal/sound-instance.ts @@ -0,0 +1,256 @@ +import type { SoundAsset } from '../sound-asset.js'; +import { + assertMixerNotStopped, + assertValidRate, + assertValidVolume, + BusInternals, + smoothParamTo, + StoppableInstance, +} from './audio-internals.js'; + +/** + * How a {@link SoundInstance} starts playing. + */ +export interface SoundInstanceOptions { + volume: number; + rate: number; + loop: boolean; + /** Where in the sound to start, in seconds. */ + offsetSeconds: number; +} + +/** + * How long a stopped sound takes to fade out, in seconds. Cutting a waveform + * off mid-cycle clicks; a fade this short isn't heard as one. + */ +const stopFadeSeconds = 0.02; + +/** + * One playback of a sound: a one-use `AudioBufferSourceNode` feeding its own + * `GainNode`, connected to a bus. Changes to volume and rate apply while it + * plays, and it can move to another bus of the same mixer without + * restarting. + */ +export class SoundInstance implements StoppableInstance { + private _bus: BusInternals; + private readonly _sound: SoundAsset; + private readonly _source: AudioBufferSourceNode; + private readonly _gain: GainNode; + private _rate: number; + private _loop: boolean; + // Position tracking: where in the sound the current stretch of playback + // (since the start or the last rate or loop change) began, and when. + private _segmentStartPositionSeconds: number; + private _segmentStartTime: number; + private _hasEnded = false; + private _isStopping = false; + + /** + * Starts playing `sound` on `bus`. + * @param bus - The bus to play through. + * @param sound - The sound to play. + * @param options - How to play it. + * @throws If the bus's mixer has been stopped, or the volume or rate is invalid. + */ + constructor( + bus: BusInternals, + sound: SoundAsset, + options: SoundInstanceOptions, + ) { + const { volume, rate, loop, offsetSeconds } = options; + + assertMixerNotStopped(bus.mixer); + + assertValidVolume(volume, 'a sound'); + assertValidRate(rate); + + const { context } = bus.mixer; + + this._bus = bus; + this._sound = sound; + this._rate = rate; + this._loop = loop; + this._segmentStartPositionSeconds = offsetSeconds; + this._segmentStartTime = context.currentTime; + + this._source = context.createBufferSource(); + this._source.buffer = sound.buffer; + this._source.loop = loop; + this._source.playbackRate.value = rate; + + this._gain = context.createGain(); + this._gain.gain.value = volume; + + this._source.connect(this._gain); + this._gain.connect(bus.gain); + + this._source.addEventListener('ended', this._handleEnded); + bus.mixer.instances.add(this); + + this._source.start(0, offsetSeconds); + } + + /** + * Whether the sound is still playing: it hasn't ended or been stopped. + */ + get isPlaying(): boolean { + return !this._hasEnded && !this._isStopping; + } + + /** + * Whether the sound has ended, either by playing to its end, by being + * stopped, or because its mixer was stopped. + */ + get hasEnded(): boolean { + return this._hasEnded; + } + + /** + * How far into the sound playback is, in seconds of the sound itself + * (so at a rate of 2, it advances two seconds per second). + */ + get positionSeconds(): number { + const elapsed = + this._bus.mixer.context.currentTime - this._segmentStartTime; + const position = this._segmentStartPositionSeconds + elapsed * this._rate; + const duration = this._sound.durationSeconds; + + if (this._loop && duration > 0) { + return position % duration; + } + + return position; + } + + /** + * Changes the volume smoothly. + * @param volume - The new volume. + * @throws If `volume` is negative or not finite. + */ + public setVolume(volume: number): void { + assertValidVolume(volume, 'a sound'); + + if (!this.isPlaying) { + return; + } + + smoothParamTo(this._gain.gain, volume, this._bus.mixer.context.currentTime); + } + + /** + * Changes the playback rate (and so the pitch) from now on. + * @param rate - The new rate. + * @throws If `rate` is 0 or less, or not finite. + */ + public setRate(rate: number): void { + assertValidRate(rate); + + if (!this.isPlaying) { + return; + } + + this._startNewSegment(); + this._rate = rate; + + // Set exactly rather than ramped, so the playback position stays known. + const { currentTime } = this._bus.mixer.context; + + this._source.playbackRate.cancelScheduledValues(currentTime); + this._source.playbackRate.setValueAtTime(rate, currentTime); + } + + /** + * Turns looping on or off without restarting. + * @param loop - Whether to loop. + */ + public setLoop(loop: boolean): void { + if (!this.isPlaying) { + return; + } + + this._startNewSegment(); + this._loop = loop; + this._source.loop = loop; + } + + /** + * Moves the sound to another bus of the same mixer, without restarting. + * @param bus - The bus to move to. + * @throws If `bus` belongs to a different mixer. + */ + public setBus(bus: BusInternals): void { + if (bus.mixer !== this._bus.mixer) { + throw new Error( + 'Unable to move a sound to a bus of a different sound mixer. A sound can only move between buses of the mixer it started on.', + ); + } + + if (!this.isPlaying) { + this._bus = bus; + + return; + } + + this._gain.disconnect(); + this._gain.connect(bus.gain); + this._bus = bus; + } + + /** + * Fades the sound out over a few milliseconds and stops it. Does nothing + * if it's already stopping or has ended. + */ + public stop(): void { + if (!this.isPlaying) { + return; + } + + this._isStopping = true; + + const { currentTime } = this._bus.mixer.context; + const gain = this._gain.gain; + + // A linear ramp, since `setTargetAtTime` only approaches 0. + gain.cancelScheduledValues(currentTime); + gain.setValueAtTime(gain.value, currentTime); + gain.linearRampToValueAtTime(0, currentTime + stopFadeSeconds); + this._source.stop(currentTime + stopFadeSeconds); + } + + /** + * Stops the sound at once, without waiting for the context to report it + * ended, which a closing context never does. + */ + public stopImmediately(): void { + if (this._hasEnded) { + return; + } + + this._source.removeEventListener('ended', this._handleEnded); + + try { + this._source.stop(); + } catch { + // Already stopped: nothing left to do but disconnect. + } + + this._end(); + } + + private _startNewSegment(): void { + this._segmentStartPositionSeconds = this.positionSeconds; + this._segmentStartTime = this._bus.mixer.context.currentTime; + } + + private _end(): void { + this._hasEnded = true; + this._source.disconnect(); + this._gain.disconnect(); + this._bus.mixer.instances.delete(this); + } + + private readonly _handleEnded = (): void => { + this._source.removeEventListener('ended', this._handleEnded); + this._end(); + }; +} diff --git a/src/audio/mixer-bus.ts b/src/audio/mixer-bus.ts new file mode 100644 index 000000000..a1af80731 --- /dev/null +++ b/src/audio/mixer-bus.ts @@ -0,0 +1,23 @@ +/** + * A named stage in a {@link SoundMixer}'s tree that sounds play through. + * Its volume and mute apply to every sound routed through it, and through + * the buses under it, including sounds that are already playing. Create + * buses with {@link SoundMixer.createBus}. + */ +export interface MixerBus { + /** The bus's name, unique within its mixer. */ + readonly name: string; + + /** The bus this one feeds into, or `null` for the mixer's `master` bus. */ + readonly parent: MixerBus | null; + + /** + * Linear gain: 0 is silent, 1 leaves sounds unchanged. Changes ramp over + * a few milliseconds, so they don't click on sounds already playing. + * @throws When set to a negative or non-finite number. + */ + volume: number; + + /** Silences the bus without changing {@link MixerBus.volume}. */ + muted: boolean; +} diff --git a/src/audio/play-sound.test.ts b/src/audio/play-sound.test.ts new file mode 100644 index 000000000..96f4b9cf1 --- /dev/null +++ b/src/audio/play-sound.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { MixerBus } from './mixer-bus.js'; +import { playSound } from './play-sound.js'; +import { createSoundMixer, SoundMixer } from './sound-mixer.js'; +import { + createFakeSoundAsset, + FakeAudioContext, +} from './test-helpers/fake-audio-context.js'; + +describe('playSound', () => { + let context: FakeAudioContext; + let mixer: SoundMixer; + let sfx: MixerBus; + + beforeEach(() => { + context = new FakeAudioContext(); + mixer = createSoundMixer(context.asAudioContext()); + sfx = mixer.createBus('sfx'); + // Most tests are about playback after the player has interacted. + window.dispatchEvent(new Event('pointerup')); + }); + + afterEach(async () => { + await mixer.stop(); + }); + + it('plays the sound through its own gain into the bus', () => { + const sound = createFakeSoundAsset(); + + playSound(sfx, sound, { volume: 0.4, rate: 1.5, loop: true }); + + const [source] = context.sources; + const [, sfxGain, instanceGain] = context.gains; + + expect(source.buffer).toBe(sound.buffer); + expect(source.loop).toBe(true); + expect(source.playbackRate.value).toBe(1.5); + expect(source.connections).toEqual(new Set([instanceGain])); + expect(instanceGain.gain.value).toBeCloseTo(0.4); + expect(instanceGain.connections).toEqual(new Set([sfxGain])); + expect(source.startCall).toEqual({ when: 0, offset: 0 }); + }); + + it('defaults to full volume, normal rate and no looping', () => { + const sound = playSound(sfx, createFakeSoundAsset()); + const [source] = context.sources; + + expect(sound.volume).toBe(1); + expect(sound.isPlaying).toBe(true); + expect(source.loop).toBe(false); + expect(source.playbackRate.value).toBe(1); + }); + + it('plays overlapping instances of the same sound', () => { + const sound = createFakeSoundAsset(); + + playSound(sfx, sound); + playSound(sfx, sound); + + expect(context.sources).toHaveLength(2); + }); + + it('ramps a volume change', () => { + const sound = playSound(sfx, createFakeSoundAsset()); + const instanceGain = context.gains[2]; + + context.currentTime = 1; + sound.volume = 0.2; + + expect(sound.volume).toBeCloseTo(0.2); + expect(instanceGain.gain.calls).toEqual(['setTargetAtTime(0.2, 1)']); + }); + + it('fades out and stops the source when stopped', () => { + const sound = playSound(sfx, createFakeSoundAsset()); + const [source] = context.sources; + const instanceGain = context.gains[2]; + + context.currentTime = 2; + sound.stop(); + + expect(sound.isPlaying).toBe(false); + expect(instanceGain.gain.calls).toEqual([ + 'setValueAtTime(1, 2)', + 'linearRampToValueAtTime(0, 2.02)', + ]); + expect(source.stopTime).toBeCloseTo(2.02); + }); + + it('disconnects its nodes once it has ended', () => { + const sound = playSound(sfx, createFakeSoundAsset()); + const [source] = context.sources; + const instanceGain = context.gains[2]; + + source.end(); + + expect(sound.isPlaying).toBe(false); + expect(source.connections.size).toBe(0); + expect(instanceGain.connections.size).toBe(0); + }); + + it('throws for an invalid volume or rate', () => { + const sound = createFakeSoundAsset(); + + expect(() => playSound(sfx, sound, { volume: -1 })).toThrow(/volume/); + expect(() => playSound(sfx, sound, { rate: 0 })).toThrow(/rate/); + expect(context.sources).toHaveLength(0); + }); + + it("throws for a bus that wasn't made by a mixer", () => { + const bus: MixerBus = { + name: 'fake', + parent: null, + volume: 1, + muted: false, + }; + + expect(() => playSound(bus, createFakeSoundAsset())).toThrow( + /not made by a sound mixer/, + ); + }); + + describe('before the first gesture', () => { + let lockedContext: FakeAudioContext; + let lockedMixer: SoundMixer; + + beforeEach(() => { + lockedContext = new FakeAudioContext(); + lockedMixer = createSoundMixer(lockedContext.asAudioContext()); + }); + + afterEach(async () => { + await lockedMixer.stop(); + }); + + it('drops a non-looping sound', () => { + const sound = playSound(lockedMixer.master, createFakeSoundAsset()); + + expect(sound.isPlaying).toBe(false); + expect(lockedContext.sources).toHaveLength(0); + expect(() => { + sound.stop(); + }).not.toThrow(); + }); + + it('starts a looping sound', () => { + const sound = playSound(lockedMixer.master, createFakeSoundAsset(), { + loop: true, + }); + + expect(sound.isPlaying).toBe(true); + expect(lockedContext.sources).toHaveLength(1); + }); + + it('plays a sound requested by the gesture that unlocks audio', () => { + let sound: ReturnType | null = null; + + const playOnClick = (): void => { + sound = playSound(lockedMixer.master, createFakeSoundAsset()); + }; + + window.addEventListener('pointerup', playOnClick); + window.dispatchEvent(new Event('pointerup')); + window.removeEventListener('pointerup', playOnClick); + + // The context is still resuming, but the sound is scheduled. + expect(lockedContext.state).toBe('suspended'); + expect(sound).not.toBeNull(); + expect(sound!.isPlaying).toBe(true); + }); + + it('plays a non-looping sound when the context already runs', async () => { + const runningContext = new FakeAudioContext('running'); + const runningMixer = createSoundMixer(runningContext.asAudioContext()); + + const sound = playSound(runningMixer.master, createFakeSoundAsset()); + + expect(sound.isPlaying).toBe(true); + + await runningMixer.stop(); + }); + }); +}); diff --git a/src/audio/play-sound.ts b/src/audio/play-sound.ts new file mode 100644 index 000000000..d9f1924e2 --- /dev/null +++ b/src/audio/play-sound.ts @@ -0,0 +1,125 @@ +import { + assertMixerNotStopped, + assertValidRate, + assertValidVolume, + canStartOneShot, + getBusInternals, +} from './internal/audio-internals.js'; +import { SoundInstance } from './internal/sound-instance.js'; +import type { MixerBus } from './mixer-bus.js'; +import type { SoundAsset } from './sound-asset.js'; + +/** + * How {@link playSound} plays a sound. + */ +export interface PlaySoundOptions { + /** The sound's own gain, multiplied by its bus's and every bus above it. */ + volume: number; + + /** The playback rate. Also shifts the pitch: 2 is an octave up. */ + rate: number; + + /** Whether the sound repeats until stopped. */ + loop: boolean; +} + +/** + * A sound started by {@link playSound}. + */ +export interface PlayingSound { + /** + * The sound's own gain. Changes ramp over a few milliseconds. + * @throws When set to a negative or non-finite number. + */ + volume: number; + + /** + * `false` once the sound has played to its end or been stopped, and for a + * sound that was dropped because the player hadn't interacted with the + * page yet. + */ + readonly isPlaying: boolean; + + /** Fades the sound out over a few milliseconds and stops it. */ + stop(): void; +} + +const defaultPlaySoundOptions: PlaySoundOptions = { + volume: 1, + rate: 1, + loop: false, +}; + +/** + * Plays `sound` on `bus`. Any number of sounds, including the same one, can + * play at once. + * + * Until the player first interacts with the page, browsers keep audio + * suspended. A looping sound requested before then starts as soon as audio + * runs; a non-looping one is dropped (its handle reports `isPlaying` as + * `false`), so a burst of stale sound effects doesn't play on the first + * click. A sound requested in the handler of that first click plays. + * @param bus - The bus to play through, which sets which volume and mute settings apply. + * @param sound - The sound to play. + * @param options - The volume, rate and looping to play it with. + * @returns A handle to change the sound's volume or stop it. + * @throws If the bus's mixer was stopped, or the volume or rate is invalid. + */ +export function playSound( + bus: MixerBus, + sound: SoundAsset, + options: Partial = {}, +): PlayingSound { + const { volume, rate, loop } = { ...defaultPlaySoundOptions, ...options }; + const busInternals = getBusInternals(bus); + + assertMixerNotStopped(busInternals.mixer); + + assertValidVolume(volume, 'a sound'); + assertValidRate(rate); + + if (!loop && !canStartOneShot(busInternals.mixer)) { + return createDroppedSound(volume); + } + + const instance = new SoundInstance(busInternals, sound, { + volume, + rate, + loop, + offsetSeconds: 0, + }); + + let currentVolume = volume; + + return { + get volume(): number { + return currentVolume; + }, + set volume(value: number) { + instance.setVolume(value); + currentVolume = value; + }, + get isPlaying(): boolean { + return instance.isPlaying; + }, + stop: (): void => { + instance.stop(); + }, + }; +} + +const createDroppedSound = (volume: number): PlayingSound => { + let currentVolume = volume; + + return { + get volume(): number { + return currentVolume; + }, + set volume(value: number) { + assertValidVolume(value, 'a sound'); + currentVolume = value; + }, + isPlaying: false, + stop: (): void => {}, + }; +}; diff --git a/src/audio/sound-asset-cache.test.ts b/src/audio/sound-asset-cache.test.ts new file mode 100644 index 000000000..a0feef369 --- /dev/null +++ b/src/audio/sound-asset-cache.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SoundAssetCache } from './sound-asset-cache.js'; +import { createSoundMixer, SoundMixer } from './sound-mixer.js'; +import { FakeAudioContext } from './test-helpers/fake-audio-context.js'; + +describe('SoundAssetCache', () => { + let context: FakeAudioContext; + let mixer: SoundMixer; + let cache: SoundAssetCache; + let fetchMock: ReturnType; + + beforeEach(() => { + context = new FakeAudioContext(); + mixer = createSoundMixer(context.asAudioContext()); + cache = new SoundAssetCache(mixer); + fetchMock = vi.fn(() => + Promise.resolve(new Response(new ArrayBuffer(8), { status: 200 })), + ); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + await mixer.stop(); + }); + + it('fetches, decodes and caches a sound', async () => { + const buffer = { duration: 2.5 }; + + context.decodeAudioData = vi.fn(() => Promise.resolve(buffer)); + + const sound = await cache.getOrLoad('laser.mp3'); + + expect(fetchMock).toHaveBeenCalledWith('laser.mp3'); + expect(sound).toEqual({ buffer, durationSeconds: 2.5 }); + expect(cache.get('laser.mp3')).toBe(sound); + }); + + it('decodes a sound once for concurrent requests', async () => { + const decode = vi.fn(() => Promise.resolve({ duration: 1 })); + + context.decodeAudioData = decode; + + const [first, second] = await Promise.all([ + cache.getOrLoad('music.mp3'), + cache.getOrLoad('music.mp3'), + ]); + + expect(first).toBe(second); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(decode).toHaveBeenCalledTimes(1); + }); + + it("doesn't fetch a cached sound again", async () => { + await cache.load('laser.mp3'); + await cache.getOrLoad('laser.mp3'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws when getting a sound that isn't loaded", () => { + expect(() => cache.get('laser.mp3')).toThrow( + /"laser.mp3" not found in the cache/, + ); + }); + + it('rejects with the URL when the file fails to load', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 })); + + await expect(cache.getOrLoad('missing.mp3')).rejects.toThrow( + /Failed to load the sound at "missing.mp3"/, + ); + }); + + it("rejects with the URL when the file can't be decoded", async () => { + context.decodeAudioData = () => + Promise.reject(new DOMException('Unable to decode', 'EncodingError')); + + await expect(cache.getOrLoad('sound.ogg')).rejects.toThrow( + /Unable to decode the sound at "sound.ogg"/, + ); + }); + + it('loads again after a failed load', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('Network error')); + + await expect(cache.getOrLoad('laser.mp3')).rejects.toThrow(); + await expect(cache.getOrLoad('laser.mp3')).resolves.toBeDefined(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/audio/sound-asset-cache.ts b/src/audio/sound-asset-cache.ts new file mode 100644 index 000000000..4f33c9944 --- /dev/null +++ b/src/audio/sound-asset-cache.ts @@ -0,0 +1,118 @@ +import type { AssetCache } from '../asset-loading/asset-cache.js'; +import { getMixerInternals } from './internal/audio-internals.js'; +import type { SoundAsset } from './sound-asset.js'; +import type { SoundMixer } from './sound-mixer.js'; + +/** + * Loads and decodes sound files once, and keeps them for playing as often + * as needed. Requests for a file that's still loading share that load. + * Sounds stay usable after the world that played them stops. + */ +export class SoundAssetCache implements AssetCache { + public assets = new Map(); + + private readonly _context: BaseAudioContext; + private readonly _loading = new Map>(); + + /** + * Creates a cache that decodes sounds for `mixer`. + * @param mixer - The mixer whose audio context decodes the sounds. + */ + constructor(mixer: SoundMixer) { + this._context = getMixerInternals(mixer).context; + } + + /** + * Gets a loaded sound. + * @param url - The URL the sound was loaded from. + * @returns The sound. + * @throws If the sound at `url` hasn't been loaded. + */ + public get(url: string): SoundAsset { + const sound = this.assets.get(url); + + if (!sound) { + throw new Error(`Sound with URL "${url}" not found in the cache.`); + } + + return sound; + } + + /** + * Loads and decodes the sound at `url`, and caches it. + * @param url - The URL of the sound file. + * @returns A promise that resolves once the sound is cached. + * @throws The promise rejects if the file can't be fetched or decoded. + */ + public async load(url: string): Promise { + await this._loadShared(url); + } + + /** + * Gets the sound at `url`, loading and decoding it first if it isn't + * cached yet. + * @param url - The URL of the sound file. + * @returns A promise that resolves to the sound. + * @throws The promise rejects if the file can't be fetched or decoded. + */ + public getOrLoad(url: string): Promise { + const sound = this.assets.get(url); + + if (sound) { + return Promise.resolve(sound); + } + + return this._loadShared(url); + } + + private _loadShared(url: string): Promise { + const loading = this._loading.get(url); + + if (loading) { + return loading; + } + + const load = this._fetchAndDecode(url).finally(() => { + this._loading.delete(url); + }); + + this._loading.set(url, load); + + return load; + } + + private async _fetchAndDecode(url: string): Promise { + let bytes: ArrayBuffer; + + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + bytes = await response.arrayBuffer(); + } catch (error) { + throw new Error(`Failed to load the sound at "${url}".`, { + cause: error, + }); + } + + let buffer: AudioBuffer; + + try { + buffer = await this._context.decodeAudioData(bytes); + } catch (error) { + throw new Error( + `Unable to decode the sound at "${url}". Use a format every browser decodes, such as MP3, AAC or WAV.`, + { cause: error }, + ); + } + + const sound: SoundAsset = { buffer, durationSeconds: buffer.duration }; + + this.assets.set(url, sound); + + return sound; + } +} diff --git a/src/audio/sound-asset.test.ts b/src/audio/sound-asset.test.ts new file mode 100644 index 000000000..bfe966817 --- /dev/null +++ b/src/audio/sound-asset.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSoundAsset } from './sound-asset.js'; +import { FakeAudioBuffer } from './test-helpers/fake-audio-context.js'; + +describe('createSoundAsset', () => { + beforeEach(() => { + vi.stubGlobal('AudioBuffer', FakeAudioBuffer); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('copies each channel into a buffer', () => { + const left = new Float32Array([0, 0.5, -0.5, 1]); + const right = new Float32Array([1, -1, 0.25, 0]); + + const sound = createSoundAsset({ sampleRate: 4, channels: [left, right] }); + const buffer = sound.buffer as unknown as FakeAudioBuffer; + + expect(buffer.sampleRate).toBe(4); + expect(buffer.numberOfChannels).toBe(2); + expect(buffer.channels).toEqual([left, right]); + expect(sound.durationSeconds).toBe(1); + }); + + it('throws without channels', () => { + expect(() => createSoundAsset({ sampleRate: 44100, channels: [] })).toThrow( + /without any channels/, + ); + }); + + it('throws for empty channels', () => { + expect(() => + createSoundAsset({ sampleRate: 44100, channels: [new Float32Array(0)] }), + ).toThrow(/empty channels/); + }); + + it('throws when the channels differ in length', () => { + expect(() => + createSoundAsset({ + sampleRate: 44100, + channels: [new Float32Array(3), new Float32Array(4)], + }), + ).toThrow(/same number of samples/); + }); +}); diff --git a/src/audio/sound-asset.ts b/src/audio/sound-asset.ts new file mode 100644 index 000000000..687c91da0 --- /dev/null +++ b/src/audio/sound-asset.ts @@ -0,0 +1,65 @@ +/** + * Decoded audio, ready to play any number of times, on any bus, at once. + * Load sounds from files with a `SoundAssetCache`, or make one from samples + * with {@link createSoundAsset}. + */ +export interface SoundAsset { + /** The decoded samples. */ + readonly buffer: AudioBuffer; + + /** How long the sound lasts at a playback rate of 1, in seconds. */ + readonly durationSeconds: number; +} + +/** + * Samples to make a {@link SoundAsset} from. + */ +export interface SoundAssetSamples { + /** Samples per second, e.g. `44100`. */ + sampleRate: number; + + /** + * One array of samples per channel (one for mono, two for stereo), each + * in the range -1 to 1, and all the same length. + */ + channels: readonly Float32Array[]; +} + +/** + * Makes a {@link SoundAsset} from samples, for sounds synthesized or + * generated at runtime. + * @param samples - The sample rate and the samples of each channel. + * @returns The sound. + * @throws If there are no channels, the channels are empty, or they differ in length. + */ +export function createSoundAsset(samples: SoundAssetSamples): SoundAsset { + const { sampleRate, channels } = samples; + + if (channels.length === 0) { + throw new Error('Unable to create a sound asset without any channels.'); + } + + const { length } = channels[0]; + + if (length === 0) { + throw new Error('Unable to create a sound asset from empty channels.'); + } + + if (channels.some((channel) => channel.length !== length)) { + throw new Error( + 'Unable to create a sound asset: every channel must have the same number of samples.', + ); + } + + const buffer = new AudioBuffer({ + length, + numberOfChannels: channels.length, + sampleRate, + }); + + channels.forEach((channel, index) => { + buffer.copyToChannel(channel, index); + }); + + return { buffer, durationSeconds: buffer.duration }; +} diff --git a/src/audio/sound-mixer.test.ts b/src/audio/sound-mixer.test.ts new file mode 100644 index 000000000..34783ec7f --- /dev/null +++ b/src/audio/sound-mixer.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { playSound } from './play-sound.js'; +import { createSoundMixer, SoundMixer } from './sound-mixer.js'; +import { + createFakeSoundAsset, + FakeAudioContext, +} from './test-helpers/fake-audio-context.js'; + +describe('createSoundMixer', () => { + let context: FakeAudioContext; + let mixer: SoundMixer; + + beforeEach(() => { + context = new FakeAudioContext(); + mixer = createSoundMixer(context.asAudioContext()); + }); + + afterEach(async () => { + await mixer.stop(); + }); + + describe('buses', () => { + it('connects the master bus to the destination', () => { + const [masterGain] = context.gains; + + expect(mixer.master.name).toBe('master'); + expect(mixer.master.parent).toBeNull(); + expect(masterGain.connections).toEqual(new Set([context.destination])); + }); + + it('creates buses under master by default', () => { + const music = mixer.createBus('music'); + const [masterGain, musicGain] = context.gains; + + expect(music.parent).toBe(mixer.master); + expect(musicGain.connections).toEqual(new Set([masterGain])); + }); + + it('nests a bus under the given parent', () => { + const sfx = mixer.createBus('sfx'); + const ui = mixer.createBus('ui', sfx); + const [, sfxGain, uiGain] = context.gains; + + expect(ui.parent).toBe(sfx); + expect(uiGain.connections).toEqual(new Set([sfxGain])); + }); + + it('throws when a bus name is already used', () => { + mixer.createBus('music'); + + expect(() => mixer.createBus('music')).toThrow(/already has a bus/); + expect(() => mixer.createBus('master')).toThrow(/already has a bus/); + }); + + it('throws when the parent belongs to another mixer', async () => { + const otherMixer = createSoundMixer( + new FakeAudioContext().asAudioContext(), + ); + + expect(() => mixer.createBus('music', otherMixer.master)).toThrow( + /different sound mixer/, + ); + + await otherMixer.stop(); + }); + + it('gets buses by name', () => { + const music = mixer.createBus('music'); + + expect(mixer.getBus('music')).toBe(music); + expect(mixer.getBus('master')).toBe(mixer.master); + expect(() => mixer.getBus('voice')).toThrow(/no bus named "voice"/); + }); + + it('ramps the gain to a new volume', () => { + const music = mixer.createBus('music'); + const [, musicGain] = context.gains; + + context.currentTime = 3; + music.volume = 0.25; + + expect(music.volume).toBe(0.25); + expect(musicGain.gain.value).toBe(0.25); + expect(musicGain.gain.calls).toEqual(['setTargetAtTime(0.25, 3)']); + }); + + it('silences a muted bus and restores its volume when unmuted', () => { + const music = mixer.createBus('music'); + const [, musicGain] = context.gains; + + music.volume = 0.5; + music.muted = true; + + expect(musicGain.gain.value).toBe(0); + expect(music.volume).toBe(0.5); + + music.muted = false; + + expect(musicGain.gain.value).toBe(0.5); + }); + + it('throws for a negative or non-finite volume', () => { + expect(() => { + mixer.master.volume = -0.1; + }).toThrow(/"master" must be a finite number/); + expect(() => { + mixer.master.volume = Number.NaN; + }).toThrow(/finite number/); + }); + }); + + describe('unlocking', () => { + it('resumes the context on a pointer release', () => { + window.dispatchEvent(new Event('pointerup')); + + expect(context.resumeCalls).toBe(1); + }); + + it.each(['touchend', 'click'])('resumes the context on %s', (type) => { + window.dispatchEvent(new Event(type)); + + expect(context.resumeCalls).toBe(1); + }); + + it('resumes on a key press, but not on Escape', () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(context.resumeCalls).toBe(0); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + + expect(context.resumeCalls).toBe(1); + }); + + it('keeps listening until the context runs', () => { + window.dispatchEvent(new Event('pointerup')); + window.dispatchEvent(new Event('pointerup')); + + expect(context.resumeCalls).toBe(2); + + context.setState('running'); + window.dispatchEvent(new Event('pointerup')); + + expect(context.resumeCalls).toBe(2); + }); + + it('listens again when the context is interrupted', () => { + context.setState('running'); + context.setState('interrupted'); + window.dispatchEvent(new Event('pointerup')); + + expect(context.resumeCalls).toBe(1); + expect(mixer.state).toBe('interrupted'); + }); + + it("doesn't resume on gestures while the game has suspended audio", async () => { + context.setState('running'); + await mixer.suspend(); + window.dispatchEvent(new Event('pointerup')); + + expect(context.resumeCalls).toBe(0); + + await mixer.resume(); + + expect(context.resumeCalls).toBe(1); + }); + + it("doesn't listen when the context starts out running", async () => { + const runningContext = new FakeAudioContext('running'); + const runningMixer = createSoundMixer(runningContext.asAudioContext()); + + window.dispatchEvent(new Event('pointerup')); + + expect(runningContext.resumeCalls).toBe(0); + + await runningMixer.stop(); + }); + }); + + describe('stop', () => { + it('stops listening for gestures and closes the context', async () => { + await mixer.stop(); + window.dispatchEvent(new Event('pointerup')); + + expect(context.resumeCalls).toBe(0); + expect(mixer.state).toBe('closed'); + }); + + it('stops every playing sound', async () => { + context.setState('running'); + + const sound = playSound(mixer.master, createFakeSoundAsset()); + const [source] = context.sources; + + await mixer.stop(); + + expect(sound.isPlaying).toBe(false); + expect(source.stopTime).toBe(0); + expect(source.connections.size).toBe(0); + }); + + it('refuses to play or create buses afterwards', async () => { + await mixer.stop(); + + expect(() => playSound(mixer.master, createFakeSoundAsset())).toThrow( + /stopped sound mixer/, + ); + expect(() => mixer.createBus('music')).toThrow(/has been stopped/); + }); + + it('does nothing when called again', async () => { + await mixer.stop(); + + await expect(mixer.stop()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/audio/sound-mixer.ts b/src/audio/sound-mixer.ts new file mode 100644 index 000000000..90ce29895 --- /dev/null +++ b/src/audio/sound-mixer.ts @@ -0,0 +1,244 @@ +import { createMixerBus } from './internal/create-mixer-bus.js'; +import { + getBusInternals, + MixerInternals, + registerMixerInternals, +} from './internal/audio-internals.js'; +import type { MixerBus } from './mixer-bus.js'; + +/** + * The state of a {@link SoundMixer}'s audio. `'interrupted'` is reported by + * Safari while another app (or a phone call) has taken over audio. + */ +export type SoundMixerState = AudioContextState | 'interrupted'; + +/** + * Owns a game's audio output: the browser's `AudioContext` and a tree of + * {@link MixerBus | buses} under a `master` bus. Create one per game with + * {@link createSoundMixer}. + */ +export interface SoundMixer { + /** The root bus. Every other bus, and so every sound, ends up here. */ + readonly master: MixerBus; + + /** + * The state of the mixer's audio. Browsers start audio `'suspended'` + * until the player interacts with the page; the mixer resumes it on the + * first click, tap or key press. + */ + readonly state: SoundMixerState; + + /** + * Creates a bus that feeds into `parent`. + * @param name - The bus's name, unique within this mixer. + * @param parent - The bus to feed into. Defaults to `master`. + * @returns The new bus. + * @throws If a bus named `name` already exists, or `parent` belongs to a different mixer. + */ + createBus(name: string, parent?: MixerBus): MixerBus; + + /** + * Gets a bus by name. + * @param name - The bus's name. `'master'` returns the master bus. + * @returns The bus. + * @throws If this mixer has no bus named `name`. + */ + getBus(name: string): MixerBus; + + /** + * Pauses all audio, for example while the game is paused or its tab is + * hidden. Sounds continue from the same place on {@link SoundMixer.resume}. + * The mixer doesn't resume on its own while suspended this way. + * @returns A promise that resolves once audio is suspended. + */ + suspend(): Promise; + + /** + * Resumes audio after {@link SoundMixer.suspend}. + * @returns A promise that resolves once audio is running. + */ + resume(): Promise; + + /** + * Stops every sound, closes the `AudioContext` and stops listening for + * user gestures. The mixer can't play sounds afterwards. Does nothing if + * the mixer is already stopped. + * @returns A promise that resolves once the context is closed. + */ + stop(): Promise; +} + +// The events that can unlock audio. Per the HTML specification a touch +// activates the page on release (`pointerup`/`touchend`), not on press, and +// Escape doesn't count as a key press for this. +const gestureEvents = ['pointerup', 'touchend', 'click', 'keydown'] as const; + +const gestureListenerOptions: AddEventListenerOptions = { + capture: true, + passive: true, +}; + +/** + * Creates a {@link SoundMixer} with a `master` bus. + * + * Browsers don't play audio until the player has interacted with the page. + * The mixer listens for the first click, tap or key press and resumes audio + * then, and again whenever audio stops without the game asking (Safari + * interrupts it for calls and other apps). Stop the mixer with + * {@link SoundMixer.stop} when the game is torn down. + * @param context - The audio context to play through. Defaults to a new `AudioContext`. + * @returns The mixer. + */ +export function createSoundMixer( + context: AudioContext = new AudioContext(), +): SoundMixer { + const internals: MixerInternals = { + context, + instances: new Set(), + hasHadGesture: false, + isStopped: false, + }; + + const buses = new Map(); + let isSuspendedByGame = false; + let isListeningForGestures = false; + + const master = createMixerBus(internals, 'master', null, context.destination); + + buses.set(master.name, master); + + const getState = (): SoundMixerState => context.state; + + const handleGesture = (event: Event): void => { + if (event instanceof KeyboardEvent && event.key === 'Escape') { + return; + } + + internals.hasHadGesture = true; + + if (!isSuspendedByGame && getState() !== 'running') { + // A resume from an event the browser doesn't count as a gesture fails; + // the listeners stay until audio actually runs. + context.resume().catch(() => {}); + } + }; + + const startListening = (): void => { + if (isListeningForGestures) { + return; + } + + isListeningForGestures = true; + + for (const type of gestureEvents) { + window.addEventListener(type, handleGesture, gestureListenerOptions); + } + }; + + const stopListening = (): void => { + if (!isListeningForGestures) { + return; + } + + isListeningForGestures = false; + + for (const type of gestureEvents) { + window.removeEventListener(type, handleGesture, gestureListenerOptions); + } + }; + + const handleStateChange = (): void => { + const state = getState(); + + if (state === 'running') { + stopListening(); + + return; + } + + if (state !== 'closed' && !isSuspendedByGame) { + startListening(); + } + }; + + const assertNotStopped = (action: string): void => { + if (internals.isStopped) { + throw new Error(`Unable to ${action}: the sound mixer has been stopped.`); + } + }; + + context.addEventListener('statechange', handleStateChange); + handleStateChange(); + + const mixer: SoundMixer = { + master, + get state(): SoundMixerState { + return getState(); + }, + createBus(name: string, parent: MixerBus = master): MixerBus { + assertNotStopped(`create the bus "${name}"`); + + if (buses.has(name)) { + throw new Error( + `Unable to create the bus "${name}": the mixer already has a bus with that name.`, + ); + } + + const parentInternals = getBusInternals(parent); + + if (parentInternals.mixer !== internals) { + throw new Error( + `Unable to create the bus "${name}": its parent "${parent.name}" belongs to a different sound mixer.`, + ); + } + + const bus = createMixerBus(internals, name, parent, parentInternals.gain); + + buses.set(name, bus); + + return bus; + }, + getBus(name: string): MixerBus { + const bus = buses.get(name); + + if (!bus) { + throw new Error(`The sound mixer has no bus named "${name}".`); + } + + return bus; + }, + suspend: async (): Promise => { + assertNotStopped('suspend audio'); + isSuspendedByGame = true; + stopListening(); + await context.suspend(); + }, + resume: async (): Promise => { + assertNotStopped('resume audio'); + isSuspendedByGame = false; + handleStateChange(); + await context.resume(); + }, + stop: async (): Promise => { + if (internals.isStopped) { + return; + } + + internals.isStopped = true; + stopListening(); + context.removeEventListener('statechange', handleStateChange); + + for (const instance of [...internals.instances]) { + instance.stopImmediately(); + } + + if (getState() !== 'closed') { + await context.close(); + } + }, + }; + + registerMixerInternals(mixer, internals); + + return mixer; +} diff --git a/src/audio/systems/audio-system.test.ts b/src/audio/systems/audio-system.test.ts deleted file mode 100644 index 0d7da627f..000000000 --- a/src/audio/systems/audio-system.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { Howl } from 'howler'; -import { createAudioEcsSystem } from './audio-system'; -import { addAudioComponent } from '../components'; -import { EcsWorld } from '../../ecs'; - -vi.mock(import('howler'), { spy: true }); - -describe('createAudioEcsSystem (Audio)', () => { - it('should play sound when playSound is true', () => { - const ecsWorld = new EcsWorld(); - const audioSystem = createAudioEcsSystem(); - ecsWorld.addSystem(audioSystem); - - const entity = ecsWorld.createEntity(); - const audioComponent = addAudioComponent(ecsWorld, entity, { - sound: new Howl({ - src: ['test-sound.mp3'], - }), - playSound: true, - }); - - ecsWorld.update(); - - expect(audioComponent.sound.play).toHaveBeenCalledTimes(1); - expect(audioComponent.playSound).toBe(false); - }); - - it('should not play sound when playSound is false', () => { - const ecsWorld = new EcsWorld(); - const audioSystem = createAudioEcsSystem(); - ecsWorld.addSystem(audioSystem); - - const entity = ecsWorld.createEntity(); - const audioComponent = addAudioComponent(ecsWorld, entity, { - sound: new Howl({ - src: ['test-sound.mp3'], - }), - }); - - ecsWorld.update(); - - expect(audioComponent.sound.play).not.toHaveBeenCalled(); - expect(audioComponent.playSound).toBe(false); - }); - - it('should reset playSound to false after playing', () => { - const ecsWorld = new EcsWorld(); - const audioSystem = createAudioEcsSystem(); - ecsWorld.addSystem(audioSystem); - - const entity = ecsWorld.createEntity(); - const audioComponent = addAudioComponent(ecsWorld, entity, { - sound: new Howl({ - src: ['test-sound.mp3'], - }), - playSound: true, - }); - - // First update should play the sound - ecsWorld.update(); - expect(audioComponent.sound.play).toHaveBeenCalledTimes(1); - expect(audioComponent.playSound).toBe(false); - - // Second update should not play the sound - ecsWorld.update(); - expect(audioComponent.sound.play).toHaveBeenCalledTimes(1); - }); - - it('should handle multiple entities with audio components', () => { - const ecsWorld = new EcsWorld(); - const audioSystem = createAudioEcsSystem(); - ecsWorld.addSystem(audioSystem); - - const entity1 = ecsWorld.createEntity(); - const audioComponent1 = addAudioComponent(ecsWorld, entity1, { - sound: new Howl({ - src: ['test-sound.mp3'], - }), - playSound: true, - }); - - const entity2 = ecsWorld.createEntity(); - const audioComponent2 = addAudioComponent(ecsWorld, entity2, { - sound: new Howl({ - src: ['test-sound.mp3'], - }), - }); - - const entity3 = ecsWorld.createEntity(); - const audioComponent3 = addAudioComponent(ecsWorld, entity3, { - sound: new Howl({ - src: ['test-sound.mp3'], - }), - playSound: true, - }); - - ecsWorld.update(); - - expect(audioComponent1.sound.play).toHaveBeenCalledTimes(1); - expect(audioComponent2.sound.play).not.toHaveBeenCalled(); - expect(audioComponent3.sound.play).toHaveBeenCalledTimes(1); - expect(audioComponent1.playSound).toBe(false); - expect(audioComponent2.playSound).toBe(false); - expect(audioComponent3.playSound).toBe(false); - }); - - it('should allow re-triggering sound playback', () => { - const ecsWorld = new EcsWorld(); - const audioSystem = createAudioEcsSystem(); - ecsWorld.addSystem(audioSystem); - - const entity = ecsWorld.createEntity(); - const audioComponent = addAudioComponent(ecsWorld, entity, { - sound: new Howl({ - src: ['test-sound.mp3'], - }), - playSound: true, - }); - - // First play - ecsWorld.update(); - expect(audioComponent.sound.play).toHaveBeenCalledTimes(1); - expect(audioComponent.playSound).toBe(false); - - // Re-trigger the sound - audioComponent.playSound = true; - ecsWorld.update(); - expect(audioComponent.sound.play).toHaveBeenCalledTimes(2); - expect(audioComponent.playSound).toBe(false); - }); -}); diff --git a/src/audio/systems/audio-system.ts b/src/audio/systems/audio-system.ts deleted file mode 100644 index f7a294737..000000000 --- a/src/audio/systems/audio-system.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { AudioEcsComponent, audioId } from '../components/index.js'; -import { EcsSystem } from '../../ecs/ecs-system.js'; - -/** - * Creates an ECS system to handle audio playback. - * - * Each tick, entities with an {@link AudioEcsComponent} whose `playSound` is - * `true` have `sound.play()` called and `playSound` reset to `false`. When - * the world stops, any matching entity whose `sound` is still playing has it - * stopped and unloaded. - * @returns An ECS system that manages audio playback for entities with AudioEcsComponent. - */ -export const createAudioEcsSystem = (): EcsSystem<[AudioEcsComponent]> => ({ - query: [audioId], - update: (_world, { components: [audioComponents] }) => { - for (const audioComponent of audioComponents) { - if (audioComponent.playSound) { - audioComponent.sound.play(); - audioComponent.playSound = false; - } - } - }, - cleanup(world) { - const { - components: [audioComponents], - } = world.query<[AudioEcsComponent]>([audioId]); - - for (const audioComponent of audioComponents) { - if (audioComponent.sound.playing()) { - audioComponent.sound.stop(); - audioComponent.sound.unload(); - } - } - }, -}); diff --git a/src/audio/systems/index.ts b/src/audio/systems/index.ts index 749d72c00..0756b4493 100644 --- a/src/audio/systems/index.ts +++ b/src/audio/systems/index.ts @@ -1 +1 @@ -export * from './audio-system.js'; +export * from './sound-system.js'; diff --git a/src/audio/systems/sound-system.test.ts b/src/audio/systems/sound-system.test.ts new file mode 100644 index 000000000..bc9b00397 --- /dev/null +++ b/src/audio/systems/sound-system.test.ts @@ -0,0 +1,303 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { EcsWorld } from '../../ecs/index.js'; +import { + addSoundComponent, + SoundEcsComponent, + soundId, +} from '../components/index.js'; +import type { MixerBus } from '../mixer-bus.js'; +import { createSoundMixer, SoundMixer } from '../sound-mixer.js'; +import { + createFakeSoundAsset, + FakeAudioContext, +} from '../test-helpers/fake-audio-context.js'; +import { createSoundEcsSystem } from './sound-system.js'; + +describe('createSoundEcsSystem', () => { + let context: FakeAudioContext; + let mixer: SoundMixer; + let sfx: MixerBus; + let world: EcsWorld; + + const addSound = ( + options: Partial = {}, + ): { entity: number; component: SoundEcsComponent } => { + const entity = world.createEntity(); + const component = addSoundComponent(world, entity, { + sound: createFakeSoundAsset(), + bus: sfx, + ...options, + }); + + return { entity, component }; + }; + + const instanceGainOf = (sourceIndex: number) => + [...context.sources[sourceIndex].connections][0] as unknown as { + gain: { value: number }; + connections: Set; + }; + + beforeEach(() => { + context = new FakeAudioContext(); + mixer = createSoundMixer(context.asAudioContext()); + sfx = mixer.createBus('sfx'); + world = new EcsWorld(); + world.addSystem(createSoundEcsSystem()); + window.dispatchEvent(new Event('pointerup')); + }); + + afterEach(async () => { + world.stop(); + await mixer.stop(); + }); + + it('starts the sound once', () => { + const { component } = addSound({ volume: 0.5, rate: 2, loop: true }); + + world.update(); + world.update(); + + expect(context.sources).toHaveLength(1); + + const [source] = context.sources; + + expect(source.buffer).toBe(component.sound.buffer); + expect(source.loop).toBe(true); + expect(source.playbackRate.value).toBe(2); + expect(instanceGainOf(0).gain.value).toBe(0.5); + expect(source.startCall).toEqual({ when: 0, offset: 0 }); + }); + + it("doesn't start a paused sound until it's unpaused", () => { + const { component } = addSound({ paused: true }); + + world.update(); + + expect(context.sources).toHaveLength(0); + + component.paused = false; + world.update(); + + expect(context.sources).toHaveLength(1); + }); + + it('resumes from where it was paused, scaled by the rate', () => { + const { component } = addSound({ rate: 2 }); + + world.update(); + context.currentTime = 0.4; + component.paused = true; + world.update(); + + expect(context.sources[0].stopTime).not.toBeNull(); + + context.currentTime = 5; + component.paused = false; + world.update(); + + expect(context.sources[1].startCall?.offset).toBeCloseTo(0.8); + }); + + it('tracks the position across a rate change', () => { + const { component } = addSound(); + + world.update(); + context.currentTime = 0.5; + component.rate = 2; + world.update(); + context.currentTime = 0.75; + component.paused = true; + world.update(); + component.paused = false; + world.update(); + + expect(context.sources[1].startCall?.offset).toBeCloseTo(1); + }); + + it('wraps the resume position of a looping sound', () => { + const { component } = addSound({ + sound: createFakeSoundAsset(2), + loop: true, + }); + + world.update(); + context.currentTime = 5; + component.paused = true; + world.update(); + component.paused = false; + world.update(); + + expect(context.sources[1].startCall?.offset).toBeCloseTo(1); + }); + + it('applies volume, rate and loop changes to the playing sound', () => { + const { component } = addSound(); + + world.update(); + component.volume = 0.2; + component.rate = 0.5; + component.loop = true; + world.update(); + + const [source] = context.sources; + + expect(context.sources).toHaveLength(1); + expect(instanceGainOf(0).gain.value).toBeCloseTo(0.2); + expect(source.playbackRate.value).toBe(0.5); + expect(source.loop).toBe(true); + }); + + it('moves the sound to a new bus without restarting it', () => { + const music = mixer.createBus('music'); + const musicGain = context.gains[context.gains.length - 1]; + const { component } = addSound(); + + world.update(); + const instanceGain = instanceGainOf(0); + + component.bus = music; + world.update(); + + expect(context.sources).toHaveLength(1); + expect(instanceGain.connections).toEqual(new Set([musicGain])); + }); + + it('throws when the bus changes to one of a different mixer', async () => { + const otherMixer = createSoundMixer( + new FakeAudioContext('running').asAudioContext(), + ); + const { component } = addSound(); + + world.update(); + component.bus = otherMixer.master; + + expect(() => world.update()).toThrow(/different sound mixer/); + + await otherMixer.stop(); + }); + + it('starts a new sound from the beginning when the sound changes', () => { + const { component } = addSound(); + + world.update(); + context.currentTime = 1; + + const newSound = createFakeSoundAsset(); + + component.sound = newSound; + world.update(); + + expect(context.sources[0].stopTime).not.toBeNull(); + expect(context.sources[1].buffer).toBe(newSound.buffer); + expect(context.sources[1].startCall?.offset).toBe(0); + }); + + it('reports a finished sound and plays nothing more', () => { + const { component } = addSound(); + + world.update(); + context.sources[0].end(); + world.update(); + + expect(component.hasFinished).toBe(true); + + world.update(); + + expect(context.sources).toHaveLength(1); + }); + + it('stops the sound when the component is removed', () => { + const { entity } = addSound(); + + world.update(); + world.removeComponent(entity, soundId); + world.update(); + + expect(context.sources[0].stopTime).not.toBeNull(); + }); + + it('stops the sound when the entity is removed', () => { + const { entity } = addSound(); + + world.update(); + world.removeEntity(entity); + world.update(); + + expect(context.sources[0].stopTime).not.toBeNull(); + }); + + it('plays a new component added after the previous one finished', () => { + const { entity, component } = addSound(); + + world.update(); + context.sources[0].end(); + world.update(); + + expect(component.hasFinished).toBe(true); + + world.removeComponent(entity, soundId); + addSoundComponent(world, entity, { + sound: component.sound, + bus: sfx, + }); + world.update(); + + expect(context.sources).toHaveLength(2); + }); + + it('stops every sound it started when the world stops', () => { + addSound(); + addSound({ loop: true }); + + world.update(); + world.stop(); + + expect(context.sources.map((source) => source.stopTime)).not.toContain( + null, + ); + }); + + it('reports a sound as finished when its mixer is stopped', async () => { + const { component } = addSound({ loop: true }); + + world.update(); + await mixer.stop(); + world.update(); + + expect(component.hasFinished).toBe(true); + expect(context.sources).toHaveLength(1); + }); + + describe('before the first gesture', () => { + let lockedContext: FakeAudioContext; + let lockedMixer: SoundMixer; + + beforeEach(() => { + lockedContext = new FakeAudioContext(); + lockedMixer = createSoundMixer(lockedContext.asAudioContext()); + }); + + afterEach(async () => { + await lockedMixer.stop(); + }); + + it('reports a non-looping sound as finished without playing it', () => { + const { component } = addSound({ bus: lockedMixer.master }); + + world.update(); + + expect(component.hasFinished).toBe(true); + expect(lockedContext.sources).toHaveLength(0); + }); + + it('starts a looping sound', () => { + const { component } = addSound({ bus: lockedMixer.master, loop: true }); + + world.update(); + + expect(component.hasFinished).toBe(false); + expect(lockedContext.sources).toHaveLength(1); + }); + }); +}); diff --git a/src/audio/systems/sound-system.ts b/src/audio/systems/sound-system.ts new file mode 100644 index 000000000..6c4942f0c --- /dev/null +++ b/src/audio/systems/sound-system.ts @@ -0,0 +1,178 @@ +import { EcsSystem } from '../../ecs/ecs-system.js'; +import { SoundEcsComponent, soundId } from '../components/index.js'; +import { + canStartOneShot, + getBusInternals, +} from '../internal/audio-internals.js'; +import { SoundInstance } from '../internal/sound-instance.js'; +import type { MixerBus } from '../mixer-bus.js'; +import type { SoundAsset } from '../sound-asset.js'; + +/** + * What the system last applied for one component, to tell what the game + * has changed since. + */ +interface TrackedSound { + /** The playing instance, or `null` while paused or not yet started. */ + instance: SoundInstance | null; + sound: SoundAsset; + bus: MixerBus; + volume: number; + rate: number; + loop: boolean; + /** Where to resume from, in seconds into the sound. */ + positionSeconds: number; + /** The last update the component was found in, to notice its removal. */ + lastSeenUpdate: number; +} + +const createTrackedSound = (component: SoundEcsComponent): TrackedSound => ({ + instance: null, + sound: component.sound, + bus: component.bus, + volume: component.volume, + rate: component.rate, + loop: component.loop, + positionSeconds: 0, + lastSeenUpdate: 0, +}); + +const start = (component: SoundEcsComponent, tracked: TrackedSound): void => { + const bus = getBusInternals(component.bus); + + if (!component.loop && !canStartOneShot(bus.mixer)) { + component.hasFinished = true; + + return; + } + + tracked.instance = new SoundInstance(bus, component.sound, { + volume: component.volume, + rate: component.rate, + loop: component.loop, + offsetSeconds: tracked.positionSeconds, + }); + tracked.bus = component.bus; + tracked.volume = component.volume; + tracked.rate = component.rate; + tracked.loop = component.loop; +}; + +const applyChanges = ( + component: SoundEcsComponent, + tracked: TrackedSound, + instance: SoundInstance, +): void => { + if (component.volume !== tracked.volume) { + instance.setVolume(component.volume); + tracked.volume = component.volume; + } + + if (component.rate !== tracked.rate) { + instance.setRate(component.rate); + tracked.rate = component.rate; + } + + if (component.loop !== tracked.loop) { + instance.setLoop(component.loop); + tracked.loop = component.loop; + } + + if (component.bus !== tracked.bus) { + instance.setBus(getBusInternals(component.bus)); + tracked.bus = component.bus; + } +}; + +const reconcile = ( + component: SoundEcsComponent, + tracked: TrackedSound, +): void => { + if (component.hasFinished) { + return; + } + + // The system only lets go of instances it stops itself, so one that has + // ended played to its end, or its mixer was stopped. + if (tracked.instance?.hasEnded) { + tracked.instance = null; + component.hasFinished = true; + + return; + } + + if (component.sound !== tracked.sound) { + tracked.instance?.stop(); + tracked.instance = null; + tracked.sound = component.sound; + tracked.positionSeconds = 0; + } + + if (component.paused) { + if (tracked.instance) { + tracked.positionSeconds = tracked.instance.positionSeconds; + tracked.instance.stop(); + tracked.instance = null; + } + + return; + } + + if (!tracked.instance) { + start(component, tracked); + + return; + } + + applyChanges(component, tracked, tracked.instance); +}; + +/** + * Creates a system that plays each entity's {@link SoundEcsComponent}. + * + * Every update it starts sounds that haven't started, pauses and resumes + * them as `paused` changes, applies changes to `volume`, `rate`, `loop`, + * `bus` and `sound` to the playing sound, sets `hasFinished` once a + * non-looping sound has played to its end, and stops the sound of any + * component or entity removed since the last update. When the world stops, + * every sound it started is stopped. Sound assets are left alone, so a new + * world can play them again. + * @returns The ECS system. + */ +export const createSoundEcsSystem = (): EcsSystem<[SoundEcsComponent]> => { + const trackedSounds = new Map(); + let updateCount = 0; + + return { + query: [soundId], + update: (_world, { components: [soundComponents] }) => { + updateCount++; + + for (const component of soundComponents) { + let tracked = trackedSounds.get(component); + + if (!tracked) { + tracked = createTrackedSound(component); + trackedSounds.set(component, tracked); + } + + tracked.lastSeenUpdate = updateCount; + reconcile(component, tracked); + } + + for (const [component, tracked] of trackedSounds) { + if (tracked.lastSeenUpdate !== updateCount) { + tracked.instance?.stop(); + trackedSounds.delete(component); + } + } + }, + cleanup: () => { + for (const tracked of trackedSounds.values()) { + tracked.instance?.stop(); + } + + trackedSounds.clear(); + }, + }; +}; diff --git a/src/audio/test-helpers/fake-audio-context.ts b/src/audio/test-helpers/fake-audio-context.ts new file mode 100644 index 000000000..8afb33744 --- /dev/null +++ b/src/audio/test-helpers/fake-audio-context.ts @@ -0,0 +1,183 @@ +// A minimal stand-in for the Web Audio API, which jsdom doesn't have. It +// records the graph (connections, parameter values, start and stop calls) +// instead of producing sound, and lets a test move time and state along. + +export class FakeAudioParam { + public value: number; + public readonly calls: string[] = []; + + constructor(value: number) { + this.value = value; + } + + public setTargetAtTime(value: number, startTime: number): this { + this.calls.push(`setTargetAtTime(${value}, ${startTime})`); + this.value = value; + + return this; + } + + public setValueAtTime(value: number, startTime: number): this { + this.calls.push(`setValueAtTime(${value}, ${startTime})`); + this.value = value; + + return this; + } + + public linearRampToValueAtTime(value: number, endTime: number): this { + this.calls.push(`linearRampToValueAtTime(${value}, ${endTime})`); + this.value = value; + + return this; + } + + public cancelScheduledValues(): this { + return this; + } +} + +export class FakeAudioNode extends EventTarget { + public readonly connections = new Set(); + + public connect(destination: FakeAudioNode): FakeAudioNode { + this.connections.add(destination); + + return destination; + } + + public disconnect(): void { + this.connections.clear(); + } +} + +export class FakeGainNode extends FakeAudioNode { + public readonly gain = new FakeAudioParam(1); +} + +export class FakeBufferSourceNode extends FakeAudioNode { + public buffer: unknown = null; + public loop = false; + public readonly playbackRate = new FakeAudioParam(1); + public startCall: { when: number; offset: number } | null = null; + public stopTime: number | null = null; + + public start(when = 0, offset = 0): void { + this.startCall = { when, offset }; + } + + public stop(when = 0): void { + this.stopTime = when; + } + + /** Simulates the browser reporting that the source has ended. */ + public end(): void { + this.dispatchEvent(new Event('ended')); + } +} + +export class FakeAudioContext extends EventTarget { + public state: string; + public currentTime = 0; + public readonly destination = new FakeAudioNode(); + public readonly gains: FakeGainNode[] = []; + public readonly sources: FakeBufferSourceNode[] = []; + public resumeCalls = 0; + public decodeAudioData: (bytes: ArrayBuffer) => Promise; + + constructor(state = 'suspended') { + super(); + this.state = state; + this.decodeAudioData = () => Promise.resolve({ duration: 1 }); + } + + public createGain(): FakeGainNode { + const gain = new FakeGainNode(); + + this.gains.push(gain); + + return gain; + } + + public createBufferSource(): FakeBufferSourceNode { + const source = new FakeBufferSourceNode(); + + this.sources.push(source); + + return source; + } + + public resume(): Promise { + this.resumeCalls++; + + return Promise.resolve(); + } + + public suspend(): Promise { + this.setState('suspended'); + + return Promise.resolve(); + } + + public close(): Promise { + this.setState('closed'); + + return Promise.resolve(); + } + + /** Changes the state and fires `statechange`, as the browser would. */ + public setState(state: string): void { + this.state = state; + this.dispatchEvent(new Event('statechange')); + } + + /** The fake typed as the real thing, for the APIs that take one. */ + public asAudioContext(): AudioContext { + return this as unknown as AudioContext; + } +} + +/** + * A stand-in for `AudioBuffer`, which jsdom doesn't have. Install it with + * `vi.stubGlobal('AudioBuffer', FakeAudioBuffer)`. + */ +export class FakeAudioBuffer { + public readonly length: number; + public readonly numberOfChannels: number; + public readonly sampleRate: number; + public readonly channels: Float32Array[]; + + constructor(options: { + length: number; + numberOfChannels: number; + sampleRate: number; + }) { + this.length = options.length; + this.numberOfChannels = options.numberOfChannels; + this.sampleRate = options.sampleRate; + this.channels = Array.from( + { length: options.numberOfChannels }, + () => new Float32Array(options.length), + ); + } + + get duration(): number { + return this.length / this.sampleRate; + } + + public copyToChannel(source: Float32Array, channel: number): void { + this.channels[channel].set(source); + } +} + +/** + * A sound asset backed by a fake buffer, for tests that only need + * something to play. + * @param durationSeconds - The sound's duration. + * @returns The sound asset. + */ +export const createFakeSoundAsset = ( + durationSeconds = 2, +): { buffer: AudioBuffer; durationSeconds: number } => ({ + buffer: { duration: durationSeconds } as unknown as AudioBuffer, + durationSeconds, +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 9fb4ecd4e..f10794603 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,5 +3,11 @@ "compilerOptions": { "rootDir": "./src" }, - "exclude": ["node_modules", "demo", "**/*.spec.ts", "**/*.test.ts"] + "exclude": [ + "node_modules", + "demo", + "**/*.spec.ts", + "**/*.test.ts", + "**/test-helpers/**" + ] } diff --git a/vite.config.base.js b/vite.config.base.js index b7c7caf56..b2f5d1172 100644 --- a/vite.config.base.js +++ b/vite.config.base.js @@ -14,7 +14,11 @@ export default defineConfig({ reporter: ['text', 'html', 'lcov'], reportsDirectory: './coverage', include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts'], + exclude: [ + 'src/**/*.test.ts', + 'src/**/*.spec.ts', + 'src/**/test-helpers/**', + ], }, }, }); From 43d7d027f16ea0fbd1a9d133d969e7c70b7f2990 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 23:36:35 +0000 Subject: [PATCH 2/2] test(audio): cover dropped-sound volume, refused resumes and ended instances Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GR7HgBEY8CatWd78XsJkag --- src/audio/internal/sound-instance.test.ts | 79 +++++++++++++++++++++++ src/audio/play-sound.test.ts | 11 ++++ src/audio/sound-asset-cache.test.ts | 8 +++ src/audio/sound-mixer.test.ts | 25 +++++++ 4 files changed, 123 insertions(+) create mode 100644 src/audio/internal/sound-instance.test.ts diff --git a/src/audio/internal/sound-instance.test.ts b/src/audio/internal/sound-instance.test.ts new file mode 100644 index 000000000..9eb7b9f44 --- /dev/null +++ b/src/audio/internal/sound-instance.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { MixerBus } from '../mixer-bus.js'; +import { createSoundMixer, SoundMixer } from '../sound-mixer.js'; +import { + createFakeSoundAsset, + FakeAudioContext, +} from '../test-helpers/fake-audio-context.js'; +import { getBusInternals } from './audio-internals.js'; +import { SoundInstance } from './sound-instance.js'; + +describe('SoundInstance', () => { + let context: FakeAudioContext; + let mixer: SoundMixer; + let sfx: MixerBus; + let instance: SoundInstance; + + beforeEach(() => { + context = new FakeAudioContext('running'); + mixer = createSoundMixer(context.asAudioContext()); + sfx = mixer.createBus('sfx'); + instance = new SoundInstance(getBusInternals(sfx), createFakeSoundAsset(), { + volume: 1, + rate: 1, + loop: false, + offsetSeconds: 0, + }); + }); + + afterEach(async () => { + await mixer.stop(); + }); + + it('ignores volume, rate and loop changes once it has ended', () => { + const [source] = context.sources; + const instanceGain = context.gains[context.gains.length - 1]; + + source.end(); + instance.setVolume(0.5); + instance.setRate(2); + instance.setLoop(true); + + expect(instance.hasEnded).toBe(true); + expect(instanceGain.gain.calls).toEqual([]); + expect(source.playbackRate.calls).toEqual([]); + expect(source.loop).toBe(false); + }); + + it('still validates changes once it has ended', () => { + context.sources[0].end(); + + expect(() => { + instance.setVolume(-1); + }).toThrow(/volume/); + expect(() => { + instance.setRate(0); + }).toThrow(/rate/); + }); + + it("doesn't reconnect to a new bus once stopped", () => { + const [, sfxGain, instanceGain] = context.gains; + const music = mixer.createBus('music'); + + instance.stop(); + instance.setBus(getBusInternals(music)); + + expect(instanceGain.connections).toEqual(new Set([sfxGain])); + }); + + it('does nothing when stopped immediately a second time', () => { + const [source] = context.sources; + + instance.stopImmediately(); + source.stopTime = null; + instance.stopImmediately(); + + expect(instance.hasEnded).toBe(true); + expect(source.stopTime).toBeNull(); + }); +}); diff --git a/src/audio/play-sound.test.ts b/src/audio/play-sound.test.ts index 96f4b9cf1..25e194575 100644 --- a/src/audio/play-sound.test.ts +++ b/src/audio/play-sound.test.ts @@ -143,6 +143,17 @@ describe('playSound', () => { }).not.toThrow(); }); + it('keeps the volume set on a dropped sound', () => { + const sound = playSound(lockedMixer.master, createFakeSoundAsset()); + + sound.volume = 0.3; + + expect(sound.volume).toBeCloseTo(0.3); + expect(() => { + sound.volume = -1; + }).toThrow(/volume/); + }); + it('starts a looping sound', () => { const sound = playSound(lockedMixer.master, createFakeSoundAsset(), { loop: true, diff --git a/src/audio/sound-asset-cache.test.ts b/src/audio/sound-asset-cache.test.ts index a0feef369..d66f61bf8 100644 --- a/src/audio/sound-asset-cache.test.ts +++ b/src/audio/sound-asset-cache.test.ts @@ -64,6 +64,14 @@ describe('SoundAssetCache', () => { ); }); + it("throws for a mixer that wasn't made by createSoundMixer", () => { + const mixerCopy: SoundMixer = { ...mixer }; + + expect(() => new SoundAssetCache(mixerCopy)).toThrow( + /not made by `createSoundMixer`/, + ); + }); + it('rejects with the URL when the file fails to load', async () => { fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 })); diff --git a/src/audio/sound-mixer.test.ts b/src/audio/sound-mixer.test.ts index 34783ec7f..9c3043b98 100644 --- a/src/audio/sound-mixer.test.ts +++ b/src/audio/sound-mixer.test.ts @@ -91,6 +91,7 @@ describe('createSoundMixer', () => { music.volume = 0.5; music.muted = true; + expect(music.muted).toBe(true); expect(musicGain.gain.value).toBe(0); expect(music.volume).toBe(0.5); @@ -132,6 +133,23 @@ describe('createSoundMixer', () => { expect(context.resumeCalls).toBe(1); }); + it('keeps listening after the browser refuses a resume', async () => { + context.resume = (): Promise => { + context.resumeCalls++; + + return Promise.reject(new Error('Not allowed to start')); + }; + + window.dispatchEvent(new Event('pointerup')); + // Lets the refused resume settle before the next gesture. + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + window.dispatchEvent(new Event('pointerup')); + + expect(context.resumeCalls).toBe(2); + }); + it('keeps listening until the context runs', () => { window.dispatchEvent(new Event('pointerup')); window.dispatchEvent(new Event('pointerup')); @@ -153,6 +171,13 @@ describe('createSoundMixer', () => { expect(mixer.state).toBe('interrupted'); }); + it("doesn't add its listeners twice when the state changes before audio runs", () => { + context.setState('interrupted'); + window.dispatchEvent(new Event('pointerup')); + + expect(context.resumeCalls).toBe(1); + }); + it("doesn't resume on gestures while the game has suspended audio", async () => { context.setState('running'); await mixer.suspend();