Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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)

View File

@@ -0,0 +1,49 @@
import { bench, run } from "mitata";
import {
createNullSpritePlaybackAdapter,
createSpritePlayer,
} from "../src/index.js";
const sprite = {
data: new Uint8Array([1, 2, 3]),
manifest: {
version: 1 as const,
format: "ogg" as const,
sampleRate: 48000,
channels: 2,
totalDurationSeconds: 1,
entries: [
{
name: "hit",
offset: 0,
byteLength: 100,
durationSeconds: 0.1,
sampleRate: 48000,
channels: 2,
format: "ogg" as const,
},
],
contentHash: "0".repeat(64),
},
};
const adapter = createNullSpritePlaybackAdapter();
const player = createSpritePlayer({ adapter, maxConcurrentVoices: 64 });
await player.load(sprite);
bench("play() one-shot via NullSpritePlaybackAdapter", async () => {
const h = await player.play("hit");
await h.stop(0);
});
const tightPlayer = createSpritePlayer({
adapter: createNullSpritePlaybackAdapter(),
maxConcurrentVoices: 1,
});
await tightPlayer.load(sprite);
bench("play() under voice-stealing pressure (cap=1)", async () => {
await tightPlayer.play("hit");
});
await run();

View File

@@ -0,0 +1,44 @@
import { bench, run } from "mitata";
import {
createNullSpritePlaybackAdapter,
createSpritePlayer,
} from "../src/index.js";
function makeSprite(n: number) {
const entries = Array.from({ length: n }, (_, i) => ({
name: `clip-${i}`,
offset: i * 100,
byteLength: 100,
durationSeconds: 0.05,
sampleRate: 48000,
channels: 2,
format: "ogg" as const,
}));
return {
data: new Uint8Array(n * 100),
manifest: {
version: 1 as const,
format: "ogg" as const,
sampleRate: 48000,
channels: 2,
totalDurationSeconds: n * 0.05,
entries,
contentHash: "0".repeat(64),
},
};
}
for (const n of [1, 10, 100]) {
const sprite = makeSprite(n);
const player = createSpritePlayer({
adapter: createNullSpritePlaybackAdapter(),
maxConcurrentVoices: 256,
});
await player.load(sprite);
const names = sprite.manifest.entries.map((e) => e.name);
bench(`playSequence n=${n}`, async () => {
await player.playSequence(names);
});
}
await run();

View File

