first commit
This commit is contained in:
80
docs/media/audio/audio-sprite-player/README.md
Normal file
80
docs/media/audio/audio-sprite-player/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# @suaveplan/audio-sprite-player - Universal audio sprite playback engine: validates manifests, schedules voices, performs voice-stealing, and delegates output through an injectable SpritePlaybackAdapter.
|
||||
|
||||
Universal audio sprite playback engine. Consumes manifests produced by `@suaveplan/audio-sprite-packer`, validates them, schedules voices, runs voice-stealing under `maxConcurrentVoices`, and delegates actual audio output through an injectable `SpritePlaybackAdapter` implemented by engine-tier packages such as `@suaveplan/audio-engine-browser` and `@suaveplan/audio-engine-server`.
|
||||
|
||||
This package is **universal**: zero browser-only APIs, zero Node-only APIs. Every platform-specific concern (decode, gain ramps, pitch scheduling, mixer routing) is reached through the adapter seam.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapter](./src/adapter/adapter.md) — `SpritePlaybackAdapter` is the engine-tier seam that the universal `@suaveplan/audio-sprite-player` reaches through to…
|
||||
- [null](./src/adapter/null/null.md) — `NullSpritePlaybackAdapter` is the recording adapter that conforms to the `SpritePlaybackAdapter` contract without prod…
|
||||
- [null](./src/adapter/null/null.md) — `NullSpritePlaybackAdapter` is the recording adapter that conforms to the `SpritePlaybackAdapter` contract without prod…
|
||||
- [adapter](./src/adapter/adapter.md) — `SpritePlaybackAdapter` is the engine-tier seam that the universal `@suaveplan/audio-sprite-player` reaches through to…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module is the canonical registry of error codes for `@suaveplan/audio-sprite-player`. It defines a stable string i…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module is the canonical registry of error codes for `@suaveplan/audio-sprite-player`. It defines a stable string i…
|
||||
- [events](./src/events/events.md) — `SpritePlayerEvents` is the typed event surface a `SpritePlayer` exposes for downstream consumers — analytics, UI feedb…
|
||||
- [events](./src/events/events.md) — `SpritePlayerEvents` is the typed event surface a `SpritePlayer` exposes for downstream consumers — analytics, UI feedb…
|
||||
- [schemas](./src/schemas/schemas.md) — This module owns every runtime Zod schema exposed at the public boundary of `@suaveplan/audio-sprite-player`. Universal…
|
||||
- [schemas](./src/schemas/schemas.md) — This module owns every runtime Zod schema exposed at the public boundary of `@suaveplan/audio-sprite-player`. Universal…
|
||||
- [sprite-player](./src/sprite-player/sprite-player.md) — `SpritePlayer` is the public orchestrator for `@suaveplan/audio-sprite-player`. It accepts a validated `AudioSprite` (a…
|
||||
- [sprite-player](./src/sprite-player/sprite-player.md) — `SpritePlayer` is the public orchestrator for `@suaveplan/audio-sprite-player`. It accepts a validated `AudioSprite` (a…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — `SpritePlayerTelemetry` is the OTel-facing facade that `SpritePlayer` opens for every public invocation. It manages one…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — `SpritePlayerTelemetry` is the OTel-facing facade that `SpritePlayer` opens for every public invocation. It manages one…
|
||||
- [voice-manager](./src/voice-manager/voice-manager.md) — `VoiceManager` enforces the active-voice cap and runs the voice-stealing algorithm that keeps sprite playback bounded u…
|
||||
- [voice-manager](./src/voice-manager/voice-manager.md) — `VoiceManager` enforces the active-voice cap and runs the voice-stealing algorithm that keeps sprite playback bounded u…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/audio-sprite-player
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSpritePlayer,
|
||||
NullSpritePlaybackAdapter,
|
||||
} from "@suaveplan/audio-sprite-player";
|
||||
|
||||
const adapter = new NullSpritePlaybackAdapter(); // swap for a real engine adapter
|
||||
const player = createSpritePlayer({ adapter, maxConcurrentVoices: 16 });
|
||||
|
||||
await player.load(sprite); // sprite produced by @suaveplan/audio-sprite-packer
|
||||
const handle = await player.play("hit", { volume: 0.8, fadeInMs: 10 });
|
||||
|
||||
player.events.on("voice-stolen", (e) => {
|
||||
console.log(`voice ${e.stolenId} replaced by ${e.replacedBy}`);
|
||||
});
|
||||
|
||||
await player.playSequence(["step", "step", "land"], { gapMs: 50 });
|
||||
await player.stopAll(20);
|
||||
await player.dispose();
|
||||
```
|
||||
|
||||
## Public surface
|
||||
|
||||
- `SpritePlayer` / `createSpritePlayer` — the orchestrator.
|
||||
- `SpritePlaybackAdapter` (type) — engine seam.
|
||||
- `NullSpritePlaybackAdapter` — recording adapter for tests and baseline conformance.
|
||||
- `VoiceManager` — voice cap + stealing primitive (exported for advanced use).
|
||||
- `SpritePlayerEvents` — typed event subscriber on `play` / `stop` / `complete` / `voice-stolen` / `error`.
|
||||
- `SpritePlayerTelemetry` — OTel tracer + counter + gauge + histogram.
|
||||
- `AUDIO_SPRITE_PLAYER_CODES`, `AudioSpritePlayerError` — stable error contract.
|
||||
- Zod schemas at `./schemas`.
|
||||
|
||||
## Documentation
|
||||
|
||||
See the co-located `.md` files inside `src/`:
|
||||
|
||||
- [`sprite-player/sprite-player.md`](./src/sprite-player/sprite-player.md)
|
||||
- [`voice-manager/voice-manager.md`](./src/voice-manager/voice-manager.md)
|
||||
- [`adapter/adapter.md`](./src/adapter/adapter.md)
|
||||
- [`adapter/null/null.md`](./src/adapter/null/null.md)
|
||||
- [`events/events.md`](./src/events/events.md)
|
||||
- [`telemetry/telemetry.md`](./src/telemetry/telemetry.md)
|
||||
- [`schemas.md`](./src/schemas.md)
|
||||
- [`error-codes.md`](./src/error-codes.md)
|
||||
|
||||
112
docs/media/audio/audio-sprite-player/src/adapter/adapter.md
Normal file
112
docs/media/audio/audio-sprite-player/src/adapter/adapter.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# SpritePlaybackAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SpritePlaybackAdapter` is the engine-tier seam that the universal `@suaveplan/audio-sprite-player` reaches through to make real audio. The interface is intentionally tiny — three methods — so engine packages such as `@suaveplan/audio-engine-browser` (Web Audio API) and `@suaveplan/audio-engine-server` (native bindings) can implement it without inheriting any orchestration concerns.
|
||||
|
||||
Adapters are responsible for the work that requires platform globals: decoding the sprite blob, allocating mixer nodes, ramping gain for fade-in/out, scheduling pitch (playback rate), and disconnecting the voice after `stop`. They are NOT responsible for manifest validation, entry lookup, voice management, scheduling between sequence entries, or telemetry — those belong to the universal layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Tiny three-method contract: `init`, `play`, `dispose`.
|
||||
- `ResolvedPlayOptions` is the only option shape the adapter ever sees — the universal layer applies defaults and Zod validation upstream.
|
||||
- `init` and `play` accept an optional `AbortSignal`; the contract requires adapters to honour it.
|
||||
- The adapter receives the entire `AudioSprite` (blob + manifest) at `init` time so it can decode the data once and keep decoded buffers for the lifetime of the player.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SpritePlaybackAdapter } from "@suaveplan/audio-sprite-player";
|
||||
|
||||
class MyAdapter implements SpritePlaybackAdapter {
|
||||
async init() {}
|
||||
async play(entry, opts) {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
entryName: entry.name,
|
||||
startedAt: Date.now(),
|
||||
async stop() {},
|
||||
setVolume() {},
|
||||
isPlaying: () => false,
|
||||
};
|
||||
}
|
||||
async dispose() {}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AudioSprite,
|
||||
ResolvedPlayOptions,
|
||||
SpritePlaybackAdapter,
|
||||
SpritePlayHandle,
|
||||
} from "@suaveplan/audio-sprite-player";
|
||||
|
||||
class BrowserAdapter implements SpritePlaybackAdapter {
|
||||
private buffer: AudioBuffer | null = null;
|
||||
constructor(private ctx: AudioContext) {}
|
||||
|
||||
async init(sprite: AudioSprite, signal?: AbortSignal) {
|
||||
if (signal?.aborted) throw new Error("aborted");
|
||||
this.buffer = await this.ctx.decodeAudioData(sprite.data.slice().buffer);
|
||||
}
|
||||
|
||||
async play(
|
||||
entry: { name: string; offset: number; byteLength: number; durationSeconds: number },
|
||||
opts: ResolvedPlayOptions,
|
||||
): Promise<SpritePlayHandle> {
|
||||
const source = this.ctx.createBufferSource();
|
||||
source.buffer = this.buffer;
|
||||
source.playbackRate.value = opts.pitch;
|
||||
source.loop = opts.loop;
|
||||
const gain = this.ctx.createGain();
|
||||
gain.gain.setValueAtTime(0, this.ctx.currentTime);
|
||||
gain.gain.linearRampToValueAtTime(
|
||||
opts.volume,
|
||||
this.ctx.currentTime + opts.fadeInMs / 1000,
|
||||
);
|
||||
source.connect(gain).connect(this.ctx.destination);
|
||||
source.start(opts.when / 1000, entry.offset / entry.durationSeconds);
|
||||
// ... build and return SpritePlayHandle
|
||||
throw new Error("snippet only");
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this.buffer = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface SpritePlaybackAdapter`
|
||||
|
||||
```ts
|
||||
interface SpritePlaybackAdapter {
|
||||
init(sprite: AudioSprite, signal?: AbortSignal): Promise<void>;
|
||||
play(entry: AudioSpriteEntry, opts: ResolvedPlayOptions, signal?: AbortSignal): Promise<SpritePlayHandle>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Contract for `ResolvedPlayOptions`
|
||||
|
||||
- `volume` ∈ `[0, 1]` — final gain.
|
||||
- `pitch` ∈ `[0.5, 2]` — playback rate.
|
||||
- `loop` — when `true`, the adapter MUST loop the voice and MUST NOT auto-emit completion.
|
||||
- `fadeInMs` / `fadeOutMs` — millisecond gain ramps; `0` means an instant transition.
|
||||
- `startOffsetMs` — offset INTO the entry, not the sprite.
|
||||
- `durationMs` — voice duration the player expects; adapters MAY honour or ignore depending on capability.
|
||||
- `when` — absolute schedule time in the player's clock domain; adapters convert to their own time base.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The adapter SHOULD honour `AbortSignal` during `init` and `play` and throw a real error on abortion; the universal player wraps it into `AUDIO_SPRITE_PLAYER.ABORTED`.
|
||||
- The `SpritePlayHandle` returned by `play` is returned directly to the caller, so its `stop`, `setVolume`, and `isPlaying` methods must be safe to call from any context.
|
||||
- Engine packages MUST NOT re-implement manifest validation, voice management, or sequence scheduling — those live in `@suaveplan/audio-sprite-player`. Adding them in an adapter is a contract violation.
|
||||
@@ -0,0 +1,94 @@
|
||||
# NullSpritePlaybackAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`NullSpritePlaybackAdapter` is the recording adapter that conforms to the `SpritePlaybackAdapter` contract without producing any audio. It is the fixture every test in this package uses, and it is exported so downstream engine adapters can use it as the contract-conformance baseline before plugging in real audio output.
|
||||
|
||||
Every method on the adapter appends a typed entry to a public read-only `calls` array. Every `play` returns a `SpritePlayHandle` whose `stop`, `setVolume`, and `isPlaying` invocations are also recorded. The timestamps come from an injectable `Clock`, so tests using `FakeClock` (or any deterministic clock) produce identical traces on every run.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements the full `SpritePlaybackAdapter` interface: `init`, `play`, `dispose`.
|
||||
- Records every method call with `{ kind, at, payload }` where `at` is the clock reading and `payload` carries the relevant arguments.
|
||||
- Records every handle lifecycle event (`stop` with `fadeOutMs`, `setVolume` with value).
|
||||
- Honours `AbortSignal`: throws an `aborted` error when invoked with an already-aborted signal.
|
||||
- Pure — no platform globals, no side effects beyond the in-memory `calls` array.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NullSpritePlaybackAdapter, createSpritePlayer } from "@suaveplan/audio-sprite-player";
|
||||
|
||||
const adapter = new NullSpritePlaybackAdapter();
|
||||
const player = createSpritePlayer({ adapter });
|
||||
await player.load(sprite);
|
||||
await player.play("hit");
|
||||
console.log(adapter.calls.map((c) => c.kind));
|
||||
// → ["init", "play"]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createNullSpritePlaybackAdapter,
|
||||
createSpritePlayer,
|
||||
} from "@suaveplan/audio-sprite-player";
|
||||
import { FakeClock } from "@suaveplan/testing";
|
||||
|
||||
const fakeClock = new FakeClock({ startTime: 1_000_000 });
|
||||
const adapter = createNullSpritePlaybackAdapter({
|
||||
clock: () => fakeClock.now(),
|
||||
});
|
||||
const player = createSpritePlayer({
|
||||
adapter,
|
||||
clock: () => fakeClock.now(),
|
||||
idGenerator: (() => {
|
||||
let i = 0;
|
||||
return () => `id-${++i}`;
|
||||
})(),
|
||||
});
|
||||
|
||||
await player.load(sprite);
|
||||
await player.play("hit");
|
||||
fakeClock.advance(100);
|
||||
await player.play("hit");
|
||||
const playRecords = adapter.calls.filter((c) => c.kind === "play");
|
||||
console.log(playRecords.length); // 2
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new NullSpritePlaybackAdapter(options?)`
|
||||
|
||||
`options.clock?: Clock` — defaults to `() => Date.now()`.
|
||||
|
||||
### `calls: ReadonlyArray<NullCall>`
|
||||
|
||||
Append-only log. `NullCall` is `{ kind: "init" | "play" | "dispose" | "stop" | "setVolume"; at: number; payload?: unknown }`.
|
||||
|
||||
### `init(sprite, signal?)`
|
||||
|
||||
Records `{ kind: "init" }`. Throws if `signal.aborted`.
|
||||
|
||||
### `play(entry, opts, signal?)`
|
||||
|
||||
Records `{ kind: "play", payload: { entryName, id, opts } }`. Returns a handle. Throws if `signal.aborted`.
|
||||
|
||||
### `dispose()`
|
||||
|
||||
Records `{ kind: "dispose" }`.
|
||||
|
||||
### `createNullSpritePlaybackAdapter(options?)`
|
||||
|
||||
Functional alias for `new NullSpritePlaybackAdapter(options)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The adapter is exported from the package root and from the `./adapter/null/null` subpath so consumers can import it without pulling the rest of the surface.
|
||||
- It is a valid implementation of `SpritePlaybackAdapter` from the type level, so the `adapter contract compiles from type-only imports` smoke test in `null.test.ts` confirms a downstream engine could ship an adapter using nothing but `import type` from this package.
|
||||
- The handle returned by `play` carries the same `id` recorded in the `play` call's payload, so consumers can correlate handle lifecycle calls back to their originating `play` deterministically.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Error Codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the canonical registry of error codes for `@suaveplan/audio-sprite-player`. It defines a stable string identifier for every failure path the package can surface, along with severity metadata and an i18n message key. Consumers branch on the typed `code` field rather than on brittle exception messages, which gives downstream UI and observability layers a stable contract even as message wording changes.
|
||||
|
||||
The module also exports the concrete `AudioSpritePlayerError` class. Every exception thrown anywhere inside this package extends `SuaveplanError` through this class so `instanceof AudioSpritePlayerError` is a reliable filter at any layer of the application.
|
||||
|
||||
## Features
|
||||
|
||||
- **Typed codes** via `defineCodes("AUDIO_SPRITE_PLAYER", [...])` produce the literal union `AudioSpritePlayerErrorCode`.
|
||||
- **Frozen metadata table** maps every code to a human-readable description, severity (`"warning" | "error"`), and i18n message key.
|
||||
- **Concrete exception class** with a widened public constructor for ergonomic `throw new AudioSpritePlayerError(...)` from any call site.
|
||||
- **Idempotent registration hook** so build tools that register packages multiple times do not double-register.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_SPRITE_PLAYER_CODES,
|
||||
AudioSpritePlayerError,
|
||||
} from "@suaveplan/audio-sprite-player";
|
||||
|
||||
try {
|
||||
await player.play("missing");
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AudioSpritePlayerError &&
|
||||
err.code === AUDIO_SPRITE_PLAYER_CODES.UNKNOWN_ENTRY
|
||||
) {
|
||||
console.warn("sprite has no entry called 'missing'");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_SPRITE_PLAYER_CODES,
|
||||
AUDIO_SPRITE_PLAYER_ERROR_METADATA,
|
||||
AudioSpritePlayerError,
|
||||
} from "@suaveplan/audio-sprite-player";
|
||||
|
||||
function reportAudioFailure(err: unknown, log: LoggerLike) {
|
||||
if (!(err instanceof AudioSpritePlayerError)) throw err;
|
||||
const meta = AUDIO_SPRITE_PLAYER_ERROR_METADATA[err.code];
|
||||
log[meta.severity === "warning" ? "warn" : "error"](meta.messageKey, {
|
||||
code: err.code,
|
||||
description: meta.description,
|
||||
context: err.context ?? {},
|
||||
});
|
||||
if (err.code === AUDIO_SPRITE_PLAYER_CODES.ABORTED) return; // expected cancel
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AUDIO_SPRITE_PLAYER_CODES`
|
||||
|
||||
Frozen record of every code constant. Codes: `NOT_LOADED`, `UNKNOWN_ENTRY`, `INVALID_MANIFEST`, `ADAPTER_INIT_FAILED`, `PLAYBACK_FAILED`, `VOICE_LIMIT_EXCEEDED`, `ABORTED`, `INVALID_OPTIONS`.
|
||||
|
||||
### `AudioSpritePlayerErrorCode`
|
||||
|
||||
```ts
|
||||
type AudioSpritePlayerErrorCode = (typeof AUDIO_SPRITE_PLAYER_CODES)[keyof typeof AUDIO_SPRITE_PLAYER_CODES];
|
||||
```
|
||||
|
||||
### `AUDIO_SPRITE_PLAYER_ERROR_METADATA`
|
||||
|
||||
Frozen lookup: code → `{ description, severity, messageKey }`.
|
||||
|
||||
### `AudioSpritePlayerError`
|
||||
|
||||
```ts
|
||||
class AudioSpritePlayerError extends SuaveplanError<AudioSpritePlayerErrorCode> {
|
||||
public static readonly namespace = "AUDIO_SPRITE_PLAYER";
|
||||
public constructor(code, messageKey, options?);
|
||||
}
|
||||
```
|
||||
|
||||
### `registerAudioSpritePlayerErrorCodes(): void`
|
||||
|
||||
Idempotent registration hook for compatibility with the `@suaveplan/error` lifecycle. Invoked once at module load.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The package depends on `@suaveplan/error` for `defineCodes` and the base `SuaveplanError` class.
|
||||
- Metadata is frozen with `Object.freeze` so callers cannot mutate severity, description, or i18n keys at runtime.
|
||||
- Every code in the registry is reachable from a real call site in `src/` and is covered by at least one test (`error-codes.test.ts`).
|
||||
78
docs/media/audio/audio-sprite-player/src/events/events.md
Normal file
78
docs/media/audio/audio-sprite-player/src/events/events.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# SpritePlayerEvents
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SpritePlayerEvents` is the typed event surface a `SpritePlayer` exposes for downstream consumers — analytics, UI feedback, observability sinks, debug overlays. It is a thin facade over `@suaveplan/events`'s `EventBus`, narrowed to the five event names the player can emit.
|
||||
|
||||
The class is intentionally write-internal / read-external: the player constructs it and emits, consumers subscribe and never emit. The internal `EventBus` is not exposed so consumers cannot dispatch arbitrary events through the player's surface, which keeps the contract auditable.
|
||||
|
||||
## Features
|
||||
|
||||
- Five typed event names: `play`, `stop`, `complete`, `voice-stolen`, `error`.
|
||||
- Strongly-typed `on` returning an unsubscribe closure — no string-typed payloads at the call site.
|
||||
- `clear()` wipes all listeners — used by `SpritePlayer.dispose()` to break consumer-to-player references.
|
||||
- Backed by `@suaveplan/events.EventBus` so payloads flow through the same FIFO dispatch used elsewhere in the platform.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createSpritePlayer } from "@suaveplan/audio-sprite-player";
|
||||
|
||||
const player = createSpritePlayer({ adapter });
|
||||
const off = player.events.on("play", (e) => {
|
||||
console.log(`play ${e.entryName} (${e.handleId}) at ${e.startedAt}`);
|
||||
});
|
||||
// ...later
|
||||
off();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createSpritePlayer } from "@suaveplan/audio-sprite-player";
|
||||
|
||||
const player = createSpritePlayer({ adapter });
|
||||
const offs = [
|
||||
player.events.on("voice-stolen", (e) => {
|
||||
metrics.voicesStolen.add(1, { entry: e.entryName });
|
||||
}),
|
||||
player.events.on("complete", (e) => {
|
||||
histograms.playMs.record(e.durationMs, { entry: e.entryName });
|
||||
}),
|
||||
player.events.on("error", (e) => {
|
||||
log.error("sprite playback failed", {
|
||||
handleId: e.handleId,
|
||||
code: e.code,
|
||||
message: e.message,
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
return () => offs.forEach((off) => off());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SpritePlayerEvents`
|
||||
|
||||
- `on(event, listener): () => void` — subscribes, returns the unsubscribe closure.
|
||||
- `emit(event, payload): void` — internal; called by `SpritePlayer`.
|
||||
- `clear(): void` — drops every listener.
|
||||
|
||||
### Event payloads
|
||||
|
||||
- `play` → `{ handleId, entryName, startedAt }`
|
||||
- `stop` → `{ handleId, entryName, fadeOutMs }`
|
||||
- `complete` → `{ handleId, entryName, durationMs }`
|
||||
- `voice-stolen` → `{ stolenId, replacedBy, entryName }`
|
||||
- `error` → `{ handleId, entryName, code, message }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The class deliberately exposes `emit` so the orchestrator can fire events; downstream consumers should treat it as internal and never call it (TypeScript visibility cannot enforce package-level boundaries, so the convention is documented here).
|
||||
- `clear()` is invoked by `SpritePlayer.dispose()` to release references — consumers should not call it directly because it would silence other listeners.
|
||||
- The underlying `EventBus` dispatches synchronously in FIFO order; throwing in a listener does not poison the bus but does prevent later listeners from running for that emission.
|
||||
82
docs/media/audio/audio-sprite-player/src/schemas/schemas.md
Normal file
82
docs/media/audio/audio-sprite-player/src/schemas/schemas.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns every runtime Zod schema exposed at the public boundary of `@suaveplan/audio-sprite-player`. Universal purity rules forbid the player from importing runtime symbols from `@suaveplan/audio-sprite-packer` (the producer) — so the manifest shape is mirrored here as a hand-authored Zod schema that the player applies before touching the injected `SpritePlaybackAdapter`.
|
||||
|
||||
Every async method on `SpritePlayer` re-validates its inputs through these schemas before doing any other work, so untrusted manifests or option bags never reach the adapter or telemetry instruments.
|
||||
|
||||
## Features
|
||||
|
||||
- **Manifest validator** (`AudioSpriteManifestSchema`) accepts only `version: 1` manifests with at least one entry, sane sample rates, channels, and a 64-character content hash.
|
||||
- **Entry validator** (`AudioSpriteEntrySchema`) enforces non-negative offsets/lengths, positive sample rates and channel counts, and a known audio format.
|
||||
- **`SpritePlayerOptions` validator** with custom structural check on the adapter and a hard ceiling of 256 on `maxConcurrentVoices`.
|
||||
- **`PlayOptions` validator** with volume `[0, 1]`, pitch `[0.5, 2]`, integer non-negative `fadeInMs`/`fadeOutMs`/`startOffsetMs`, and non-negative `durationMs`.
|
||||
- **`SequenceOptions` validator** that extends `PlayOptions` with an integer non-negative `gapMs`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PlayOptionsSchema } from "@suaveplan/audio-sprite-player/schemas";
|
||||
|
||||
const result = PlayOptionsSchema.safeParse({ volume: 0.75, loop: true });
|
||||
if (!result.success) throw new Error("invalid play options");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AudioSpriteManifestSchema,
|
||||
SpritePlayerOptionsSchema,
|
||||
} from "@suaveplan/audio-sprite-player";
|
||||
|
||||
function loadAndValidate(rawManifest: unknown) {
|
||||
const manifest = AudioSpriteManifestSchema.parse(rawManifest);
|
||||
return manifest.entries.map((e) => e.name);
|
||||
}
|
||||
|
||||
function buildPlayer(opts: unknown) {
|
||||
const parsed = SpritePlayerOptionsSchema.safeParse(opts);
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, issues: parsed.error.issues };
|
||||
}
|
||||
return { ok: true as const, options: opts };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AudioSpriteEntrySchema`
|
||||
|
||||
`z.object` with `name`, `offset`, `byteLength`, `durationSeconds`, `sampleRate`, `channels`, `format`.
|
||||
|
||||
### `AudioSpriteManifestSchema`
|
||||
|
||||
`z.object` with `version: z.literal(1)`, `format`, `sampleRate`, `channels`, `totalDurationSeconds`, `entries: z.array(...).min(1)`, `contentHash: z.string().length(64)`.
|
||||
|
||||
### `AudioSpriteSchema`
|
||||
|
||||
Couples a `Uint8Array` blob with a validated manifest.
|
||||
|
||||
### `SpritePlayerOptionsSchema`
|
||||
|
||||
Validates `adapter`, optional `logger`, `clock`, `idGenerator`, `rng`, and an integer `maxConcurrentVoices` between 1 and 256.
|
||||
|
||||
### `PlayOptionsSchema`
|
||||
|
||||
Validates every field of `PlayOptions` at the player's public boundary.
|
||||
|
||||
### `SequenceOptionsSchema`
|
||||
|
||||
`PlayOptionsSchema.extend({ gapMs: z.number().int().nonnegative().optional() })`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The audio-format enum is duplicated rather than imported from the packer to honour the universal purity rule: the player declares only a types-only dependency on `@suaveplan/audio-sprite-packer`.
|
||||
- `SpritePlayerOptionsSchema.adapter` uses `z.custom` with a structural duck-type check (`init`, `play`, `dispose`) — Zod cannot introspect an interface, so we check for the methods directly.
|
||||
- The 256-voice ceiling is enforced at the schema layer so a misconfiguration cannot reach the `VoiceManager` and exhaust memory.
|
||||
@@ -0,0 +1,90 @@
|
||||
# SpritePlayer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SpritePlayer` is the public orchestrator for `@suaveplan/audio-sprite-player`. It accepts a validated `AudioSprite` (a binary blob plus a JSON manifest produced by `@suaveplan/audio-sprite-packer`) and exposes fire-and-forget playback primitives — `play`, `playSequence`, `playRandom`, `stopAll` — on top of an injectable `SpritePlaybackAdapter` that engine-tier packages implement.
|
||||
|
||||
The class deliberately owns no audio output of its own. Every actual decode, gain ramp, pitch scheduling, and mixer routing concern is reached through the adapter. The orchestrator's job is manifest validation, entry lookup, option defaulting, voice management with stealing, sequence and random scheduling, abort propagation, telemetry, and a typed event surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict Zod validation of `SpritePlayerOptions`, the loaded manifest, and every `PlayOptions` / `SequenceOptions` bag at the public boundary.
|
||||
- Voice manager with oldest-first stealing capped at `maxConcurrentVoices` (default 32, hard ceiling 256).
|
||||
- Sequence playback with `durationMs + gapMs` scheduling and rollback of started voices on failure.
|
||||
- Uniform random playback with optional filter predicate and `Rng` injection for determinism in tests.
|
||||
- Pre-aborted and mid-flight `AbortSignal` paths across `load`, `play`, `playSequence`, and `playRandom`.
|
||||
- Typed event bus (`play`, `stop`, `complete`, `voice-stolen`, `error`) and OTel span/counter/gauge/histogram on every invocation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSpritePlayer,
|
||||
NullSpritePlaybackAdapter,
|
||||
} from "@suaveplan/audio-sprite-player";
|
||||
|
||||
const adapter = new NullSpritePlaybackAdapter();
|
||||
const player = createSpritePlayer({ adapter });
|
||||
await player.load(sprite);
|
||||
const handle = await player.play("hit");
|
||||
await handle.stop();
|
||||
await player.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createSpritePlayer } from "@suaveplan/audio-sprite-player";
|
||||
import { BrowserSpritePlaybackAdapter } from "@suaveplan/audio-engine-browser";
|
||||
|
||||
const adapter = new BrowserSpritePlaybackAdapter({ audioContext });
|
||||
const player = createSpritePlayer({
|
||||
adapter,
|
||||
maxConcurrentVoices: 16,
|
||||
clock: () => audioContext.currentTime * 1000,
|
||||
rng: seededRng(42),
|
||||
});
|
||||
|
||||
player.events.on("voice-stolen", (e) => {
|
||||
metrics.spritePlayerVoiceStolen.add(1, { entry: e.entryName });
|
||||
});
|
||||
|
||||
await player.load(sprite);
|
||||
const ctrl = new AbortController();
|
||||
await player.playSequence(["step", "step", "land"], {
|
||||
gapMs: 50,
|
||||
fadeInMs: 10,
|
||||
fadeOutMs: 20,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SpritePlayer`
|
||||
|
||||
- `constructor(options: SpritePlayerOptions)` — validates options; throws `INVALID_OPTIONS` on failure.
|
||||
- `load(sprite, signal?)` — validates manifest, initialises adapter. Throws `INVALID_MANIFEST` / `ADAPTER_INIT_FAILED` / `ABORTED`.
|
||||
- `play(entryName, opts?)` — looks up entry, allocates voice (with stealing), delegates to adapter. Throws `NOT_LOADED` / `UNKNOWN_ENTRY` / `INVALID_OPTIONS` / `PLAYBACK_FAILED` / `VOICE_LIMIT_EXCEEDED` / `ABORTED`.
|
||||
- `playSequence(entryNames, opts?)` — see spec §playSequence.
|
||||
- `playRandom(filterFn?, opts?)` — uniform random pick from filtered entries.
|
||||
- `stopAll(fadeOutMs?)` — `Promise.all` over every active voice.
|
||||
- `list()` / `has(name)` — read accessors for loaded entries.
|
||||
- `dispose()` — idempotent teardown.
|
||||
- `readonly events: SpritePlayerEvents` — typed event subscriber.
|
||||
- `readonly telemetry: SpritePlayerTelemetry` — OTel instruments.
|
||||
|
||||
### `createSpritePlayer(options)`
|
||||
|
||||
Functional alias for `new SpritePlayer(options)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The player never imports a runtime symbol from `@suaveplan/audio-sprite-packer`; the producer is consumed as a types-only dependency.
|
||||
- Auto-completion is scheduled via `setTimeout(resolved.durationMs)` so the universal layer can fire `complete` events without knowing the adapter's actual end-of-voice signal — adapter implementations are free to stop the voice earlier and the player will skip its own completion emission via the `isPlaying()` guard.
|
||||
- `playRandom` uses `rng.nextUint32() % entries.length` so tests can inject a deterministic `Rng`.
|
||||
- The wrapper handle returned by `play` is the same handle registered in the `VoiceManager`, so a stolen handle's `stop()` is the same call path as a user-driven `stop()` — the only difference is which event the player emits.
|
||||
@@ -0,0 +1,93 @@
|
||||
# SpritePlayerTelemetry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SpritePlayerTelemetry` is the OTel-facing facade that `SpritePlayer` opens for every public invocation. It manages one tracer plus three metric instruments — a counter, an observable gauge, and a histogram — so consumers running a real OTel SDK can plug spans and metrics straight into their pipeline without any glue code in the player.
|
||||
|
||||
The facade is constructed with a `getVoiceCount` callback rather than a direct reference to the player, so it can be unit-tested in isolation and so the gauge callback closes over a stable function reference for the lifetime of the player.
|
||||
|
||||
## Features
|
||||
|
||||
- One tracer (`audio-sprite-player`) for spans named `audio-sprite-player.play`.
|
||||
- One counter (`audio_sprite_player.play_total`) keyed by `sprite.entry_name`.
|
||||
- One observable gauge (`audio_sprite_player.voices_active`) driven by the injected voice-count getter.
|
||||
- One histogram (`audio_sprite_player.play_duration_ms`) recorded on `complete` events.
|
||||
- Spans are closed via `recordSuccess` / `recordFailure`, with `error.code` attribute attached on the failure path.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SpritePlayerTelemetry } from "@suaveplan/audio-sprite-player";
|
||||
|
||||
const telemetry = new SpritePlayerTelemetry(() => activeVoices.size);
|
||||
const h = telemetry.startPlay("hit");
|
||||
try {
|
||||
await adapter.play(/* ... */);
|
||||
telemetry.recordSuccess(h, { entryName: "hit" });
|
||||
} catch (err) {
|
||||
telemetry.recordFailure(h, {
|
||||
entryName: "hit",
|
||||
errorCode: "AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createSpritePlayer } from "@suaveplan/audio-sprite-player";
|
||||
|
||||
const player = createSpritePlayer({ adapter });
|
||||
|
||||
// Consumers wanting to inspect the live voice count without subscribing
|
||||
// to events can call _runGaugeCallback (the OTel SDK normally drives it).
|
||||
player.events.on("voice-stolen", () => {
|
||||
player.telemetry._runGaugeCallback();
|
||||
console.log(player.telemetry.observedVoiceCount);
|
||||
});
|
||||
|
||||
// recordCompletion is also exposed for adapter authors who want to drive
|
||||
// the histogram from the engine-side "voice ended" signal directly.
|
||||
player.telemetry.recordCompletion(450, "hit");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new SpritePlayerTelemetry(getVoiceCount: () => number)`
|
||||
|
||||
Constructs the facade. The callback is invoked by the OTel SDK whenever it polls the observable gauge.
|
||||
|
||||
### `startPlay(entryName): PlayTelemetryHandle`
|
||||
|
||||
Opens a span and records the wall-clock start time.
|
||||
|
||||
### `recordSuccess(handle, attrs): void`
|
||||
|
||||
Increments the counter, sets `OK` status, closes the span.
|
||||
|
||||
### `recordFailure(handle, attrs): void`
|
||||
|
||||
Sets `ERROR` status, attaches `error.code` attribute when provided, closes the span. The counter is not incremented on failure (`play_total` reflects accepted plays only).
|
||||
|
||||
### `recordCompletion(durationMs, entryName): void`
|
||||
|
||||
Records an observation on the duration histogram.
|
||||
|
||||
### `observedVoiceCount: number`
|
||||
|
||||
Last value observed by the gauge callback.
|
||||
|
||||
### `_runGaugeCallback(): void`
|
||||
|
||||
Test-visible hook that drives the gauge callback synchronously. Useful in environments without a real OTel SDK — the default `@opentelemetry/api` ships a noop SDK that never polls the gauge.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default `@opentelemetry/api` package provides a noop tracer and meter when no SDK is installed; this facade still records the calls correctly via the noop instruments, so calling code never has to gate on SDK presence.
|
||||
- The gauge callback is stored as a member so its identity is stable for the lifetime of the player; rebinding on every observation would defeat OTel's deduplication.
|
||||
- `recordFailure` deliberately does not increment the counter — a failed play is not a play.
|
||||
@@ -0,0 +1,89 @@
|
||||
# VoiceManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`VoiceManager` enforces the active-voice cap and runs the voice-stealing algorithm that keeps sprite playback bounded under pressure. It is a small, self-contained primitive consumed exclusively by `SpritePlayer`; it has no knowledge of audio, manifests, or adapters, which makes it trivial to test in isolation.
|
||||
|
||||
The manager is intentionally simple: oldest voice (lowest `startedAt`, ties broken by allocation order) is the victim when stealing fires. Priority-based stealing is out of scope; consumers that need it can layer it on top by filtering entries before calling `play`.
|
||||
|
||||
## Features
|
||||
|
||||
- Constant-time `register` and amortised constant-time `release`.
|
||||
- Linear-scan `pickOldest` (sprite voice counts are small — typical caps are 16–32, hard ceiling 256).
|
||||
- Allocation order tracked monotonically so identical `startedAt` values do not produce non-deterministic steals.
|
||||
- Throws `AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED` when `allowVoiceStealing` is `false` and the cap is reached, so consumers can detect pressure without losing the request silently.
|
||||
- Exposes a read-only `snapshot()` array used by `SpritePlayer.stopAll` to drive `Promise.all`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { VoiceManager } from "@suaveplan/audio-sprite-player/voice-manager/voice-manager";
|
||||
|
||||
const vm = new VoiceManager(4);
|
||||
const { stolen } = vm.reserve(true);
|
||||
if (stolen) await stolen.stop(0);
|
||||
vm.register(myHandle);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_SPRITE_PLAYER_CODES,
|
||||
AudioSpritePlayerError,
|
||||
VoiceManager,
|
||||
} from "@suaveplan/audio-sprite-player";
|
||||
|
||||
function reserveStrict(vm: VoiceManager, allowSteal: boolean) {
|
||||
try {
|
||||
return vm.reserve(allowSteal);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AudioSpritePlayerError &&
|
||||
err.code === AUDIO_SPRITE_PLAYER_CODES.VOICE_LIMIT_EXCEEDED
|
||||
) {
|
||||
// Drop the request rather than steal.
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new VoiceManager(maxConcurrentVoices: number)`
|
||||
|
||||
Constructs a manager with the given cap. The cap is validated by `SpritePlayerOptionsSchema` upstream; consumers calling the manager directly should validate themselves.
|
||||
|
||||
### `reserve(allowVoiceStealing: boolean): AllocateResult`
|
||||
|
||||
- Returns `{ stolen: null }` when below cap.
|
||||
- Returns `{ stolen: handle }` when at cap and stealing is allowed.
|
||||
- Throws `AudioSpritePlayerError(VOICE_LIMIT_EXCEEDED)` when at cap and stealing is disallowed.
|
||||
|
||||
### `register(handle): void`
|
||||
|
||||
Appends the handle with a fresh allocation order.
|
||||
|
||||
### `release(id): void`
|
||||
|
||||
Removes the named handle if present; otherwise a no-op (safe to call after a voice has already been released).
|
||||
|
||||
### `activeCount` / `cap`
|
||||
|
||||
Read-only accessors.
|
||||
|
||||
### `list()` / `snapshot()`
|
||||
|
||||
Return the active handles in insertion order.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The records array is kept small (≤ 256 entries by spec), so a linear scan is faster than maintaining a heap.
|
||||
- `pickOldest` iterates the full array on every overflow; the cost is `O(cap)` per steal, which is negligible compared to the cost of starting a real voice.
|
||||
- Tie-break is strictly by allocation order. This is the only piece of state the manager keeps beyond the handle array.
|
||||
Reference in New Issue
Block a user