9.2 KiB
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
import type { AudioSprite, AudioSpriteEntry, AudioSpriteManifest } from "@suaveplan/audio-sprite-packer"
interface SpritePlaybackAdapter {
init(sprite: AudioSprite, signal?: AbortSignal): Promise<void>
play(entry: AudioSpriteEntry, opts: ResolvedPlayOptions, signal?: AbortSignal): Promise<SpritePlayHandle>
dispose(): Promise<void>
}
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<void>
setVolume(volume: number): void
isPlaying(): boolean
}
Orchestrator
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<void>
play(entryName: string, opts?: PlayOptions): Promise<SpritePlayHandle>
playSequence(entryNames: string[], opts?: SequenceOptions): Promise<SpritePlayHandle[]>
playRandom(filterFn?: (entry: AudioSpriteEntry) => boolean, opts?: PlayOptions): Promise<SpritePlayHandle>
stopAll(fadeOutMs?: number): Promise<void>
list(): readonly AudioSpriteEntry[]
has(entryName: string): boolean
readonly events: SpritePlayerEvents
dispose(): Promise<void>
}
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
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:
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 addBrowserSpritePlaybackAdapterbuilt onAudioContext+decodeAudioData; not implemented here)@suaveplan/audio-engine-server— [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: separate follow-up change will addServerSpritePlaybackAdapterbuilt 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.