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

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, 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"