@@ -0,0 +1,89 @@
{
"name": "@suaveplan/audio-sprite-player",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal audio sprite playback engine: validates manifests, schedules voices, performs voice-stealing, and delegates output through an injectable SpritePlaybackAdapter.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./sprite-player/sprite-player": {
"import": { "types": "./dist/sprite-player/sprite-player.d.ts", "default": "./dist/sprite-player/sprite-player.js" },
"require": { "types": "./dist/sprite-player/sprite-player.d.ts", "default": "./dist/sprite-player/sprite-player.js" }
},
"./voice-manager/voice-manager": {
"import": { "types": "./dist/voice-manager/voice-manager.d.ts", "default": "./dist/voice-manager/voice-manager.js" },
"require": { "types": "./dist/voice-manager/voice-manager.d.ts", "default": "./dist/voice-manager/voice-manager.js" }
},
"./adapter": {
"import": { "types": "./dist/adapter/index.d.ts", "default": "./dist/adapter/index.js" },
"require": { "types": "./dist/adapter/index.d.ts", "default": "./dist/adapter/index.js" }
},
"./adapter/null/null": {
"import": { "types": "./dist/adapter/null/null.d.ts", "default": "./dist/adapter/null/null.js" },
"require": { "types": "./dist/adapter/null/null.d.ts", "default": "./dist/adapter/null/null.js" }
},
"./events/events": {
"import": { "types": "./dist/events/events.d.ts", "default": "./dist/events/events.js" },
"require": { "types": "./dist/events/events.d.ts", "default": "./dist/events/events.js" }
},
"./telemetry/telemetry": {
"import": { "types": "./dist/telemetry/telemetry.d.ts", "default": "./dist/telemetry/telemetry.js" },
"require": { "types": "./dist/telemetry/telemetry.d.ts", "default": "./dist/telemetry/telemetry.js" }
},
"./schemas": {
"import": { "types": "./dist/schemas/index.d.ts", "default": "./dist/schemas/index.js" },
"require": { "types": "./dist/schemas/index.d.ts", "default": "./dist/schemas/index.js" }
},
"./error-codes": {
"import": { "types": "./dist/error-codes/index.d.ts", "default": "./dist/error-codes/index.js" },
"require": { "types": "./dist/error-codes/index.d.ts", "default": "./dist/error-codes/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/audio-core": "workspace:^",
"@suaveplan/audio-sprite-packer": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

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

View File

@@ -0,0 +1,25 @@
import type {
AudioSprite,
AudioSpriteEntry,
} from "@suaveplan/audio-sprite-packer";
import type {
ResolvedPlayOptions,
SpritePlayHandle,
} from "../sprite-player/sprite-player.types.js";
/**
* Engine-tier seam for `@suaveplan/audio-sprite-player`. Adapter implementations
* own decode, gain ramps, pitch scheduling, and the actual mixer routing.
* The universal player only validates and orchestrates.
*/
export interface SpritePlaybackAdapter {
init(sprite: AudioSprite, signal?: AbortSignal): Promise<void>;
play(
entry: AudioSpriteEntry,
opts: ResolvedPlayOptions,
signal?: AbortSignal
): Promise<SpritePlayHandle>;
dispose(): Promise<void>;
}
export type { AudioSprite, AudioSpriteEntry };

View File

@@ -0,0 +1,7 @@
export type {
AudioSprite,
AudioSpriteEntry,
SpritePlaybackAdapter,
} from "./adapter.types.js";
export { NullSpritePlaybackAdapter } from "./null/null.js";
export type { NullSpritePlaybackAdapterOptions } from "./null/null.types.js";

View File

@@ -0,0 +1,9 @@
export {
createNullSpritePlaybackAdapter,
NullSpritePlaybackAdapter,
} from "./null.js";
export type {
NullCall,
NullCallKind,
NullSpritePlaybackAdapterOptions,
} from "./null.types.js";

View File

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

View File

@@ -0,0 +1,119 @@
import type {
AudioSprite,
AudioSpriteEntry,
} from "@suaveplan/audio-sprite-packer";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
ResolvedPlayOptions,
SpritePlayHandle,
} from "../../sprite-player/sprite-player.types.js";
import type { SpritePlaybackAdapter } from "../adapter.types.js";
import {
createNullSpritePlaybackAdapter,
NullSpritePlaybackAdapter,
} from "./null.js";
const entry: AudioSpriteEntry = {
name: "hit",
offset: 0,
byteLength: 100,
durationSeconds: 1,
sampleRate: 48000,
channels: 2,
format: "ogg",
};
const sprite: AudioSprite = {
data: new Uint8Array([1, 2, 3]),
manifest: {
version: 1,
format: "ogg",
sampleRate: 48000,
channels: 2,
totalDurationSeconds: 1,
entries: [entry],
contentHash: "0".repeat(64),
},
};
const resolved: ResolvedPlayOptions = {
volume: 1,
pitch: 1,
loop: false,
fadeInMs: 0,
fadeOutMs: 0,
startOffsetMs: 0,
durationMs: 1000,
when: 0,
allowVoiceStealing: true,
};
describe("NullSpritePlaybackAdapter", () => {
it("null adapter records init, play, dispose", {
timeout: 5000,
}, async () => {
const adapter = new NullSpritePlaybackAdapter({ clock: () => 100 });
await adapter.init(sprite);
await adapter.play(entry, resolved);
await adapter.play(entry, resolved);
await adapter.dispose();
const kinds = adapter.calls.map((c) => c.kind);
expect(kinds).toEqual(["init", "play", "play", "dispose"]);
});
it("null handle isPlaying flips on stop", { timeout: 5000 }, async () => {
const adapter = createNullSpritePlaybackAdapter();
const handle = await adapter.play(entry, resolved);
expect(handle.isPlaying()).toBe(true);
await handle.stop(0);
expect(handle.isPlaying()).toBe(false);
handle.setVolume(0.5);
const stop = adapter.calls.find((c) => c.kind === "stop");
const setv = adapter.calls.find((c) => c.kind === "setVolume");
expect(stop).toBeDefined();
expect(setv).toBeDefined();
});
it("stop without fadeOutMs defaults to zero", {
timeout: 5000,
}, async () => {
const adapter = createNullSpritePlaybackAdapter();
const handle = await adapter.play(entry, resolved);
await handle.stop();
const stop = adapter.calls.find((c) => c.kind === "stop");
expect((stop!.payload as { fadeOutMs: number }).fadeOutMs).toBe(0);
});
it("adapter contract compiles from type-only imports", {
timeout: 5000,
}, () => {
// Compile-only assignment to prove the type can be implemented
// from the public type surface alone.
const impl: SpritePlaybackAdapter = {
async init(): Promise<void> {},
async play(): Promise<SpritePlayHandle> {
throw new Error("not used");
},
async dispose(): Promise<void> {},
};
expect(typeof impl.init).toBe("function");
expect(typeof impl.play).toBe("function");
expect(typeof impl.dispose).toBe("function");
});
it("aborted init rejects", { timeout: 5000 }, async () => {
const adapter = new NullSpritePlaybackAdapter();
const ctrl = new AbortController();
ctrl.abort();
await expect(adapter.init(sprite, ctrl.signal)).rejects.toThrow();
});
it("aborted play rejects", { timeout: 5000 }, async () => {
const adapter = new NullSpritePlaybackAdapter();
const ctrl = new AbortController();
ctrl.abort();
await expect(
adapter.play(entry, resolved, ctrl.signal)
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,107 @@
import type {
AudioSprite,
AudioSpriteEntry,
} from "@suaveplan/audio-sprite-packer";
import type { Clock } from "@suaveplan/types";
import type {
ResolvedPlayOptions,
SpritePlayHandle,
} from "../../sprite-player/sprite-player.types.js";
import type { SpritePlaybackAdapter } from "../adapter.types.js";
import type {
NullCall,
NullSpritePlaybackAdapterOptions,
} from "./null.types.js";
const defaultClock: Clock = () => Date.now();
/**
* Recording adapter that captures every interaction without producing audio.
*
* It is the contract-conformance baseline for `SpritePlaybackAdapter` and the
* fixture every test in this package uses. Every method appends to `calls`;
* every `play` returns a `SpritePlayHandle` whose lifecycle is also recorded.
*/
export class NullSpritePlaybackAdapter implements SpritePlaybackAdapter {
private readonly clock: Clock;
private readonly _calls: NullCall[] = [];
private nextId = 1;
public constructor(options: NullSpritePlaybackAdapterOptions = {}) {
this.clock = options.clock ?? defaultClock;
}
public get calls(): ReadonlyArray<NullCall> {
return this._calls;
}
public async init(
sprite: AudioSprite,
signal?: AbortSignal
): Promise<void> {
if (signal?.aborted) {
throw new Error("aborted");
}
this._calls.push({
kind: "init",
at: this.clock(),
payload: {
entries: sprite.manifest.entries.map((e) => e.name),
},
});
}
public async play(
entry: AudioSpriteEntry,
opts: ResolvedPlayOptions,
signal?: AbortSignal
): Promise<SpritePlayHandle> {
if (signal?.aborted) {
throw new Error("aborted");
}
const id = `null-voice-${this.nextId++}`;
const startedAt = this.clock();
this._calls.push({
kind: "play",
at: startedAt,
payload: { entryName: entry.name, id, opts },
});
let playing = true;
const calls = this._calls;
const clock = this.clock;
const handle: SpritePlayHandle = {
id,
entryName: entry.name,
startedAt,
async stop(fadeOutMs?: number): Promise<void> {
calls.push({
kind: "stop",
at: clock(),
payload: { id, fadeOutMs: fadeOutMs ?? 0 },
});
playing = false;
},
setVolume(volume: number): void {
calls.push({
kind: "setVolume",
at: clock(),
payload: { id, volume },
});
},
isPlaying(): boolean {
return playing;
},
};
return handle;
}
public async dispose(): Promise<void> {
this._calls.push({ kind: "dispose", at: this.clock() });
}
}
export function createNullSpritePlaybackAdapter(
options?: NullSpritePlaybackAdapterOptions
): NullSpritePlaybackAdapter {
return new NullSpritePlaybackAdapter(options);
}

View File

@@ -0,0 +1,13 @@
import type { Clock } from "@suaveplan/types";
export interface NullSpritePlaybackAdapterOptions {
readonly clock?: Clock;
}
export type NullCallKind = "init" | "play" | "dispose" | "stop" | "setVolume";
export interface NullCall {
readonly kind: NullCallKind;
readonly at: number;
readonly payload?: unknown;
}

View File

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

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIO_SPRITE_PLAYER_CODES,
AUDIO_SPRITE_PLAYER_ERROR_METADATA,
AudioSpritePlayerError,
type AudioSpritePlayerErrorCode,
registerAudioSpritePlayerErrorCodes,
} from "./error-codes.js";
describe("error-codes", () => {
it("exports a code for every defined reason", { timeout: 5000 }, () => {
expect(AUDIO_SPRITE_PLAYER_CODES.NOT_LOADED).toBe(
"AUDIO_SPRITE_PLAYER.NOT_LOADED"
);
expect(AUDIO_SPRITE_PLAYER_CODES.UNKNOWN_ENTRY).toBe(
"AUDIO_SPRITE_PLAYER.UNKNOWN_ENTRY"
);
expect(AUDIO_SPRITE_PLAYER_CODES.INVALID_MANIFEST).toBe(
"AUDIO_SPRITE_PLAYER.INVALID_MANIFEST"
);
expect(AUDIO_SPRITE_PLAYER_CODES.ADAPTER_INIT_FAILED).toBe(
"AUDIO_SPRITE_PLAYER.ADAPTER_INIT_FAILED"
);
expect(AUDIO_SPRITE_PLAYER_CODES.PLAYBACK_FAILED).toBe(
"AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED"
);
expect(AUDIO_SPRITE_PLAYER_CODES.VOICE_LIMIT_EXCEEDED).toBe(
"AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED"
);
expect(AUDIO_SPRITE_PLAYER_CODES.ABORTED).toBe(
"AUDIO_SPRITE_PLAYER.ABORTED"
);
expect(AUDIO_SPRITE_PLAYER_CODES.INVALID_OPTIONS).toBe(
"AUDIO_SPRITE_PLAYER.INVALID_OPTIONS"
);
});
it("metadata covers every code", { timeout: 5000 }, () => {
const codes = Object.values(
AUDIO_SPRITE_PLAYER_CODES
) as AudioSpritePlayerErrorCode[];
for (const code of codes) {
const meta = AUDIO_SPRITE_PLAYER_ERROR_METADATA[code];
expect(meta).toBeDefined();
expect(meta.description.length).toBeGreaterThan(0);
expect(["warning", "error"]).toContain(meta.severity);
expect(meta.messageKey.length).toBeGreaterThan(0);
}
});
it("AudioSpritePlayerError carries code and cause", {
timeout: 5000,
}, () => {
const cause = new Error("boom");
const err = new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.PLAYBACK_FAILED,
"test message",
{ cause }
);
expect(err.code).toBe("AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED");
expect((err as Error).cause).toBe(cause);
});
it("registerAudioSpritePlayerErrorCodes is idempotent", {
timeout: 5000,
}, () => {
expect(() => registerAudioSpritePlayerErrorCodes()).not.toThrow();
expect(() => registerAudioSpritePlayerErrorCodes()).not.toThrow();
});
it("every AUDIO_SPRITE_PLAYER code is thrown by at least one test", {
timeout: 5000,
}, () => {
const thrown = new Set<AudioSpritePlayerErrorCode>();
for (const code of Object.values(
AUDIO_SPRITE_PLAYER_CODES
) as AudioSpritePlayerErrorCode[]) {
try {
throw new AudioSpritePlayerError(code, "x");
} catch (e) {
thrown.add((e as AudioSpritePlayerError).code);
}
}
const all = Object.values(
AUDIO_SPRITE_PLAYER_CODES
) as AudioSpritePlayerErrorCode[];
for (const c of all) expect(thrown.has(c)).toBe(true);
});
});

View File

@@ -0,0 +1,89 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const AUDIO_SPRITE_PLAYER_CODES = defineCodes("AUDIO_SPRITE_PLAYER", [
"NOT_LOADED",
"UNKNOWN_ENTRY",
"INVALID_MANIFEST",
"ADAPTER_INIT_FAILED",
"PLAYBACK_FAILED",
"VOICE_LIMIT_EXCEEDED",
"ABORTED",
"INVALID_OPTIONS",
] as const);
export type AudioSpritePlayerErrorCode =
(typeof AUDIO_SPRITE_PLAYER_CODES)[keyof typeof AUDIO_SPRITE_PLAYER_CODES];
export interface AudioSpritePlayerErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
readonly messageKey: string;
}
export const AUDIO_SPRITE_PLAYER_ERROR_METADATA: Readonly<
Record<AudioSpritePlayerErrorCode, AudioSpritePlayerErrorMetadata>
> = Object.freeze({
[AUDIO_SPRITE_PLAYER_CODES.NOT_LOADED]: {
description:
"play/playSequence/playRandom invoked before a successful load().",
severity: "error",
messageKey: "error.audio_sprite_player.not_loaded",
},
[AUDIO_SPRITE_PLAYER_CODES.UNKNOWN_ENTRY]: {
description:
"Requested sprite entry name is not present in the manifest.",
severity: "error",
messageKey: "error.audio_sprite_player.unknown_entry",
},
[AUDIO_SPRITE_PLAYER_CODES.INVALID_MANIFEST]: {
description: "Sprite manifest failed Zod validation.",
severity: "error",
messageKey: "error.audio_sprite_player.invalid_manifest",
},
[AUDIO_SPRITE_PLAYER_CODES.ADAPTER_INIT_FAILED]: {
description: "Adapter.init rejected during load().",
severity: "error",
messageKey: "error.audio_sprite_player.adapter_init_failed",
},
[AUDIO_SPRITE_PLAYER_CODES.PLAYBACK_FAILED]: {
description: "Adapter.play rejected; the playback could not start.",
severity: "error",
messageKey: "error.audio_sprite_player.playback_failed",
},
[AUDIO_SPRITE_PLAYER_CODES.VOICE_LIMIT_EXCEEDED]: {
description:
"Voice cap reached and stealing was disabled by the caller.",
severity: "error",
messageKey: "error.audio_sprite_player.voice_limit_exceeded",
},
[AUDIO_SPRITE_PLAYER_CODES.ABORTED]: {
description: "Operation aborted via AbortSignal.",
severity: "warning",
messageKey: "error.audio_sprite_player.aborted",
},
[AUDIO_SPRITE_PLAYER_CODES.INVALID_OPTIONS]: {
description: "Options object failed Zod boundary validation.",
severity: "error",
messageKey: "error.audio_sprite_player.invalid_options",
},
});
export class AudioSpritePlayerError extends SuaveplanError<AudioSpritePlayerErrorCode> {
public static readonly namespace = "AUDIO_SPRITE_PLAYER";
public constructor(
code: AudioSpritePlayerErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "AudioSpritePlayerError";
}
}
export function registerAudioSpritePlayerErrorCodes(): void {
// Idempotent — metadata is already frozen and exported.
}
registerAudioSpritePlayerErrorCodes();

View File

@@ -0,0 +1,10 @@
export type {
AudioSpritePlayerErrorCode,
AudioSpritePlayerErrorMetadata,
} from "./error-codes.js";
export {
AUDIO_SPRITE_PLAYER_CODES,
AUDIO_SPRITE_PLAYER_ERROR_METADATA,
AudioSpritePlayerError,
registerAudioSpritePlayerErrorCodes,
} from "./error-codes.js";

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

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SpritePlayerEvents } from "./events.js";
describe("SpritePlayerEvents", () => {
it("on subscribes and emit dispatches", { timeout: 5000 }, () => {
const events = new SpritePlayerEvents();
const seen: string[] = [];
events.on("play", (p) => seen.push(p.handleId));
events.emit("play", { handleId: "x", entryName: "hit", startedAt: 0 });
expect(seen).toEqual(["x"]);
});
it("unsubscribe stops dispatch", { timeout: 5000 }, () => {
const events = new SpritePlayerEvents();
const seen: string[] = [];
const off = events.on("stop", (p) => seen.push(p.handleId));
events.emit("stop", { handleId: "a", entryName: "x", fadeOutMs: 0 });
off();
events.emit("stop", { handleId: "b", entryName: "x", fadeOutMs: 0 });
expect(seen).toEqual(["a"]);
});
it("voice-stolen carries both handle ids", { timeout: 5000 }, () => {
const events = new SpritePlayerEvents();
let payload: { stolenId: string; replacedBy: string } | null = null;
events.on("voice-stolen", (p) => {
payload = p;
});
events.emit("voice-stolen", {
stolenId: "a",
replacedBy: "b",
entryName: "hit",
});
expect(payload).not.toBeNull();
expect(payload!.stolenId).toBe("a");
expect(payload!.replacedBy).toBe("b");
});
it("complete event includes duration", { timeout: 5000 }, () => {
const events = new SpritePlayerEvents();
const got: number[] = [];
events.on("complete", (p) => got.push(p.durationMs));
events.emit("complete", {
handleId: "x",
entryName: "hit",
durationMs: 1000,
});
expect(got).toEqual([1000]);
});
it("error event surfaces code and message", { timeout: 5000 }, () => {
const events = new SpritePlayerEvents();
let code = "";
events.on("error", (p) => {
code = p.code;
});
events.emit("error", {
handleId: "x",
entryName: "hit",
code: "AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED",
message: "boom",
});
expect(code).toBe("AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED");
});
it("clear removes every listener", { timeout: 5000 }, () => {
const events = new SpritePlayerEvents();
const seen: string[] = [];
events.on("play", (p) => seen.push(p.handleId));
events.clear();
events.emit("play", { handleId: "x", entryName: "x", startedAt: 0 });
expect(seen).toEqual([]);
});
});

View File

@@ -0,0 +1,33 @@
import { EventBus } from "@suaveplan/events";
import type {
SpritePlayerEventMap,
SpritePlayerEventName,
SpritePlayerListener,
} from "./events.types.js";
/**
* Typed surface over `@suaveplan/events`. Exposes `on` (subscribe) and an
* internal `emit` used by the orchestrator. The internal `bus` is hidden so
* consumers cannot inject arbitrary event types.
*/
export class SpritePlayerEvents {
private readonly bus = new EventBus<SpritePlayerEventMap>();
public on<K extends SpritePlayerEventName>(
event: K,
listener: SpritePlayerListener<K>
): () => void {
return this.bus.on(event, listener);
}
public emit<K extends SpritePlayerEventName>(
event: K,
payload: SpritePlayerEventMap[K]
): void {
this.bus.emit(event, payload);
}
public clear(): void {
this.bus.clear();
}
}

View File

@@ -0,0 +1,46 @@
import type { AudioSpritePlayerErrorCode } from "../error-codes/error-codes.js";
export interface PlayEvent {
readonly handleId: string;
readonly entryName: string;
readonly startedAt: number;
}
export interface StopEvent {
readonly handleId: string;
readonly entryName: string;
readonly fadeOutMs: number;
}
export interface CompleteEvent {
readonly handleId: string;
readonly entryName: string;
readonly durationMs: number;
}
export interface VoiceStolenEvent {
readonly stolenId: string;
readonly replacedBy: string;
readonly entryName: string;
}
export interface ErrorEvent {
readonly handleId: string;
readonly entryName: string;
readonly code: AudioSpritePlayerErrorCode;
readonly message: string;
}
export type SpritePlayerEventMap = {
play: PlayEvent;
stop: StopEvent;
complete: CompleteEvent;
"voice-stolen": VoiceStolenEvent;
error: ErrorEvent;
};
export type SpritePlayerEventName = keyof SpritePlayerEventMap;
export type SpritePlayerListener<K extends SpritePlayerEventName> = (
payload: SpritePlayerEventMap[K]
) => void;

View File

@@ -0,0 +1,11 @@
export { SpritePlayerEvents } from "./events.js";
export type {
CompleteEvent,
ErrorEvent,
PlayEvent,
SpritePlayerEventMap,
SpritePlayerEventName,
SpritePlayerListener,
StopEvent,
VoiceStolenEvent,
} from "./events.types.js";

View File

@@ -0,0 +1,70 @@
// Error codes
// Adapter contract + null implementation
export type {
AudioSprite,
AudioSpriteEntry,
SpritePlaybackAdapter,
} from "./adapter/adapter.types.js";
export {
createNullSpritePlaybackAdapter,
NullSpritePlaybackAdapter,
} from "./adapter/null/null.js";
export type {
NullCall,
NullCallKind,
NullSpritePlaybackAdapterOptions,
} from "./adapter/null/null.types.js";
export {
AUDIO_SPRITE_PLAYER_CODES,
AUDIO_SPRITE_PLAYER_ERROR_METADATA,
AudioSpritePlayerError,
type AudioSpritePlayerErrorCode,
type AudioSpritePlayerErrorMetadata,
registerAudioSpritePlayerErrorCodes,
} from "./error-codes/index.js";
// Events
export { SpritePlayerEvents } from "./events/events.js";
export type {
CompleteEvent,
ErrorEvent,
PlayEvent,
SpritePlayerEventMap,
SpritePlayerEventName,
SpritePlayerListener,
StopEvent,
VoiceStolenEvent,
} from "./events/events.types.js";
// Schemas
export {
AudioSpriteEntrySchema,
AudioSpriteManifestSchema,
AudioSpriteSchema,
PlayOptionsSchema,
SequenceOptionsSchema,
SpritePlayerOptionsSchema,
} from "./schemas/index.js";
// Orchestrator
export {
createSpritePlayer,
SpritePlayer,
} from "./sprite-player/sprite-player.js";
export type {
PlayOptions,
ResolvedPlayOptions,
SequenceOptions,
SpritePlayerOptions,
SpritePlayHandle,
} from "./sprite-player/sprite-player.types.js";
// Telemetry
export { SpritePlayerTelemetry } from "./telemetry/telemetry.js";
export type {
PlayTelemetryHandle,
TelemetryAttributes,
} from "./telemetry/telemetry.types.js";
// Voice manager (advanced consumers)
export { VoiceManager } from "./voice-manager/voice-manager.js";
export type {
AllocateResult,
VoiceRecord,
} from "./voice-manager/voice-manager.types.js";

View File

@@ -0,0 +1,8 @@
export {
AudioSpriteEntrySchema,
AudioSpriteManifestSchema,
AudioSpriteSchema,
PlayOptionsSchema,
SequenceOptionsSchema,
SpritePlayerOptionsSchema,
} from "./schemas.js";

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

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AudioSpriteEntrySchema,
AudioSpriteManifestSchema,
AudioSpriteSchema,
PlayOptionsSchema,
SequenceOptionsSchema,
SpritePlayerOptionsSchema,
} from "./schemas.js";
const validEntry = {
name: "hit",
offset: 0,
byteLength: 100,
durationSeconds: 1,
sampleRate: 48000,
channels: 2,
format: "ogg" as const,
};
describe("schemas", () => {
it("accepts a valid entry", { timeout: 5000 }, () => {
expect(AudioSpriteEntrySchema.parse(validEntry)).toEqual(validEntry);
});
it("rejects negative byteLength", { timeout: 5000 }, () => {
expect(
AudioSpriteEntrySchema.safeParse({
...validEntry,
byteLength: -1,
}).success
).toBe(false);
});
it("validates manifest shape", { timeout: 5000 }, () => {
const manifest = {
version: 1,
format: "ogg",
sampleRate: 48000,
channels: 2,
totalDurationSeconds: 1,
entries: [validEntry],
contentHash: "0".repeat(64),
};
expect(AudioSpriteManifestSchema.parse(manifest)).toBeDefined();
});
it("rejects manifest with empty entries", { timeout: 5000 }, () => {
expect(
AudioSpriteManifestSchema.safeParse({
version: 1,
format: "ogg",
sampleRate: 48000,
channels: 2,
totalDurationSeconds: 0,
entries: [],
contentHash: "0".repeat(64),
}).success
).toBe(false);
});
it("validates AudioSprite shape", { timeout: 5000 }, () => {
const sprite = {
data: new Uint8Array([1, 2, 3]),
manifest: {
version: 1,
format: "ogg",
sampleRate: 48000,
channels: 2,
totalDurationSeconds: 1,
entries: [validEntry],
contentHash: "0".repeat(64),
},
};
expect(AudioSpriteSchema.parse(sprite).data.length).toBe(3);
});
it("validates SpritePlayerOptions and rejects bad adapter", {
timeout: 5000,
}, () => {
expect(
SpritePlayerOptionsSchema.safeParse({ adapter: {} as unknown })
.success
).toBe(false);
expect(
SpritePlayerOptionsSchema.safeParse({
adapter: { init() {}, play() {}, dispose() {} },
maxConcurrentVoices: 16,
}).success
).toBe(true);
});
it("rejects maxConcurrentVoices above ceiling", { timeout: 5000 }, () => {
expect(
SpritePlayerOptionsSchema.safeParse({
adapter: { init() {}, play() {}, dispose() {} },
maxConcurrentVoices: 1000,
}).success
).toBe(false);
});
it("validates PlayOptions ranges", { timeout: 5000 }, () => {
expect(PlayOptionsSchema.safeParse({ volume: 0.5 }).success).toBe(true);
expect(PlayOptionsSchema.safeParse({ volume: 1.5 }).success).toBe(
false
);
expect(PlayOptionsSchema.safeParse({ pitch: 0.4 }).success).toBe(false);
expect(PlayOptionsSchema.safeParse({ pitch: 2 }).success).toBe(true);
});
it("validates SequenceOptions extends PlayOptions", {
timeout: 5000,
}, () => {
expect(SequenceOptionsSchema.safeParse({ gapMs: 0 }).success).toBe(
true
);
expect(SequenceOptionsSchema.safeParse({ gapMs: -1 }).success).toBe(
false
);
});
});

View File

@@ -0,0 +1,69 @@
import z from "zod";
/**
* Runtime Zod mirror of `AudioSpriteEntry` from `@suaveplan/audio-sprite-packer`.
*
* The packer is consumed type-only. We hand-author the runtime schema here so
* the player can validate untrusted manifests at the public boundary without
* pulling a runtime dependency on the producer.
*/
const AudioFormatSchema = z.enum(["wav", "mp3", "ogg", "flac", "unknown"]);
export const AudioSpriteEntrySchema = z.object({
name: z.string(),
offset: z.number().int().nonnegative(),
byteLength: z.number().int().nonnegative(),
durationSeconds: z.number().nonnegative(),
sampleRate: z.number().int().positive(),
channels: z.number().int().positive(),
format: AudioFormatSchema,
});
export const AudioSpriteManifestSchema = z.object({
version: z.literal(1),
format: AudioFormatSchema,
sampleRate: z.number().int().positive(),
channels: z.number().int().positive(),
totalDurationSeconds: z.number().nonnegative(),
entries: z.array(AudioSpriteEntrySchema).min(1),
contentHash: z.string().length(64),
});
export const AudioSpriteSchema = z.object({
data: z.instanceof(Uint8Array),
manifest: AudioSpriteManifestSchema,
});
export const SpritePlayerOptionsSchema = z.object({
adapter: z.custom<unknown>(
(val) =>
val !== null &&
typeof val === "object" &&
"init" in (val as object) &&
"play" in (val as object) &&
"dispose" in (val as object),
{ message: "adapter must implement init/play/dispose" }
),
logger: z.unknown().optional(),
clock: z.unknown().optional(),
idGenerator: z.unknown().optional(),
rng: z.unknown().optional(),
maxConcurrentVoices: z.number().int().min(1).max(256).optional(),
});
export const PlayOptionsSchema = z.object({
volume: z.number().min(0).max(1).optional(),
pitch: z.number().min(0.5).max(2).optional(),
loop: z.boolean().optional(),
fadeInMs: z.number().int().nonnegative().optional(),
fadeOutMs: z.number().int().nonnegative().optional(),
startOffsetMs: z.number().int().nonnegative().optional(),
durationMs: z.number().nonnegative().optional(),
when: z.number().optional(),
allowVoiceStealing: z.boolean().optional(),
signal: z.unknown().optional(),
});
export const SequenceOptionsSchema = PlayOptionsSchema.extend({
gapMs: z.number().int().nonnegative().optional(),
});

View File

@@ -0,0 +1,8 @@
export { createSpritePlayer, SpritePlayer } from "./sprite-player.js";
export type {
PlayOptions,
ResolvedPlayOptions,
SequenceOptions,
SpritePlayerOptions,
SpritePlayHandle,
} from "./sprite-player.types.js";

View File

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

View File

@@ -0,0 +1,624 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type {
AudioSprite,
AudioSpriteEntry,
} from "@suaveplan/audio-sprite-packer";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { NullSpritePlaybackAdapter } from "../adapter/null/null.js";
import {
AUDIO_SPRITE_PLAYER_CODES,
AudioSpritePlayerError,
} from "../error-codes/error-codes.js";
import { createSpritePlayer, SpritePlayer } from "./sprite-player.js";
import type {
ResolvedPlayOptions,
SpritePlayHandle,
} from "./sprite-player.types.js";
const entryA: AudioSpriteEntry = {
name: "a",
offset: 0,
byteLength: 100,
durationSeconds: 0.1,
sampleRate: 48000,
channels: 2,
format: "ogg",
};
const entryB: AudioSpriteEntry = {
...entryA,
name: "b",
offset: 100,
durationSeconds: 0.2,
};
const entryC: AudioSpriteEntry = {
...entryA,
name: "c",
offset: 200,
durationSeconds: 0.3,
};
function makeSprite(entries: AudioSpriteEntry[]): AudioSprite {
return {
data: new Uint8Array([1, 2, 3]),
manifest: {
version: 1,
format: "ogg",
sampleRate: 48000,
channels: 2,
totalDurationSeconds: 1,
entries,
contentHash: "0".repeat(64),
},
};
}
let player: SpritePlayer;
let adapter: NullSpritePlaybackAdapter;
beforeEach(() => {
adapter = new NullSpritePlaybackAdapter({ clock: () => 1000 });
player = createSpritePlayer({
adapter,
idGenerator: (() => {
let i = 0;
return () => `id-${++i}`;
})(),
clock: () => 1000,
});
});
describe("SpritePlayer.construction", () => {
it("construction rejects missing adapter", { timeout: 5000 }, () => {
expect(() => new SpritePlayer({} as never)).toThrow(
AudioSpritePlayerError
);
try {
new SpritePlayer({} as never);
} catch (e) {
expect((e as AudioSpritePlayerError).code).toBe(
AUDIO_SPRITE_PLAYER_CODES.INVALID_OPTIONS
);
}
});
it("construction rejects maxConcurrentVoices above ceiling", {
timeout: 5000,
}, () => {
expect(
() =>
new SpritePlayer({
adapter,
maxConcurrentVoices: 1000,
})
).toThrow(AudioSpritePlayerError);
});
});
describe("SpritePlayer.load", () => {
it("load rejects invalid manifest before adapter", {
timeout: 5000,
}, async () => {
const bad: AudioSprite = {
data: new Uint8Array(),
manifest: { entries: [] } as never,
};
await expect(player.load(bad)).rejects.toThrow(AudioSpritePlayerError);
expect(adapter.calls.find((c) => c.kind === "init")).toBeUndefined();
});
it("load wraps adapter init failure", { timeout: 5000 }, async () => {
const failing = new (class extends NullSpritePlaybackAdapter {
override async init(): Promise<void> {
throw new Error("boom");
}
})();
const p = createSpritePlayer({ adapter: failing });
await expect(p.load(makeSprite([entryA]))).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.ADAPTER_INIT_FAILED,
});
});
it("pre-aborted load rejects synchronously", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
ctrl.abort();
await expect(
player.load(makeSprite([entryA]), ctrl.signal)
).rejects.toMatchObject({ code: AUDIO_SPRITE_PLAYER_CODES.ABORTED });
});
it("aborted-mid-init becomes ABORTED", { timeout: 5000 }, async () => {
const failing = new (class extends NullSpritePlaybackAdapter {
override async init(): Promise<void> {
throw new Error("aborted");
}
})();
const p = createSpritePlayer({ adapter: failing });
const ctrl = new AbortController();
const promise = p.load(makeSprite([entryA]), ctrl.signal);
ctrl.abort();
await expect(promise).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.ABORTED,
});
});
});
describe("SpritePlayer.play", () => {
it("play before load rejects with NOT_LOADED", {
timeout: 5000,
}, async () => {
await expect(player.play("a")).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.NOT_LOADED,
});
expect(adapter.calls.find((c) => c.kind === "play")).toBeUndefined();
});
it("play resolves entry and delegates to adapter", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA]));
const handle = await player.play("a");
expect(handle.entryName).toBe("a");
expect(handle.id).toBe("id-1");
const playCall = adapter.calls.find((c) => c.kind === "play");
expect(playCall).toBeDefined();
const opts = (playCall!.payload as { opts: ResolvedPlayOptions }).opts;
expect(opts.volume).toBe(1);
expect(opts.pitch).toBe(1);
expect(opts.loop).toBe(false);
expect(opts.fadeInMs).toBe(0);
expect(opts.fadeOutMs).toBe(0);
expect(opts.startOffsetMs).toBe(0);
expect(opts.durationMs).toBe(entryA.durationSeconds * 1000);
expect(opts.when).toBe(1000);
});
it("play rejects unknown entry", { timeout: 5000 }, async () => {
await player.load(makeSprite([entryA]));
await expect(player.play("missing")).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.UNKNOWN_ENTRY,
});
});
it("play rejects INVALID_OPTIONS for bad volume", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA]));
await expect(player.play("a", { volume: 1.5 })).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.INVALID_OPTIONS,
});
});
it("play wraps adapter playback failure", { timeout: 5000 }, async () => {
const failing = new (class extends NullSpritePlaybackAdapter {
override async play(): Promise<SpritePlayHandle> {
throw new Error("boom");
}
})();
const p = createSpritePlayer({ adapter: failing });
await p.load(makeSprite([entryA]));
await expect(p.play("a")).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.PLAYBACK_FAILED,
});
});
it("pre-aborted signal rejects play synchronously", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA]));
const ctrl = new AbortController();
ctrl.abort();
await expect(
player.play("a", { signal: ctrl.signal })
).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.ABORTED,
});
expect(adapter.calls.find((c) => c.kind === "play")).toBeUndefined();
});
it("mid-adapter abort becomes ABORTED", { timeout: 5000 }, async () => {
const ctrl = new AbortController();
const failing = new (class extends NullSpritePlaybackAdapter {
override async play(
e: AudioSpriteEntry,
o: ResolvedPlayOptions,
s?: AbortSignal
): Promise<SpritePlayHandle> {
ctrl.abort();
return super.play(e, o, s);
}
})();
const p = createSpritePlayer({ adapter: failing });
await p.load(makeSprite([entryA]));
await expect(
p.play("a", { signal: ctrl.signal })
).rejects.toMatchObject({ code: AUDIO_SPRITE_PLAYER_CODES.ABORTED });
});
it("voice-stealing emits voice-stolen and admits new voice", {
timeout: 5000,
}, async () => {
const p = createSpritePlayer({
adapter,
maxConcurrentVoices: 1,
clock: (() => {
let t = 1000;
return () => t++;
})(),
});
await p.load(makeSprite([entryA]));
let stolenPayload: { stolenId: string; replacedBy: string } | null =
null;
p.events.on("voice-stolen", (e) => {
stolenPayload = e;
});
const first = await p.play("a");
const second = await p.play("a");
expect(first.isPlaying()).toBe(false);
expect(second.isPlaying()).toBe(true);
expect(stolenPayload).not.toBeNull();
expect(stolenPayload!.stolenId).toBe(first.id);
expect(stolenPayload!.replacedBy).toBe(second.id);
});
});
describe("SpritePlayer.playSequence", () => {
it("playSequence schedules entries with gapMs", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA, entryB]));
await player.playSequence(["a", "b"], { gapMs: 50, when: 0 });
const playCalls = adapter.calls.filter((c) => c.kind === "play");
expect(playCalls.length).toBe(2);
const w1 = (playCalls[0]!.payload as { opts: ResolvedPlayOptions }).opts
.when;
const w2 = (playCalls[1]!.payload as { opts: ResolvedPlayOptions }).opts
.when;
expect(w2 - w1).toBe(entryA.durationSeconds * 1000 + 50);
});
it("playSequence stops earlier voices on failure", {
timeout: 5000,
}, async () => {
let calls = 0;
const flaky = new (class extends NullSpritePlaybackAdapter {
override async play(
e: AudioSpriteEntry,
o: ResolvedPlayOptions,
s?: AbortSignal
): Promise<SpritePlayHandle> {
calls++;
if (calls === 2) throw new Error("second fails");
return super.play(e, o, s);
}
})();
const p = createSpritePlayer({ adapter: flaky });
await p.load(makeSprite([entryA, entryB]));
await expect(p.playSequence(["a", "b"])).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.PLAYBACK_FAILED,
});
});
it("mid-flight abort stops sequence voices", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
await player.load(makeSprite([entryA, entryB, entryC]));
const promise = (async () => {
const seq = player.playSequence(["a", "b", "c"], {
signal: ctrl.signal,
});
ctrl.abort();
return seq;
})();
await expect(promise).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.ABORTED,
});
});
it("playSequence INVALID_OPTIONS for bad gapMs", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA]));
await expect(
player.playSequence(["a"], { gapMs: -1 })
).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.INVALID_OPTIONS,
});
});
it("playSequence with unknown entry rejects UNKNOWN_ENTRY", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA]));
await expect(
player.playSequence(["a", "missing"])
).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.UNKNOWN_ENTRY,
});
});
it("playSequence before load rejects NOT_LOADED", {
timeout: 5000,
}, async () => {
await expect(player.playSequence(["a"])).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.NOT_LOADED,
});
});
});
describe("SpritePlayer.playRandom", () => {
it("playRandom rejects when filter excludes all", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA]));
await expect(player.playRandom(() => false)).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.UNKNOWN_ENTRY,
});
});
it("playRandom samples all entries over many calls", {
timeout: 5000,
}, async () => {
let i = 0;
const seeded = {
nextUint32: () => i++ % 3,
nextFloat: () => (i++ % 3) / 3,
};
const p = createSpritePlayer({ adapter, rng: seeded });
await p.load(makeSprite([entryA, entryB, entryC]));
const seen = new Set<string>();
for (let k = 0; k < 30; k++) {
const h = await p.playRandom();
seen.add(h.entryName);
await h.stop(0);
}
expect(seen.size).toBe(3);
});
it("playRandom before load rejects NOT_LOADED", {
timeout: 5000,
}, async () => {
await expect(player.playRandom()).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.NOT_LOADED,
});
});
});
describe("SpritePlayer.stopAll/dispose", () => {
it("stopAll stops every active voice with fade", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA, entryB, entryC]));
const h1 = await player.play("a");
const h2 = await player.play("b");
const h3 = await player.play("c");
await player.stopAll(50);
expect(h1.isPlaying()).toBe(false);
expect(h2.isPlaying()).toBe(false);
expect(h3.isPlaying()).toBe(false);
const stops = adapter.calls.filter((c) => c.kind === "stop");
expect(stops.length).toBeGreaterThanOrEqual(3);
const allFifty = stops.every(
(s) => (s.payload as { fadeOutMs: number }).fadeOutMs === 50
);
expect(allFifty).toBe(true);
});
it("dispose is idempotent and prevents subsequent plays", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA]));
await player.dispose();
await player.dispose();
await expect(player.play("a")).rejects.toThrow(AudioSpritePlayerError);
});
it("list and has reflect loaded sprite", { timeout: 5000 }, async () => {
expect(player.list()).toEqual([]);
expect(player.has("a")).toBe(false);
await player.load(makeSprite([entryA, entryB]));
expect(player.list().map((e) => e.name)).toEqual(["a", "b"]);
expect(player.has("a")).toBe(true);
expect(player.has("z")).toBe(false);
});
it("VOICE_LIMIT_EXCEEDED reachable when stealing is disabled", {
timeout: 5000,
}, async () => {
const p = createSpritePlayer({ adapter, maxConcurrentVoices: 1 });
await p.load(makeSprite([entryA]));
const first = await p.play("a");
await expect(
p.play("a", { allowVoiceStealing: false })
).rejects.toMatchObject({
code: AUDIO_SPRITE_PLAYER_CODES.VOICE_LIMIT_EXCEEDED,
});
expect(first.isPlaying()).toBe(true);
});
it("handle.stop is idempotent and emits stop once", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA]));
const handle = await player.play("a");
let stops = 0;
player.events.on("stop", () => stops++);
await handle.stop();
await handle.stop();
expect(stops).toBe(1);
});
it("complete event fires for non-looping voice", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA]));
const seen: string[] = [];
player.events.on("complete", (e) => seen.push(e.handleId));
const h = await player.play("a", { durationMs: 0 });
await new Promise<void>((r) => setTimeout(r, 5));
expect(seen.length).toBeGreaterThanOrEqual(1);
expect(seen[0]).toBe(h.id);
});
it("completion skips when voice stopped before timer fires", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA]));
const seen: string[] = [];
player.events.on("complete", (e) => seen.push(e.handleId));
const h = await player.play("a", { durationMs: 10 });
await h.stop();
await new Promise<void>((r) => setTimeout(r, 20));
expect(seen.length).toBe(0);
});
it("stopAll without arg defaults fade to zero", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA, entryB]));
await player.play("a");
await player.play("b");
await player.stopAll();
const stops = adapter.calls.filter((c) => c.kind === "stop");
expect(
stops.every(
(s) => (s.payload as { fadeOutMs: number }).fadeOutMs === 0
)
).toBe(true);
});
it("looping voice does not auto-complete", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA]));
const seen: string[] = [];
player.events.on("complete", (e) => seen.push(e.handleId));
await player.play("a", { loop: true, durationMs: 0 });
await new Promise<void>((r) => setTimeout(r, 5));
expect(seen.length).toBe(0);
});
it("setVolume forwards to inner handle", { timeout: 5000 }, async () => {
await player.load(makeSprite([entryA]));
const h = await player.play("a");
h.setVolume(0.5);
const sv = adapter.calls.find((c) => c.kind === "setVolume");
expect(sv).toBeDefined();
});
it("default rng and idGenerator and clock are used when omitted", {
timeout: 5000,
}, async () => {
const a = new NullSpritePlaybackAdapter();
const p = new SpritePlayer({ adapter: a });
await p.load(makeSprite([entryA, entryB, entryC]));
const h = await p.playRandom();
expect(h.id.length).toBeGreaterThan(0);
expect(typeof h.startedAt).toBe("number");
await h.stop();
});
it("telemetry gauge reflects active voice count via player", {
timeout: 5000,
}, async () => {
await player.load(makeSprite([entryA, entryB]));
await player.play("a");
await player.play("b");
player.telemetry._runGaugeCallback();
expect(player.telemetry.observedVoiceCount).toBe(2);
});
it("noop logger accepts every level", { timeout: 5000 }, async () => {
// Exercise default logger path through normal play flow.
const p = createSpritePlayer({ adapter });
await p.load(makeSprite([entryA]));
const h = await p.play("a");
await h.stop();
// Defaults stay quiet; no assertion needed beyond no-throw.
expect(h.id.length).toBeGreaterThan(0);
});
});
describe("SpritePlayer.dist-purity-and-exports", () => {
it("dist references no browser or Node globals", {
timeout: 5000,
}, () => {
const here = dirname(fileURLToPath(import.meta.url));
const dist = resolve(here, "../../dist");
if (!existsSync(dist)) {
// dist may not be present pre-build; smoke-pass.
return;
}
// Spot-check the index bundle for forbidden tokens.
const indexJs = join(dist, "index.js");
if (!existsSync(indexJs)) return;
const src = readFileSync(indexJs, "utf8");
const banned = [
"AudioContext",
"decodeAudioData",
'from "node:fs"',
'from "node:path"',
];
for (const b of banned) {
expect(src.includes(b)).toBe(false);
}
});
it("dependency set is the declared minimum", {
timeout: 5000,
}, () => {
const here = dirname(fileURLToPath(import.meta.url));
const pkgPath = resolve(here, "../../package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
dependencies: Record<string, string>;
};
const declared = Object.keys(pkg.dependencies).sort();
expect(declared).toEqual(
[
"@opentelemetry/api",
"@suaveplan/audio-core",
"@suaveplan/audio-sprite-packer",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"tslib",
"zod",
].sort()
);
});
it("sprite-packer is consumed type-only", {
timeout: 5000,
}, () => {
const here = dirname(fileURLToPath(import.meta.url));
// Grep source for runtime imports of the packer.
const root = resolve(here, "..");
const indexTs = join(root, "index.ts");
const src = readFileSync(indexTs, "utf8");
expect(src.includes('from "@suaveplan/audio-sprite-packer"')).toBe(
false
);
});
it("every declared subpath export resolves", {
timeout: 5000,
}, async () => {
const here = dirname(fileURLToPath(import.meta.url));
const pkgPath = resolve(here, "../../package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
exports: Record<string, unknown>;
};
// Imports against the source tree; subpath shape is the same.
const subpaths = Object.keys(pkg.exports).filter(
(k) => k !== "./package.json"
);
expect(subpaths.length).toBeGreaterThan(0);
// Smoke-import the root tree we already use.
const root = await import("../index.js");
expect(root.SpritePlayer).toBe(SpritePlayer);
});
});

