19 KiB
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, orBuffer
Scenario: Dependency set is the declared minimum
- WHEN
package.json#dependenciesis 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
AudioSpritePlayerErrorwith codeAUDIO_SPRITE_PLAYER.INVALID_OPTIONSand the error context namesadapter
Scenario: maxConcurrentVoices above the ceiling is rejected
- WHEN a caller constructs with
maxConcurrentVoices: 1000 - THEN the constructor throws
AUDIO_SPRITE_PLAYER.INVALID_OPTIONSand does NOT calladapter.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
loadis called with a manifest missingentries - THEN it rejects with
AUDIO_SPRITE_PLAYER.INVALID_MANIFESTANDadapter.initwas never called (recorded viaNullSpritePlaybackAdapter.calls)
Scenario: Adapter init failure is wrapped
- WHEN an adapter whose
initrejects withnew Error("boom")is loaded - THEN the player rejects with
AUDIO_SPRITE_PLAYER.ADAPTER_INIT_FAILEDanderror.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_LOADEDANDadapter.playwas 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_ENTRYANDadapter.playwas 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_OPTIONSANDadapter.playwas never called
Scenario: Defaulted options reach the adapter as ResolvedPlayOptions
- WHEN
play("hit")is called with noopts - THEN
adapter.playreceives{ 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.playis called twice withwhenvalues exactly 150 ms apart (100 ms duration ofa+ 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 withAUDIO_SPRITE_PLAYER.PLAYBACK_FAILEDwhose context contains the failing index1
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_ENTRYANDadapter.playwas 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: 2and threeplaycalls fire in sequence with monotonically increasingclock.now()timestamps - THEN the first handle's
isPlaying()returnsfalse, avoice-stolenevent was emitted naming that handle's id, and the thirdadapter.playcall 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: 1and a secondplay("hit", { allowVoiceStealing: false })is attempted while a voice is active - THEN the call rejects with
AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDEDAND 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 reportsisPlaying() === falseoncestopAllresolves
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
playevent then exactly onecompleteevent, 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-stolenpayload whosestolenIdmatches the displaced handle and whosereplacedBymatches 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.playspan withokstatus, theaudio_sprite_player.play_totalcounter increments by 1,audio_sprite_player.voices_activewent up by 1 then back down, andaudio_sprite_player.play_duration_msrecorded one observation
Scenario: A failure attaches the error code to the span
- WHEN
adapter.playrejects - THEN the recorded span status is
ERRORand its attributes includeerror.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.ABORTEDANDadapter.playwas 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 withAUDIO_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
NullSpritePlaybackAdapterruns oneloadand twoplaycalls - THEN
callshas length 3 in orderinit, play, playAND every handle exposes consistentisPlayingbefore/afterstop
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 --noEmitAND 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
.mdfile
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 forfrom "@suaveplan/audio-sprite-packer" - THEN the only matches are inside
.d.tsfiles (type position) and the runtime bundle contains zero matches
Verified by: src/sprite-player/sprite-player.test.ts::"sprite-packer is consumed type-only"