Initial Commit

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

View File

@@ -0,0 +1,357 @@
# Design: enterprise-audio-core
**Change ID**: enterprise-audio-core
**Package**: `@suaveplan/audio-core`
`@suaveplan/audio-core` is the foundation of the `@suaveplan/audio-*` family. This document carries
both the **cross-cutting architecture** for the whole audio ecosystem (because `audio-core` defines
the abstractions every sibling package builds on) and the **package-specific design** for the core
itself. Sibling change proposals reference §1–§4 of this document for the shared model.
---
## 1. Ecosystem architecture
### 1.1. Goal
A cross-platform audio processing ecosystem that runs identically in browsers (Web Audio API),
servers (Node/Bun), and offline render pipelines. The **universal data model** (pages, plans, edits)
is separated from **platform-specific rendering engines** (browser AudioNodes, server block
processors) so every package above the core is environment-agnostic.
The ecosystem is:
- **Plugin-first** — every capability, from a gain boost to beat detection to a full DAW timeline,
is a plugin.
- **Stream-native** — audio flows through `TransformStream` pipelines using `@suaveplan/streams`.
- **Non-destructive** — edits are metadata; materialisation is deferred until read/stream/render.
- **Feature-rich** — capable of everything from format conversion to a working DAW with beat
detection, auto-mixing, pitch/tempo adjustment, tempo/key detection, and chainable effects.
### 1.2. Three-layer separation
| Layer | Responsibility | Environments |
|---|---|---|
| **Data Model** (`audio-core`) | PCM pages, edit plans, block processors, op registry | Universal |
| **Timeline / Transport** (`audio-timeline`, `audio-transport`) | Tracks, clips, musical time, scheduling, undo/redo | Universal |
| **Rendering Engine** (`audio-engine-*`) | Node graphs, speakers, file I/O, MIDI | Browser / Server / Offline |
### 1.3. The six plugin slots
The core defines six plugin slots; every audio feature occupies one of them.
1. **Plan Op** — transforms segment lists (changes length / rate / channels).
2. **Process Op** — block-level DSP on `Float32Array[]`.
3. **Analysis Op** — reads audio and emits features (BPM, key, spectrum); runs as a sidecar scan,
not part of the render pipeline.
4. **Codec** — decodes / encodes a specific format; registered in a `CodecRegistry`.
5. **Timeline Adapter** — renders timeline state to a platform backend.
6. **FX Plugin** — dual-mode effect with both `processBlock()` (universal) and optional
`createAudioNode()` (browser).
### 1.4. Zero duplication of genesis primitives
Audio packages reuse existing genesis packages rather than re-implementing them.
| Capability | Genesis package | Usage in audio |
|---|---|---|
| Streams / backpressure | `@suaveplan/streams` | Audio `TransformStream` pipelines, progressive decode/encode |
| Scheduling / clocks | `@suaveplan/timing` | Transport lookahead scheduler, tempo clock |
| Event bus | `@suaveplan/events` | Document lifecycle, engine, transport, per-track events |
| OTel metrics / tracing | `@suaveplan/events-otel`, `@suaveplan/opentelemetry` | Plugin latency histograms, CPU counters, transport spans |
| Undo / redo | `@suaveplan/undo` | Timeline undo/redo with branching history and command merging |
| State machines | `@suaveplan/fsm` | Transport playback state, engine lifecycle, MIDI controller state |
| Serialization | `@suaveplan/serialize` | Session save/load (edit lists, transport state, plugin presets) |
| Binary packing | `@suaveplan/bitpacker`, `@suaveplan/packer` | Compressed sprite maps, custom binary formats, state compression |
| Metadata extraction | `@suaveplan/audio-metadata` | Format detection, ID3/MP4/FLAC tags during decode |
| Voice interfaces | `@suaveplan/voice` | TTS/STT integration points in the timeline |
| Error substrate | `@suaveplan/error` | `AudioError` base class, typed `AUDIO.*` codes |
| Schema registry | `@suaveplan/schema-registry` | Plugin preset Zod schemas, descriptor registration |
| Validation | `@suaveplan/validation` | Runtime boundary validation where Zod is unavailable |
| Testing | `@suaveplan/testing` | `FakeClock`, shared runner primitives |
### 1.5. No platform assumptions in universal packages
Universal packages (`audio-core`, `audio-timeline`, `audio-transport`, `audio-analysis-*`,
`audio-plugins-fx`) import zero browser APIs (`window`, `document`, `navigator`, `AudioContext`) and
zero Node-only APIs (`fs`, `process`) except via injected adapters, and pass all unit tests under
`bun test` (server) and vitest with a `happy-dom` shim (browser).
### 1.6. Architecture diagram
```text
┌─────────────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER │
│ @suaveplan/react-audio* | @suaveplan/r3f-audio | audio-cli │
└─────────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────────┐
│ TIMELINE / TRANSPORT │
│ @suaveplan/audio-timeline ──TimelineAdapter──► rendering engine │
│ @suaveplan/audio-transport ──TransportScheduler──► lookahead loop │
└─────────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────────┐
│ ANALYSIS + FX PLUGINS │
│ @suaveplan/audio-analysis-* | @suaveplan/audio-plugins-fx │
│ (BlockProcessor + AnalysisOp interfaces) │
└─────────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────────┐
│ UNIVERSAL CORE — @suaveplan/audio-core │
│ AudioDocument (pages, edits, plans) · PlanEngine (segment compile) │
│ ProcessPipeline (block DSP) · OpRegistry (plan + process ops) │
└─────────────────────────────────────────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
BROWSER ENGINE SERVER I/O ONLY OFFLINE RENDER
AudioContext / fs read/write audio-core
AudioWorklet / file streams .renderPlan()
AudioNodes
```
---
## 2. Core abstractions
### 2.1. `AudioDocument`
The universal audio container — replaces the browser-only `AudioBuffer`. PCM is stored as paged
`Float32Array[]` data so the model maps cleanly to both browser memory limits and server stream
chunks. `length`, `duration`, and the stats are virtual: they read cached values invalidated by
`version` bumps, so editing is O(1) (push to `edits[]`) and materialisation is deferred to `read()`
or `stream()`.
### 2.2. `Segment` — the canonical definition
A `CompiledPlan` is a list of `Segment` records describing how output samples map back to source
samples. **There is exactly one `Segment` shape across the whole audio family:**
```ts
interface Segment {
from: number // source sample start
count: number // source sample count
to: number // output sample start
rate?: number // playback rate (1 = normal); affects resampling
ref?: AudioDocument | null // external source reference (null = silence segment)
interp?: Interpolator // custom resampler for non-unity rate
}
```
Plan ops receive and return `Segment[]`; process ops run per-block over the rendered output of the
segment list. The earlier draft variants (`{ srcStart, srcEnd, dstOffset }` and the positional
tuple `[from, count, to]`) are superseded by this record form — they are not used anywhere.
### 2.3. `PlanEngine`
Compiles `AudioEdit[]` → `CompiledPlan` in two passes:
1. **Resolve pass** — expand high-level edits into primitive edits using source stats
(`op.resolve`).
2. **Plan pass** — transform the `Segment[]` for each edit (`op.plan`); ops without a `plan`
function are appended to `pipeline` as process-stage ops.
```ts
function compilePlan(doc: AudioDocument, final: boolean): CompiledPlan {
let segs: Segment[] = [{ from: 0, count: doc.sourceLength, to: 0 }]
let pipeline: AudioEdit[] = []
let limit = doc.sourceLength
let sr = doc.sampleRate
let ch = doc.channels
for (const edit of doc.edits) {
const op = registry.get(edit.type)
if (!op) throw new AudioError('AUDIO_PLUGIN_NOT_FOUND', { context: { type: edit.type } })
if (op.resolve) {
const resolved = op.resolve({ stats: doc.stats, sampleRate: sr, channels: ch, opts: edit.opts })
if (resolved === false) continue
// resolved edits are spliced in and recursed over
}
if (op.plan) {
segs = op.plan(segs, mkPlanCtx(planLen(segs), sr, edit.opts))
if (!final) limit = adjustLimit(limit, edit.type)
} else {
pipeline.push(edit)
}
if (op.sr) sr = op.sr(sr, edit.opts) ?? sr
if (op.ch) ch = op.ch(ch, edit.opts) ?? ch
}
return { segs, pipeline, totalLen: planLen(segs), sampleRate: sr, limit: Math.max(0, limit) }
}
```
### 2.4. `ProcessPipeline`
Runs process ops per `BLOCK_SIZE` (1024 samples) using double-buffer rotation. `init` allocates
`ProcessState[]`; `patch` hot-swaps parameter values without re-allocation; `process` runs each op
over a block. On a **structural** change (op added/removed, detected via a pipeline signature) the
pipeline reinitialises affected processors and crossfades over ~20 ms to avoid clicks.
### 2.5. `BlockProcessor` — the universal DSP interface
Every DSP plugin (FX, analysis, codec post-processing) implements:
```ts
interface BlockProcessor {
readonly name: string
setup(config: ProcessorConfig): void
process(input: Float32Array[], output: Float32Array[], ctx: BlockContext): void
reset(): void
dispose(): void
}
```
This is the interface that works everywhere: browser worklets, server batch pipelines, offline
renders, and WebAssembly modules.
### 2.6. Error isolation
A crashing process op must not corrupt the stream. `ProcessPipeline.process` wraps each op:
```ts
try {
op.process(input, output, ctx)
} catch (e) {
emit(doc, 'plugin:error', { plugin: op.name, error: e })
for (let c = 0; c < input.length; c++) output[c].set(input[c]) // pass-through
}
```
---
## 3. Integration with genesis packages
- **`@suaveplan/streams`** — `AudioDocument.stream()` and `PlanEngine.streamPlan()` yield blocks
composable with `TransformStream`; a helper wraps a compiled plan + process pipeline as a
`TransformStream<Float32Array[], Float32Array[]>` so downstream FX get native backpressure.
Progressive decode (in `audio-codecs`) returns a `ReadableStream<AudioPage>`; the document is
editable before decode completes because edits are metadata.
- **`@suaveplan/events`** — backs the typed `AudioDocumentEventMap` (`edit`, `undo`,
`plugin:error`, `page:loaded`). The interceptor API is the attachment point for OTel metrics,
schema validation, and cross-context bridges in the engine packages.
- **`@suaveplan/serialize`** — `AudioDocument.toJSON()` round-trips through `serialize`/
`deserialize`, preserving `Float32Array` page data, `edits`, `version`, `source`, `metadata`.
- **`@suaveplan/audio-metadata`** — supplies the `AudioMetadata` type carried on
`AudioDocument.metadata`. `audio-core` depends on `@suaveplan/audio-metadata` for this type only;
population happens in `audio-codecs` during decode.
---
## 4. Error hierarchy
Every audio package extends `@suaveplan/error`. `audio-core` registers the foundation codes:
```ts
defineCodes('AUDIO_CORE', [
{ code: 'AUDIO_PLUGIN_NOT_FOUND', severity: 'error', retryable: false },
{ code: 'AUDIO_INVALID_EDIT', severity: 'error', retryable: false },
{ code: 'AUDIO_STREAM_ABORTED', severity: 'warning', retryable: true },
{ code: 'AUDIO_PAGE_OUT_OF_RANGE', severity: 'error', retryable: false },
{ code: 'AUDIO_PROCESS_ERROR', severity: 'error', retryable: true },
])
```
Sibling packages register their own `AUDIO_<DOMAIN>` namespaces (engine, codec, analysis, etc.).
---
## 5. Package-specific design
### 5.1. Paged memory model
```ts
const PAGE_SIZE = 1024 * 1024 // samples (~23 MB stereo @ 48 kHz)
const BLOCK_SIZE = 1024 // samples per process block
```
`AudioDocument` owns an `Array<AudioPage | null>` where `null` marks an evicted page that can be
reloaded from cache. Pages are allocated from a pool to reduce GC pressure. Primitives:
- `walkPages(doc, channel, startSample, count, visitor)` — iterate overlapping pages.
- `copyPages(doc, channel, startSample, count, target, targetOffset)` — copy into a pre-allocated
buffer without allocating.
- `readPages(doc, offset?, duration?)` — read a range as `Float32Array[]`.
### 5.2. Streaming
`AudioDocument.stream()` is an async generator that compiles a non-final plan, initialises the
process pipeline, and yields `Float32Array[]` blocks of at most `BLOCK_SIZE` samples:
```ts
async function* stream(opts?: StreamOptions) {
const plan = planEngine.compile(doc, false)
const procs = processPipeline.init(plan.pipeline, plan.totalLen, plan.sampleRate, doc.channels)
let outPos = opts?.startSample ?? 0
const end = opts?.endSample ?? plan.totalLen
while (outPos < end) {
if (opts?.signal?.aborted) throw new AudioError('AUDIO_STREAM_ABORTED')
const blockLen = Math.min(BLOCK_SIZE, end - outPos)
renderBlock(doc, plan.segs, outPos, blockLen, bufA)
const out = procs.length
? processPipeline.process(bufA, bufB, procs, outPos, plan.sampleRate)
: bufA
yield out.map((b) => b.subarray(0, blockLen))
outPos += blockLen
}
}
```
### 5.3. Built-in operations
Plan ops: `trim`, `crop`, `speed`, `stretch`, `reverse`, `insert`, `remove`, `repeat`, `pad`,
`resample`. Process ops: `gain`, `normalize`, `fade`, `pan`, `mix`, `dither`, `transform`.
`stretch` is dual-stage — a plan op that changes duration plus a process op that preserves pitch.
Pitch *shifting* without a duration change is **not** a core built-in: it ships as the
`PitchShifterPlugin` in `@suaveplan/audio-plugins-fx`. (The earlier draft's `registry/built-in/
pitch.ts` entry is dropped — there is no core `pitch` op.)
### 5.4. File layout
```text
packages/audio/audio-core/
package.json, tsconfig.json, vitest.config.ts
src/
index.ts
error-codes.ts
document/ document.ts .md .types.ts .test.ts index.ts
page/ page.ts .md .types.ts .test.ts index.ts
plan/ plan-engine.ts (+ .md .types.ts .test.ts) · segment.ts (+ .md .types.ts .test.ts) · index.ts
process/ process-pipeline.ts (+ .md .types.ts .test.ts) · block-processor.ts (+ .md .types.ts .test.ts) · index.ts
registry/ op-registry.ts (+ .md .types.ts .test.ts) · built-in/{trim,crop,speed,stretch,reverse,insert,remove,repeat,pad,resample,gain,normalize,fade,pan,mix,dither,transform}.ts · index.ts
stream/ stream.ts .md .types.ts .test.ts index.ts
render/ render.ts .md .test.ts index.ts
stats/ stats.ts .md .types.ts .test.ts index.ts
__benchmarks__/ plan.bench.ts stream.bench.ts process.bench.ts
tests/
integration.test.ts property.test.ts purity.test.ts exports.test.ts
```
### 5.5. Benchmarks
- `plan.bench.ts` — compile 100 edits into a plan (1000 iterations).
- `stream.bench.ts` — stream 5 minutes of 48 kHz stereo through 10 process ops.
- `process.bench.ts` — run `normalize` + `fade` + `pan` over 1M samples.
---
## 6. Decisions and trade-offs
- **One `Segment` shape, everywhere.** The richer record form (`from`/`count`/`to` + optional
`rate`/`ref`/`interp`) is canonical because `ref` enables `insert`/`mix` from other documents and
`rate`/`interp` enable `speed`/`resample` without a separate segment variant. Picking one shape
now avoids translation layers between core and the timeline/engine packages.
- **`audio-core` carries the ecosystem architecture.** Rather than a free-standing architecture
spec under `openspec/specs/`, the cross-cutting model lives in this design doc because every
abstraction it describes (`OpRegistry`, `BlockProcessor`, the six plugin slots, the error base) is
*defined* by `audio-core`. Sibling proposals reference §1–§4 here.
- **Pitch shift is a plugin, not a core op.** Core built-ins are limited to operations expressible
as pure segment transforms or simple per-block DSP. Phase-vocoder pitch shifting is stateful and
belongs in `audio-plugins-fx`.
- **No design.md for the React/r3f tier yet.** `react-audio*` and `r3f-audio` are out of scope for
this 13-change batch; they land in a later wave once the universal and platform tiers are green.

View File

@@ -0,0 +1,440 @@
# OpenSpec Change: enterprise-audio-core
- **Change ID**: enterprise-audio-core
- **Package**: `@suaveplan/audio-core`
- **Category**: universal
- **Target path**: `packages/audio/audio-core/`
- **Status**: PROPOSED
- **Date**: 2026-05-14
- **Design**: `./design.md` (also carries the cross-cutting `@suaveplan/audio-*` ecosystem architecture)
- **Spec delta**: `./specs/audio-core/spec.md`
---
## Summary
Deliver `@suaveplan/audio-core` on the genesis branch from scratch at enterprise quality. The package provides the universal audio data model and processing primitives for the genesis monorepo: a paged audio container (`AudioDocument`), non-destructive edit compilation (`PlanEngine`), block-level DSP pipeline (`ProcessPipeline`), and typed operation registry (`OpRegistry`). Zero browser or server dependencies — runs identically in Bun, Node.js, Deno, and browsers. This is the lowest-level audio package in the ecosystem; every other audio package depends on it.
---
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/error` (`workspace:^`) | Base class for `AudioError`; `AUDIO_CORE.*` codes |
| `@suaveplan/types` (`workspace:^`) | `LoggerLike`, `Clock`, `IdGenerator`, `Rng` |
| `@suaveplan/events` (`workspace:^`) | Typed event bus for `AudioDocument` lifecycle events |
| `@suaveplan/streams` (`workspace:^`) | WHATWG `TransformStream` for audio stream transforms |
| `@suaveplan/serialize` (`workspace:^`) | Structured serialization for `AudioDocument.toJSON()` |
| `@suaveplan/audio-metadata` (`workspace:^`) | Supplies the `AudioMetadata` type carried on `AudioDocument.metadata` (type-only consumption) |
| `zod` (`catalog:forms`) | Zod schemas for all public option types |
| `@opentelemetry/api` (`catalog:observability`) | Spans + counters on compile, process, and stream ops |
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`).
---
## Public API
### Core types
```ts
class AudioError extends SuaveplanError
interface AudioDocumentOptions {
id?: string
sampleRate: number
channels: number
sourceLength: number
pages?: AudioPage[]
edits?: AudioEdit[]
source?: string | URL
metadata?: AudioMetadata
}
interface AudioPage {
readonly channelData: Float32Array[]
readonly startSample: number
}
interface AudioEdit {
type: string
opts: Record<string, unknown>
}
interface AudioStats {
peak: number
rms: number
lufs?: number
dcOffset: number
}
interface StreamOptions {
blockSize?: number
startSample?: number
endSample?: number
}
interface AudioDocumentEventMap {
'edit': (e: { edit: AudioEdit; version: number }) => void
'undo': (e: { edit: AudioEdit; version: number }) => void
'plugin:error': (e: { plugin: string; error: unknown }) => void
'page:loaded': (e: { page: AudioPage }) => void
}
class AudioDocument {
constructor(opts: AudioDocumentOptions)
readonly id: string
readonly pages: AudioPage[]
readonly sampleRate: number
readonly channels: number
readonly sourceLength: number
get length(): number
get duration(): number
edits: AudioEdit[]
version: number
stats?: AudioStats
source?: string | URL
metadata?: AudioMetadata
run(...edits: AudioEdit[]): this
undo(n?: number): AudioEdit | AudioEdit[] | null
clone(): AudioDocument
toJSON(): AudioDocumentJSON
read(offset?: number, duration?: number): Promise<Float32Array[]>
stream(opts?: StreamOptions): AsyncGenerator<Float32Array[]>
push(data: Float32Array[] | Float32Array, fmt?: PCMFormat): this
stop(): this
on<K extends keyof AudioDocumentEventMap>(event: K, listener: AudioDocumentEventMap[K]): void
off<K extends keyof AudioDocumentEventMap>(event: K, listener: AudioDocumentEventMap[K]): void
}
```
### Plan engine
```ts
interface CompiledPlan {
segs: Segment[]
pipeline: AudioEdit[]
totalLen: number
sampleRate: number
limit: number
}
class PlanEngine {
constructor(registry: OpRegistry)
compile(doc: AudioDocument, final?: boolean): CompiledPlan
readPlan(doc: AudioDocument, plan: CompiledPlan, offset?: number, duration?: number): Float32Array[]
streamPlan(doc: AudioDocument, plan: CompiledPlan, opts?: StreamOptions): AsyncGenerator<Float32Array[]>
ensurePages(doc: AudioDocument, plan: CompiledPlan, offset?: number, duration?: number): Promise<void>
}
interface Segment {
from: number // source sample start
count: number // source sample count
to: number // output sample start
rate?: number // playback rate (1 = normal)
ref?: AudioDocument | null // external source reference (null = silence)
interp?: Interpolator // custom resampler for non-unity rate
}
interface PlanContext {
totalLen: number
sampleRate: number
offset: number
length: number
opts: Record<string, unknown>
}
type PlanFn = (segs: Segment[], ctx: PlanContext) => Segment[]
type ResolveFn = (ctx: { stats?: AudioStats; sampleRate: number; channels: number; opts: Record<string, unknown> }) => AudioEdit[] | false | void
type SampleRateTransformFn = (sr: number, opts: Record<string, unknown>) => number | void
type ChannelTransformFn = (ch: number, opts: Record<string, unknown>) => number | void
```
### Process pipeline
```ts
interface ProcessState {
op: AudioOpDescriptor
params: Record<string, unknown>
buffers: Float32Array[]
}
interface BlockContext {
blockOffsetSamples: number
sampleRate: number
channels: number
}
class ProcessPipeline {
constructor(registry: OpRegistry)
init(pipeline: AudioEdit[], totalDuration: number, sampleRate: number, channels: number): ProcessState[]
patch(state: ProcessState[], pipeline: AudioEdit[]): void
process(
input: Float32Array[],
output: Float32Array[],
state: ProcessState[],
blockOffsetSamples: number,
sampleRate: number
): Float32Array[]
}
```
### Operation registry
```ts
interface AudioOpDescriptor {
readonly name: string
readonly params?: string[]
readonly resolve?: ResolveFn
readonly plan?: PlanFn
readonly process?: ProcessFn
readonly sr?: SampleRateTransformFn
readonly ch?: ChannelTransformFn
}
type ProcessFn = (
input: Float32Array[],
output: Float32Array[],
ctx: BlockContext
) => void
class OpRegistry {
register(descriptor: AudioOpDescriptor): void
unregister(name: string): void
get(name: string): AudioOpDescriptor | undefined
list(): AudioOpDescriptor[]
static createWithDefaults(): OpRegistry
}
```
### Block processor interface
```ts
interface ProcessorConfig {
sampleRate: number
channels: number
blockSize: number
}
interface BlockProcessor {
readonly name: string
setup(config: ProcessorConfig): void
process(input: Float32Array[], output: Float32Array[], ctx: BlockContext): void
reset(): void
dispose(): void
}
```
### Paged memory primitives
```ts
const PAGE_SIZE = 1024 * 1024 // samples (~23MB stereo @ 48kHz)
const BLOCK_SIZE = 1024 // samples per process block
function walkPages(
doc: AudioDocument,
channel: number,
startSample: number,
count: number,
visitor: (page: AudioPage, offset: number, length: number) => void
): void
function copyPages(
doc: AudioDocument,
channel: number,
startSample: number,
count: number,
target: Float32Array,
targetOffset: number
): void
function readPages(doc: AudioDocument, offset?: number, duration?: number): Float32Array[]
```
### Built-in operations
Plan ops: `trim`, `crop`, `speed`, `stretch`, `reverse`, `insert`, `remove`, `repeat`, `pad`, `resample`.
Process ops: `gain`, `normalize`, `fade`, `pan`, `mix`, `dither`, `transform`.
### Error codes
```ts
type AudioCoreErrorCode =
| 'AUDIO_PLUGIN_NOT_FOUND'
| 'AUDIO_INVALID_EDIT'
| 'AUDIO_STREAM_ABORTED'
| 'AUDIO_PAGE_OUT_OF_RANGE'
| 'AUDIO_PROCESS_ERROR'
const AUDIO_CORE_ERROR_METADATA: Readonly<Record<AudioCoreErrorCode, CodeMetadata>>
function registerAudioCoreErrorCodes(): void
```
### Zod schemas
```ts
AudioDocumentOptionsSchema
AudioPageSchema
AudioEditSchema
AudioStatsSchema
StreamOptionsSchema
CompiledPlanSchema
AudioOpDescriptorSchema
ProcessorConfigSchema
```
---
## New additions for genesis
1. **Zod boundary validation** — every public options object validated at entry.
2. **OTel instrumentation** — spans on `plan.compile`, `process.block`, `stream.read`; counters for `audio_core_edits_total`, `audio_core_stream_blocks_total`; histograms for `audio_core_compile_duration_ms`, `audio_core_process_duration_ms`.
3. **Error isolation** — crashing process ops emit `plugin:error` and pass input through unmodified without corrupting the stream.
4. **AbortSignal propagation** — threaded through `read()`, `stream()`, `ensurePages()`.
5. **Page pool allocator** — reusable `Float32Array` pages to reduce GC pressure during long streams.
6. **Property-based tests** — plan compilation is deterministic and reversible for identity edits; stream yields correct total sample count; clone produces equivalent `toJSON()`.
7. **Benchmark suite** — `plan.bench.ts` (100 edits × 1000 iterations), `stream.bench.ts` (5 min stereo through 10 ops), `process.bench.ts` (normalize + fade + pan over 1M samples).
---
## File layout
```text
packages/audio/audio-core/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
error-codes.ts
document/
document.ts
document.md
document.types.ts
document.test.ts
index.ts
page/
page.ts
page.md
page.types.ts
page.test.ts
index.ts
plan/
plan-engine.ts
plan-engine.md
plan-engine.types.ts
plan-engine.test.ts
segment.ts
segment.md
segment.types.ts
segment.test.ts
index.ts
process/
process-pipeline.ts
process-pipeline.md
process-pipeline.types.ts
process-pipeline.test.ts
block-processor.ts
block-processor.md
block-processor.types.ts
block-processor.test.ts
index.ts
registry/
op-registry.ts
op-registry.md
op-registry.types.ts
op-registry.test.ts
built-in/
trim.ts
crop.ts
speed.ts
stretch.ts
reverse.ts
insert.ts
remove.ts
repeat.ts
pad.ts
resample.ts
gain.ts
normalize.ts
fade.ts
pan.ts
mix.ts
dither.ts
transform.ts
index.ts
stream/
stream.ts
stream.md
stream.types.ts
stream.test.ts
index.ts
render/
render.ts
render.md
render.test.ts
index.ts
stats/
stats.ts
stats.md
stats.types.ts
stats.test.ts
index.ts
__benchmarks__/
plan.bench.ts
stream.bench.ts
process.bench.ts
tests/
integration.test.ts
```
---
## Impact
**Breaking changes**: none — fresh build, package does not yet exist on genesis.
**Prerequisites**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/streams`, `@suaveplan/serialize`, `@suaveplan/audio-metadata` published to Verdaccio.
**No dependents may import browser or server APIs through this package.**
### Affected dependents
`@suaveplan/audio-core` is the foundation of the audio family — every other audio package depends
on it. At the time of this change none of the dependents exist on genesis yet (they land in later
changes in this same batch), so there is no existing dependent surface to test or migrate. Each
dependent is created against this package's spec delta, not retrofitted.
- `@suaveplan/audio-codecs` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created against this spec in `enterprise-audio-codecs`)
- `@suaveplan/audio-analysis-beat` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-analysis-beat`)
- `@suaveplan/audio-analysis-pitch` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-analysis-pitch`)
- `@suaveplan/audio-analysis-loudness` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-analysis-loudness`)
- `@suaveplan/audio-analysis-spectrum` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-analysis-spectrum`)
- `@suaveplan/audio-timeline` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-timeline`)
- `@suaveplan/audio-transport` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-transport`)
- `@suaveplan/audio-engine-browser` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-engine-browser`)
- `@suaveplan/audio-engine-server` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-engine-server`)
- `@suaveplan/audio-plugins-fx` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-plugins-fx`)
- `@suaveplan/audio-controllers` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-controllers`)
- `@suaveplan/audio-cli` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-cli`)
Once landed, `@suaveplan/audio-core` is a foundation package: any subsequent change to its public
surface MUST run `bunx turbo test --filter=...@suaveplan/audio-core` per `openspec/AGENTS.md` §13
step 10.

View File

@@ -0,0 +1,341 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-core (audio-core)
**Package**: `@suaveplan/audio-core`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity
The `@suaveplan/audio-core` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All environment-specific behaviour SHALL be reached only through injected adapters. The package's `dependencies` SHALL be limited to `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/streams`, `@suaveplan/serialize`, `tslib`, and `zod`; `@opentelemetry/api` is the only permitted non-workspace runtime dependency.
#### Scenario: Built dist references no platform globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `node:fs`, `node:path`, `process`, or `Buffer`
#### Scenario: Tests pass under both runtimes
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
- **THEN** both runs pass with identical results
**Verified by:** `tests/purity.test.ts::"dist references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace and zod"`
---
### Requirement: AudioDocument paged container
The package SHALL export an `AudioDocument` class constructed from `AudioDocumentOptions`. It SHALL expose readonly `id`, `pages`, `sampleRate`, `channels`, and `sourceLength`, plus virtual `length` and `duration` getters whose cached values are invalidated by `version` bumps. PCM SHALL be stored as paged `AudioPage` records (`{ channelData: Float32Array[]; startSample: number }`); a `null` slot in the page array indicates an evicted page that can be reloaded. Construction with no `id` SHALL derive a stable id via the injected `IdGenerator`.
#### Scenario: Virtual length tracks edits without materialisation
- **WHEN** a caller appends a `trim` edit that halves the range
- **THEN** `doc.length` reflects the trimmed sample count, `doc.version` has incremented, and no `AudioPage` has been allocated or copied
#### Scenario: Evicted page is represented as null
- **WHEN** a page is evicted from `doc.pages`
- **THEN** the slot holds `null` and a subsequent read for that range triggers a reload rather than throwing
**Verified by:** `src/document/document.test.ts::"virtual length reflects edits with O(1) cost"` AND `src/page/page.test.ts::"evicted page slot is null and reloadable"`
---
### Requirement: Non-destructive edit history
`AudioDocument.run(...edits)` SHALL append `AudioEdit` records, increment `version`, and emit an `edit` event. `undo(n?)` SHALL pop the last `n` edits (default 1), emit an `undo` event, and return the removed edit(s) or `null` when the history is empty. `clone()` SHALL produce a document with an independent `pages` array and `edits` array such that mutating one does not affect the other. Editing SHALL be O(1) in the number of samples — materialisation is deferred to `read()` / `stream()`.
#### Scenario: Run then undo restores edit state
- **WHEN** a caller runs two edits then calls `undo(2)`
- **THEN** `doc.edits` is empty, `doc.version` has incremented twice more, and `undo` returned both removed edits
#### Scenario: Clone is structurally independent
- **WHEN** a caller clones a document and runs an edit on the clone
- **THEN** the original's `edits` and `version` are unchanged
**Verified by:** `src/document/document.test.ts::"run appends and undo removes edits with events"` AND `src/document/document.test.ts::"clone produces an independent document"`
---
### Requirement: Document serialization round-trip
`AudioDocument.toJSON()` SHALL return an `AudioDocumentJSON` value that, when passed through `@suaveplan/serialize`, round-trips exactly — including `Float32Array` page data, the `edits` array, `version`, `source`, and `metadata`. A document reconstructed from the deserialized JSON SHALL produce a `toJSON()` value deep-equal to the original's.
#### Scenario: toJSON survives serialize/deserialize
- **WHEN** a caller serializes `doc.toJSON()` and deserializes it back into an `AudioDocument`
- **THEN** the reconstructed document's `toJSON()` is deep-equal to the original's
**Verified by:** `src/document/document.test.ts::"toJSON round-trips through @suaveplan/serialize"` AND `tests/property.test.ts::"clone toJSON equals original toJSON"`
---
### Requirement: Typed document event surface
`AudioDocument` SHALL expose `on` / `off` methods typed against `AudioDocumentEventMap` with the topics `edit`, `undo`, `plugin:error`, and `page:loaded`, backed by `@suaveplan/events`. Listener registration and removal SHALL be idempotent — registering the same listener twice SHALL fire it once, and `off` for an unregistered listener SHALL be a no-op.
#### Scenario: Edit event carries the edit and version
- **WHEN** a listener is registered for `edit` and a caller runs an edit
- **THEN** the listener is invoked once with `{ edit, version }` matching the appended edit and the new version
**Verified by:** `src/document/document.test.ts::"on/off manage typed listeners idempotently"`
---
### Requirement: Paged memory primitives
The package SHALL export `PAGE_SIZE` (`1024 * 1024` samples) and `BLOCK_SIZE` (`1024` samples) constants, and the primitives `walkPages(doc, channel, startSample, count, visitor)`, `copyPages(doc, channel, startSample, count, target, targetOffset)`, and `readPages(doc, offset?, duration?)`. Pages SHALL be allocated from a reusable pool (`allocatePage` / `releasePage`) to reduce GC pressure during long streams. `walkPages` SHALL invoke the visitor once per overlapping page with the correct intra-page offset and length; `copyPages` SHALL write into a caller-supplied pre-allocated buffer without allocating.
#### Scenario: walkPages visits every overlapping page
- **WHEN** a caller calls `walkPages` over a range spanning three pages
- **THEN** the visitor is invoked exactly three times with offsets and lengths that sum to the requested count
#### Scenario: Page pool reuses released buffers
- **WHEN** a page is released and a new page of the same shape is allocated
- **THEN** the allocator returns the previously released `Float32Array[]` rather than allocating fresh memory
**Verified by:** `src/page/page.test.ts::"walkPages visits each overlapping page with correct offsets"` AND `src/page/page.test.ts::"page pool reuses released buffers"`
---
### Requirement: Plan compilation
The package SHALL export a `PlanEngine` class constructed from an `OpRegistry`. `compile(doc, final?)` SHALL convert `doc.edits` into a `CompiledPlan` (`{ segs, pipeline, totalLen, sampleRate, limit }`) in two passes: a resolve pass that expands high-level edits into primitive edits via each op's `resolve` function, and a plan pass that transforms the `Segment[]` via each op's `plan` function. Ops without a `plan` function SHALL be appended to `pipeline` as process-stage ops. An edit whose `type` is not registered SHALL throw `AudioError` with code `AUDIO_PLUGIN_NOT_FOUND`. When `final` is false the engine SHALL track a `limit` marking the safe source boundary for incremental decode.
#### Scenario: Identity edit preserves total length
- **WHEN** a document with an identity edit (e.g. `gain` at unity) is compiled
- **THEN** `plan.totalLen` equals `doc.sourceLength`
#### Scenario: Unknown op type throws AUDIO_PLUGIN_NOT_FOUND
- **WHEN** `compile` encounters an edit whose `type` is not in the registry
- **THEN** it throws `AudioError` whose `code === "AUDIO_PLUGIN_NOT_FOUND"` with the offending `type` in `context`
#### Scenario: Compilation is deterministic
- **WHEN** the same document is compiled twice
- **THEN** the two `CompiledPlan` values are deep-equal
**Verified by:** `src/plan/plan-engine.test.ts::"compile resolves and plans edits into a CompiledPlan"` AND `src/plan/plan-engine.test.ts::"compile throws AUDIO_PLUGIN_NOT_FOUND for unknown op"` AND `tests/property.test.ts::"plan compilation is deterministic"`
---
### Requirement: Plan-driven read and stream
`PlanEngine.readPlan(doc, plan, offset?, duration?)` SHALL render a static range into `Float32Array[]` using the compiled segments. `streamPlan(doc, plan, opts?)` SHALL be an async generator yielding `Float32Array[]` blocks of at most `BLOCK_SIZE` samples that, summed, equal the requested range. `ensurePages(doc, plan, offset?, duration?)` SHALL preload any evicted pages a range requires before rendering. `AudioDocument.read()` and `AudioDocument.stream()` SHALL delegate to these methods. An out-of-range page request SHALL throw `AudioError` with code `AUDIO_PAGE_OUT_OF_RANGE`.
#### Scenario: Stream yields the exact total sample count
- **WHEN** a caller fully drains `streamPlan` for a known range
- **THEN** the concatenated block lengths equal the requested sample count
#### Scenario: Out-of-range read throws AUDIO_PAGE_OUT_OF_RANGE
- **WHEN** `readPlan` is asked for a range beyond `plan.totalLen`
- **THEN** it throws `AudioError` whose `code === "AUDIO_PAGE_OUT_OF_RANGE"`
**Verified by:** `src/plan/plan-engine.test.ts::"streamPlan yields the exact total sample count"` AND `src/plan/plan-engine.test.ts::"readPlan throws AUDIO_PAGE_OUT_OF_RANGE beyond plan length"` AND `tests/property.test.ts::"stream yields exact total sample count"`
---
### Requirement: Block-level process pipeline
The package SHALL export a `ProcessPipeline` class constructed from an `OpRegistry`. `init(pipeline, totalDuration, sampleRate, channels)` SHALL allocate `ProcessState[]`. `patch(state, pipeline)` SHALL hot-swap edit parameter values without re-allocating buffers. `process(input, output, state, blockOffsetSamples, sampleRate)` SHALL run each process op in order over a single block. When the pipeline structure changes between blocks (ops added or removed, detected via a pipeline signature), the pipeline SHALL reinitialise affected processors and crossfade over approximately 20 ms to avoid clicks.
#### Scenario: patch updates parameters without re-allocation
- **WHEN** a caller calls `patch` with the same op set but changed parameter values
- **THEN** the existing `ProcessState[]` buffers are reused and the new parameter values take effect on the next `process` call
#### Scenario: Structural change crossfades
- **WHEN** an op is added to the pipeline between two `process` calls
- **THEN** the pipeline reinitialises and the output transitions over a crossfade window rather than discontinuously
**Verified by:** `src/process/process-pipeline.test.ts::"patch swaps parameters without re-allocating buffers"` AND `src/process/process-pipeline.test.ts::"structural pipeline change triggers a crossfade"`
---
### Requirement: Process-op error isolation
When a process op throws inside `ProcessPipeline.process`, the pipeline SHALL NOT propagate the throw. It SHALL emit a `plugin:error` event carrying `{ plugin, error }` and copy the block's `input` channels into `output` unmodified, so a crashing op degrades to pass-through without corrupting the stream. Subsequent ops in the pipeline SHALL still run on the passed-through block.
#### Scenario: Crashing op degrades to pass-through
- **WHEN** a registered process op throws on a block
- **THEN** a `plugin:error` event fires with the op name, `output` equals `input` for that op's stage, and the stream continues
**Verified by:** `src/process/process-pipeline.test.ts::"crashing process op emits plugin:error and passes input through"`
---
### Requirement: Operation registry and the six plugin slots
The package SHALL export an `OpRegistry` class with `register(descriptor)`, `unregister(name)`, `get(name)`, `list()`, and a static `createWithDefaults()`. An `AudioOpDescriptor` SHALL carry a readonly `name` and optional `params`, `resolve`, `plan`, `process`, `sr`, and `ch` members. Registering a descriptor whose `name` is already registered SHALL replace the prior descriptor. `createWithDefaults()` SHALL return a registry pre-populated with every built-in op.
#### Scenario: register then get round-trips a descriptor
- **WHEN** a caller registers a descriptor and calls `get` with its name
- **THEN** the same descriptor is returned, and it appears in `list()`
#### Scenario: createWithDefaults pre-registers every built-in op
- **WHEN** a caller calls `OpRegistry.createWithDefaults()`
- **THEN** `get` resolves every built-in plan op and process op named in this spec
**Verified by:** `src/registry/op-registry.test.ts::"register/get/unregister/list manage descriptors"` AND `src/registry/op-registry.test.ts::"createWithDefaults pre-registers all built-in ops"`
---
### Requirement: Built-in plan operations
The default registry SHALL ship these plan ops: `trim`, `crop`, `speed`, `stretch`, `reverse`, `insert`, `remove`, `repeat`, `pad`, and `resample`. Each SHALL transform the `Segment[]` so the compiled `totalLen`, `sampleRate`, or `channels` reflect the operation: `trim`/`crop`/`remove` shorten output, `repeat`/`pad`/`insert` lengthen it, `speed`/`stretch` change duration, `reverse` inverts segment order, and `resample` changes `sampleRate` via the op's `sr` transform.
#### Scenario: trim restricts the output range
- **WHEN** a `trim` edit selecting samples `[1000, 2000)` is compiled
- **THEN** `plan.totalLen === 1000` and rendering yields the original samples from that range
#### Scenario: resample changes the plan sample rate
- **WHEN** a `resample` edit to 48000 Hz is compiled on a 44100 Hz document
- **THEN** `plan.sampleRate === 48000`
**Verified by:** `src/registry/built-in/trim.test.ts::"trim restricts output to the selected range"` AND `src/registry/built-in/resample.test.ts::"resample updates the plan sample rate"`
---
### Requirement: Built-in process operations
The default registry SHALL ship these process ops: `gain`, `normalize`, `fade`, `pan`, `mix`, `dither`, and `transform`. Each SHALL implement a `process` function operating block-by-block on `Float32Array[]`: `gain` applies a linear multiplier, `normalize` applies peak or LUFS normalisation, `fade` applies a linear or exponential ramp, `pan` applies a constant-power stereo law, `mix` sums another document's blocks, `dither` applies TPDF dither, and `transform` applies a caller-supplied per-sample function.
#### Scenario: gain scales every sample
- **WHEN** a `gain` op with multiplier `0.5` processes a block
- **THEN** every output sample equals the corresponding input sample multiplied by `0.5`
#### Scenario: pan applies a constant-power law
- **WHEN** a `pan` op at hard-left processes a stereo block
- **THEN** the left channel carries the constant-power-scaled signal and the right channel is silent
**Verified by:** `src/registry/built-in/gain.test.ts::"gain scales every sample by the multiplier"` AND `src/registry/built-in/pan.test.ts::"pan applies a constant-power stereo law"`
---
### Requirement: BlockProcessor interface
The package SHALL export a `BlockProcessor` interface (`name`, `setup(config)`, `process(input, output, ctx)`, `reset()`, `dispose()`) and the supporting `ProcessorConfig` and `BlockContext` types. This is the universal DSP contract implemented by every FX, analysis, and codec-post-processing plugin across the audio family. A reference `NoopBlockProcessor` SHALL ship for tests, copying `input` to `output` unchanged.
#### Scenario: NoopBlockProcessor passes audio through unchanged
- **WHEN** `NoopBlockProcessor.process` runs on a block
- **THEN** `output` is byte-equal to `input`
**Verified by:** `src/process/block-processor.test.ts::"NoopBlockProcessor copies input to output unchanged"`
---
### Requirement: Streaming integration with @suaveplan/streams
`AudioDocument.stream()` and `PlanEngine.streamPlan()` SHALL produce values composable with `@suaveplan/streams` `TransformStream`. The package SHALL expose a helper that wraps a compiled plan plus process pipeline as a `TransformStream<Float32Array[], Float32Array[]>`, so downstream FX can be appended with native stream backpressure.
#### Scenario: Plan renders through a TransformStream
- **WHEN** a compiled plan is wrapped as a `TransformStream` and piped to a sink
- **THEN** the sink receives the same blocks `streamPlan` would yield directly
**Verified by:** `src/stream/stream.test.ts::"compiled plan renders through a @suaveplan/streams TransformStream"`
---
### Requirement: Audio statistics
The package SHALL export `computeStats(pages)` returning `AudioStats` (`{ peak, rms, dcOffset, lufs? }`) over a page set. `peak` SHALL be the maximum absolute sample, `rms` the root-mean-square across all channels, and `dcOffset` the mean sample value. Stats SHALL be attachable to `AudioDocument.stats`.
#### Scenario: computeStats reports peak, rms, and dcOffset
- **WHEN** `computeStats` runs over a known signal (e.g. a unity-amplitude sine)
- **THEN** `peak` is `1`, `rms` is approximately `0.707`, and `dcOffset` is approximately `0`
**Verified by:** `src/stats/stats.test.ts::"computeStats reports peak, rms, and dcOffset for a known signal"`
---
### Requirement: AbortSignal propagation
`AudioDocument.read()`, `AudioDocument.stream()`, `PlanEngine.streamPlan()`, and `PlanEngine.ensurePages()` SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to throw `AudioError` with code `AUDIO_STREAM_ABORTED` before any page work; a signal aborted mid-stream SHALL terminate the generator at the next block boundary with the same error.
#### Scenario: Pre-aborted signal short-circuits stream
- **WHEN** `stream()` is called with an already-aborted signal
- **THEN** it throws `AudioError` whose `code === "AUDIO_STREAM_ABORTED"` before yielding any block
#### Scenario: Mid-stream abort terminates at the next block boundary
- **WHEN** a signal is aborted after the first block is yielded
- **THEN** the generator throws `AUDIO_STREAM_ABORTED` at the next block boundary and yields nothing further
**Verified by:** `src/document/document.test.ts::"stream throws AUDIO_STREAM_ABORTED for a pre-aborted signal"` AND `src/plan/plan-engine.test.ts::"streamPlan honours mid-stream abort"`
---
### Requirement: Zod boundary validation
Every public options object — `AudioDocumentOptions`, `AudioPage`, `AudioEdit`, `AudioStats`, `StreamOptions`, `CompiledPlan`, `AudioOpDescriptor`, `ProcessorConfig` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point SHALL validate its options argument against the schema before use. A validation failure SHALL throw `AudioError` with code `AUDIO_INVALID_EDIT` carrying the Zod issue list in `context`.
#### Scenario: Invalid options are rejected at the boundary
- **WHEN** `AudioDocument` is constructed with a negative `sampleRate`
- **THEN** it throws `AudioError` whose `code === "AUDIO_INVALID_EDIT"` with the Zod issues in `context`
**Verified by:** `src/document/document.test.ts::"constructor rejects invalid options with AUDIO_INVALID_EDIT"`
---
### Requirement: Structured error codes
The package SHALL export `AudioCoreErrorCode`, `AUDIO_CORE_ERROR_METADATA`, and `registerAudioCoreErrorCodes()`, registering the codes `AUDIO_PLUGIN_NOT_FOUND`, `AUDIO_INVALID_EDIT`, `AUDIO_STREAM_ABORTED`, `AUDIO_PAGE_OUT_OF_RANGE`, and `AUDIO_PROCESS_ERROR` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
#### Scenario: Every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_CORE_ERROR_METADATA` is asserted thrown (or carried on a report) by at least one test
**Verified by:** `src/error-codes.test.ts::"every AUDIO_CORE code is reachable from a call site"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@opentelemetry/api`: spans `plan.compile`, `process.block`, and `stream.read`; counters `audio_core_edits_total` and `audio_core_stream_blocks_total`; histograms `audio_core_compile_duration_ms` and `audio_core_process_duration_ms`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: Instrumentation is inert without an exporter
- **WHEN** the package is imported and used with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
**Verified by:** `src/plan/plan-engine.test.ts::"compile records a span and histogram when an exporter is registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module (`./document/document`, `./page/page`, `./plan/plan-engine`, `./plan/segment`, `./process/process-pipeline`, `./process/block-processor`, `./registry/op-registry`, `./stream/stream`, `./render/render`, `./stats/stats`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
#### Scenario: Each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-core/plan/plan-engine`
- **THEN** the import resolves to the plan-engine module without pulling the whole package barrel
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`

View File

@@ -0,0 +1,159 @@
# Tasks: enterprise-audio-core
**Change ID**: enterprise-audio-core
**Package**: `@suaveplan/audio-core`
**Target path**: `packages/audio/audio-core/`
**Prerequisite packages on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/streams`, `@suaveplan/serialize`
---
## 1. Scaffold
- [ ] Create directory `packages/audio/audio-core/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
- [ ] Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, full `exports` map, deps: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/events workspace:^`, `@suaveplan/streams workspace:^`, `@suaveplan/serialize workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`.
- [ ] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
- [ ] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`.
- [ ] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
---
## 2. Types + errors
- [ ] Write `src/document/document.types.ts` — `AudioDocumentOptions`, `AudioDocumentJSON`, `AudioPage`, `AudioEdit`, `AudioStats`, `StreamOptions`, `PCMFormat`, `AudioDocumentEventMap`.
- [ ] Write `src/document/document.md`.
- [ ] Write `src/plan/plan-engine.types.ts` — `CompiledPlan`, `Segment`, `PlanContext`, `PlanFn`, `ResolveFn`, `SampleRateTransformFn`, `ChannelTransformFn`.
- [ ] Write `src/plan/plan-engine.md`.
- [ ] Write `src/plan/segment.types.ts` — segment helpers and Zod schema.
- [ ] Write `src/plan/segment.md`.
- [ ] Write `src/process/process-pipeline.types.ts` — `ProcessState`, `BlockContext`, `ProcessFn`.
- [ ] Write `src/process/process-pipeline.md`.
- [ ] Write `src/process/block-processor.types.ts` — `ProcessorConfig`, `BlockProcessor`.
- [ ] Write `src/process/block-processor.md`.
- [ ] Write `src/registry/op-registry.types.ts` — `AudioOpDescriptor`.
- [ ] Write `src/registry/op-registry.md`.
- [ ] Write `src/stream/stream.types.ts` — stream option types.
- [ ] Write `src/stream/stream.md`.
- [ ] Write `src/stats/stats.types.ts` — `AudioStats` and computed-stat types.
- [ ] Write `src/stats/stats.md`.
- [ ] Write `src/error-codes.ts` — `AudioCoreErrorCode`, `AUDIO_CORE_ERROR_METADATA`, `registerAudioCoreErrorCodes()`.
- [ ] Write Zod schemas for every public options object (`AudioDocumentOptionsSchema`, `AudioPageSchema`, `AudioEditSchema`, `AudioStatsSchema`, `StreamOptionsSchema`, `CompiledPlanSchema`, `AudioOpDescriptorSchema`, `ProcessorConfigSchema`).
---
## 3. Page primitives
- [ ] Implement `PAGE_SIZE` and `BLOCK_SIZE` constants.
- [ ] Implement `walkPages()` — iterate overlapping pages with a visitor callback.
- [ ] Implement `copyPages()` — copy samples to a pre-allocated target buffer.
- [ ] Implement `readPages()` — read a range as `Float32Array[]`.
- [ ] Implement page-pool allocator (`allocatePage`, `releasePage`) to reduce GC pressure.
- [ ] Write `src/page/page.ts` and `src/page/page.test.ts`.
- [ ] Write `src/page/page.md`.
---
## 4. AudioDocument
- [ ] Implement `AudioDocument` constructor with id, sampleRate, channels, sourceLength, pages, edits.
- [ ] Implement virtual `length` and `duration` getters (cached, invalidated by version).
- [ ] Implement `run(...edits)` — append edits, increment version, emit `edit` event.
- [ ] Implement `undo(n?)` — pop last n edits, emit `undo` event, return removed edits.
- [ ] Implement `clone()` — deep copy with independent page array.
- [ ] Implement `toJSON()` / from-JSON round-trip via `@suaveplan/serialize`.
- [ ] Implement `read(offset?, duration?)` — resolve through `PlanEngine` then `readPages`.
- [ ] Implement `stream(opts?)` — async generator yielding `Float32Array[]` blocks via compiled plan + process pipeline.
- [ ] Implement `push(data, fmt?)` / `stop()` — recorder push model.
- [ ] Implement `on` / `off` event subscription via `@suaveplan/events`.
- [ ] Wire `AbortSignal` through `read()` and `stream()`.
- [ ] Write `src/document/document.test.ts` (≤500 lines): construct, run edit, undo, clone equality, read range, stream total samples, abort.
- [ ] Write `src/document/document.md`.
---
## 5. PlanEngine
- [ ] Implement `compilePlan()` two-pass resolver + plan compiler per spec algorithm.
- [ ] Implement `PlanEngine.compile(doc, final?)` — returns `CompiledPlan` with segs, pipeline, totalLen, sampleRate, limit.
- [ ] Implement `PlanEngine.readPlan()` — render a static range using compiled segments.
- [ ] Implement `PlanEngine.streamPlan()` — async generator over compiled plan blocks.
- [ ] Implement `PlanEngine.ensurePages()` — preload evicted pages needed for a range.
- [ ] Wire OTel span on `plan.compile` + histogram `audio_core_compile_duration_ms`.
- [ ] Write `src/plan/plan-engine.test.ts`.
- [ ] Write `src/plan/segment.test.ts` — segment overlap, merge, split helpers.
- [ ] Write `src/plan/plan-engine.md`.
- [ ] Write `src/plan/segment.md`.
---
## 6. ProcessPipeline
- [ ] Implement `ProcessPipeline.init()` — allocate `ProcessState[]` for a pipeline.
- [ ] Implement `ProcessPipeline.patch()` — hot-swap pipeline edits without re-allocating buffers.
- [ ] Implement `ProcessPipeline.process()` — run each process op in order with error isolation (emit `plugin:error`, pass input through on crash).
- [ ] Wire OTel span on `process.block` + histogram `audio_core_process_duration_ms`.
- [ ] Write `src/process/process-pipeline.test.ts`.
- [ ] Write `src/process/process-pipeline.md`.
---
## 7. OpRegistry + built-in ops
- [ ] Implement `OpRegistry.register()` / `unregister()` / `get()` / `list()`.
- [ ] Implement `OpRegistry.createWithDefaults()` — pre-register all built-in ops.
- [ ] Implement plan ops: `trim`, `crop`, `speed`, `stretch`, `reverse`, `insert`, `remove`, `repeat`, `pad`, `resample`.
- [ ] Implement process ops: `gain`, `normalize`, `fade`, `pan`, `mix`, `dither`, `transform`.
- [ ] Write `src/registry/op-registry.test.ts`.
- [ ] Write `src/registry/op-registry.md`.
- [ ] Write per-op tests under `src/registry/built-in/` (co-located with each `.ts` file).
---
## 8. BlockProcessor
- [ ] Implement `BlockProcessor` interface and a `NoopBlockProcessor` reference implementation for tests.
- [ ] Write `src/process/block-processor.test.ts`.
- [ ] Write `src/process/block-processor.md`.
---
## 9. Stream renderer
- [ ] Implement `renderBlock()` — copy source segments into output buffers per compiled plan.
- [ ] Integrate with `@suaveplan/streams` `TransformStream` for downstream transforms.
- [ ] Write `src/stream/stream.test.ts`.
- [ ] Write `src/stream/stream.md`.
---
## 10. Stats
- [ ] Implement `computeStats(pages)` — peak, RMS, DC offset over a page set.
- [ ] Write `src/stats/stats.test.ts`.
- [ ] Write `src/stats/stats.md`.
---
## 11. Benchmarks
- [ ] Write `src/__benchmarks__/plan.bench.ts` — compile 100 edits into a plan (1000 iterations).
- [ ] Write `src/__benchmarks__/stream.bench.ts` — stream 5 minutes of 48kHz stereo audio through 10 process ops.
- [ ] Write `src/__benchmarks__/process.bench.ts` — run `normalize` + `fade` + `pan` over 1M samples.
---
## 12. Integration + property tests
- [ ] Write `tests/integration.test.ts` — full round-trip: construct document, run edits, compile plan, stream, read range, clone, toJSON round-trip.
- [ ] Write `tests/property.test.ts` (fast-check) — identity edit preserves plan length; stream yields exact total sample count; clone `toJSON()` equals original `toJSON()`.
---
## 13. Docs + verify + publish
- [ ] All co-located `.md` files (≥200 words each).
- [ ] Write `README.md` at package root.
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/audio-core` — green.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/audio-core` — 100%.
- [ ] `bun scripts/publish.ts --package @suaveplan/audio-core`.
- [ ] Fold `specs/audio-core/spec.md` into `openspec/specs/audio-core/spec.md`.
- [ ] Archive change folder to `openspec/archive/2026-05-14-enterprise-audio-core/`.