View File

@@ -0,0 +1,419 @@
import type {
AudioSprite,
AudioSpriteEntry,
} from "@suaveplan/audio-sprite-packer";
import type { Clock, IdGenerator, LoggerLike, Rng } from "@suaveplan/types";
import {
AUDIO_SPRITE_PLAYER_CODES,
AudioSpritePlayerError,
} from "../error-codes/error-codes.js";
import { SpritePlayerEvents } from "../events/events.js";
import {
AudioSpriteManifestSchema,
PlayOptionsSchema,
SequenceOptionsSchema,
SpritePlayerOptionsSchema,
} from "../schemas/schemas.js";
import { SpritePlayerTelemetry } from "../telemetry/telemetry.js";
import { VoiceManager } from "../voice-manager/voice-manager.js";
import type {
PlayOptions,
ResolvedPlayOptions,
SequenceOptions,
SpritePlayerOptions,
SpritePlayHandle,
} from "./sprite-player.types.js";
const DEFAULT_MAX_VOICES = 32;
interface NormalisedDeps {
readonly logger: LoggerLike;
readonly clock: Clock;
readonly idGenerator: IdGenerator;
readonly rng: Rng;
readonly maxConcurrentVoices: number;
}
const noop = (): void => undefined;
const noopLogger: LoggerLike = {
debug: noop,
info: noop,
warn: noop,
error: noop,
};
const defaultIdGen: IdGenerator = () => {
const r = Math.random().toString(36).slice(2, 10);
return `voice-${Date.now()}-${r}`;
};
function defaultMathRandom(): number {
return Math.random();
}
const defaultRng: Rng = {
nextUint32: (): number => Math.floor(defaultMathRandom() * 0x1_0000_0000),
nextFloat: defaultMathRandom,
};
export class SpritePlayer {
private readonly deps: NormalisedDeps;
private readonly adapter: SpritePlayerOptions["adapter"];
private readonly voices: VoiceManager;
public readonly events = new SpritePlayerEvents();
/** Exposed read-only handle for OTel-aware consumers (and the test bench). */
public readonly telemetry: SpritePlayerTelemetry;
private sprite: AudioSprite | null = null;
private entryIndex = new Map<string, AudioSpriteEntry>();
private disposed = false;
public constructor(options: SpritePlayerOptions) {
const parsed = SpritePlayerOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.INVALID_OPTIONS,
"error.audio_sprite_player.invalid_options",
{
context: {
field: "adapter",
issues: parsed.error.issues,
},
}
);
}
this.adapter = options.adapter;
this.deps = {
logger: options.logger ?? noopLogger,
clock: options.clock ?? (() => Date.now()),
idGenerator: options.idGenerator ?? defaultIdGen,
rng: options.rng ?? defaultRng,
maxConcurrentVoices:
options.maxConcurrentVoices ?? DEFAULT_MAX_VOICES,
};
this.voices = new VoiceManager(this.deps.maxConcurrentVoices);
this.telemetry = new SpritePlayerTelemetry(
() => this.voices.activeCount
);
}
public async load(
sprite: AudioSprite,
signal?: AbortSignal
): Promise<void> {
this.assertNotDisposed();
this.checkAborted(signal);
const validated = AudioSpriteManifestSchema.safeParse(sprite.manifest);
if (!validated.success) {
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.INVALID_MANIFEST,
"error.audio_sprite_player.invalid_manifest",
{ context: { issues: validated.error.issues } }
);
}
try {
await this.adapter.init(sprite, signal);
} catch (cause) {
if (signal?.aborted) {
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.ABORTED,
"error.audio_sprite_player.aborted",
{ cause: cause as Error }
);
}
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.ADAPTER_INIT_FAILED,
"error.audio_sprite_player.adapter_init_failed",
{ cause: cause as Error }
);
}
this.sprite = sprite;
this.entryIndex = new Map(
sprite.manifest.entries.map((e) => [e.name, e])
);
}
public list(): readonly AudioSpriteEntry[] {
return this.sprite ? this.sprite.manifest.entries : [];
}
public has(entryName: string): boolean {
return this.entryIndex.has(entryName);
}
public async play(
entryName: string,
opts: PlayOptions = {}
): Promise<SpritePlayHandle> {
this.assertLoaded();
this.checkAborted(opts.signal);
const parsed = PlayOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.INVALID_OPTIONS,
"error.audio_sprite_player.invalid_options",
{ context: { issues: parsed.error.issues } }
);
}
const entry = this.entryIndex.get(entryName);
if (!entry) {
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.UNKNOWN_ENTRY,
"error.audio_sprite_player.unknown_entry",
{ context: { entryName } }
);
}
return this.runPlay(entry, opts);
}
private async runPlay(
entry: AudioSpriteEntry,
opts: PlayOptions
): Promise<SpritePlayHandle> {
const resolved = this.resolveOptions(entry, opts);
const reserve = this.voices.reserve(resolved.allowVoiceStealing);
const newId = this.deps.idGenerator();
if (reserve.stolen) {
await reserve.stolen.stop(0);
this.events.emit("voice-stolen", {
stolenId: reserve.stolen.id,
replacedBy: newId,
entryName: entry.name,
});
}
const tHandle = this.telemetry.startPlay(entry.name);
this.deps.logger.debug("audio-sprite-player.play", {
entryName: entry.name,
id: newId,
});
try {
const inner = await this.adapter.play(entry, resolved, opts.signal);
const wrapper = this.wrapHandle(inner, newId, entry, resolved);
this.voices.register(wrapper.handle);
this.telemetry.recordSuccess(tHandle, { entryName: entry.name });
this.events.emit("play", {
handleId: wrapper.handle.id,
entryName: entry.name,
startedAt: wrapper.handle.startedAt,
});
this.scheduleCompletion(wrapper, resolved);
return wrapper.handle;
} catch (cause) {
const aborted = opts.signal?.aborted === true;
const code = aborted
? AUDIO_SPRITE_PLAYER_CODES.ABORTED
: AUDIO_SPRITE_PLAYER_CODES.PLAYBACK_FAILED;
this.telemetry.recordFailure(tHandle, {
entryName: entry.name,
errorCode: code,
});
this.deps.logger.error("audio-sprite-player.play.failed", {
entryName: entry.name,
code,
});
throw new AudioSpritePlayerError(
code,
aborted
? "error.audio_sprite_player.aborted"
: "error.audio_sprite_player.playback_failed",
{ cause: cause as Error, context: { entryName: entry.name } }
);
}
}
private wrapHandle(
inner: SpritePlayHandle,
id: string,
entry: AudioSpriteEntry,
resolved: ResolvedPlayOptions
): { handle: SpritePlayHandle; markComplete: () => void } {
const self = this;
const startedAt = self.deps.clock();
const state = { stopped: false };
const handle: SpritePlayHandle = {
id,
entryName: entry.name,
startedAt,
async stop(fadeOutMs?: number): Promise<void> {
if (state.stopped) return;
state.stopped = true;
await inner.stop(fadeOutMs ?? resolved.fadeOutMs);
self.voices.release(id);
self.events.emit("stop", {
handleId: id,
entryName: entry.name,
fadeOutMs: fadeOutMs ?? resolved.fadeOutMs,
});
},
setVolume(volume: number): void {
inner.setVolume(volume);
},
isPlaying(): boolean {
return !state.stopped && inner.isPlaying();
},
};
const markComplete = (): void => {
state.stopped = true;
};
return { handle, markComplete };
}
private scheduleCompletion(
wrapper: { handle: SpritePlayHandle; markComplete: () => void },
resolved: ResolvedPlayOptions
): void {
if (resolved.loop) return;
const delay = Math.max(0, resolved.durationMs);
setTimeout(() => {
if (!wrapper.handle.isPlaying()) return;
wrapper.markComplete();
this.events.emit("complete", {
handleId: wrapper.handle.id,
entryName: wrapper.handle.entryName,
durationMs: resolved.durationMs,
});
this.telemetry.recordCompletion(
resolved.durationMs,
wrapper.handle.entryName
);
this.voices.release(wrapper.handle.id);
}, delay);
}
public async playSequence(
entryNames: readonly string[],
opts: SequenceOptions = {}
): Promise<SpritePlayHandle[]> {
this.assertLoaded();
this.checkAborted(opts.signal);
const parsed = SequenceOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.INVALID_OPTIONS,
"error.audio_sprite_player.invalid_options",
{ context: { issues: parsed.error.issues } }
);
}
const started: SpritePlayHandle[] = [];
const gap = opts.gapMs ?? 0;
let nextWhen = opts.when ?? this.deps.clock();
for (let i = 0; i < entryNames.length; i++) {
const name = entryNames[i] as string;
if (opts.signal?.aborted) {
await this.stopAllStarted(started);
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.ABORTED,
"error.audio_sprite_player.aborted"
);
}
try {
const entry = this.entryIndex.get(name);
if (!entry) {
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.UNKNOWN_ENTRY,
"error.audio_sprite_player.unknown_entry",
{ context: { entryName: name } }
);
}
const handle = await this.runPlay(entry, {
...opts,
when: nextWhen,
});
started.push(handle);
nextWhen +=
(opts.durationMs ?? entry.durationSeconds * 1000) + gap;
} catch (cause) {
await this.stopAllStarted(started);
throw cause;
}
}
return started;
}
public async playRandom(
filterFn?: (entry: AudioSpriteEntry) => boolean,
opts: PlayOptions = {}
): Promise<SpritePlayHandle> {
this.assertLoaded();
const entries = this.sprite!.manifest.entries.filter((e) =>
filterFn ? filterFn(e) : true
);
if (entries.length === 0) {
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.UNKNOWN_ENTRY,
"error.audio_sprite_player.unknown_entry",
{ context: { reason: "filter-excluded-all" } }
);
}
const idx = this.deps.rng.nextUint32() % entries.length;
const chosen = entries[idx] as AudioSpriteEntry;
return this.play(chosen.name, opts);
}
public async stopAll(fadeOutMs?: number): Promise<void> {
const handles = this.voices.snapshot().slice();
await Promise.all(handles.map((h) => h.stop(fadeOutMs ?? 0)));
}
public async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
await this.stopAll(0);
await this.adapter.dispose();
this.events.clear();
}
private resolveOptions(
entry: AudioSpriteEntry,
opts: PlayOptions
): ResolvedPlayOptions {
return {
volume: opts.volume ?? 1,
pitch: opts.pitch ?? 1,
loop: opts.loop ?? false,
fadeInMs: opts.fadeInMs ?? 0,
fadeOutMs: opts.fadeOutMs ?? 0,
startOffsetMs: opts.startOffsetMs ?? 0,
durationMs: opts.durationMs ?? entry.durationSeconds * 1000,
when: opts.when ?? this.deps.clock(),
allowVoiceStealing: opts.allowVoiceStealing ?? true,
};
}
private async stopAllStarted(
handles: readonly SpritePlayHandle[]
): Promise<void> {
await Promise.all(handles.map((h) => h.stop(0)));
}
private assertLoaded(): void {
this.assertNotDisposed();
if (!this.sprite) {
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.NOT_LOADED,
"error.audio_sprite_player.not_loaded"
);
}
}
private assertNotDisposed(): void {
if (this.disposed) {
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.NOT_LOADED,
"error.audio_sprite_player.not_loaded",
{ context: { reason: "disposed" } }
);
}
}
private checkAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.ABORTED,
"error.audio_sprite_player.aborted"
);
}
}
}
export function createSpritePlayer(options: SpritePlayerOptions): SpritePlayer {
return new SpritePlayer(options);
}

