# OpenSpec Change: enterprise-audio-sprite-player - **Change ID**: enterprise-audio-sprite-player - **Package**: `@suaveplan/audio-sprite-player` - **Category**: universal - **Target path**: `packages/media/audio/audio-sprite-player` - **Status**: PROPOSED - **Date**: 2026-05-15 - **Design**: ./design.md - **Spec delta**: ./specs/audio-sprite-player/spec.md --- ## Summary Deliver `@suaveplan/audio-sprite-player` on the genesis branch from scratch at enterprise quality. This package closes the **sprite-playback consumer gap**: `@suaveplan/audio-sprite-packer` produces sprite descriptors (a concatenated PCM/encoded blob plus a JSON manifest of named regions with byte offset, byte length, duration, sample rate, channel count, and format) but **nothing on genesis currently consumes them**. `audio-sprite-player` owns sprite playback at the universal tier — it orchestrates "fire-and-forget" trigger audio (game cues, UI sounds, hit/miss feedback) on top of an injectable `SpritePlaybackAdapter` that engine-tier packages (`@suaveplan/audio-engine-browser`, `@suaveplan/audio-engine-server`) implement against `AudioContext` / native bindings. The universal layer owns descriptor validation, voice scheduling, voice-stealing, sequence and random playback, fade scheduling, OTel instrumentation, abort propagation, and the typed event surface; the adapter owns decode + actual audio output. Packer produces, player consumes. With this package landed, the sprite descriptor format produced by `audio-sprite-packer` finally has a first-party consumer that downstream engines can wire into a real audio backend without re-implementing manifest validation, scheduling, or voice management. --- ## Current-state audit (genesis) The package does not exist on genesis yet — this is a fresh build, genesis-original (no upstream `../suaveplan/enterprise/audio-sprite-player.md` to mirror). The producer it consumes from, `@suaveplan/audio-sprite-packer`, is **also in-flight** as the `enterprise-audio-sprite-packer` change (status: PROPOSED). This proposal therefore depends on the same wave landing: the type imports (`AudioSprite`, `AudioSpriteEntry`, `AudioSpriteManifest`) come from that package's public surface, and the workspace dep is declared as `workspace:^`. Both changes are expected to archive in the same wave; if `audio-sprite-packer` slips, `audio-sprite-player` ships against the merged type contract without a compiled producer (tests use literal `AudioSprite` fixtures, which is correct for a universal-tier package — it never decodes bytes). No prior source, no test results, no coverage data. --- ## Available workspace deps on genesis | Package | Role | |---|---| | `@suaveplan/audio-core` (`workspace:^`) | `AudioDocument` types reused for adapter-side context (types-only import surface) | | `@suaveplan/audio-sprite-packer` (`workspace:^`) | `AudioSprite`, `AudioSpriteEntry`, `AudioSpriteManifest` — **types-only** import (`import type`) | | `@suaveplan/error` (`workspace:^`) | Base class for `AudioSpritePlayerError`; `AUDIO_SPRITE_PLAYER.*` codes via `defineCodes` | | `@suaveplan/types` (`workspace:^`) | `LoggerLike`, `Clock`, `IdGenerator` for handle ids | | `@suaveplan/events` (`workspace:^`) | Typed event bus powering `SpritePlayerEvents` | | `zod` (`catalog:forms`) | Zod schemas at every public boundary (`SpritePlayerOptions`, `PlayOptions`, `SequenceOptions`, `AudioSpriteManifest` validation) | | `@opentelemetry/api` (`catalog:observability`) | Span `audio-sprite-player.play`, counter `audio_sprite_player.play_total`, gauge `audio_sprite_player.voices_active`, histogram `audio_sprite_player.play_duration_ms` | | `tslib` (`catalog:build`) | Runtime helpers | Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`). --- ## Public API ### Types ```ts import type { AudioSprite, AudioSpriteEntry, AudioSpriteManifest } from "@suaveplan/audio-sprite-packer" interface SpritePlaybackAdapter { init(sprite: AudioSprite, signal?: AbortSignal): Promise play(entry: AudioSpriteEntry, opts: ResolvedPlayOptions, signal?: AbortSignal): Promise dispose(): Promise } interface PlayOptions { volume?: number // 0.0–1.0, default 1.0 pitch?: number // 0.5–2.0 playback rate, default 1.0 loop?: boolean // default false fadeInMs?: number // default 0 fadeOutMs?: number // default 0 startOffsetMs?: number // default 0 — into the entry, not the sprite durationMs?: number // default = entry.durationSeconds * 1000 when?: number // absolute schedule time, default = clock.now() signal?: AbortSignal } interface SequenceOptions extends PlayOptions { gapMs?: number // default 0 } interface SpritePlayHandle { readonly id: string readonly entryName: string readonly startedAt: number stop(fadeOutMs?: number): Promise setVolume(volume: number): void isPlaying(): boolean } ``` ### Orchestrator ```ts interface SpritePlayerOptions { adapter: SpritePlaybackAdapter logger?: LoggerLike clock?: Clock idGenerator?: IdGenerator maxConcurrentVoices?: number // default 32 — voice-stealing oldest-first } class SpritePlayer { constructor(opts: SpritePlayerOptions) load(sprite: AudioSprite, signal?: AbortSignal): Promise play(entryName: string, opts?: PlayOptions): Promise playSequence(entryNames: string[], opts?: SequenceOptions): Promise playRandom(filterFn?: (entry: AudioSpriteEntry) => boolean, opts?: PlayOptions): Promise stopAll(fadeOutMs?: number): Promise list(): readonly AudioSpriteEntry[] has(entryName: string): boolean readonly events: SpritePlayerEvents dispose(): Promise } interface SpritePlayerEvents { on(event: "play" | "stop" | "complete" | "voice-stolen" | "error", listener: (payload: unknown) => void): () => void } class NullSpritePlaybackAdapter implements SpritePlaybackAdapter { readonly calls: ReadonlyArray<{ kind: "init" | "play" | "dispose"; at: number; payload?: unknown }> } ``` ### Error codes ```ts type AudioSpritePlayerErrorCode = | 'AUDIO_SPRITE_PLAYER.NOT_LOADED' | 'AUDIO_SPRITE_PLAYER.UNKNOWN_ENTRY' | 'AUDIO_SPRITE_PLAYER.INVALID_MANIFEST' | 'AUDIO_SPRITE_PLAYER.ADAPTER_INIT_FAILED' | 'AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED' | 'AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED' | 'AUDIO_SPRITE_PLAYER.ABORTED' | 'AUDIO_SPRITE_PLAYER.INVALID_OPTIONS' ``` Every code is reachable from a real call site in `src/` and exercised by at least one test (§2c). Codes are registered through `defineCodes(...)` with stable metadata and i18n keys. --- ## Module layout Universal package; co-located 5-file feature modules per `CLAUDE.md`: ```text packages/media/audio/audio-sprite-player/ package.json tsup.config.ts vitest.config.ts README.md src/ index.ts # explicit named re-exports (rule 10) error-codes.ts # AUDIO_SPRITE_PLAYER_* defineCodes registry error-codes.md schemas.ts # Zod boundary schemas schemas.md sprite-player/ index.ts sprite-player.ts sprite-player.test.ts sprite-player.types.ts sprite-player.md voice-manager/ index.ts voice-manager.ts voice-manager.test.ts voice-manager.types.ts voice-manager.md adapter/ index.ts adapter.types.ts adapter.md null/ index.ts null.ts null.test.ts null.types.ts null.md events/ index.ts events.ts events.test.ts events.types.ts events.md telemetry/ index.ts telemetry.ts telemetry.test.ts telemetry.types.ts telemetry.md ``` Subpath exports in `package.json` mirror this layout for tree-shaking (e.g. `./sprite-player/sprite-player`, `./adapter/null/null`). --- ## Impact ### Affected dependents Run `bunx turbo ls --filter=...@suaveplan/audio-sprite-player` to enumerate. This is a fresh genesis-original package, so there are no in-tree consumers yet. The following packages are **expected adapter providers** that will each open their own follow-up OpenSpec change to ship a concrete `SpritePlaybackAdapter` implementation — those implementations are **explicitly out of scope** for this change: - `@suaveplan/audio-engine-browser` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: separate follow-up change will add `BrowserSpritePlaybackAdapter` built on `AudioContext` + `decodeAudioData`; not implemented here) - `@suaveplan/audio-engine-server` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: separate follow-up change will add `ServerSpritePlaybackAdapter` built on a native binding; not implemented here) - `@suaveplan/audio-sprite-packer` — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: types-only producer; this change consumes its public type surface and adds no reverse dep) No other dependents. Verified by absence from `bunx turbo ls` (package is being introduced by this change). If/when downstream `audio-engine-*` adapters ship, their proposals MUST list `@suaveplan/audio-sprite-player` as an affected dependent and rerun its tests.