Initial Commit
This commit is contained in:
357
openspec/archive/2026-05-14-enterprise-audio-core/design.md
Normal file
357
openspec/archive/2026-05-14-enterprise-audio-core/design.md
Normal 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.
|
||||
440
openspec/archive/2026-05-14-enterprise-audio-core/proposal.md
Normal file
440
openspec/archive/2026-05-14-enterprise-audio-core/proposal.md
Normal 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.
|
||||
@@ -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"`
|
||||
159
openspec/archive/2026-05-14-enterprise-audio-core/tasks.md
Normal file
159
openspec/archive/2026-05-14-enterprise-audio-core/tasks.md
Normal 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/`.
|
||||
Reference in New Issue
Block a user