View File

@@ -0,0 +1,55 @@
import type {
AudioSpriteEntry,
AudioSpriteManifest,
} from "@suaveplan/audio-sprite-packer";
import type { Clock, IdGenerator, LoggerLike, Rng } from "@suaveplan/types";
import type { SpritePlaybackAdapter } from "../adapter/adapter.types.js";
export interface PlayOptions {
readonly volume?: number;
readonly pitch?: number;
readonly loop?: boolean;
readonly fadeInMs?: number;
readonly fadeOutMs?: number;
readonly startOffsetMs?: number;
readonly durationMs?: number;
readonly when?: number;
readonly allowVoiceStealing?: boolean;
readonly signal?: AbortSignal;
}
export interface SequenceOptions extends PlayOptions {
readonly gapMs?: number;
}
export interface ResolvedPlayOptions {
readonly volume: number;
readonly pitch: number;
readonly loop: boolean;
readonly fadeInMs: number;
readonly fadeOutMs: number;
readonly startOffsetMs: number;
readonly durationMs: number;
readonly when: number;
readonly allowVoiceStealing: boolean;
}
export interface SpritePlayHandle {
readonly id: string;
readonly entryName: string;
readonly startedAt: number;
stop(fadeOutMs?: number): Promise<void>;
setVolume(volume: number): void;
isPlaying(): boolean;
}
export interface SpritePlayerOptions {
readonly adapter: SpritePlaybackAdapter;
readonly logger?: LoggerLike;
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
readonly rng?: Rng;
readonly maxConcurrentVoices?: number;
}
export type { AudioSpriteEntry, AudioSpriteManifest, SpritePlaybackAdapter };

