Files
2026-05-27 23:29:53 +01:00

9.2 KiB
Raw Permalink Blame History

OpenSpec Change: enterprise-audio-sprite-player

  • Change ID: enterprise-audio-sprite-player
  • Package: @suaveplan/audio-sprite-player
  • Category: universal
  • Target path: packages/media/audio/audio-sprite-player
  • Status: PROPOSED
  • Date: 2026-05-15
  • Design: ./design.md
  • Spec delta: ./specs/audio-sprite-player/spec.md

Summary

Deliver @suaveplan/audio-sprite-player on the genesis branch from scratch at enterprise quality. This package closes the sprite-playback consumer gap: @suaveplan/audio-sprite-packer produces sprite descriptors (a concatenated PCM/encoded blob plus a JSON manifest of named regions with byte offset, byte length, duration, sample rate, channel count, and format) but nothing on genesis currently consumes them. audio-sprite-player owns sprite playback at the universal tier — it orchestrates "fire-and-forget" trigger audio (game cues, UI sounds, hit/miss feedback) on top of an injectable SpritePlaybackAdapter that engine-tier packages (@suaveplan/audio-engine-browser, @suaveplan/audio-engine-server) implement against AudioContext / native bindings. The universal layer owns descriptor validation, voice scheduling, voice-stealing, sequence and random playback, fade scheduling, OTel instrumentation, abort propagation, and the typed event surface; the adapter owns decode + actual audio output.

Packer produces, player consumes. With this package landed, the sprite descriptor format produced by audio-sprite-packer finally has a first-party consumer that downstream engines can wire into a real audio backend without re-implementing manifest validation, scheduling, or voice management.


Current-state audit (genesis)

The package does not exist on genesis yet — this is a fresh build, genesis-original (no upstream ../suaveplan/enterprise/audio-sprite-player.md to mirror).

The producer it consumes from, @suaveplan/audio-sprite-packer, is also in-flight as the enterprise-audio-sprite-packer change (status: PROPOSED). This proposal therefore depends on the same wave landing: the type imports (AudioSprite, AudioSpriteEntry, AudioSpriteManifest) come from that package's public surface, and the workspace dep is declared as workspace:^. Both changes are expected to archive in the same wave; if audio-sprite-packer slips, audio-sprite-player ships against the merged type contract without a compiled producer (tests use literal AudioSprite fixtures, which is correct for a universal-tier package — it never decodes bytes).

No prior source, no test results, no coverage data.


Available workspace deps on genesis

Package Role
@suaveplan/audio-core (workspace:^) AudioDocument types reused for adapter-side context (types-only import surface)
@suaveplan/audio-sprite-packer (workspace:^) AudioSprite, AudioSpriteEntry, AudioSpriteManifest — types-only import (import type)
@suaveplan/error (workspace:^) Base class for AudioSpritePlayerError; AUDIO_SPRITE_PLAYER.* codes via defineCodes
@suaveplan/types (workspace:^) LoggerLike, Clock, IdGenerator for handle ids
@suaveplan/events (workspace:^) Typed event bus powering SpritePlayerEvents
zod (catalog:forms) Zod schemas at every public boundary (SpritePlayerOptions, PlayOptions, SequenceOptions, AudioSpriteManifest validation)
@opentelemetry/api (catalog:observability) Span audio-sprite-player.play, counter audio_sprite_player.play_total, gauge audio_sprite_player.voices_active, histogram audio_sprite_player.play_duration_ms
tslib (catalog:build) Runtime helpers

Dev deps: vitest, @vitest/coverage-istanbul, fast-check, mitata (all catalog:testing).


Public API

Types

import type { AudioSprite, AudioSpriteEntry, AudioSpriteManifest } from "@suaveplan/audio-sprite-packer"

interface SpritePlaybackAdapter {
  init(sprite: AudioSprite, signal?: AbortSignal): Promise<void>
  play(entry: AudioSpriteEntry, opts: ResolvedPlayOptions, signal?: AbortSignal): Promise<SpritePlayHandle>
  dispose(): Promise<void>
}

interface PlayOptions {
  volume?: number          // 0.0–1.0, default 1.0
  pitch?: number           // 0.5–2.0 playback rate, default 1.0
  loop?: boolean           // default false
  fadeInMs?: number        // default 0
  fadeOutMs?: number       // default 0
  startOffsetMs?: number   // default 0 — into the entry, not the sprite
  durationMs?: number      // default = entry.durationSeconds * 1000
  when?: number            // absolute schedule time, default = clock.now()
  signal?: AbortSignal
}

interface SequenceOptions extends PlayOptions {
  gapMs?: number           // default 0
}

