Initial Commit
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
# Design: `@suaveplan/audio-codec-ffmpeg-wasm`
|
||||
|
||||
Non-normative design notes. The normative contract lives in
|
||||
`./specs/audio-codec-ffmpeg-wasm/spec.md`.
|
||||
|
||||
---
|
||||
|
||||
## Why wasm + a Web Worker
|
||||
|
||||
`@suaveplan/audio-codecs` defines a tier-agnostic plugin contract. The server-tier
|
||||
`audio-codec-ffmpeg-native` package wraps a system `ffmpeg` binary via subprocess. The
|
||||
browser cannot spawn subprocesses, and there is no `ffmpeg` analogue baked into Web Audio.
|
||||
The only credible way to reach the same format coverage in a sandboxed page is `ffmpeg.wasm`
|
||||
— ffmpeg compiled to WebAssembly and exposed via the `@ffmpeg/ffmpeg` JS façade.
|
||||
|
||||
Two non-negotiable design choices follow from that:
|
||||
|
||||
1. **The wasm core runs inside a `Worker`.** Decoding a 5-minute MP3 takes hundreds of
|
||||
milliseconds of solid CPU; doing that on the main thread blocks input handling, layout,
|
||||
and the compositor. `@ffmpeg/ffmpeg` already supports a worker boundary; we always use
|
||||
it. The worker is the unit of cancellation: `AbortSignal.abort` translates to
|
||||
`worker.terminate()`, which is the only reliable way to interrupt wasm.
|
||||
2. **The wasm payload is lazy-loaded.** `@ffmpeg/core` is ~30 MB; eagerly fetching it at
|
||||
`import` time would crater page load. The codec stays inert at module load and pulls the
|
||||
core only on the first `decode`/`encode`/`createDecoder`/`createEncoder` call. A second
|
||||
call reuses the loaded core; `dispose()` tears it down.
|
||||
|
||||
## Lazy load strategy
|
||||
|
||||
The `core-loader` module exposes `loadCore(opts): Promise<LoadedCore>` and memoises the
|
||||
result per `FfmpegWasmCodec` instance. Resolution order for the core URL:
|
||||
|
||||
1. `opts.corePath` (explicit local URL) — preferred for self-hosted, COEP-correct serving.
|
||||
2. `opts.coreUrl` (explicit CDN URL).
|
||||
3. The package's own bundled default (`unpkg.com/@ffmpeg/core@x.y.z/dist/ffmpeg-core.js`)
|
||||
— documented but not the recommended path; only used when neither option is set.
|
||||
|
||||
When `opts.enableMultithread === true`:
|
||||
|
||||
- The loader inspects `globalThis.crossOriginIsolated`. If it is not `true`, the loader
|
||||
throws `AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED` with a context payload
|
||||
explaining the COOP/COEP requirement (see "Cross-origin isolation" below). This is the
|
||||
only path that surfaces that code — when `enableMultithread` is unset or `false` the
|
||||
loader falls back to the single-thread core silently.
|
||||
- When COI is true, the loader dynamic-imports `@ffmpeg/core-mt` (declared in
|
||||
`optionalDependencies` so single-thread consumers do not pay the install cost). If the
|
||||
dynamic import rejects (not installed), the loader emits a warning via `opts.logger` and
|
||||
falls back to `@ffmpeg/core` rather than failing — multi-thread is an optimisation, not a
|
||||
correctness boundary.
|
||||
|
||||
## Cross-origin isolation
|
||||
|
||||
The multi-threaded core uses `SharedArrayBuffer`, which browsers gate behind cross-origin
|
||||
isolation:
|
||||
|
||||
- The page must be served with `Cross-Origin-Opener-Policy: same-origin` and
|
||||
`Cross-Origin-Embedder-Policy: require-corp` (or `credentialless`).
|
||||
- All loaded resources must opt-in via `Cross-Origin-Resource-Policy` or be
|
||||
`crossorigin="anonymous"` with proper CORS.
|
||||
- `globalThis.crossOriginIsolated` is `true` only when both headers are present.
|
||||
|
||||
The codec **never silently downgrades correctness** because of COI. The flag controls
|
||||
performance only:
|
||||
|
||||
| `enableMultithread` | `crossOriginIsolated` | Result |
|
||||
|---|---|---|
|
||||
| `false` (default) | any | Single-thread core loads |
|
||||
| `true` | `true` | `@ffmpeg/core-mt` loads (with fallback to single-thread if optional dep missing) |
|
||||
| `true` | `false` | Throws `AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED` on first use |
|
||||
|
||||
The "throw" case is the only way that error code is surfaced, satisfying §2c (every
|
||||
registered code reachable from a real call site).
|
||||
|
||||
## Worker isolation
|
||||
|
||||
`@ffmpeg/ffmpeg`'s `FFmpeg` class spawns a dedicated worker. We do not subclass `FFmpeg`;
|
||||
the `worker-host` module wraps it to add:
|
||||
|
||||
- `AbortSignal` plumbing → `worker.terminate()` on abort plus a `Promise.reject` with
|
||||
`AUDIO_CODEC_FFMPEG_WASM.ABORTED`.
|
||||
- Timeout watchdog (default 60s for decode, 120s for encode; both configurable via
|
||||
`EncodeOpts`/`DecodeOpts` `signal` plus a wrapping `AbortSignal.timeout`) →
|
||||
`AUDIO_CODEC_FFMPEG_WASM.TIMEOUT`.
|
||||
- A `progress` event listener that bridges into `opts.progressCallback`.
|
||||
- Crash detection: an unexpected worker `error` event maps to
|
||||
`AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED` if it fires before `load()` resolved, else
|
||||
to `AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED` / `_ENCODE_FAILED` depending on the active
|
||||
call.
|
||||
- Memory-pressure detection: wasm linear memory is capped at 4 GB; ffmpeg.wasm surfaces
|
||||
`OutOfMemory` via stderr or an `Aborted()` JS exception. The host parses this and re-emits
|
||||
`AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED` with the requested allocation size in
|
||||
`context`.
|
||||
|
||||
Test seam: `opts.workerFactory` overrides `new Worker(coreUrl, …)` so tests can supply a
|
||||
controllable shim. The shim is exercised in `tests/` under `happy-dom` plus a hand-rolled
|
||||
`Worker` polyfill — see `worker-host.test.ts`.
|
||||
|
||||
## Decode pipeline
|
||||
|
||||
Per the `AudioCodec` contract, `decode(bytes)` returns
|
||||
`DecodeResult = { channelData: Float32Array[]; sampleRate: number; format: string }`. The
|
||||
ffmpeg command we run is:
|
||||
|
||||
```text
|
||||
ffmpeg -hide_banner -nostdin -i input.<ext> -f f32le -ac <preserve> -ar <preserve> output.raw
|
||||
```
|
||||
|
||||
Steps:
|
||||
|
||||
1. Inspect `bytes` magic to pick the input container/extension hint (re-using
|
||||
`@suaveplan/audio-metadata`'s sniffer through `audio-codecs`). Failure to detect →
|
||||
`AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC`.
|
||||
2. `ffmpeg.writeFile('input.' + ext, bytes)` — writes into MEMFS (in-memory virtual FS).
|
||||
3. Run a probe pass with `ffprobe`-equivalent flags to learn the source `sampleRate` and
|
||||
`channels`. (`@ffmpeg/ffmpeg` exposes this as a JSON log line.)
|
||||
4. Run the f32le extraction; ffmpeg writes interleaved `Float32` samples.
|
||||
5. `ffmpeg.readFile('output.raw')` → `Uint8Array`.
|
||||
6. Deinterleave into `Float32Array[]` per channel.
|
||||
7. `ffmpeg.deleteFile('input.' + ext)` and `ffmpeg.deleteFile('output.raw')` to free MEMFS.
|
||||
|
||||
Any ffmpeg non-zero exit → `AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED` with the stderr tail in
|
||||
`context`.
|
||||
|
||||
## Encode pipeline
|
||||
|
||||
`encode(pcm, opts)` accepts `Float32Array[]` per channel plus an `EncodeOpts` with at
|
||||
minimum `format`, `sampleRate`, and a format-specific `bitrate` / `quality`. Steps:
|
||||
|
||||
1. Interleave channels into a single `Float32Array`, then `new Uint8Array(buffer)` of the
|
||||
underlying bytes.
|
||||
2. `ffmpeg.writeFile('input.raw', interleavedBytes)`.
|
||||
3. Run a format-specific command, for example for MP3:
|
||||
|
||||
```text
|
||||
ffmpeg -f f32le -ar <rate> -ac <ch> -i input.raw -c:a libmp3lame -b:a <bitrate> output.mp3
|
||||
```
|
||||
|
||||
4. `ffmpeg.readFile('output.<ext>')` → `Uint8Array` returned to caller.
|
||||
5. Cleanup both MEMFS entries.
|
||||
|
||||
Each supported format owns a small command-builder function in `encode-pipeline.ts`. A
|
||||
`format` outside the supported set → `AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC` *before*
|
||||
any wasm call.
|
||||
|
||||
## Streaming "emulation" — be honest
|
||||
|
||||
`@ffmpeg/ffmpeg` is not naturally streaming. Its MEMFS surface is whole-file in, whole-file
|
||||
out. We expose `StreamingDecoder` / `StreamingEncoder` because the `AudioCodec` contract
|
||||
asks for it, but the implementation is **buffered emulation**, and the spec scenario calls
|
||||
this out plainly. The behaviour:
|
||||
|
||||
- `StreamingDecoder.write(chunk)`: append `chunk` to an internal `Uint8Array` buffer; do
|
||||
**not** call ffmpeg. Always returns `null` (no decoded pages yet).
|
||||
- `StreamingDecoder.flush()`: write the buffered bytes to MEMFS, run the same decode
|
||||
pipeline as `decode()`, and return the resulting `DecodeResult`. Subsequent `flush()`
|
||||
calls on the same decoder are no-ops.
|
||||
- Progress events fire during the `flush()` call only, not during `write()`.
|
||||
- `StreamingEncoder` is symmetric.
|
||||
|
||||
This is documented in the spec scenario "Streaming is buffered emulation, not chunked
|
||||
decode" so callers do not assume true streaming behaviour. Real streaming would require a
|
||||
fork of `@ffmpeg/ffmpeg` that pipes stdout/stdin through `MessagePort` — out of scope.
|
||||
|
||||
## Format support matrix
|
||||
|
||||
| Format | Decode | Encode | Encoder cmd flag |
|
||||
|---|---|---|---|
|
||||
| `wav` | ✅ | ✅ | `-c:a pcm_s16le` (default) |
|
||||
| `mp3` | ✅ | ✅ | `-c:a libmp3lame` |
|
||||
| `ogg` | ✅ | ✅ | `-c:a libvorbis` |
|
||||
| `opus` | ✅ | ✅ | `-c:a libopus` |
|
||||
| `flac` | ✅ | ✅ | `-c:a flac` |
|
||||
| `aac` | ✅ | ✅ | `-c:a aac` |
|
||||
| `webm` | ✅ | ✅ | `-c:a libopus -f webm` |
|
||||
|
||||
The exact codec availability depends on which `@ffmpeg/core` build is loaded. The default
|
||||
`@ffmpeg/core` build ships `libmp3lame`, `libvorbis`, `libopus`, `flac`, `aac`, `pcm_*`. A
|
||||
runtime probe (`ffmpeg -encoders` parsed once at load) checks availability; missing entries
|
||||
get pruned from `extensions`/`mimeTypes` rather than silently failing later.
|
||||
|
||||
## Trade-offs vs the native server-tier counterpart
|
||||
|
||||
| Axis | `audio-codec-ffmpeg-native` (server) | `audio-codec-ffmpeg-wasm` (browser) |
|
||||
|---|---|---|
|
||||
| Throughput | Hardware SIMD, full CPU | 10–50× slower; wasm SIMD only when COI |
|
||||
| Startup cost | Negligible (system binary) | ~30 MB wasm fetch + ~200ms instantiate |
|
||||
| Memory ceiling | OS-bound | 4 GB hard wasm cap |
|
||||
| Sandbox | None | Page sandbox + Worker isolation |
|
||||
| Streaming | True streaming via pipes | Buffered emulation |
|
||||
| Cancellation | `child.kill()` | `worker.terminate()` |
|
||||
| Format reach | Whatever the system ffmpeg supports | Whatever the loaded core ships |
|
||||
|
||||
The two are interchangeable at the registry level: applications register the appropriate
|
||||
codec per tier; consumers of `CodecRegistry.decode(source)` see the same `AudioDocument`
|
||||
shape regardless.
|
||||
|
||||
## OTel instrumentation
|
||||
|
||||
The codec wraps every public method in a span:
|
||||
|
||||
- `audio-codec-ffmpeg-wasm.decode` — attributes: `format`, `bytes`, `channels`,
|
||||
`sampleRate`, `multithread`.
|
||||
- `audio-codec-ffmpeg-wasm.encode` — attributes: `format`, `samples`, `channels`,
|
||||
`sampleRate`, `bitrate`.
|
||||
- Counter `audio_codec_ffmpeg_wasm_decode_total` (labels: `format`, `outcome`).
|
||||
- Counter `audio_codec_ffmpeg_wasm_encode_total` (labels: `format`, `outcome`).
|
||||
- Histogram `audio_codec_ffmpeg_wasm_decode_duration_ms` (labels: `format`).
|
||||
- Histogram `audio_codec_ffmpeg_wasm_encode_duration_ms` (labels: `format`).
|
||||
|
||||
All instrumentation is a no-op when no exporter is registered; nothing executes at module
|
||||
import. The injectable `Clock` drives the histogram measurements so tests are
|
||||
deterministic.
|
||||
|
||||
## Browser-tier purity
|
||||
|
||||
`CLAUDE.md` rule "Universal: ZERO browser/React deps | Browser: React/DOM ok | Server:
|
||||
Node.js ok" makes the constraint explicit. This package is browser-tier — DOM and Worker
|
||||
APIs are allowed; Node-only APIs are forbidden **even via dynamic import**. The build is
|
||||
configured `"browser": true` in `package.json` and the test in `tests/purity.test.ts`
|
||||
greps the built `dist/` for `node:`, `process.`, `Buffer.`, and `require(` to confirm.
|
||||
|
||||
Source bytes arrive as `ArrayBuffer` / `Uint8Array` / `ReadableStream<Uint8Array>` —
|
||||
never as a file path. File-path sources are the registry's problem and resolved by the
|
||||
universal `audio-codecs` source-resolution code paths before bytes ever reach this codec.
|
||||
@@ -0,0 +1,209 @@
|
||||
# OpenSpec Change: enterprise-audio-codec-ffmpeg-wasm
|
||||
|
||||
- **Change ID**: enterprise-audio-codec-ffmpeg-wasm
|
||||
- **Package**: `@suaveplan/audio-codec-ffmpeg-wasm`
|
||||
- **Category**: browser
|
||||
- **Target path**: `packages/media/audio/audio-codec-ffmpeg-wasm/`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-15
|
||||
- **Design**: `./design.md`
|
||||
- **Spec delta**: `./specs/audio-codec-ffmpeg-wasm/spec.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver `@suaveplan/audio-codec-ffmpeg-wasm` on the genesis branch from scratch at enterprise quality. The package is the browser-tier concrete codec plugin that implements the `AudioCodec` plugin contract from `@suaveplan/audio-codecs` by wrapping `@ffmpeg/ffmpeg` (ffmpeg.wasm) and `@ffmpeg/core` running inside a Web Worker. It exposes a `FfmpegWasmCodec` class covering decode and encode for `wav`, `mp3`, `ogg`, `opus`, `flac`, `aac`, and `webm` — the same format surface as the native server-tier counterpart so the two are interchangeable at the `CodecRegistry` level. A single-line `register(registry)` helper installs the codec into a shared registry. The wasm core (~30 MB) is lazy-loaded on the first `decode`/`encode` call, never at module import. When the host page is cross-origin-isolated (`crossOriginIsolated === true`) and `enableMultithread` is set, the package loads the multi-threaded `@ffmpeg/core-mt` variant via `optionalDependencies`; otherwise it falls back to the single-thread core. The codec is genesis-original — there is no upstream counterpart to migrate.
|
||||
|
||||
---
|
||||
|
||||
## 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. It is genesis-original: no upstream `../suaveplan/packages/audio-codec-ffmpeg-wasm` exists and no entry in `STATUS.md`'s `Pending` list — the change is authored under §0b's third clause ("does not exist in `Pending` or `Completed`").
|
||||
|
||||
---
|
||||
|
||||
## Available workspace deps on genesis
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@suaveplan/audio-codecs` (`workspace:^`) | `AudioCodec` interface, `CodecRegistry`, `DecodeResult`, streaming factory types |
|
||||
| `@suaveplan/audio-core` (`workspace:^`) | `AudioDocument`, `AudioPage` (consumed indirectly via the codec contract) |
|
||||
| `@suaveplan/streams` (`workspace:^`) | WHATWG `ReadableStream` / `TransformStream` primitives for chunked source feeding |
|
||||
| `@suaveplan/error` (`workspace:^`) | Base class for `AudioCodecFfmpegWasmError`; `AUDIO_CODEC_FFMPEG_WASM.*` codes |
|
||||
| `@suaveplan/types` (`workspace:^`) | `LoggerLike`, `Clock` shared abstractions |
|
||||
| `@ffmpeg/ffmpeg` (catalog entry — add) | Main-thread façade over ffmpeg.wasm; spawns the worker and drives MEMFS |
|
||||
| `@ffmpeg/core` (catalog entry — add) | Single-threaded wasm core (~30 MB) |
|
||||
| `@ffmpeg/core-mt` (`optionalDependencies`) | Multi-threaded wasm core — only loaded when `crossOriginIsolated && enableMultithread` |
|
||||
| `zod` (`catalog:forms`) | Schemas for `FfmpegWasmCodecOptions` and the `register` argument |
|
||||
| `@opentelemetry/api` (`catalog:observability`) | Spans + counter + histogram on decode and encode |
|
||||
| `tslib` (`catalog:build`) | TypeScript helpers |
|
||||
|
||||
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `happy-dom`, `fast-check`, `mitata` (all `catalog:testing`). The `@suaveplan/testing/runner` wrapper is the only path to test-runner primitives.
|
||||
|
||||
> **Catalog action item:** `@ffmpeg/ffmpeg` and `@ffmpeg/core` are not currently in the root `package.json` `catalog` block. Adding both at exact pinned versions is the first task — see `tasks.md §1`.
|
||||
|
||||
---
|
||||
|
||||
## Public API
|
||||
|
||||
### Core types
|
||||
|
||||
```ts
|
||||
class AudioCodecFfmpegWasmError extends SuaveplanError
|
||||
|
||||
interface FfmpegWasmCodecOptions {
|
||||
/** CDN URL for `@ffmpeg/core` wasm; mutually exclusive with `corePath`. */
|
||||
coreUrl?: string
|
||||
/** Local URL for `@ffmpeg/core` wasm; mutually exclusive with `coreUrl`. */
|
||||
corePath?: string
|
||||
/** Test seam — overrides the default `new Worker(...)` construction. */
|
||||
workerFactory?: () => Worker
|
||||
/**
|
||||
* Opt into the multi-threaded `@ffmpeg/core-mt` variant.
|
||||
* Requires `crossOriginIsolated === true`; otherwise throws on first use.
|
||||
* Default `false` (auto-detect: if `crossOriginIsolated` is true, mt is preferred only when this flag is also true).
|
||||
*/
|
||||
enableMultithread?: boolean
|
||||
/** Receives progress in `[0, 1]` from ffmpeg's `progress` event. */
|
||||
progressCallback?: (progress: number) => void
|
||||
/** Structured logger; defaults to a no-op. */
|
||||
logger?: LoggerLike
|
||||
/** Injectable clock for OTel histograms and timeouts; defaults to `Date.now`-based. */
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
class FfmpegWasmCodec implements AudioCodec {
|
||||
readonly name: 'ffmpeg-wasm'
|
||||
readonly extensions: ['wav', 'mp3', 'ogg', 'opus', 'flac', 'aac', 'webm']
|
||||
readonly mimeTypes: string[]
|
||||
|
||||
constructor(options?: FfmpegWasmCodecOptions)
|
||||
|
||||
canDecode(bytes: Uint8Array): boolean
|
||||
decode(bytes: Uint8Array | ArrayBuffer): Promise<DecodeResult>
|
||||
encode(pcm: Float32Array[], opts: EncodeOpts): Promise<Uint8Array>
|
||||
|
||||
createDecoder(): StreamingDecoder
|
||||
createEncoder(): StreamingEncoder
|
||||
|
||||
/** Terminates the worker and releases the loaded core. */
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/** One-line install helper. Constructs the codec with `options` and registers it. */
|
||||
function register(registry: CodecRegistry, options?: FfmpegWasmCodecOptions): FfmpegWasmCodec
|
||||
```
|
||||
|
||||
### Error codes
|
||||
|
||||
```ts
|
||||
type AudioCodecFfmpegWasmErrorCode =
|
||||
| 'AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED'
|
||||
| 'AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED'
|
||||
| 'AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED'
|
||||
| 'AUDIO_CODEC_FFMPEG_WASM.ENCODE_FAILED'
|
||||
| 'AUDIO_CODEC_FFMPEG_WASM.TIMEOUT'
|
||||
| 'AUDIO_CODEC_FFMPEG_WASM.ABORTED'
|
||||
| 'AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC'
|
||||
| 'AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED'
|
||||
| 'AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED'
|
||||
|
||||
const AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA:
|
||||
Readonly<Record<AudioCodecFfmpegWasmErrorCode, CodeMetadata>>
|
||||
function registerAudioCodecFfmpegWasmErrorCodes(): void
|
||||
```
|
||||
|
||||
### Zod schemas
|
||||
|
||||
```ts
|
||||
FfmpegWasmCodecOptionsSchema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module layout
|
||||
|
||||
Five files per feature module, co-located with `.md` siblings per `CLAUDE.md` "Package Structure":
|
||||
|
||||
```text
|
||||
packages/media/audio/audio-codec-ffmpeg-wasm/
|
||||
package.json
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
README.md
|
||||
src/
|
||||
index.ts
|
||||
error-codes.ts
|
||||
error-codes.md
|
||||
error-codes.test.ts
|
||||
codec/
|
||||
ffmpeg-wasm-codec.ts
|
||||
ffmpeg-wasm-codec.md
|
||||
ffmpeg-wasm-codec.types.ts
|
||||
ffmpeg-wasm-codec.schemas.ts
|
||||
ffmpeg-wasm-codec.test.ts
|
||||
index.ts
|
||||
worker/
|
||||
worker-host.ts
|
||||
worker-host.md
|
||||
worker-host.types.ts
|
||||
worker-host.test.ts
|
||||
index.ts
|
||||
core-loader/
|
||||
core-loader.ts
|
||||
core-loader.md
|
||||
core-loader.types.ts
|
||||
core-loader.test.ts
|
||||
index.ts
|
||||
decode/
|
||||
decode-pipeline.ts
|
||||
decode-pipeline.md
|
||||
decode-pipeline.types.ts
|
||||
decode-pipeline.test.ts
|
||||
index.ts
|
||||
encode/
|
||||
encode-pipeline.ts
|
||||
encode-pipeline.md
|
||||
encode-pipeline.types.ts
|
||||
encode-pipeline.test.ts
|
||||
index.ts
|
||||
streaming/
|
||||
streaming-decoder.ts
|
||||
streaming-decoder.md
|
||||
streaming-decoder.test.ts
|
||||
streaming-encoder.ts
|
||||
streaming-encoder.md
|
||||
streaming-encoder.test.ts
|
||||
index.ts
|
||||
register/
|
||||
register.ts
|
||||
register.md
|
||||
register.test.ts
|
||||
index.ts
|
||||
__benchmarks__/
|
||||
decode.bench.ts
|
||||
encode.bench.ts
|
||||
tests/
|
||||
purity.test.ts
|
||||
exports.test.ts
|
||||
integration.test.ts
|
||||
```
|
||||
|
||||
Every feature module's `index.ts` is an explicit named-export barrel per `CLAUDE.md` rule 10; no `export *`.
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
**Breaking changes**: none — fresh build, package does not exist on genesis.
|
||||
|
||||
**Prerequisites**: `@suaveplan/audio-codecs`, `@suaveplan/audio-core`, `@suaveplan/streams`, `@suaveplan/error`, `@suaveplan/types` published to Verdaccio. All five are scheduled in the same `enterprise-audio-*` batch and must land before this package builds.
|
||||
|
||||
### Affected dependents
|
||||
|
||||
`@suaveplan/audio-codec-ffmpeg-wasm` is a leaf codec plugin — nothing in the workspace currently depends on it. It is installed at runtime by browser-tier applications via the `register(registry)` helper; those applications are out-of-tree consumers and do not appear in `bunx turbo ls --filter=...@suaveplan/audio-codec-ffmpeg-wasm`. There is therefore no in-repo dependent surface to sweep.
|
||||
|
||||
- _(no in-repo dependents)_ — [x] no action needed (reason: leaf plugin; consumers are out-of-tree browser applications)
|
||||
|
||||
Once landed, any subsequent change to this package's public surface MUST run `bunx turbo test --filter=...@suaveplan/audio-codec-ffmpeg-wasm` per `openspec/AGENTS.md` §13 step 10 so that any future in-repo dependent (e.g. a browser audio engine that bundles a default codec set) is covered automatically.
|
||||
@@ -0,0 +1,296 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: audio-codec-ffmpeg-wasm (audio-codec-ffmpeg-wasm)
|
||||
|
||||
**Package**: `@suaveplan/audio-codec-ffmpeg-wasm`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Browser-tier purity
|
||||
|
||||
The `@suaveplan/audio-codec-ffmpeg-wasm` package SHALL be browser-tier and MUST NOT import Node-only APIs (`node:fs`, `node:path`, `node:crypto`, `process`, `Buffer`, `require`) at any point — neither statically nor through a dynamic `import()`. Top-level imports SHALL be limited to `@suaveplan/audio-codecs`, `@suaveplan/audio-core`, `@suaveplan/streams`, `@suaveplan/error`, `@suaveplan/types`, `@ffmpeg/ffmpeg`, `zod`, `@opentelemetry/api`, and `tslib`. `@ffmpeg/core` SHALL be loaded only via the `@ffmpeg/ffmpeg` runtime, not via a static `import`. `@ffmpeg/core-mt` SHALL be declared in `optionalDependencies` and reached only via dynamic `import()` from inside the COI-gated branch of the core loader. `package.json` SHALL declare `"browser": true` and `"sideEffects": false`.
|
||||
|
||||
#### Scenario: built dist references no Node APIs
|
||||
|
||||
- **WHEN** the built `dist/` is grep'd for `node:`, `process.`, `Buffer.`, or top-level `require(`
|
||||
- **THEN** the search returns zero matches in any file under `dist/`
|
||||
|
||||
#### Scenario: `@ffmpeg/core-mt` is loaded only inside a dynamic import
|
||||
|
||||
- **WHEN** the built `dist/` is searched for `@ffmpeg/core-mt`
|
||||
- **THEN** every occurrence is inside an `import("@ffmpeg/core-mt")` expression, never inside a static `import … from` statement
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"dist contains no Node APIs"` AND `tests/purity.test.ts::"@ffmpeg/core-mt is reached only via dynamic import"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AudioCodec contract conformance
|
||||
|
||||
The package SHALL export a `FfmpegWasmCodec` class implementing the `AudioCodec` interface from `@suaveplan/audio-codecs`. The instance SHALL expose `name === "ffmpeg-wasm"`, an `extensions` array covering `"wav"`, `"mp3"`, `"ogg"`, `"opus"`, `"flac"`, `"aac"`, and `"webm"` (filtered down to only those the loaded core's encoder/decoder probe actually supports), and a matching `mimeTypes` array. The class SHALL implement `canDecode(bytes)`, `decode(bytes)`, `encode(pcm, opts)`, `createDecoder()`, and `createEncoder()` per the `AudioCodec` contract, plus a `dispose(): Promise<void>` that terminates the worker and releases the loaded core.
|
||||
|
||||
#### Scenario: instance satisfies the AudioCodec interface
|
||||
|
||||
- **WHEN** an `FfmpegWasmCodec` is constructed and inspected
|
||||
- **THEN** it has the declared `name`, non-empty `extensions` and `mimeTypes` arrays, and the methods `canDecode`, `decode`, `encode`, `createDecoder`, `createEncoder`, `dispose` are all functions
|
||||
|
||||
#### Scenario: decode return shape matches DecodeResult
|
||||
|
||||
- **WHEN** `decode` succeeds against a valid WAV input
|
||||
- **THEN** the result is `{ channelData: Float32Array[]; sampleRate: number; format: string }` with `channelData.length === channels` and `format === "wav"`
|
||||
|
||||
**Verified by:** `src/codec/ffmpeg-wasm-codec.test.ts::"instance satisfies the AudioCodec interface"` AND `src/codec/ffmpeg-wasm-codec.test.ts::"decode returns a well-formed DecodeResult"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Lazy core load on first use
|
||||
|
||||
The package SHALL NOT load the `@ffmpeg/core` wasm payload at module import time, at `FfmpegWasmCodec` construction time, or via any module-level side effect. The core SHALL be loaded only on the first call to `decode`, `encode`, `createDecoder`, or `createEncoder`. Subsequent calls SHALL reuse the loaded core. A core-load failure SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED` carrying the resolved core URL and the underlying error as `cause`.
|
||||
|
||||
#### Scenario: importing the package loads no wasm
|
||||
|
||||
- **WHEN** the package is imported and `new FfmpegWasmCodec()` is constructed
|
||||
- **THEN** no network request to a `@ffmpeg/core` resource has been issued and no wasm module has been instantiated
|
||||
|
||||
#### Scenario: core load failure throws AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED
|
||||
|
||||
- **WHEN** `decode` is called with a `corePath` that resolves to a non-existent URL
|
||||
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED"` with the URL in `context.coreUrl`
|
||||
|
||||
**Verified by:** `src/core-loader/core-loader.test.ts::"importing the package does not load wasm"` AND `src/core-loader/core-loader.test.ts::"core load failure throws AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Worker isolation for the wasm core
|
||||
|
||||
The package SHALL run the ffmpeg.wasm core inside a dedicated `Worker` so the main thread is never blocked by decode or encode work. Construction of the worker SHALL go through `opts.workerFactory()` when supplied (test seam); otherwise the package uses `@ffmpeg/ffmpeg`'s default worker construction. A worker that fails to spawn SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED` carrying the underlying error as `cause`.
|
||||
|
||||
#### Scenario: workerFactory test seam is honoured
|
||||
|
||||
- **WHEN** `decode` is called on an `FfmpegWasmCodec` constructed with `workerFactory: () => fakeWorker`
|
||||
- **THEN** the fake worker receives the decode messages and no real `Worker` constructor is invoked
|
||||
|
||||
#### Scenario: worker spawn failure throws AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED
|
||||
|
||||
- **WHEN** `opts.workerFactory` returns a worker that immediately emits an `error` event before `load()` resolves
|
||||
- **THEN** the first method call throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED"`
|
||||
|
||||
**Verified by:** `src/worker/worker-host.test.ts::"workerFactory test seam is honoured"` AND `src/worker/worker-host.test.ts::"worker spawn failure throws AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Decode pipeline
|
||||
|
||||
`decode(bytes)` SHALL write the input bytes into ffmpeg's MEMFS, run an ffmpeg command that emits `f32le` interleaved samples to a MEMFS output, read that output back, deinterleave into `Float32Array[]` per channel, and return a `DecodeResult` carrying the sniffed `sampleRate` and the input `format` name. Both MEMFS entries SHALL be deleted before returning. A non-zero ffmpeg exit SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED` carrying the stderr tail in `context.stderr` and the resolved `format` in `context.format`.
|
||||
|
||||
#### Scenario: decode produces normalised float PCM
|
||||
|
||||
- **WHEN** `decode` is called with a 16-bit WAV buffer
|
||||
- **THEN** the result `channelData` is `Float32Array[]` with all samples in `[-1, 1]` and `sampleRate` equals the source rate
|
||||
|
||||
#### Scenario: ffmpeg failure throws AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED
|
||||
|
||||
- **WHEN** `decode` is called with bytes that ffmpeg rejects (e.g. a truncated MP3 header)
|
||||
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED"` with the stderr tail in `context.stderr`
|
||||
|
||||
**Verified by:** `src/decode/decode-pipeline.test.ts::"decode produces normalised float PCM"` AND `src/decode/decode-pipeline.test.ts::"ffmpeg failure throws AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Encode pipeline
|
||||
|
||||
`encode(pcm, opts)` SHALL interleave `Float32Array[]` channels into a single buffer, write it to MEMFS as `f32le`, run the format-specific ffmpeg command (one per `wav`/`mp3`/`ogg`/`opus`/`flac`/`aac`/`webm`) honouring `opts.bitrate` / `opts.quality` when provided, read the encoded output from MEMFS, delete both MEMFS entries, and return the encoded `Uint8Array`. A `format` outside the supported set SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC` *before* the worker is invoked. A non-zero ffmpeg exit SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.ENCODE_FAILED` carrying the stderr tail in `context.stderr`.
|
||||
|
||||
#### Scenario: encode produces a valid file for the requested format
|
||||
|
||||
- **WHEN** `encode` is called with a single-channel sine wave and `format: "mp3"`
|
||||
- **THEN** the returned bytes start with an MP3 frame sync (`0xFFFB` or `0xFFFA`) and decoding them back yields PCM whose length matches the input within ±1 frame
|
||||
|
||||
#### Scenario: unsupported format throws AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC before the worker is touched
|
||||
|
||||
- **WHEN** `encode` is called with `format: "alac"`
|
||||
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC"` and no message is posted to the worker
|
||||
|
||||
**Verified by:** `src/encode/encode-pipeline.test.ts::"encode produces a valid file for the requested format"` AND `src/encode/encode-pipeline.test.ts::"unsupported format throws AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC before the worker is touched"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Buffered streaming emulation
|
||||
|
||||
`createDecoder()` and `createEncoder()` SHALL return objects implementing the `StreamingDecoder` and `StreamingEncoder` interfaces from `@suaveplan/audio-codecs`. The implementation SHALL be buffered emulation: `write(chunk)` SHALL append the chunk to an internal `Uint8Array` and return `null` without invoking the worker; `flush()` SHALL submit the accumulated bytes through the same decode or encode pipeline as the non-streaming method and return the single resulting `DecodeResult` or encoded `Uint8Array`. Subsequent `flush()` calls SHALL be no-ops. Progress events SHALL fire only during the `flush()` call.
|
||||
|
||||
#### Scenario: write does not invoke the worker
|
||||
|
||||
- **WHEN** a caller invokes `createDecoder()` and calls `write(chunk)` three times
|
||||
- **THEN** no message has been posted to the worker and every call returned `null`
|
||||
|
||||
#### Scenario: flush yields the same bytes as a one-shot decode
|
||||
|
||||
- **WHEN** a caller streams a WAV file through `createDecoder()` in three chunks and calls `flush()`
|
||||
- **THEN** the resulting `DecodeResult.channelData` is byte-equal to `decode(fullBuffer).channelData`
|
||||
|
||||
**Verified by:** `src/streaming/streaming-decoder.test.ts::"write does not invoke the worker"` AND `src/streaming/streaming-decoder.test.ts::"flush yields the same bytes as a one-shot decode"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal propagation terminates the worker
|
||||
|
||||
`decode`, `encode`, `StreamingDecoder.flush`, and `StreamingEncoder.flush` SHALL accept an `AbortSignal` via their options object. A signal that is already aborted SHALL short-circuit the call with `AudioCodecFfmpegWasmError` code `AUDIO_CODEC_FFMPEG_WASM.ABORTED` before any worker message is posted. A signal aborted mid-call SHALL trigger `worker.terminate()`, reject the pending call with the same code, release any worker reference, and require the next call to spawn a fresh worker.
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits before worker touch
|
||||
|
||||
- **WHEN** `decode` is called with an already-aborted signal
|
||||
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.ABORTED"` and no message has been posted to the worker
|
||||
|
||||
#### Scenario: mid-call abort terminates the worker
|
||||
|
||||
- **WHEN** a signal is aborted after the worker received the decode message but before it responded
|
||||
- **THEN** `worker.terminate()` is invoked, the decode promise rejects with `AUDIO_CODEC_FFMPEG_WASM.ABORTED`, and the next `decode` call constructs a new worker
|
||||
|
||||
**Verified by:** `src/worker/worker-host.test.ts::"pre-aborted signal short-circuits before worker touch"` AND `src/worker/worker-host.test.ts::"mid-call abort terminates the worker"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Timeout watchdog
|
||||
|
||||
`decode` and `encode` SHALL apply a timeout to the underlying worker call. The default timeout SHALL be 60 000 ms for decode and 120 000 ms for encode; both SHALL be overridable via the call options. A timeout SHALL terminate the worker and throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.TIMEOUT` carrying the elapsed milliseconds in `context.elapsedMs` and the configured limit in `context.timeoutMs`. The watchdog SHALL be driven by `opts.clock` when supplied so tests are deterministic.
|
||||
|
||||
#### Scenario: timeout throws AUDIO_CODEC_FFMPEG_WASM.TIMEOUT
|
||||
|
||||
- **WHEN** `decode` is called with `timeoutMs: 10` against a worker that never responds, using an injectable `Clock`
|
||||
- **THEN** the call throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.TIMEOUT"` with `elapsedMs >= 10` and `timeoutMs === 10`
|
||||
|
||||
**Verified by:** `src/worker/worker-host.test.ts::"timeout throws AUDIO_CODEC_FFMPEG_WASM.TIMEOUT"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Memory-limit detection
|
||||
|
||||
When ffmpeg.wasm exhausts its 4 GB linear-memory cap — surfaced as an `Aborted()` JS exception or an `OutOfMemory`/`Cannot enlarge memory` stderr line — the worker host SHALL re-emit the failure as `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED` carrying the requested allocation size (when parseable from stderr) in `context.requestedBytes`. The codec MUST NOT silently swallow OOM into the generic `DECODE_FAILED` / `ENCODE_FAILED` codes.
|
||||
|
||||
#### Scenario: wasm OOM maps to AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED
|
||||
|
||||
- **WHEN** the test worker emits a stderr line `"Cannot enlarge memory arrays."`
|
||||
- **THEN** the pending call throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED"`
|
||||
|
||||
**Verified by:** `src/worker/worker-host.test.ts::"wasm OOM maps to AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Cross-origin-isolation gate for the multi-thread variant
|
||||
|
||||
When `FfmpegWasmCodecOptions.enableMultithread` is `true`, the core loader SHALL inspect `globalThis.crossOriginIsolated`. If it is not exactly `true`, the loader SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED` *before* attempting to load `@ffmpeg/core-mt`. When COI is `true` and the dynamic `import("@ffmpeg/core-mt")` rejects (optional dep not installed), the loader SHALL log a warning via `opts.logger` and fall back to single-thread `@ffmpeg/core` rather than throwing. When `enableMultithread` is unset or `false`, the single-thread core SHALL be loaded unconditionally and the COI flag SHALL NOT be consulted.
|
||||
|
||||
#### Scenario: enableMultithread without COI throws AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED
|
||||
|
||||
- **WHEN** `decode` is first called on an `FfmpegWasmCodec` constructed with `enableMultithread: true` in an environment where `globalThis.crossOriginIsolated === false`
|
||||
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED"` and `@ffmpeg/core-mt` has not been dynamic-imported
|
||||
|
||||
#### Scenario: missing @ffmpeg/core-mt falls back silently
|
||||
|
||||
- **WHEN** COI is `true`, `enableMultithread` is `true`, and `import("@ffmpeg/core-mt")` rejects with a module-not-found error
|
||||
- **THEN** the single-thread `@ffmpeg/core` loads successfully, a warning is recorded via `opts.logger`, and the call completes
|
||||
|
||||
**Verified by:** `src/core-loader/core-loader.test.ts::"enableMultithread without COI throws AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED"` AND `src/core-loader/core-loader.test.ts::"missing @ffmpeg/core-mt falls back silently"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Progress callback wiring
|
||||
|
||||
When `FfmpegWasmCodecOptions.progressCallback` is supplied, the codec SHALL invoke it with a number in `[0, 1]` for every `progress` event emitted by `@ffmpeg/ffmpeg` during a decode or encode call. The callback SHALL be invoked at least once per call that runs through the worker (zero and one are both acceptable bookends). Callback exceptions SHALL be caught and logged via `opts.logger` without aborting the in-flight call.
|
||||
|
||||
#### Scenario: progress callback receives values in [0, 1]
|
||||
|
||||
- **WHEN** `decode` runs against a multi-second WAV with `progressCallback` set
|
||||
- **THEN** the callback is invoked at least once with a number in `[0, 1]`, and the final invocation is `1` (or `decode` completes without one — both are acceptable)
|
||||
|
||||
#### Scenario: progress callback exception does not abort decode
|
||||
|
||||
- **WHEN** `progressCallback` throws on its first invocation
|
||||
- **THEN** the error is logged via `opts.logger` and `decode` still resolves with a valid `DecodeResult`
|
||||
|
||||
**Verified by:** `src/codec/ffmpeg-wasm-codec.test.ts::"progress callback receives values in [0, 1]"` AND `src/codec/ffmpeg-wasm-codec.test.ts::"progress callback exception does not abort decode"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Structured error codes — every code reachable
|
||||
|
||||
The package SHALL export `AudioCodecFfmpegWasmError`, `AudioCodecFfmpegWasmErrorCode`, `AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA`, and `registerAudioCodecFfmpegWasmErrorCodes()`, registering the codes `AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED`, `AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED`, `AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED`, `AUDIO_CODEC_FFMPEG_WASM.ENCODE_FAILED`, `AUDIO_CODEC_FFMPEG_WASM.TIMEOUT`, `AUDIO_CODEC_FFMPEG_WASM.ABORTED`, `AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC`, `AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED`, and `AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioCodecFfmpegWasmError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. Every 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** for each code in `AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA`, at least one test asserts that code is thrown
|
||||
|
||||
**Verified by:** `src/error-codes.test.ts::"every AUDIO_CODEC_FFMPEG_WASM code is reachable from a call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary validation
|
||||
|
||||
`FfmpegWasmCodecOptionsSchema` SHALL be exported from `src/codec/ffmpeg-wasm-codec.schemas.ts` and validate `FfmpegWasmCodecOptions` at the `FfmpegWasmCodec` constructor and at the `register(registry, options?)` entry point. A validation failure SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC` carrying the Zod issue list in `context.issues`. Options SHALL reject `coreUrl` and `corePath` being set simultaneously.
|
||||
|
||||
#### Scenario: invalid options are rejected at the constructor
|
||||
|
||||
- **WHEN** `new FfmpegWasmCodec({ coreUrl: "https://a/", corePath: "/b/" })`
|
||||
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC"` with Zod issues in `context.issues`
|
||||
|
||||
#### Scenario: register validates its options argument
|
||||
|
||||
- **WHEN** `register(registry, { enableMultithread: "yes" as unknown as boolean })` is called
|
||||
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC"` with Zod issues in `context.issues`
|
||||
|
||||
**Verified by:** `src/codec/ffmpeg-wasm-codec.test.ts::"invalid options are rejected at the constructor"` AND `src/register/register.test.ts::"register validates its options argument"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation
|
||||
|
||||
The package SHALL instrument its public entry points via `@opentelemetry/api`: spans `audio-codec-ffmpeg-wasm.decode` and `audio-codec-ffmpeg-wasm.encode`; counters `audio_codec_ffmpeg_wasm_decode_total` and `audio_codec_ffmpeg_wasm_encode_total`; histograms `audio_codec_ffmpeg_wasm_decode_duration_ms` and `audio_codec_ffmpeg_wasm_encode_duration_ms`. Spans SHALL carry the resolved `format` and `multithread` flag as attributes. 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
|
||||
|
||||
#### Scenario: a decode records a span, counter, and histogram
|
||||
|
||||
- **WHEN** `decode` runs against a real in-memory `MeterProvider` and `TracerProvider`
|
||||
- **THEN** `audio-codec-ffmpeg-wasm.decode` records one span carrying the `format` and `multithread` attributes, `audio_codec_ffmpeg_wasm_decode_total` increments by 1, and `audio_codec_ffmpeg_wasm_decode_duration_ms` records one observation
|
||||
|
||||
**Verified by:** `src/codec/ffmpeg-wasm-codec.test.ts::"decode records a span, counter, and histogram when an exporter is registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `register(registry)` install helper
|
||||
|
||||
The package SHALL export a `register(registry: CodecRegistry, options?: FfmpegWasmCodecOptions): FfmpegWasmCodec` function. It SHALL validate `options` via `FfmpegWasmCodecOptionsSchema`, construct a `FfmpegWasmCodec` with those options, call `registry.register(codec)`, and return the constructed instance so callers can `dispose()` it later. A second call with the same registry SHALL replace the prior registration (matching `CodecRegistry.register`'s replace-on-duplicate-name behaviour from `audio-codecs`).
|
||||
|
||||
#### Scenario: register installs the codec and returns the instance
|
||||
|
||||
- **WHEN** `register(registry)` is called against a fresh `CodecRegistry`
|
||||
- **THEN** the returned `FfmpegWasmCodec` is identity-equal to `registry.get("ffmpeg-wasm")` and `registry.detect(wavBytes)` returns `"ffmpeg-wasm"`
|
||||
|
||||
**Verified by:** `src/register/register.test.ts::"register installs the codec and returns the instance"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per feature module
|
||||
|
||||
`package.json` SHALL declare one `exports` entry per feature module (`./codec/ffmpeg-wasm-codec`, `./worker/worker-host`, `./core-loader/core-loader`, `./decode/decode-pipeline`, `./encode/encode-pipeline`, `./streaming/streaming-decoder`, `./streaming/streaming-encoder`, `./register/register`, `./error-codes`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`. Test files SHALL import runner primitives only from `@suaveplan/testing/runner` (rule 11) — direct imports from `vitest` or `bun:test` are forbidden.
|
||||
|
||||
#### Scenario: each feature module is independently importable
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/audio-codec-ffmpeg-wasm/register/register`
|
||||
- **THEN** the import resolves without pulling the decode or encode pipelines, and no wasm has loaded
|
||||
|
||||
#### Scenario: no test file imports vitest or bun:test directly
|
||||
|
||||
- **WHEN** `bun scripts/lint/check-test-runner-imports.ts` is run against this package
|
||||
- **THEN** the gate passes with zero violations
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"` AND `tests/exports.test.ts::"no test file imports vitest or bun:test directly"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,211 @@
|
||||
# Tasks: `@suaveplan/audio-codec-ffmpeg-wasm`
|
||||
|
||||
Ordered implementation checklist for the `enterprise-audio-codec-ffmpeg-wasm` change.
|
||||
Every numbered task corresponds to scenarios in
|
||||
`./specs/audio-codec-ffmpeg-wasm/spec.md`. Do not start a task whose prerequisites are
|
||||
still pending in `STATUS.md`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Scaffold the package
|
||||
|
||||
- [ ] 1.1 Add `@ffmpeg/ffmpeg` and `@ffmpeg/core` to the root `package.json` `catalog`
|
||||
block at exact pinned versions. Note in the PR description that this is the only
|
||||
catalog addition required by this change.
|
||||
- [ ] 1.2 Create `packages/media/audio/audio-codec-ffmpeg-wasm/` with `package.json`
|
||||
(`"name": "@suaveplan/audio-codec-ffmpeg-wasm"`, `"version": "0.1.0"`,
|
||||
`"browser": true`, `"sideEffects": false`, `"type": "module"`), workspace deps on
|
||||
`@suaveplan/audio-codecs`, `audio-core`, `error`, `types`, `streams` all at
|
||||
`workspace:^`, runtime deps on `@ffmpeg/ffmpeg`, `@ffmpeg/core`, `zod catalog:forms`,
|
||||
`@opentelemetry/api catalog:observability`, `tslib catalog:build`, and
|
||||
`optionalDependencies: { "@ffmpeg/core-mt": "<pinned>" }`.
|
||||
- [ ] 1.3 Declare one `exports` entry per feature module plus the root `.` barrel
|
||||
(see proposal "Module layout").
|
||||
- [ ] 1.4 Add `tsconfig.json` extending the shared base; add `vitest.config.ts` using
|
||||
`createTestConfig()` from `@suaveplan/testing/config` with `pool: 'forks'` and
|
||||
`setupFiles: ['@suaveplan/testing/polyfills']`. Configure `happy-dom` for any test
|
||||
that touches `Worker`.
|
||||
- [ ] 1.5 Add `README.md` at the package root only (no category READMEs).
|
||||
- [ ] 1.6 Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
|
||||
|
||||
## 2. Types and error codes
|
||||
|
||||
- [ ] 2.1 Author `src/codec/ffmpeg-wasm-codec.types.ts` with `FfmpegWasmCodecOptions`,
|
||||
`LoadedCore`, `WorkerMessage` union, and internal pipeline types.
|
||||
- [ ] 2.2 Author `src/codec/ffmpeg-wasm-codec.schemas.ts` exporting
|
||||
`FfmpegWasmCodecOptionsSchema` (Zod) — uses `import z from "zod"` per
|
||||
`MEMORY.md`. Reject `coreUrl` and `corePath` set simultaneously with
|
||||
`.superRefine`.
|
||||
- [ ] 2.3 Author `src/error-codes.ts` defining `AudioCodecFfmpegWasmError`,
|
||||
`AudioCodecFfmpegWasmErrorCode`, `AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA`, and
|
||||
`registerAudioCodecFfmpegWasmErrorCodes()` via `@suaveplan/error`'s `defineCodes`.
|
||||
Cover all 9 codes from the spec.
|
||||
- [ ] 2.4 Author `src/error-codes.test.ts` asserting every code is reachable from a
|
||||
real call site (one assertion per code; cross-references the other test files).
|
||||
|
||||
## 3. Worker host (`src/worker/`)
|
||||
|
||||
- [ ] 3.1 Implement `worker-host.ts`: wraps `@ffmpeg/ffmpeg`'s `FFmpeg` instance, owns the
|
||||
`Worker` lifecycle, exposes `submitDecode`, `submitEncode`, `terminate`, and a
|
||||
`progress` event hook.
|
||||
- [ ] 3.2 `AbortSignal` plumbing: pre-aborted signal short-circuits before any worker
|
||||
message; mid-call abort calls `worker.terminate()` and rejects pending calls with
|
||||
`AUDIO_CODEC_FFMPEG_WASM.ABORTED`.
|
||||
- [ ] 3.3 Timeout watchdog using injectable `Clock`; defaults 60s decode / 120s encode;
|
||||
maps to `AUDIO_CODEC_FFMPEG_WASM.TIMEOUT`.
|
||||
- [ ] 3.4 Crash + OOM detection: parse stderr for `Cannot enlarge memory arrays` /
|
||||
`OutOfMemory` → `AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED`; pre-`load` worker
|
||||
error → `AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED`.
|
||||
- [ ] 3.5 Honour `opts.workerFactory` test seam.
|
||||
- [ ] 3.6 Tests in `worker-host.test.ts` covering scenarios from spec Requirements
|
||||
"Worker isolation", "AbortSignal propagation", "Timeout watchdog", and
|
||||
"Memory-limit detection".
|
||||
|
||||
## 4. Core loader (`src/core-loader/`)
|
||||
|
||||
- [ ] 4.1 Implement `core-loader.ts`: resolve core URL (precedence corePath → coreUrl →
|
||||
bundled default), memoise the loaded core per codec instance.
|
||||
- [ ] 4.2 COI gate: when `enableMultithread === true` and
|
||||
`globalThis.crossOriginIsolated !== true`, throw
|
||||
`AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED` *before* attempting
|
||||
`import("@ffmpeg/core-mt")`.
|
||||
- [ ] 4.3 Optional-dep fallback: when COI is true but
|
||||
`import("@ffmpeg/core-mt")` rejects, log a warning via `opts.logger` and fall back
|
||||
to `@ffmpeg/core`. Do not throw.
|
||||
- [ ] 4.4 Failure path: any load failure throws
|
||||
`AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED` with the resolved URL in `context`.
|
||||
- [ ] 4.5 Tests in `core-loader.test.ts` covering "Lazy core load on first use" and
|
||||
"Cross-origin-isolation gate" scenarios.
|
||||
|
||||
## 5. Decode pipeline (`src/decode/`)
|
||||
|
||||
- [ ] 5.1 Implement `decode-pipeline.ts`: sniff format from magic bytes →
|
||||
`ffmpeg.writeFile("input.<ext>", bytes)` → run probe pass for `sampleRate`/`channels`
|
||||
→ run `-f f32le` extraction → `ffmpeg.readFile("output.raw")` → deinterleave to
|
||||
`Float32Array[]`.
|
||||
- [ ] 5.2 MEMFS cleanup: `deleteFile` both entries on success and on failure (try/finally).
|
||||
- [ ] 5.3 ffmpeg non-zero exit → `AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED` with stderr tail.
|
||||
- [ ] 5.4 Tests in `decode-pipeline.test.ts` covering "Decode pipeline" scenarios.
|
||||
|
||||
## 6. Encode pipeline (`src/encode/`)
|
||||
|
||||
- [ ] 6.1 Implement `encode-pipeline.ts`: per-format command builders for `wav`, `mp3`,
|
||||
`ogg`, `opus`, `flac`, `aac`, `webm`, honouring `bitrate`/`quality` options.
|
||||
- [ ] 6.2 Interleave `Float32Array[]` → single `Float32Array` → `Uint8Array` view →
|
||||
MEMFS write.
|
||||
- [ ] 6.3 Unsupported format check **before** posting any worker message →
|
||||
`AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC`.
|
||||
- [ ] 6.4 MEMFS cleanup (try/finally on both entries).
|
||||
- [ ] 6.5 ffmpeg non-zero exit → `AUDIO_CODEC_FFMPEG_WASM.ENCODE_FAILED`.
|
||||
- [ ] 6.6 Tests in `encode-pipeline.test.ts` covering "Encode pipeline" scenarios for at
|
||||
least `wav`, `mp3`, and `opus`.
|
||||
|
||||
## 7. Streaming emulation (`src/streaming/`)
|
||||
|
||||
- [ ] 7.1 Implement `streaming-decoder.ts`: `write(chunk)` appends to internal buffer and
|
||||
returns `null`; `flush()` runs through the decode pipeline once.
|
||||
- [ ] 7.2 Implement `streaming-encoder.ts` as the symmetric mirror.
|
||||
- [ ] 7.3 Idempotent `flush()`: subsequent calls return `null` / empty bytes.
|
||||
- [ ] 7.4 Progress events fire only during `flush()`.
|
||||
- [ ] 7.5 Tests in `streaming-decoder.test.ts` and `streaming-encoder.test.ts` covering
|
||||
"Buffered streaming emulation" scenarios.
|
||||
|
||||
## 8. Codec assembly (`src/codec/`)
|
||||
|
||||
- [ ] 8.1 Implement `ffmpeg-wasm-codec.ts`: `FfmpegWasmCodec` class wiring constructor
|
||||
validation (Zod) → core loader → worker host → decode/encode pipelines →
|
||||
`createDecoder` / `createEncoder` → `dispose`.
|
||||
- [ ] 8.2 `canDecode(bytes)` uses `@suaveplan/audio-codecs`'s magic sniff plus the
|
||||
probed encoder list to filter unsupported variants.
|
||||
- [ ] 8.3 Progress callback wiring with try/catch around the user callback; exceptions
|
||||
logged via `opts.logger`, never aborts the call.
|
||||
- [ ] 8.4 Tests in `ffmpeg-wasm-codec.test.ts` covering "AudioCodec contract
|
||||
conformance", "Progress callback wiring", "Zod boundary validation", and OTel
|
||||
scenarios.
|
||||
|
||||
## 9. Register helper (`src/register/`)
|
||||
|
||||
- [ ] 9.1 Implement `register.ts`: validates options, constructs codec, calls
|
||||
`registry.register(codec)`, returns the instance.
|
||||
- [ ] 9.2 Tests in `register.test.ts` covering the install scenario and the Zod
|
||||
rejection scenario.
|
||||
|
||||
## 10. OpenTelemetry instrumentation
|
||||
|
||||
- [ ] 10.1 Wire `audio-codec-ffmpeg-wasm.decode` and `audio-codec-ffmpeg-wasm.encode`
|
||||
spans, with `format` and `multithread` attributes.
|
||||
- [ ] 10.2 Increment `audio_codec_ffmpeg_wasm_decode_total` /
|
||||
`audio_codec_ffmpeg_wasm_encode_total` with `format` and `outcome` labels.
|
||||
- [ ] 10.3 Record `audio_codec_ffmpeg_wasm_decode_duration_ms` /
|
||||
`audio_codec_ffmpeg_wasm_encode_duration_ms` driven by `opts.clock`.
|
||||
- [ ] 10.4 Verify no import-time side effects via `tests/purity.test.ts`.
|
||||
|
||||
## 11. Tests harness
|
||||
|
||||
- [ ] 11.1 Every test file imports runner primitives from `@suaveplan/testing/runner`
|
||||
only — never `vitest`, never `bun:test` (rule 11).
|
||||
- [ ] 11.2 Use `happy-dom` for tests that touch `Worker`; supply a hand-rolled `Worker`
|
||||
shim that lets us script worker messages and `error` events.
|
||||
- [ ] 11.3 Use a `workerFactory` test seam to inject fakes for unit tests; reserve a
|
||||
real-worker smoke test for `tests/integration.test.ts` under Bun-runs-in-Node mode.
|
||||
- [ ] 11.4 Every `it()` / `test()` carries `{ timeout: 5000 }` (`30000` for the
|
||||
integration suite). No mocks of `@ffmpeg/ffmpeg` itself — only the worker boundary
|
||||
is faked.
|
||||
- [ ] 11.5 Property test: `decode(encode(pcm)) ≈ pcm` for lossless formats (`wav`,
|
||||
`flac`).
|
||||
- [ ] 11.6 `tests/purity.test.ts` greps built `dist/` for forbidden Node API references
|
||||
and for static imports of `@ffmpeg/core-mt`.
|
||||
- [ ] 11.7 `tests/exports.test.ts` asserts each subpath export resolves and that no test
|
||||
file imports `vitest` or `bun:test` directly.
|
||||
|
||||
## 12. Benchmarks
|
||||
|
||||
- [ ] 12.1 `src/__benchmarks__/decode.bench.ts` — decode a 5-minute WAV and a 5-minute
|
||||
MP3 with `mitata`.
|
||||
- [ ] 12.2 `src/__benchmarks__/encode.bench.ts` — encode 5-minute PCM to MP3, OPUS, and
|
||||
FLAC.
|
||||
- [ ] 12.3 Document the multi-thread vs single-thread delta in the benchmark output
|
||||
comments.
|
||||
|
||||
## 13. Documentation
|
||||
|
||||
- [ ] 13.1 Every implementation `.ts` has a sibling `.md` (Purpose ≥2 sentences,
|
||||
Features, Usage: Basic, Usage: Advanced, API Reference, Implementation Notes;
|
||||
≥200 words excl. code; both simple AND advanced examples).
|
||||
- [ ] 13.2 Excluded from the `.md` rule: `.test.ts`, `.types.ts` (inside a feature
|
||||
dir), barrel `index.ts`, `*.schemas.ts` (Zod schemas only).
|
||||
- [ ] 13.3 `README.md` covers: install, quick-start `register(registry)` usage, the COI
|
||||
requirement for `enableMultithread`, the 30 MB lazy-load disclaimer, and the
|
||||
buffered-streaming honesty note.
|
||||
- [ ] 13.4 `bunx turbo docs:check --filter=@suaveplan/audio-codec-ffmpeg-wasm` is green.
|
||||
|
||||
## 14. Pre-publish gates
|
||||
|
||||
- [ ] 14.1 `bunx turbo lint typecheck test --filter=@suaveplan/audio-codec-ffmpeg-wasm`
|
||||
is green.
|
||||
- [ ] 14.2 `bunx turbo test:coverage --filter=@suaveplan/audio-codec-ffmpeg-wasm`
|
||||
reports 100% on all touched modules with `provider: 'istanbul'`.
|
||||
- [ ] 14.3 `bun scripts/lint/check-test-runner-imports.ts` passes for the package.
|
||||
- [ ] 14.4 No `biome-ignore` comments anywhere in `src/` or `tests/` (rule 9).
|
||||
- [ ] 14.5 No `export *` in any non-root barrel; root barrel uses explicit named exports
|
||||
or namespace re-exports (rule 10).
|
||||
- [ ] 14.6 `bun scripts/folder-reorg/generate-manifest.ts` re-run; resulting
|
||||
`MANIFEST.md` diff committed.
|
||||
|
||||
## 15. Fold the spec
|
||||
|
||||
- [ ] 15.1 Fold `specs/audio-codec-ffmpeg-wasm/spec.md` into
|
||||
`openspec/specs/audio-codec-ffmpeg-wasm/spec.md` per §14 archival workflow.
|
||||
`openspec/specs/` is untouched until this step.
|
||||
|
||||
## 16. Archive
|
||||
|
||||
- [ ] 16.1 Move `openspec/changes/enterprise-audio-codec-ffmpeg-wasm/` to
|
||||
`openspec/archive/<yyyy-mm-dd>-enterprise-audio-codec-ffmpeg-wasm/` per §14.
|
||||
- [ ] 16.2 Run `openspec validate` after the move to confirm the canonical spec parses.
|
||||
Reference in New Issue
Block a user