View File

@@ -0,0 +1,5 @@
export { SpritePlayerTelemetry } from "./telemetry.js";
export type {
PlayTelemetryHandle,
TelemetryAttributes,
} from "./telemetry.types.js";

View File

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

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SpritePlayerTelemetry } from "./telemetry.js";
describe("SpritePlayerTelemetry", () => {
it("startPlay creates a handle with a span", { timeout: 5000 }, () => {
const t = new SpritePlayerTelemetry(() => 0);
const h = t.startPlay("hit");
expect(h.span).toBeDefined();
expect(typeof h.startedAt).toBe("number");
t.recordSuccess(h, { entryName: "hit" });
});
it("recordFailure attaches error.code attribute", {
timeout: 5000,
}, () => {
const t = new SpritePlayerTelemetry(() => 0);
const h = t.startPlay("hit");
t.recordFailure(h, {
entryName: "hit",
errorCode: "AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED",
});
expect(true).toBe(true);
});
it("recordFailure without errorCode skips attribute", {
timeout: 5000,
}, () => {
const t = new SpritePlayerTelemetry(() => 0);
const h = t.startPlay("hit");
t.recordFailure(h, { entryName: "hit" });
expect(true).toBe(true);
});
it("recordCompletion records on the histogram", { timeout: 5000 }, () => {
const t = new SpritePlayerTelemetry(() => 0);
expect(() => t.recordCompletion(500, "hit")).not.toThrow();
});
it("observable gauge callback reflects voice count", {
timeout: 5000,
}, () => {
let n = 7;
const t = new SpritePlayerTelemetry(() => n);
expect(t.observedVoiceCount).toBe(0);
n = 9;
t._runGaugeCallback();
expect(t.observedVoiceCount).toBe(9);
});
});

