Initial Commit
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
# Design: enterprise-audio-sprite-player
|
||||
|
||||
Non-normative. Captures rationale, architecture, algorithms, and test strategy for `@suaveplan/audio-sprite-player`. Normative requirements live in `./specs/audio-sprite-player/spec.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why a separate package vs. extending `audio-timeline`
|
||||
|
||||
`@suaveplan/audio-timeline` models multi-track, multi-clip arrangements with tempo, transport, automation, and a deterministic render graph. It is the right tool for music, podcasts, and any "edit then render" workflow where clips are stateful arrangement participants.
|
||||
|
||||
Sprite playback is the **opposite shape**:
|
||||
|
||||
- **Fire-and-forget.** A trigger fires (UI click, game footstep, hit confirmation) and the resulting voice is a transient with no place in a persistent arrangement.
|
||||
- **No clip / track model.** Sprite entries are flat named regions inside one blob, not arrangeable clips with positions and lengths the user edits.
|
||||
- **Latency-sensitive, scheduler-light.** A game wants `play("hit")` to take microseconds; it does not want to instantiate a transport, place a clip, advance a playhead, and tear it down.
|
||||
- **Voice-stealing is core**, not an afterthought. Timelines allocate every voice they need; sprite players cap voices and drop the oldest when over budget.
|
||||
|
||||
Forcing sprites through the timeline model would degrade timeline (it would gain a "transient voice" concept that doesn't belong) and bloat sprites (each one-shot allocates a track and a clip). Keeping them separate keeps each package coherent.
|
||||
|
||||
The two layers do compose downstream: a timeline track of triggered SFX can call into a `SpritePlayer` per trigger. The composition lives in the consumer, not in this package.
|
||||
|
||||
---
|
||||
|
||||
## 2. Plugin-slot pattern (mirrors `TimelineAdapter`)
|
||||
|
||||
Universal-tier purity demands the package never touch `AudioContext`, `decodeAudioData`, native audio bindings, `Buffer`, `node:fs`, or any other platform global. The package therefore owns **only** orchestration; everything that has to talk to real audio hardware is reached through the injected `SpritePlaybackAdapter`.
|
||||
|
||||
```ts
|
||||
interface SpritePlaybackAdapter {
|
||||
init(sprite: AudioSprite, signal?: AbortSignal): Promise<void>
|
||||
play(entry: AudioSpriteEntry, opts: ResolvedPlayOptions, signal?: AbortSignal): Promise<SpritePlayHandle>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
Responsibility split:
|
||||
|
||||
| Concern | Universal `SpritePlayer` | Engine `SpritePlaybackAdapter` |
|
||||
|---|---|---|
|
||||
| Manifest Zod validation | ✅ | — |
|
||||
| Entry lookup by name | ✅ | — |
|
||||
| Option defaulting + Zod validation | ✅ | — |
|
||||
| Voice manager + voice-stealing | ✅ | — |
|
||||
| Sequence and random orchestration | ✅ | — |
|
||||
| OTel span/counter/gauge/histogram | ✅ | — |
|
||||
| Typed event emission | ✅ | — |
|
||||
| Abort propagation | ✅ (passes signal through) | ✅ (must honour signal during init/play) |
|
||||
| `decodeAudioData` / native decode | — | ✅ |
|
||||
| Actual gain ramps (fadeIn/fadeOut) | — | ✅ (player passes ms request) |
|
||||
| Pitch / playback-rate scheduling | — | ✅ |
|
||||
| Hardware mixer routing | — | ✅ |
|
||||
|
||||
The adapter is the **only** seam the player has into the environment. `audio-engine-browser` will provide one implementation; `audio-engine-server` another. Tests in this package use `NullSpritePlaybackAdapter`.
|
||||
|
||||
This mirrors `TimelineAdapter` in `@suaveplan/audio-timeline`: universal package owns orchestration, environment-specific work is delegated through one interface.
|
||||
|
||||
---
|
||||
|
||||
## 3. Voice manager + voice-stealing
|
||||
|
||||
`VoiceManager` tracks active `SpritePlayHandle`s. On every `play` call:
|
||||
|
||||
1. If `voices.size < maxConcurrentVoices`, allocate and run.
|
||||
2. Otherwise, select the **oldest** active voice by `startedAt` (ties broken by allocation order), call `voice.stop(0)`, emit `voice-stolen` with `{ stolenId, replacedBy: newId, entryName }`, then run the new voice.
|
||||
3. The newly stolen handle's `isPlaying()` returns `false` immediately; its `stop()` becomes idempotent.
|
||||
|
||||
The algorithm is intentionally simple — oldest-first — because that is what sprite-driven consumers (games, UI sound layers) expect. Priority-based stealing is **out of scope**; it can be layered in a follow-up by giving entries a priority field on the consumer side and filtering before calling `play`. The cap is configurable (`maxConcurrentVoices`, default 32) and capped at a hard ceiling validated by the Zod boundary schema (≤ 256) so a misconfiguration cannot exhaust memory.
|
||||
|
||||
`voice-stolen` is a distinct event from `stop` so consumers can distinguish a user-driven stop from voice-pressure-driven stop. Both share the same underlying mechanism so consumer cleanup logic can listen to either.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sequence and random playback semantics
|
||||
|
||||
### `playSequence(entryNames, opts)`
|
||||
|
||||
Plays entries strictly in order, each starting `previousEntry.durationMs + gapMs` after the previous one began (using `clock.now()` + adapter `when`). Returns a `Promise<SpritePlayHandle[]>` that resolves once **all** voices have been started (not when they have completed); waiting on completion is the caller's job via the `complete` event. If any single `play` throws, the sequence aborts and any already-started voices are stopped with `fadeOutMs = 0`; the rejection wraps the underlying error with `AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED` and includes the failed entry name and sequence index in the error context.
|
||||
|
||||
Sequence honours `AbortSignal`: aborting between two entries stops the in-flight voices and rejects with `AUDIO_SPRITE_PLAYER.ABORTED`.
|
||||
|
||||
### `playRandom(filterFn?, opts)`
|
||||
|
||||
Uses `Math.random()` only when no `Rng` is injected via `SpritePlayerOptions`. (The package accepts an optional `Rng` from `@suaveplan/types` — TODO promoted into the spec — so determinism in tests does not depend on stubbing globals.) `filterFn` runs against every entry; if it returns `false` for all entries, the call rejects with `AUDIO_SPRITE_PLAYER.UNKNOWN_ENTRY` (filter ruled out everything is observably the same as no matching entry). With no filter, every entry has equal weight.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fade-in / fade-out
|
||||
|
||||
The player specifies fade requests in **milliseconds** on the `PlayOptions` passed to the adapter. **The player does not implement fades.** It cannot — fades need access to a real gain node / amplitude envelope, which is engine-tier.
|
||||
|
||||
Contract: when the player tells the adapter to play with `fadeInMs: 100`, the adapter MUST ramp from `0 → volume` over 100 ms; on `stop(fadeOutMs)`, the adapter MUST ramp from current `→ 0` over that many milliseconds and only then disconnect.
|
||||
|
||||
This keeps the universal layer pure while still expressing the user's intent through the public surface.
|
||||
|
||||
---
|
||||
|
||||
## 6. AbortSignal propagation
|
||||
|
||||
Every async public method accepts `AbortSignal`:
|
||||
|
||||
- `load(sprite, signal?)` — aborts before / during `adapter.init`; rejects with `AUDIO_SPRITE_PLAYER.ABORTED` and disposes the adapter.
|
||||
- `play(entryName, opts)` — `opts.signal` propagates into `adapter.play`; aborting after the voice starts triggers `handle.stop(0)`.
|
||||
- `playSequence(entryNames, opts)` — see §4. Abort between entries stops in-flight voices.
|
||||
- `playRandom(filterFn, opts)` — same as `play`.
|
||||
|
||||
If the signal is already aborted on entry, the method rejects synchronously with `AUDIO_SPRITE_PLAYER.ABORTED` before any work is started.
|
||||
|
||||
---
|
||||
|
||||
## 7. Test strategy with `NullSpritePlaybackAdapter`
|
||||
|
||||
`NullSpritePlaybackAdapter` is a recording adapter: every call to `init`, `play`, and `dispose` appends an entry to a public `calls` array with the timestamp from a `FakeClock`. `play` returns a `SpritePlayHandle` whose `stop` / `setVolume` / `isPlaying` calls are also recorded. This makes every spec scenario verifiable without a real audio backend:
|
||||
|
||||
- Voice-stealing is observable by asserting on the `calls` order and the `voice-stolen` event payload.
|
||||
- Abort propagation is observable by asserting that `init` / `play` saw the signal in its aborted state.
|
||||
- Fade requests are observable by asserting the `ResolvedPlayOptions` passed to `adapter.play`.
|
||||
- OTel instrumentation is observable through `@opentelemetry/api`'s in-memory exporter wired in the test setup.
|
||||
|
||||
The null adapter is exported from the package so downstream packages writing real adapters (engine-browser, engine-server) can also use it as a baseline contract test and as a default in their own tests.
|
||||
|
||||
---
|
||||
|
||||
## 8. Out of scope for this change
|
||||
|
||||
- `BrowserSpritePlaybackAdapter` (separate change against `@suaveplan/audio-engine-browser`).
|
||||
- `ServerSpritePlaybackAdapter` (separate change against `@suaveplan/audio-engine-server`).
|
||||
- Priority-based voice-stealing (consumer concern; can be layered).
|
||||
- 3D spatial panning / HRTF (engine concern; out of the universal contract).
|
||||
- Streaming sprite descriptors (the format is a single concatenated blob by design).
|
||||
- Hot-reloading a sprite while voices are playing (call `dispose` then construct a new player).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,292 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: audio-sprite-player (audio-sprite-player)
|
||||
|
||||
**Package**: `@suaveplan/audio-sprite-player`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The `@suaveplan/audio-sprite-player` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`, `decodeAudioData`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All environment-specific work SHALL be reached exclusively through the injected `SpritePlaybackAdapter`. The runtime `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/audio-sprite-packer` (type-only consumption — re-exports are types only), `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `tslib`, `zod`, and `@opentelemetry/api`.
|
||||
|
||||
#### Scenario: Built dist references no platform globals
|
||||
|
||||
- **WHEN** the built `dist/` is grep'd
|
||||
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `decodeAudioData`, `node:fs`, `node:path`, `process`, or `Buffer`
|
||||
|
||||
#### Scenario: Dependency set is the declared minimum
|
||||
|
||||
- **WHEN** `package.json#dependencies` is inspected
|
||||
- **THEN** the set equals the list above and contains no other workspace or external entries
|
||||
|
||||
**Verified by:** `src/sprite-player/sprite-player.test.ts::"dist references no browser or Node globals"` AND `src/sprite-player/sprite-player.test.ts::"dependency set is the declared minimum"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `SpritePlayer` construction validates options at the Zod boundary
|
||||
|
||||
`SpritePlayer` SHALL accept `SpritePlayerOptions` and validate them with a Zod schema before any work begins. `adapter` SHALL be required; `logger`, `clock`, `idGenerator`, and `maxConcurrentVoices` SHALL be optional with sensible defaults (`NoopLogger`, system clock, ULID generator, 32). `maxConcurrentVoices` SHALL be a positive integer ≤ 256. Invalid options SHALL throw `AudioSpritePlayerError` with code `AUDIO_SPRITE_PLAYER.INVALID_OPTIONS`.
|
||||
|
||||
#### Scenario: Missing adapter rejects construction
|
||||
|
||||
- **WHEN** a caller constructs `new SpritePlayer({} as any)`
|
||||
- **THEN** the constructor throws `AudioSpritePlayerError` with code `AUDIO_SPRITE_PLAYER.INVALID_OPTIONS` and the error context names `adapter`
|
||||
|
||||
#### Scenario: `maxConcurrentVoices` above the ceiling is rejected
|
||||
|
||||
- **WHEN** a caller constructs with `maxConcurrentVoices: 1000`
|
||||
- **THEN** the constructor throws `AUDIO_SPRITE_PLAYER.INVALID_OPTIONS` and does NOT call `adapter.init`
|
||||
|
||||
**Verified by:** `src/sprite-player/sprite-player.test.ts::"construction rejects missing adapter"` AND `src/sprite-player/sprite-player.test.ts::"construction rejects maxConcurrentVoices above ceiling"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `load()` validates the manifest and initialises the adapter
|
||||
|
||||
`SpritePlayer.load(sprite, signal?)` SHALL validate `sprite.manifest` against `AudioSpriteManifestSchema` (Zod) and SHALL then `await adapter.init(sprite, signal)` exactly once per successful load. Manifest validation failures SHALL throw `AUDIO_SPRITE_PLAYER.INVALID_MANIFEST`; adapter rejection SHALL be wrapped in `AUDIO_SPRITE_PLAYER.ADAPTER_INIT_FAILED` with the underlying error preserved via `cause`. A successful `load` SHALL transition the player into the "loaded" state required by `play`/`playSequence`/`playRandom`.
|
||||
|
||||
#### Scenario: Invalid manifest is rejected before the adapter is touched
|
||||
|
||||
- **WHEN** `load` is called with a manifest missing `entries`
|
||||
- **THEN** it rejects with `AUDIO_SPRITE_PLAYER.INVALID_MANIFEST` AND `adapter.init` was never called (recorded via `NullSpritePlaybackAdapter.calls`)
|
||||
|
||||
#### Scenario: Adapter init failure is wrapped
|
||||
|
||||
- **WHEN** an adapter whose `init` rejects with `new Error("boom")` is loaded
|
||||
- **THEN** the player rejects with `AUDIO_SPRITE_PLAYER.ADAPTER_INIT_FAILED` and `error.cause.message === "boom"`
|
||||
|
||||
**Verified by:** `src/sprite-player/sprite-player.test.ts::"load rejects invalid manifest before adapter"` AND `src/sprite-player/sprite-player.test.ts::"load wraps adapter init failure"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `play()` requires a prior `load()`
|
||||
|
||||
`SpritePlayer.play(entryName, opts?)` SHALL reject with `AUDIO_SPRITE_PLAYER.NOT_LOADED` when called before any successful `load`. The same code SHALL apply to `playSequence` and `playRandom`.
|
||||
|
||||
#### Scenario: Calling `play` on a fresh player rejects
|
||||
|
||||
- **WHEN** a freshly constructed player calls `play("hit")`
|
||||
- **THEN** it rejects with `AUDIO_SPRITE_PLAYER.NOT_LOADED` AND `adapter.play` was never called
|
||||
|
||||
**Verified by:** `src/sprite-player/sprite-player.test.ts::"play before load rejects with NOT_LOADED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `play()` resolves the entry by name and delegates to the adapter
|
||||
|
||||
After a successful `load`, `play(entryName, opts?)` SHALL resolve `entryName` against the manifest, apply Zod-validated defaulted `PlayOptions`, and call `adapter.play(entry, resolvedOpts, signal)`. Unknown names SHALL reject with `AUDIO_SPRITE_PLAYER.UNKNOWN_ENTRY`; invalid options SHALL reject with `AUDIO_SPRITE_PLAYER.INVALID_OPTIONS`; adapter rejection SHALL be wrapped as `AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED` preserving `cause`. On success, `play` SHALL return a `SpritePlayHandle` whose `id` was minted by the injected `IdGenerator` and whose `entryName` matches the resolved entry.
|
||||
|
||||
#### Scenario: Unknown entry name rejects without touching the adapter
|
||||
|
||||
- **WHEN** `play("missing")` is called on a player loaded with a sprite that has no `"missing"` entry
|
||||
- **THEN** the call rejects with `AUDIO_SPRITE_PLAYER.UNKNOWN_ENTRY` AND `adapter.play` was never called
|
||||
|
||||
#### Scenario: Invalid `PlayOptions` is rejected by the Zod boundary
|
||||
|
||||
- **WHEN** `play("hit", { volume: 1.5 })` is called
|
||||
- **THEN** it rejects with `AUDIO_SPRITE_PLAYER.INVALID_OPTIONS` AND `adapter.play` was never called
|
||||
|
||||
#### Scenario: Defaulted options reach the adapter as `ResolvedPlayOptions`
|
||||
|
||||
- **WHEN** `play("hit")` is called with no `opts`
|
||||
- **THEN** `adapter.play` receives `{ volume: 1, pitch: 1, loop: false, fadeInMs: 0, fadeOutMs: 0, startOffsetMs: 0, durationMs: entry.durationSeconds * 1000, when: clock.now() }`
|
||||
|
||||
**Verified by:** `src/sprite-player/sprite-player.test.ts::"play resolves entry and delegates to adapter"` AND `src/sprite-player/sprite-player.test.ts::"play rejects unknown entry"` AND `src/sprite-player/sprite-player.test.ts::"play wraps adapter playback failure"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `playSequence()` plays entries in order with `gapMs` spacing
|
||||
|
||||
`playSequence(entryNames, opts?)` SHALL play each entry in array order. Each subsequent entry's `when` SHALL be scheduled at the previous entry's scheduled start plus its `durationMs` plus `opts.gapMs ?? 0`. The method SHALL resolve once all voices have been started and SHALL return the array of `SpritePlayHandle` in the same order as `entryNames`. If any single `adapter.play` rejects, the sequence SHALL stop any already-started voices with `fadeOutMs = 0` and reject with `AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED` whose context includes the failing entry name and array index.
|
||||
|
||||
#### Scenario: Sequence schedules entries with gap
|
||||
|
||||
- **WHEN** `playSequence(["a","b"], { gapMs: 50 })` is called against entries with durations 100 ms and 200 ms
|
||||
- **THEN** `adapter.play` is called twice with `when` values exactly 150 ms apart (100 ms duration of `a` + 50 ms gap)
|
||||
|
||||
#### Scenario: Sequence failure stops earlier voices
|
||||
|
||||
- **WHEN** a sequence's second entry fails playback
|
||||
- **THEN** the first entry's handle has had `stop(0)` called AND the sequence rejects with `AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED` whose context contains the failing index `1`
|
||||
|
||||
**Verified by:** `src/sprite-player/sprite-player.test.ts::"playSequence schedules entries with gapMs"` AND `src/sprite-player/sprite-player.test.ts::"playSequence stops earlier voices on failure"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `playRandom()` picks from filtered entries
|
||||
|
||||
`playRandom(filterFn?, opts?)` SHALL pick uniformly at random from the entries for which `filterFn` returns `true` (or all entries when `filterFn` is omitted) and SHALL delegate to `play` with the chosen entry's name. When the filter excludes every entry, the method SHALL reject with `AUDIO_SPRITE_PLAYER.UNKNOWN_ENTRY` whose context records `{ reason: "filter-excluded-all" }`.
|
||||
|
||||
#### Scenario: Filter excludes everything
|
||||
|
||||
- **WHEN** `playRandom(() => false)` is called on a loaded sprite
|
||||
- **THEN** the call rejects with `AUDIO_SPRITE_PLAYER.UNKNOWN_ENTRY` AND `adapter.play` was never called
|
||||
|
||||
#### Scenario: Random pick reaches the adapter
|
||||
|
||||
- **WHEN** `playRandom()` is called repeatedly on a sprite with three entries
|
||||
- **THEN** every entry name is observed at least once over a 1000-call run
|
||||
|
||||
**Verified by:** `src/sprite-player/sprite-player.test.ts::"playRandom rejects when filter excludes all"` AND `src/sprite-player/sprite-player.test.ts::"playRandom samples all entries over many calls"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Voice-stealing under `maxConcurrentVoices`
|
||||
|
||||
When a `play` would cause the active voice count to exceed `maxConcurrentVoices`, the player SHALL stop the oldest active voice (lowest `startedAt`, ties broken by allocation order) with `fadeOutMs = 0`, emit a `voice-stolen` event with `{ stolenId, replacedBy, entryName }`, and then start the new voice. The stolen handle's `isPlaying()` SHALL return `false` immediately and its subsequent `stop()` calls SHALL be idempotent. The new voice SHALL succeed and SHALL NOT raise `AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED`.
|
||||
|
||||
#### Scenario: Oldest voice is stolen first
|
||||
|
||||
- **WHEN** `maxConcurrentVoices: 2` and three `play` calls fire in sequence with monotonically increasing `clock.now()` timestamps
|
||||
- **THEN** the first handle's `isPlaying()` returns `false`, a `voice-stolen` event was emitted naming that handle's id, and the third `adapter.play` call was made
|
||||
|
||||
**Verified by:** `src/voice-manager/voice-manager.test.ts::"oldest voice stolen when over cap"` AND `src/voice-manager/voice-manager.test.ts::"stolen handle reports not playing"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED` reachable when stealing is disabled
|
||||
|
||||
When `maxConcurrentVoices` is reached and the consumer has explicitly opted out of stealing via `PlayOptions.allowVoiceStealing: false`, the player SHALL reject the new `play` with `AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED` whose context records the current voice count and the cap.
|
||||
|
||||
#### Scenario: Opting out of stealing surfaces the cap error
|
||||
|
||||
- **WHEN** `maxConcurrentVoices: 1` and a second `play("hit", { allowVoiceStealing: false })` is attempted while a voice is active
|
||||
- **THEN** the call rejects with `AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED` AND the first voice is still playing
|
||||
|
||||
**Verified by:** `src/voice-manager/voice-manager.test.ts::"opt-out of stealing raises VOICE_LIMIT_EXCEEDED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `stopAll()` stops every active voice with optional fade
|
||||
|
||||
`stopAll(fadeOutMs?)` SHALL call `handle.stop(fadeOutMs ?? 0)` on every active voice and SHALL resolve once every adapter-side stop has resolved. After resolution, `list()` of active voices SHALL be empty (`handle.isPlaying() === false` for all).
|
||||
|
||||
#### Scenario: stopAll with fade reaches every voice
|
||||
|
||||
- **WHEN** three voices are active and `stopAll(50)` is called
|
||||
- **THEN** each handle's `stop(50)` was called exactly once AND every handle reports `isPlaying() === false` once `stopAll` resolves
|
||||
|
||||
**Verified by:** `src/sprite-player/sprite-player.test.ts::"stopAll stops every active voice with fade"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Typed event surface emits `play`, `stop`, `complete`, `voice-stolen`, `error`
|
||||
|
||||
`SpritePlayer.events` SHALL expose a typed `on` method built on `@suaveplan/events` for the five event names. `play` SHALL fire after the adapter accepts the voice; `stop` SHALL fire on user-driven stops; `complete` SHALL fire when a non-looping voice ends naturally; `voice-stolen` SHALL fire whenever the voice-stealing path runs; `error` SHALL fire when a play/stop fails after the public promise has already resolved (e.g. an asynchronous adapter failure mid-voice).
|
||||
|
||||
#### Scenario: Successful play emits `play` then `complete`
|
||||
|
||||
- **WHEN** a non-looping voice runs to completion
|
||||
- **THEN** the listener observes one `play` event then exactly one `complete` event, in that order, for the same handle id
|
||||
|
||||
#### Scenario: Voice-stealing emits `voice-stolen` with both ids
|
||||
|
||||
- **WHEN** a steal happens
|
||||
- **THEN** the listener observes a `voice-stolen` payload whose `stolenId` matches the displaced handle and whose `replacedBy` matches the new one
|
||||
|
||||
**Verified by:** `src/events/events.test.ts::"play then complete fires for a non-looping voice"` AND `src/events/events.test.ts::"voice-stolen carries both handle ids"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OTel instrumentation on every public call
|
||||
|
||||
The package SHALL open a span named `audio-sprite-player.play` covering each `play` invocation (including those reached via `playSequence` and `playRandom`), increment a counter `audio_sprite_player.play_total` keyed by `entryName`, maintain a gauge `audio_sprite_player.voices_active` reflecting the live voice count, and record a histogram `audio_sprite_player.play_duration_ms` on `complete` events. Spans SHALL be recorded with status `ERROR` on rejection paths and SHALL carry the error code as an attribute.
|
||||
|
||||
#### Scenario: A successful play produces span + counter + gauge + histogram
|
||||
|
||||
- **WHEN** a play runs and completes
|
||||
- **THEN** the in-memory OTel exporter records one `audio-sprite-player.play` span with `ok` status, the `audio_sprite_player.play_total` counter increments by 1, `audio_sprite_player.voices_active` went up by 1 then back down, and `audio_sprite_player.play_duration_ms` recorded one observation
|
||||
|
||||
#### Scenario: A failure attaches the error code to the span
|
||||
|
||||
- **WHEN** `adapter.play` rejects
|
||||
- **THEN** the recorded span status is `ERROR` and its attributes include `error.code = "AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED"`
|
||||
|
||||
**Verified by:** `src/telemetry/telemetry.test.ts::"successful play emits span, counter, gauge, histogram"` AND `src/telemetry/telemetry.test.ts::"failed play attaches error code to span"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `AbortSignal` propagation through every async public method
|
||||
|
||||
`load`, `play`, `playSequence`, and `playRandom` SHALL accept an `AbortSignal` (on the options bag for `play*`, as a direct argument for `load`). An already-aborted signal SHALL cause synchronous rejection with `AUDIO_SPRITE_PLAYER.ABORTED` before any adapter call is made. Abortion mid-flight SHALL stop in-flight voices with `fadeOutMs = 0` and reject the pending promise with `AUDIO_SPRITE_PLAYER.ABORTED`.
|
||||
|
||||
#### Scenario: Pre-aborted signal rejects synchronously
|
||||
|
||||
- **WHEN** `play("hit", { signal: AbortSignal.abort() })` is called
|
||||
- **THEN** the returned promise rejects with `AUDIO_SPRITE_PLAYER.ABORTED` AND `adapter.play` was never called
|
||||
|
||||
#### Scenario: Mid-flight abort stops the in-flight voice
|
||||
|
||||
- **WHEN** a sequence is mid-flight and its signal is aborted between entries
|
||||
- **THEN** every started handle has had `stop(0)` called AND the sequence promise rejects with `AUDIO_SPRITE_PLAYER.ABORTED`
|
||||
|
||||
**Verified by:** `src/sprite-player/sprite-player.test.ts::"pre-aborted signal rejects play synchronously"` AND `src/sprite-player/sprite-player.test.ts::"mid-flight abort stops sequence voices"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Every error code is reachable from a real call site
|
||||
|
||||
The `AUDIO_SPRITE_PLAYER.*` codes SHALL be registered via `defineCodes(...)` with stable metadata and SHALL each be raised by at least one code path in `src/` exercised by at least one test. Codes covered: `NOT_LOADED`, `UNKNOWN_ENTRY`, `INVALID_MANIFEST`, `ADAPTER_INIT_FAILED`, `PLAYBACK_FAILED`, `VOICE_LIMIT_EXCEEDED`, `ABORTED`, `INVALID_OPTIONS`.
|
||||
|
||||
#### Scenario: Conformance test asserts every code is thrown somewhere
|
||||
|
||||
- **WHEN** the openspec error-codes-thrown conformance check runs against this package
|
||||
- **THEN** every code in the registry is observed being thrown by at least one test
|
||||
|
||||
**Verified by:** `src/error-codes.test.ts::"every AUDIO_SPRITE_PLAYER code is thrown by at least one test"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `NullSpritePlaybackAdapter` records every call and is a valid adapter
|
||||
|
||||
The package SHALL export `NullSpritePlaybackAdapter` implementing `SpritePlaybackAdapter`. It SHALL append an entry to a public readonly `calls` array on every `init`, `play`, and `dispose` invocation with `{ kind, at, payload }` where `at` comes from an injected clock (defaulting to `performance.now`-equivalent provided by `@suaveplan/types`'s `Clock`). Its `play` SHALL return a `SpritePlayHandle` whose `stop`, `setVolume`, and `isPlaying` are likewise recorded; `isPlaying` SHALL return `false` after `stop` is called.
|
||||
|
||||
#### Scenario: Null adapter is observable
|
||||
|
||||
- **WHEN** a player backed by `NullSpritePlaybackAdapter` runs one `load` and two `play` calls
|
||||
- **THEN** `calls` has length 3 in order `init, play, play` AND every handle exposes consistent `isPlaying` before/after `stop`
|
||||
|
||||
**Verified by:** `src/adapter/null/null.test.ts::"null adapter records init, play, dispose"` AND `src/adapter/null/null.test.ts::"null handle isPlaying flips on stop"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `SpritePlaybackAdapter` contract is documented and externally implementable
|
||||
|
||||
The `SpritePlaybackAdapter` interface SHALL be exported from the package root and from the `./adapter` subpath. Its public shape (`init`, `play`, `dispose`) and the contract for `ResolvedPlayOptions` (volume range, pitch range, loop semantics, fade behaviour, `when` interpretation) SHALL be documented in `src/adapter/adapter.md`. Engine packages (`@suaveplan/audio-engine-browser`, `@suaveplan/audio-engine-server`) SHALL be able to implement the adapter without importing anything other than the type re-exports from `@suaveplan/audio-sprite-player`.
|
||||
|
||||
#### Scenario: Adapter can be implemented from types alone
|
||||
|
||||
- **WHEN** a stand-alone TypeScript file imports only `type { SpritePlaybackAdapter, ResolvedPlayOptions, SpritePlayHandle }` from `@suaveplan/audio-sprite-player`
|
||||
- **THEN** the file compiles with `tsc --noEmit` AND no runtime symbol is required
|
||||
|
||||
**Verified by:** `src/adapter/null/null.test.ts::"adapter contract compiles from type-only imports"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports for tree-shaking
|
||||
|
||||
`package.json#exports` SHALL declare subpath exports for every feature module: `./sprite-player/sprite-player`, `./voice-manager/voice-manager`, `./adapter` and `./adapter/null/null`, `./events/events`, `./telemetry/telemetry`, `./schemas`, `./error-codes`, plus the root export. Every subpath export SHALL resolve to a real file at build time and SHALL be reachable from `dist/`.
|
||||
|
||||
#### Scenario: Every declared subpath resolves
|
||||
|
||||
- **WHEN** the build runs and a smoke test iterates `package.json#exports`
|
||||
- **THEN** each subpath imports successfully and exposes the symbols documented in its sibling `.md` file
|
||||
|
||||
**Verified by:** `src/sprite-player/sprite-player.test.ts::"every declared subpath export resolves"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Sprite descriptor format is consumed via types-only import
|
||||
|
||||
The package SHALL consume `AudioSprite`, `AudioSpriteEntry`, and `AudioSpriteManifest` from `@suaveplan/audio-sprite-packer` via `import type` only. No runtime symbol from the packer SHALL appear in `dist/`. The producer/consumer contract SHALL be the manifest shape itself; the player SHALL NOT re-declare or fork the descriptor format.
|
||||
|
||||
#### Scenario: No runtime import from the packer
|
||||
|
||||
- **WHEN** `dist/` is grep'd for `from "@suaveplan/audio-sprite-packer"`
|
||||
- **THEN** the only matches are inside `.d.ts` files (type position) and the runtime bundle contains zero matches
|
||||
|
||||
**Verified by:** `src/sprite-player/sprite-player.test.ts::"sprite-packer is consumed type-only"`
|
||||
@@ -0,0 +1,118 @@
|
||||
# Tasks: enterprise-audio-sprite-player
|
||||
|
||||
Ordered implementation checklist. Each task is a discrete, checkable item; an unchecked item at the end means the package is not done.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [ ] 1.1 Create `packages/media/audio/audio-sprite-player/` with `package.json` (`name: "@suaveplan/audio-sprite-player"`, `version: "0.1.0"`, `private: false`, `sideEffects: false`, `type: "module"`).
|
||||
- [ ] 1.2 Declare runtime deps: `@suaveplan/audio-core workspace:^`, `@suaveplan/audio-sprite-packer workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/events workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`.
|
||||
- [ ] 1.3 Declare dev deps via catalog: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`, `tsup`, `typescript`, `@suaveplan/testing workspace:^`.
|
||||
- [ ] 1.4 Add `exports` map for every subpath declared in the proposal module layout, plus the root entry.
|
||||
- [ ] 1.5 Add `tsup.config.ts` (ESM + CJS, dual emit, no minify).
|
||||
- [ ] 1.6 Add `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`; `provider: 'istanbul'`, `pool: 'forks'`, `fileParallelism: false`.
|
||||
- [ ] 1.7 Add `tsconfig.json` extending `@suaveplan/typescript-config/universal.json`.
|
||||
- [ ] 1.8 Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
|
||||
|
||||
## 2. Types, errors, schemas
|
||||
|
||||
- [ ] 2.1 Author `src/sprite-player/sprite-player.types.ts` — `SpritePlayerOptions`, `PlayOptions`, `SequenceOptions`, `ResolvedPlayOptions`, `SpritePlayHandle`.
|
||||
- [ ] 2.2 Author `src/adapter/adapter.types.ts` — `SpritePlaybackAdapter` interface.
|
||||
- [ ] 2.3 Author `src/error-codes.ts` registering every `AUDIO_SPRITE_PLAYER.*` code via `defineCodes(...)` with i18n keys and severity.
|
||||
- [ ] 2.4 Author `src/schemas.ts` — Zod schemas for `SpritePlayerOptionsSchema`, `PlayOptionsSchema`, `SequenceOptionsSchema`, and a manifest validator re-using shapes from `@suaveplan/audio-sprite-packer` (type-only) by hand-defining the equivalent runtime Zod schema (since we cannot import runtime Zod schemas from the type-only consumer).
|
||||
|
||||
## 3. NullSpritePlaybackAdapter
|
||||
|
||||
- [ ] 3.1 `src/adapter/null/null.ts` — implementation that records every call.
|
||||
- [ ] 3.2 `src/adapter/null/null.test.ts` — verifies `calls` order, handle behaviour, type-only-import compile check.
|
||||
- [ ] 3.3 `src/adapter/null/null.md` and `src/adapter/adapter.md` — documented contract.
|
||||
|
||||
## 4. VoiceManager + voice-stealing
|
||||
|
||||
- [ ] 4.1 `src/voice-manager/voice-manager.ts` — tracks active handles, picks oldest, emits `voice-stolen`, exposes opt-out leading to `VOICE_LIMIT_EXCEEDED`.
|
||||
- [ ] 4.2 `src/voice-manager/voice-manager.test.ts` — covers steal-on-overflow, idempotent stop after steal, opt-out path.
|
||||
- [ ] 4.3 `src/voice-manager/voice-manager.md`.
|
||||
|
||||
## 5. SpritePlayer orchestrator
|
||||
|
||||
- [ ] 5.1 `src/sprite-player/sprite-player.ts` — public class. Wires construction validation, `load`, `play`, `playSequence`, `playRandom`, `stopAll`, `list`, `has`, `dispose`.
|
||||
- [ ] 5.2 `src/sprite-player/sprite-player.test.ts` — covers every spec scenario named in the delta under this module path.
|
||||
- [ ] 5.3 `src/sprite-player/sprite-player.md`.
|
||||
|
||||
## 6. Events wiring
|
||||
|
||||
- [ ] 6.1 `src/events/events.ts` — typed `SpritePlayerEvents` built on `@suaveplan/events` with the five event names.
|
||||
- [ ] 6.2 `src/events/events.test.ts` — covers play→complete, voice-stolen payload, error event on async failure.
|
||||
- [ ] 6.3 `src/events/events.md`.
|
||||
|
||||
## 7. OTel wiring
|
||||
|
||||
- [ ] 7.1 `src/telemetry/telemetry.ts` — span `audio-sprite-player.play`, counter `audio_sprite_player.play_total`, gauge `audio_sprite_player.voices_active`, histogram `audio_sprite_player.play_duration_ms`.
|
||||
- [ ] 7.2 `src/telemetry/telemetry.test.ts` — uses `@opentelemetry/api` in-memory exporter to verify each instrument fires.
|
||||
- [ ] 7.3 `src/telemetry/telemetry.md`.
|
||||
|
||||
## 8. AbortSignal wiring
|
||||
|
||||
- [ ] 8.1 Pre-aborted signal short-circuit on `load`, `play`, `playSequence`, `playRandom`.
|
||||
- [ ] 8.2 Mid-flight abort path stops in-flight voices and rejects with `AUDIO_SPRITE_PLAYER.ABORTED`.
|
||||
- [ ] 8.3 Tests for both paths in `sprite-player.test.ts`.
|
||||
|
||||
## 9. Barrel + exports
|
||||
|
||||
- [ ] 9.1 `src/index.ts` uses explicit named re-exports only (rule 10) — no `export *`.
|
||||
- [ ] 9.2 Every feature folder's `index.ts` is an explicit named barrel.
|
||||
- [ ] 9.3 Smoke test in `sprite-player.test.ts` iterates `package.json#exports` and imports each subpath.
|
||||
|
||||
## 10. Tests
|
||||
|
||||
- [ ] 10.1 All `.test.ts` files import test-runner primitives from `@suaveplan/testing/runner` ONLY (rule 11).
|
||||
- [ ] 10.2 No `vi.mock` / `jest.mock` (rule 2).
|
||||
- [ ] 10.3 Use `FakeClock` from `@suaveplan/testing` for deterministic `clock.now()` and scheduling tests.
|
||||
- [ ] 10.4 `bunx turbo test --filter=@suaveplan/audio-sprite-player` green.
|
||||
- [ ] 10.5 `bunx turbo test:coverage --filter=@suaveplan/audio-sprite-player` shows 100% on touched modules.
|
||||
|
||||
## 11. Benchmarks
|
||||
|
||||
- [ ] 11.1 `bench/play.bench.ts` using `mitata` — measure `play()` overhead with `NullSpritePlaybackAdapter` (target: < 50 µs P50, voice-stealing path < 100 µs P50).
|
||||
- [ ] 11.2 `bench/sequence.bench.ts` — measure `playSequence` scaling at 1, 10, 100 entries.
|
||||
|
||||
## 12. Docs
|
||||
|
||||
- [ ] 12.1 Every `.ts` (excl. `.test.ts`, `.types.ts` inside feature dirs, barrel `index.ts`) has a sibling `.md` meeting `docs:check` thresholds (≥200 words, all required sections, basic + advanced example).
|
||||
- [ ] 12.2 Author `README.md` at the package root (no category-level READMEs).
|
||||
- [ ] 12.3 `bunx turbo docs:check --filter=@suaveplan/audio-sprite-player` green.
|
||||
|
||||
## 13. Pre-publish gates
|
||||
|
||||
- [ ] 13.1 `bunx turbo lint --filter=@suaveplan/audio-sprite-player` green; no `// biome-ignore` comments anywhere in `src/` (rule 9).
|
||||
- [ ] 13.2 `bunx turbo typecheck --filter=@suaveplan/audio-sprite-player` green.
|
||||
- [ ] 13.3 `bun scripts/lint/check-test-runner-imports.ts` green for this package (rule 11 CI gate).
|
||||
- [ ] 13.4 `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only\|\bany\b\|console\.log" packages/media/audio/audio-sprite-player/src/ --include="*.ts"` returns zero matches (§2).
|
||||
- [ ] 13.5 `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/media/audio/audio-sprite-player/src/` returns zero matches (rule 11 verification).
|
||||
- [ ] 13.6 Dist-purity check: `dist/` references no browser/Node globals (rule: universal-tier purity in the spec delta).
|
||||
- [ ] 13.7 Dependent sweep: run `bunx turbo ls --filter=...@suaveplan/audio-sprite-player` and confirm the proposal's `### Affected dependents` list still matches (see §14a).
|
||||
- [ ] 13.8 Numbered requirement-checklist (built from the spec delta) is fully ticked.
|
||||
|
||||
## 14. Fold into canonical spec
|
||||
|
||||
- [ ] 14.1 Fold `openspec/changes/enterprise-audio-sprite-player/specs/audio-sprite-player/spec.md` into `openspec/specs/audio-sprite-player/spec.md`. This is the **first** time anything is created under `openspec/specs/audio-sprite-player/`.
|
||||
|
||||
## 15. Archive
|
||||
|
||||
- [ ] 15.1 `git mv openspec/changes/enterprise-audio-sprite-player openspec/archive/2026-05-15-enterprise-audio-sprite-player`.
|
||||
- [ ] 15.2 Commit the archival move separately from the implementation commit so the diff is auditable.
|
||||
|
||||
## 16. Follow-up notes (NOT in scope for this change)
|
||||
|
||||
The following work is explicitly **out of scope** and must each open its own OpenSpec change before any code is authored:
|
||||
|
||||
- `@suaveplan/audio-engine-browser` — add `BrowserSpritePlaybackAdapter` built on `AudioContext` + `decodeAudioData`. Its proposal MUST list `@suaveplan/audio-sprite-player` as an affected dependent and rerun this package's tests.
|
||||
- `@suaveplan/audio-engine-server` — add `ServerSpritePlaybackAdapter` built on a native binding (e.g. `node-speaker`, `@suaveplan/audio-codecs` decode + RtAudio). Same dependent-listing rule applies.
|
||||
- Priority-based voice-stealing (consumer-driven; opens a separate change against this package if/when needed).
|
||||
|
||||
Do NOT add either engine adapter inside this change. The universal package ships with `NullSpritePlaybackAdapter` only; engines provide real adapters in follow-up changes.
|
||||
Reference in New Issue
Block a user