Files
suaveplan/openspec/archive/2026-05-23-enterprise-audio-sprite-player/proposal.md
2026-05-27 23:29:53 +01:00

206 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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
```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<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
```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.