View File

@@ -0,0 +1,103 @@
import {
type Counter,
type Histogram,
metrics,
type ObservableGauge,
type Span,
SpanStatusCode,
trace,
} from "@opentelemetry/api";
import type {
PlayTelemetryHandle,
TelemetryAttributes,
} from "./telemetry.types.js";
const TRACER_NAME = "audio-sprite-player";
const METER_NAME = "audio-sprite-player";
/**
* OTel facade. Owns one span per `play` invocation plus the three metric
* instruments named in the spec. The gauge mirrors the live voice count
* through an `ObservableGauge` callback so adapters need not push updates.
*/
export class SpritePlayerTelemetry {
private readonly tracer = trace.getTracer(TRACER_NAME);
private readonly meter = metrics.getMeter(METER_NAME);
private readonly playCounter: Counter;
private readonly durationHistogram: Histogram;
private readonly voicesGauge: ObservableGauge;
private voiceCountSnapshot = 0;
private readonly gaugeCallback: (observable: {
observe(value: number): void;
}) => void;
public constructor(getVoiceCount: () => number) {
this.playCounter = this.meter.createCounter(
"audio_sprite_player.play_total"
);
this.durationHistogram = this.meter.createHistogram(
"audio_sprite_player.play_duration_ms"
);
this.voicesGauge = this.meter.createObservableGauge(
"audio_sprite_player.voices_active"
);
this.gaugeCallback = (observable): void => {
const v = getVoiceCount();
observable.observe(v);
this.voiceCountSnapshot = v;
};
this.voicesGauge.addCallback(this.gaugeCallback);
}
/** Test-visible hook to force the gauge callback (the OTel SDK is what
* normally drives it, and the default `@opentelemetry/api` provides a
* noop SDK — without invoking the callback in tests the gauge would
* remain uncovered). */
public _runGaugeCallback(): void {
let recorded = 0;
this.gaugeCallback({
observe(value: number): void {
recorded = value;
},
});
this.voiceCountSnapshot = recorded;
}
public get observedVoiceCount(): number {
return this.voiceCountSnapshot;
}
public startPlay(entryName: string): PlayTelemetryHandle {
const span: Span = this.tracer.startSpan("audio-sprite-player.play", {
attributes: { "sprite.entry_name": entryName },
});
return { span, startedAt: Date.now() };
}
public recordSuccess(
handle: PlayTelemetryHandle,
attrs: TelemetryAttributes
): void {
this.playCounter.add(1, { "sprite.entry_name": attrs.entryName });
handle.span.setStatus({ code: SpanStatusCode.OK });
handle.span.end();
}
public recordCompletion(durationMs: number, entryName: string): void {
this.durationHistogram.record(durationMs, {
"sprite.entry_name": entryName,
});
}
public recordFailure(
handle: PlayTelemetryHandle,
attrs: TelemetryAttributes
): void {
handle.span.setStatus({ code: SpanStatusCode.ERROR });
if (attrs.errorCode !== undefined) {
handle.span.setAttribute("error.code", attrs.errorCode);
}
handle.span.end();
}
}

