Files
2026-05-27 23:29:53 +01:00

294 lines
19 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: audio-sprite-player
**Package**: `@suaveplan/audio-sprite-player`
**Status**: ACTIVE
**Originating change**: `archive/2026-05-23-enterprise-audio-sprite-player`
> Every requirement below carries a `**Verified by:**` line.
## Requirements
### Requirement: Universal-tier purity
The `@suaveplan/audio-sprite-player` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`, `decodeAudioData`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All environment-specific work SHALL be reached exclusively through the injected `SpritePlaybackAdapter`. The runtime `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/audio-sprite-packer` (type-only consumption — re-exports are types only), `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `tslib`, `zod`, and `@opentelemetry/api`.
#### Scenario: Built dist references no platform globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `decodeAudioData`, `node:fs`, `node:path`, `process`, or `Buffer`
#### Scenario: Dependency set is the declared minimum
- **WHEN** `package.json#dependencies` is inspected
- **THEN** the set equals the list above and contains no other workspace or external entries
**Verified by:** `src/sprite-player/sprite-player.test.ts::"dist references no browser or Node globals"` AND `src/sprite-player/sprite-player.test.ts::"dependency set is the declared minimum"`
---
### Requirement: `SpritePlayer` construction validates options at the Zod boundary
`SpritePlayer` SHALL accept `SpritePlayerOptions` and validate them with a Zod schema before any work begins. `adapter` SHALL be required; `logger`, `clock`, `idGenerator`, and `maxConcurrentVoices` SHALL be optional with sensible defaults (`NoopLogger`, system clock, ULID generator, 32). `maxConcurrentVoices` SHALL be a positive integer ≤ 256. Invalid options SHALL throw `AudioSpritePlayerError` with code `AUDIO_SPRITE_PLAYER.INVALID_OPTIONS`.
#### Scenario: Missing adapter rejects construction
- **WHEN** a caller constructs `new SpritePlayer({} as any)`
- **THEN** the constructor throws `AudioSpritePlayerError` with code `AUDIO_SPRITE_PLAYER.INVALID_OPTIONS` and the error context names `adapter`
#### Scenario: `maxConcurrentVoices` above the ceiling is rejected
- **WHEN** a caller constructs with `maxConcurrentVoices: 1000`
- **THEN** the constructor throws `AUDIO_SPRITE_PLAYER.INVALID_OPTIONS` and does NOT call `adapter.init`
**Verified by:** `src/sprite-player/sprite-player.test.ts::"construction rejects missing adapter"` AND `src/sprite-player/sprite-player.test.ts::"construction rejects maxConcurrentVoices above ceiling"`
---
### Requirement: `load()` validates the manifest and initialises the adapter
`SpritePlayer.load(sprite, signal?)` SHALL validate `sprite.manifest` against `AudioSpriteManifestSchema` (Zod) and SHALL then `await adapter.init(sprite, signal)` exactly once per successful load. Manifest validation failures SHALL throw `AUDIO_SPRITE_PLAYER.INVALID_MANIFEST`; adapter rejection SHALL be wrapped in `AUDIO_SPRITE_PLAYER.ADAPTER_INIT_FAILED` with the underlying error preserved via `cause`. A successful `load` SHALL transition the player into the "loaded" state required by `play`/`playSequence`/`playRandom`.
#### Scenario: Invalid manifest is rejected before the adapter is touched
- **WHEN** `load` is called with a manifest missing `entries`
- **THEN** it rejects with `AUDIO_SPRITE_PLAYER.INVALID_MANIFEST` AND `adapter.init` was never called (recorded via `NullSpritePlaybackAdapter.calls`)
#### Scenario: Adapter init failure is wrapped
- **WHEN** an adapter whose `init` rejects with `new Error("boom")` is loaded
- **THEN** the player rejects with `AUDIO_SPRITE_PLAYER.ADAPTER_INIT_FAILED` and `error.cause.message === "boom"`
**Verified by:** `src/sprite-player/sprite-player.test.ts::"load rejects invalid manifest before adapter"` AND `src/sprite-player/sprite-player.test.ts::"load wraps adapter init failure"`
---
### Requirement: `play()` requires a prior `load()`
`SpritePlayer.play(entryName, opts?)` SHALL reject with `AUDIO_SPRITE_PLAYER.NOT_LOADED` when called before any successful `load`. The same code SHALL apply to `playSequence` and `playRandom`.
#### Scenario: Calling `play` on a fresh player rejects
- **WHEN** a freshly constructed player calls `play("hit")`
- **THEN** it rejects with `AUDIO_SPRITE_PLAYER.NOT_LOADED` AND `adapter.play` was never called
**Verified by:** `src/sprite-player/sprite-player.test.ts::"play before load rejects with NOT_LOADED"`
---
### Requirement: `play()` resolves the entry by name and delegates to the adapter
After a successful `load`, `play(entryName, opts?)` SHALL resolve `entryName` against the manifest, apply Zod-validated defaulted `PlayOptions`, and call `adapter.play(entry, resolvedOpts, signal)`. Unknown names SHALL reject with `AUDIO_SPRITE_PLAYER.UNKNOWN_ENTRY`; invalid options SHALL reject with `AUDIO_SPRITE_PLAYER.INVALID_OPTIONS`; adapter rejection SHALL be wrapped as `AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED` preserving `cause`. On success, `play` SHALL return a `SpritePlayHandle` whose `id` was minted by the injected `IdGenerator` and whose `entryName` matches the resolved entry.
#### Scenario: Unknown entry name rejects without touching the adapter
- **WHEN** `play("missing")` is called on a player loaded with a sprite that has no `"missing"` entry
- **THEN** the call rejects with `AUDIO_SPRITE_PLAYER.UNKNOWN_ENTRY` AND `adapter.play` was never called
#### Scenario: Invalid `PlayOptions` is rejected by the Zod boundary
- **WHEN** `play("hit", { volume: 1.5 })` is called
- **THEN** it rejects with `AUDIO_SPRITE_PLAYER.INVALID_OPTIONS` AND `adapter.play` was never called
#### Scenario: Defaulted options reach the adapter as `ResolvedPlayOptions`
- **WHEN** `play("hit")` is called with no `opts`
- **THEN** `adapter.play` receives `{ volume: 1, pitch: 1, loop: false, fadeInMs: 0, fadeOutMs: 0, startOffsetMs: 0, durationMs: entry.durationSeconds * 1000, when: clock.now() }`
**Verified by:** `src/sprite-player/sprite-player.test.ts::"play resolves entry and delegates to adapter"` AND `src/sprite-player/sprite-player.test.ts::"play rejects unknown entry"` AND `src/sprite-player/sprite-player.test.ts::"play wraps adapter playback failure"`
---
### Requirement: `playSequence()` plays entries in order with `gapMs` spacing
`playSequence(entryNames, opts?)` SHALL play each entry in array order. Each subsequent entry's `when` SHALL be scheduled at the previous entry's scheduled start plus its `durationMs` plus `opts.gapMs ?? 0`. The method SHALL resolve once all voices have been started and SHALL return the array of `SpritePlayHandle` in the same order as `entryNames`. If any single `adapter.play` rejects, the sequence SHALL stop any already-started voices with `fadeOutMs = 0` and reject with `AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED` whose context includes the failing entry name and array index.
#### Scenario: Sequence schedules entries with gap
- **WHEN** `playSequence(["a","b"], { gapMs: 50 })` is called against entries with durations 100 ms and 200 ms
- **THEN** `adapter.play` is called twice with `when` values exactly 150 ms apart (100 ms duration of `a` + 50 ms gap)
#### Scenario: Sequence failure stops earlier voices
- **WHEN** a sequence's second entry fails playback
- **THEN** the first entry's handle has had `stop(0)` called AND the sequence rejects with `AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED` whose context contains the failing index `1`
**Verified by:** `src/sprite-player/sprite-player.test.ts::"playSequence schedules entries with gapMs"` AND `src/sprite-player/sprite-player.test.ts::"playSequence stops earlier voices on failure"`
---
### Requirement: `playRandom()` picks from filtered entries
`playRandom(filterFn?, opts?)` SHALL pick uniformly at random from the entries for which `filterFn` returns `true` (or all entries when `filterFn` is omitted) and SHALL delegate to `play` with the chosen entry's name. When the filter excludes every entry, the method SHALL reject with `AUDIO_SPRITE_PLAYER.UNKNOWN_ENTRY` whose context records `{ reason: "filter-excluded-all" }`.
#### Scenario: Filter excludes everything
- **WHEN** `playRandom(() => false)` is called on a loaded sprite
- **THEN** the call rejects with `AUDIO_SPRITE_PLAYER.UNKNOWN_ENTRY` AND `adapter.play` was never called
#### Scenario: Random pick reaches the adapter
- **WHEN** `playRandom()` is called repeatedly on a sprite with three entries
- **THEN** every entry name is observed at least once over a 1000-call run
**Verified by:** `src/sprite-player/sprite-player.test.ts::"playRandom rejects when filter excludes all"` AND `src/sprite-player/sprite-player.test.ts::"playRandom samples all entries over many calls"`
---
### Requirement: Voice-stealing under `maxConcurrentVoices`
When a `play` would cause the active voice count to exceed `maxConcurrentVoices`, the player SHALL stop the oldest active voice (lowest `startedAt`, ties broken by allocation order) with `fadeOutMs = 0`, emit a `voice-stolen` event with `{ stolenId, replacedBy, entryName }`, and then start the new voice. The stolen handle's `isPlaying()` SHALL return `false` immediately and its subsequent `stop()` calls SHALL be idempotent. The new voice SHALL succeed and SHALL NOT raise `AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED`.
#### Scenario: Oldest voice is stolen first
- **WHEN** `maxConcurrentVoices: 2` and three `play` calls fire in sequence with monotonically increasing `clock.now()` timestamps
- **THEN** the first handle's `isPlaying()` returns `false`, a `voice-stolen` event was emitted naming that handle's id, and the third `adapter.play` call was made
**Verified by:** `src/voice-manager/voice-manager.test.ts::"oldest voice stolen when over cap"` AND `src/voice-manager/voice-manager.test.ts::"stolen handle reports not playing"`
---
### Requirement: `AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED` reachable when stealing is disabled
When `maxConcurrentVoices` is reached and the consumer has explicitly opted out of stealing via `PlayOptions.allowVoiceStealing: false`, the player SHALL reject the new `play` with `AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED` whose context records the current voice count and the cap.
#### Scenario: Opting out of stealing surfaces the cap error
- **WHEN** `maxConcurrentVoices: 1` and a second `play("hit", { allowVoiceStealing: false })` is attempted while a voice is active
- **THEN** the call rejects with `AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED` AND the first voice is still playing
**Verified by:** `src/voice-manager/voice-manager.test.ts::"opt-out of stealing raises VOICE_LIMIT_EXCEEDED"`
---
### Requirement: `stopAll()` stops every active voice with optional fade
`stopAll(fadeOutMs?)` SHALL call `handle.stop(fadeOutMs ?? 0)` on every active voice and SHALL resolve once every adapter-side stop has resolved. After resolution, `list()` of active voices SHALL be empty (`handle.isPlaying() === false` for all).
#### Scenario: stopAll with fade reaches every voice
- **WHEN** three voices are active and `stopAll(50)` is called
- **THEN** each handle's `stop(50)` was called exactly once AND every handle reports `isPlaying() === false` once `stopAll` resolves
**Verified by:** `src/sprite-player/sprite-player.test.ts::"stopAll stops every active voice with fade"`
---
### Requirement: Typed event surface emits `play`, `stop`, `complete`, `voice-stolen`, `error`
`SpritePlayer.events` SHALL expose a typed `on` method built on `@suaveplan/events` for the five event names. `play` SHALL fire after the adapter accepts the voice; `stop` SHALL fire on user-driven stops; `complete` SHALL fire when a non-looping voice ends naturally; `voice-stolen` SHALL fire whenever the voice-stealing path runs; `error` SHALL fire when a play/stop fails after the public promise has already resolved (e.g. an asynchronous adapter failure mid-voice).
#### Scenario: Successful play emits `play` then `complete`
- **WHEN** a non-looping voice runs to completion
- **THEN** the listener observes one `play` event then exactly one `complete` event, in that order, for the same handle id
#### Scenario: Voice-stealing emits `voice-stolen` with both ids
- **WHEN** a steal happens
- **THEN** the listener observes a `voice-stolen` payload whose `stolenId` matches the displaced handle and whose `replacedBy` matches the new one
**Verified by:** `src/events/events.test.ts::"play then complete fires for a non-looping voice"` AND `src/events/events.test.ts::"voice-stolen carries both handle ids"`
---
### Requirement: OTel instrumentation on every public call
The package SHALL open a span named `audio-sprite-player.play` covering each `play` invocation (including those reached via `playSequence` and `playRandom`), increment a counter `audio_sprite_player.play_total` keyed by `entryName`, maintain a gauge `audio_sprite_player.voices_active` reflecting the live voice count, and record a histogram `audio_sprite_player.play_duration_ms` on `complete` events. Spans SHALL be recorded with status `ERROR` on rejection paths and SHALL carry the error code as an attribute.
#### Scenario: A successful play produces span + counter + gauge + histogram
- **WHEN** a play runs and completes
- **THEN** the in-memory OTel exporter records one `audio-sprite-player.play` span with `ok` status, the `audio_sprite_player.play_total` counter increments by 1, `audio_sprite_player.voices_active` went up by 1 then back down, and `audio_sprite_player.play_duration_ms` recorded one observation
#### Scenario: A failure attaches the error code to the span
- **WHEN** `adapter.play` rejects
- **THEN** the recorded span status is `ERROR` and its attributes include `error.code = "AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED"`
**Verified by:** `src/telemetry/telemetry.test.ts::"successful play emits span, counter, gauge, histogram"` AND `src/telemetry/telemetry.test.ts::"failed play attaches error code to span"`
---
### Requirement: `AbortSignal` propagation through every async public method
`load`, `play`, `playSequence`, and `playRandom` SHALL accept an `AbortSignal` (on the options bag for `play*`, as a direct argument for `load`). An already-aborted signal SHALL cause synchronous rejection with `AUDIO_SPRITE_PLAYER.ABORTED` before any adapter call is made. Abortion mid-flight SHALL stop in-flight voices with `fadeOutMs = 0` and reject the pending promise with `AUDIO_SPRITE_PLAYER.ABORTED`.
#### Scenario: Pre-aborted signal rejects synchronously
- **WHEN** `play("hit", { signal: AbortSignal.abort() })` is called
- **THEN** the returned promise rejects with `AUDIO_SPRITE_PLAYER.ABORTED` AND `adapter.play` was never called
#### Scenario: Mid-flight abort stops the in-flight voice
- **WHEN** a sequence is mid-flight and its signal is aborted between entries
- **THEN** every started handle has had `stop(0)` called AND the sequence promise rejects with `AUDIO_SPRITE_PLAYER.ABORTED`
**Verified by:** `src/sprite-player/sprite-player.test.ts::"pre-aborted signal rejects play synchronously"` AND `src/sprite-player/sprite-player.test.ts::"mid-flight abort stops sequence voices"`
---
### Requirement: Every error code is reachable from a real call site
The `AUDIO_SPRITE_PLAYER.*` codes SHALL be registered via `defineCodes(...)` with stable metadata and SHALL each be raised by at least one code path in `src/` exercised by at least one test. Codes covered: `NOT_LOADED`, `UNKNOWN_ENTRY`, `INVALID_MANIFEST`, `ADAPTER_INIT_FAILED`, `PLAYBACK_FAILED`, `VOICE_LIMIT_EXCEEDED`, `ABORTED`, `INVALID_OPTIONS`.
#### Scenario: Conformance test asserts every code is thrown somewhere
- **WHEN** the openspec error-codes-thrown conformance check runs against this package
- **THEN** every code in the registry is observed being thrown by at least one test
**Verified by:** `src/error-codes.test.ts::"every AUDIO_SPRITE_PLAYER code is thrown by at least one test"`
---
### Requirement: `NullSpritePlaybackAdapter` records every call and is a valid adapter
The package SHALL export `NullSpritePlaybackAdapter` implementing `SpritePlaybackAdapter`. It SHALL append an entry to a public readonly `calls` array on every `init`, `play`, and `dispose` invocation with `{ kind, at, payload }` where `at` comes from an injected clock (defaulting to `performance.now`-equivalent provided by `@suaveplan/types`'s `Clock`). Its `play` SHALL return a `SpritePlayHandle` whose `stop`, `setVolume`, and `isPlaying` are likewise recorded; `isPlaying` SHALL return `false` after `stop` is called.
#### Scenario: Null adapter is observable
- **WHEN** a player backed by `NullSpritePlaybackAdapter` runs one `load` and two `play` calls
- **THEN** `calls` has length 3 in order `init, play, play` AND every handle exposes consistent `isPlaying` before/after `stop`
**Verified by:** `src/adapter/null/null.test.ts::"null adapter records init, play, dispose"` AND `src/adapter/null/null.test.ts::"null handle isPlaying flips on stop"`
---
### Requirement: `SpritePlaybackAdapter` contract is documented and externally implementable
The `SpritePlaybackAdapter` interface SHALL be exported from the package root and from the `./adapter` subpath. Its public shape (`init`, `play`, `dispose`) and the contract for `ResolvedPlayOptions` (volume range, pitch range, loop semantics, fade behaviour, `when` interpretation) SHALL be documented in `src/adapter/adapter.md`. Engine packages (`@suaveplan/audio-engine-browser`, `@suaveplan/audio-engine-server`) SHALL be able to implement the adapter without importing anything other than the type re-exports from `@suaveplan/audio-sprite-player`.
#### Scenario: Adapter can be implemented from types alone
- **WHEN** a stand-alone TypeScript file imports only `type { SpritePlaybackAdapter, ResolvedPlayOptions, SpritePlayHandle }` from `@suaveplan/audio-sprite-player`
- **THEN** the file compiles with `tsc --noEmit` AND no runtime symbol is required
**Verified by:** `src/adapter/null/null.test.ts::"adapter contract compiles from type-only imports"`
---
### Requirement: Subpath exports for tree-shaking
`package.json#exports` SHALL declare subpath exports for every feature module: `./sprite-player/sprite-player`, `./voice-manager/voice-manager`, `./adapter` and `./adapter/null/null`, `./events/events`, `./telemetry/telemetry`, `./schemas`, `./error-codes`, plus the root export. Every subpath export SHALL resolve to a real file at build time and SHALL be reachable from `dist/`.
#### Scenario: Every declared subpath resolves
- **WHEN** the build runs and a smoke test iterates `package.json#exports`
- **THEN** each subpath imports successfully and exposes the symbols documented in its sibling `.md` file
**Verified by:** `src/sprite-player/sprite-player.test.ts::"every declared subpath export resolves"`
---
### Requirement: Sprite descriptor format is consumed via types-only import
The package SHALL consume `AudioSprite`, `AudioSpriteEntry`, and `AudioSpriteManifest` from `@suaveplan/audio-sprite-packer` via `import type` only. No runtime symbol from the packer SHALL appear in `dist/`. The producer/consumer contract SHALL be the manifest shape itself; the player SHALL NOT re-declare or fork the descriptor format.
#### Scenario: No runtime import from the packer
- **WHEN** `dist/` is grep'd for `from "@suaveplan/audio-sprite-packer"`
- **THEN** the only matches are inside `.d.ts` files (type position) and the runtime bundle contains zero matches
**Verified by:** `src/sprite-player/sprite-player.test.ts::"sprite-packer is consumed type-only"`