Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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.