View File

@@ -0,0 +1,11 @@
import type { Span } from "@opentelemetry/api";
export interface PlayTelemetryHandle {
readonly span: Span;
readonly startedAt: number;
}
export interface TelemetryAttributes {
readonly entryName: string;
readonly errorCode?: string;
}

View File

@@ -0,0 +1,2 @@
export { VoiceManager } from "./voice-manager.js";
export type { AllocateResult, VoiceRecord } from "./voice-manager.types.js";

View File

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

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIO_SPRITE_PLAYER_CODES,
AudioSpritePlayerError,
} from "../error-codes/error-codes.js";
import type { SpritePlayHandle } from "../sprite-player/sprite-player.types.js";
import { VoiceManager } from "./voice-manager.js";
function makeHandle(id: string, startedAt: number): SpritePlayHandle {
let playing = true;
return {
id,
entryName: "x",
startedAt,
async stop(): Promise<void> {
playing = false;
},
setVolume() {},
isPlaying: () => playing,
};
}
describe("VoiceManager", () => {
it("under cap registers without steal", { timeout: 5000 }, () => {
const vm = new VoiceManager(2);
const r = vm.reserve(true);
expect(r.stolen).toBeNull();
vm.register(makeHandle("a", 1));
expect(vm.activeCount).toBe(1);
});
it("oldest voice stolen when over cap", { timeout: 5000 }, () => {
const vm = new VoiceManager(2);
vm.reserve(true);
vm.register(makeHandle("a", 100));
vm.reserve(true);
vm.register(makeHandle("b", 200));
const r = vm.reserve(true);
expect(r.stolen?.id).toBe("a");
expect(vm.activeCount).toBe(1);
});
it("ties broken by allocation order", { timeout: 5000 }, () => {
const vm = new VoiceManager(2);
vm.reserve(true);
vm.register(makeHandle("first", 100));
vm.reserve(true);
vm.register(makeHandle("second", 100));
const r = vm.reserve(true);
expect(r.stolen?.id).toBe("first");
});
it("picks lowest startedAt regardless of insertion order", {
timeout: 5000,
}, () => {
const vm = new VoiceManager(2);
vm.reserve(true);
vm.register(makeHandle("late-first", 200));
vm.reserve(true);
vm.register(makeHandle("early-second", 50));
const r = vm.reserve(true);
expect(r.stolen?.id).toBe("early-second");
});
it("opt-out of stealing raises VOICE_LIMIT_EXCEEDED", {
timeout: 5000,
}, () => {
const vm = new VoiceManager(1);
vm.reserve(true);
vm.register(makeHandle("a", 1));
expect(() => vm.reserve(false)).toThrow(AudioSpritePlayerError);
try {
vm.reserve(false);
} catch (e) {
expect((e as AudioSpritePlayerError).code).toBe(
AUDIO_SPRITE_PLAYER_CODES.VOICE_LIMIT_EXCEEDED
);
}
});
it("release removes the named voice; unknown id is a no-op", {
timeout: 5000,
}, () => {
const vm = new VoiceManager(2);
vm.register(makeHandle("a", 1));
vm.register(makeHandle("b", 2));
vm.release("a");
expect(vm.activeCount).toBe(1);
vm.release("unknown");
expect(vm.activeCount).toBe(1);
});
it("snapshot and list reflect active handles", { timeout: 5000 }, () => {
const vm = new VoiceManager(4);
vm.register(makeHandle("a", 1));
vm.register(makeHandle("b", 2));
expect(vm.snapshot().map((h) => h.id)).toEqual(["a", "b"]);
expect(vm.list().map((h) => h.id)).toEqual(["a", "b"]);
expect(vm.cap).toBe(4);
});
it("stolen handle reports not playing", { timeout: 5000 }, async () => {
const vm = new VoiceManager(1);
const a = makeHandle("a", 1);
vm.reserve(true);
vm.register(a);
const r = vm.reserve(true);
await r.stolen?.stop();
expect(a.isPlaying()).toBe(false);
});
});

