Initial Commit
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user