Initial Commit
This commit is contained in:
296
openspec/specs/audio-codec-ffmpeg-wasm/spec.md
Normal file
296
openspec/specs/audio-codec-ffmpeg-wasm/spec.md
Normal file
@@ -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 -->
|
||||
Reference in New Issue
Block a user