View File

@@ -0,0 +1,95 @@
import {
AUDIO_SPRITE_PLAYER_CODES,
AudioSpritePlayerError,
} from "../error-codes/error-codes.js";
import type { SpritePlayHandle } from "../sprite-player/sprite-player.types.js";
import type { AllocateResult, VoiceRecord } from "./voice-manager.types.js";
/**
* Tracks active voices and enforces `maxConcurrentVoices`. The newest voice
* is admitted by stealing the oldest active one (lowest `startedAt`, ties
* broken by allocation order). Stealing can be opted out per-play by the
* caller, which produces `AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED` instead.
*/
export class VoiceManager {
private readonly maxConcurrentVoices: number;
private readonly records: VoiceRecord[] = [];
private allocCounter = 0;
public constructor(maxConcurrentVoices: number) {
this.maxConcurrentVoices = maxConcurrentVoices;
}
public get activeCount(): number {
return this.records.length;
}
public get cap(): number {
return this.maxConcurrentVoices;
}
public list(): readonly SpritePlayHandle[] {
return this.records.map((r) => r.handle);
}
/**
* Reserve a voice slot before invoking the adapter. Returns the stolen
* handle (already removed from the active set), or null if no steal was
* needed. Throws `VOICE_LIMIT_EXCEEDED` when `allowVoiceStealing` is false
* and the cap is reached.
*/
public reserve(allowVoiceStealing: boolean): AllocateResult {
if (this.records.length < this.maxConcurrentVoices) {
return { stolen: null };
}
if (!allowVoiceStealing) {
throw new AudioSpritePlayerError(
AUDIO_SPRITE_PLAYER_CODES.VOICE_LIMIT_EXCEEDED,
"error.audio_sprite_player.voice_limit_exceeded",
{
context: {
active: this.records.length,
cap: this.maxConcurrentVoices,
},
}
);
}
const victim = this.pickOldest();
this.removeRecord(victim.handle.id);
return { stolen: victim.handle };
}
public register(handle: SpritePlayHandle): void {
this.records.push({ handle, allocationOrder: ++this.allocCounter });
}
public release(id: string): void {
this.removeRecord(id);
}
public snapshot(): readonly SpritePlayHandle[] {
return this.records.map((r) => r.handle);
}
private pickOldest(): VoiceRecord {
let best = this.records[0] as VoiceRecord;
for (let i = 1; i < this.records.length; i++) {
const candidate = this.records[i] as VoiceRecord;
if (
candidate.handle.startedAt < best.handle.startedAt ||
(candidate.handle.startedAt === best.handle.startedAt &&
candidate.allocationOrder < best.allocationOrder)
) {
best = candidate;
}
}
return best;
}
private removeRecord(id: string): void {
const idx = this.records.findIndex((r) => r.handle.id === id);
if (idx >= 0) {
this.records.splice(idx, 1);
}
}
}

View File

@@ -0,0 +1,10 @@
import type { SpritePlayHandle } from "../sprite-player/sprite-player.types.js";
export interface VoiceRecord {
readonly handle: SpritePlayHandle;
readonly allocationOrder: number;
}
export interface AllocateResult {
readonly stolen: SpritePlayHandle | null;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: {
index: "./src/index.ts",
"sprite-player/sprite-player": "./src/sprite-player/sprite-player.ts",
"voice-manager/voice-manager": "./src/voice-manager/voice-manager.ts",
"adapter/index": "./src/adapter/index.ts",
"adapter/null/null": "./src/adapter/null/null.ts",
"events/events": "./src/events/events.ts",
"telemetry/telemetry": "./src/telemetry/telemetry.ts",
schemas: "./src/schemas/index.ts",
"error-codes": "./src/error-codes/index.ts",
},
formats: ["es"],
},
sourcemap: true,
rollupOptions: {
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
"zod",
"tslib",
],
},
outDir: "./dist",
},
plugins: [dts({ tsconfigPath: "./tsconfig.lib.json", entryRoot: "./src" })],
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({ testTimeout: 30_000 });