interface SpritePlayHandle {
  readonly id: string
  readonly entryName: string
  readonly startedAt: number
  stop(fadeOutMs?: number): Promise<void>
  setVolume(volume: number): void
  isPlaying(): boolean
}

Orchestrator

interface SpritePlayerOptions {
  adapter: SpritePlaybackAdapter
  logger?: LoggerLike
  clock?: Clock
  idGenerator?: IdGenerator
  maxConcurrentVoices?: number   // default 32 — voice-stealing oldest-first
}

class SpritePlayer {
  constructor(opts: SpritePlayerOptions)
  load(sprite: AudioSprite, signal?: AbortSignal): Promise<void>
  play(entryName: string, opts?: PlayOptions): Promise<SpritePlayHandle>
  playSequence(entryNames: string[], opts?: SequenceOptions): Promise<SpritePlayHandle[]>
  playRandom(filterFn?: (entry: AudioSpriteEntry) => boolean, opts?: PlayOptions): Promise<SpritePlayHandle>
  stopAll(fadeOutMs?: number): Promise<void>
  list(): readonly AudioSpriteEntry[]
  has(entryName: string): boolean
  readonly events: SpritePlayerEvents
  dispose(): Promise<void>
}

interface SpritePlayerEvents {
  on(event: "play" | "stop" | "complete" | "voice-stolen" | "error", listener: (payload: unknown) => void): () => void
}

class NullSpritePlaybackAdapter implements SpritePlaybackAdapter {
  readonly calls: ReadonlyArray<{ kind: "init" | "play" | "dispose"; at: number; payload?: unknown }>
}

Error codes

type AudioSpritePlayerErrorCode =
  | 'AUDIO_SPRITE_PLAYER.NOT_LOADED'
  | 'AUDIO_SPRITE_PLAYER.UNKNOWN_ENTRY'
  | 'AUDIO_SPRITE_PLAYER.INVALID_MANIFEST'
  | 'AUDIO_SPRITE_PLAYER.ADAPTER_INIT_FAILED'
  | 'AUDIO_SPRITE_PLAYER.PLAYBACK_FAILED'
  | 'AUDIO_SPRITE_PLAYER.VOICE_LIMIT_EXCEEDED'
  | 'AUDIO_SPRITE_PLAYER.ABORTED'
  | 'AUDIO_SPRITE_PLAYER.INVALID_OPTIONS'

Every code is reachable from a real call site in src/ and exercised by at least one test (§2c). Codes are registered through defineCodes(...) with stable metadata and i18n keys.


Module layout

Universal package; co-located 5-file feature modules per CLAUDE.md:

packages/media/audio/audio-sprite-player/
  package.json
  tsup.config.ts
  vitest.config.ts
  README.md
  src/
    index.ts                                  # explicit named re-exports (rule 10)
    error-codes.ts                            # AUDIO_SPRITE_PLAYER_* defineCodes registry
    error-codes.md
    schemas.ts                                # Zod boundary schemas
    schemas.md
    sprite-player/
      index.ts
      sprite-player.ts
      sprite-player.test.ts
      sprite-player.types.ts
      sprite-player.md
    voice-manager/
      index.ts
      voice-manager.ts
      voice-manager.test.ts
      voice-manager.types.ts
      voice-manager.md
    adapter/
      index.ts
      adapter.types.ts
      adapter.md
      null/
        index.ts
        null.ts
        null.test.ts
        null.types.ts
        null.md
    events/
      index.ts
      events.ts
      events.test.ts
      events.types.ts
      events.md
    telemetry/
      index.ts
      telemetry.ts
      telemetry.test.ts
      telemetry.types.ts
      telemetry.md

Subpath exports in package.json mirror this layout for tree-shaking (e.g. ./sprite-player/sprite-player, ./adapter/null/null).


Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/audio-sprite-player to enumerate. This is a fresh genesis-original package, so there are no in-tree consumers yet. The following packages are expected adapter providers that will each open their own follow-up OpenSpec change to ship a concrete SpritePlaybackAdapter implementation — those implementations are explicitly out of scope for this change:

  • @suaveplan/audio-engine-browser — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: separate follow-up change will add BrowserSpritePlaybackAdapter built on AudioContext + decodeAudioData; not implemented here)
  • @suaveplan/audio-engine-server — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: separate follow-up change will add ServerSpritePlaybackAdapter built on a native binding; not implemented here)
  • @suaveplan/audio-sprite-packer — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: types-only producer; this change consumes its public type surface and adds no reverse dep)

No other dependents. Verified by absence from bunx turbo ls (package is being introduced by this change). If/when downstream audio-engine-* adapters ship, their proposals MUST list @suaveplan/audio-sprite-player as an affected dependent and rerun its tests.