Initial Commit
This commit is contained in:
273
openspec/specs/audio-codec-ffmpeg-native/spec.md
Normal file
273
openspec/specs/audio-codec-ffmpeg-native/spec.md
Normal file
@@ -0,0 +1,273 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: audio-codec-ffmpeg-native (audio-codec-ffmpeg-native)
|
||||
|
||||
**Package**: `@suaveplan/audio-codec-ffmpeg-native`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Server-tier purity
|
||||
|
||||
The `@suaveplan/audio-codec-ffmpeg-native` package SHALL be a server-tier package that imports zero browser globals (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`) and SHALL run only on Node-compatible runtimes (Node ≥ 20, Bun). The package's `package.json` SHALL NOT declare a `"browser"` field, SHALL declare `"engines": { "node": ">=20" }`, and SHALL import `node:child_process` only inside the `runner` module — not at any other module's top level. The `dependencies` SHALL be limited to `@suaveplan/audio-codecs`, `@suaveplan/audio-core`, `@suaveplan/streams`, `@suaveplan/error`, `@suaveplan/types`, `fluent-ffmpeg`, `zod`, `@opentelemetry/api`, and `tslib`; `@ffmpeg-installer/ffmpeg` SHALL be declared in `optionalDependencies`.
|
||||
|
||||
#### Scenario: built dist references no browser globals
|
||||
|
||||
- **WHEN** the built `dist/` is grep'd for `window`, `document`, `navigator`, `AudioContext`, or `AudioBuffer`
|
||||
- **THEN** it MUST NOT statically reference any of them at module scope
|
||||
|
||||
#### Scenario: child_process is confined to the runner module
|
||||
|
||||
- **WHEN** the source tree is grep'd for `from "node:child_process"` or `require("child_process")`
|
||||
- **THEN** the only match is inside `src/runner/runner.ts`
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"dist references no browser globals"` AND `tests/purity.test.ts::"child_process is imported only by the runner module"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AudioCodec contract conformance
|
||||
|
||||
The package SHALL export `FfmpegNativeCodec` implementing the `AudioCodec` interface from `@suaveplan/audio-codecs`. The codec SHALL expose readonly `name === "ffmpeg-native"`, `extensions` covering `wav`, `mp3`, `ogg`, `opus`, `flac`, `aac`, `m4a`, `webm`, `wma`, and `mimeTypes` covering `audio/wav`, `audio/mpeg`, `audio/ogg`, `audio/opus`, `audio/flac`, `audio/aac`, `audio/mp4`, `audio/webm`, `audio/x-ms-wma`. `decode(bytes)` SHALL return a `DecodeResult` (`{ channelData: Float32Array[]; sampleRate: number; format: string }`). `encode(pcm, opts)` SHALL return a `Uint8Array` whose bytes are a valid file of the requested format. `canDecode(bytes)` SHALL delegate magic-byte detection to `@suaveplan/audio-metadata` and return `true` iff the detected format is in the supported list.
|
||||
|
||||
#### Scenario: decode returns a well-formed DecodeResult
|
||||
|
||||
- **WHEN** a caller invokes `decode` on a valid 16-bit WAV buffer
|
||||
- **THEN** the result has `channelData.length === channels`, every channel is a `Float32Array` of length `frames`, `sampleRate` is positive, and `format === "wav"`
|
||||
|
||||
#### Scenario: canDecode rejects non-audio bytes
|
||||
|
||||
- **WHEN** `canDecode` is called with 64 bytes of random data whose magic matches no supported format
|
||||
- **THEN** it returns `false` without spawning any subprocess
|
||||
|
||||
**Verified by:** `src/codec/codec.test.ts::"decode returns a well-formed DecodeResult for 16-bit WAV"` AND `src/codec/codec.test.ts::"canDecode returns false for unrecognised bytes without spawning"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Binary discovery and resolution order
|
||||
|
||||
The package SHALL resolve the ffmpeg binary path on first use in this exact order: (1) `options.binaryProvider?.()` if provided, (2) `options.binaryPath` if provided, (3) the path exposed by dynamically importing `@ffmpeg-installer/ffmpeg`, (4) throw `AudioCodecFfmpegNativeError` with code `AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING`. Resolution SHALL be cached on the codec instance after first success and SHALL never happen at module import time.
|
||||
|
||||
#### Scenario: binaryProvider overrides all other sources
|
||||
|
||||
- **WHEN** a codec is constructed with both `binaryProvider: () => "/usr/local/bin/ffmpeg"` and `binaryPath: "/opt/ffmpeg"` and `@ffmpeg-installer/ffmpeg` installed
|
||||
- **THEN** the spawned process uses `/usr/local/bin/ffmpeg`
|
||||
|
||||
#### Scenario: missing binary throws BINARY_MISSING
|
||||
|
||||
- **WHEN** `decode` is called on a codec whose `binaryProvider` returns `""`, no `binaryPath` is set, and `@ffmpeg-installer/ffmpeg` is uninstallable in the test fixture
|
||||
- **THEN** it throws `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING"`
|
||||
|
||||
**Verified by:** `src/binary/binary.test.ts::"binaryProvider takes precedence over binaryPath and installer"` AND `src/binary/binary.test.ts::"unresolved binary throws AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Decode pipeline streams chunk-by-chunk
|
||||
|
||||
`decode(bytes)` SHALL feed the input through ffmpeg's `stdin`, drain `stdout` chunk-by-chunk into per-channel `Float32Array` accumulators, and SHALL NOT call `Buffer.concat` on the full output payload. Decode of a payload larger than the host's free heap SHALL complete successfully provided the resulting PCM fits — buffering of the encoded input is forbidden. A non-zero ffmpeg exit code SHALL throw `AudioCodecFfmpegNativeError` with code `AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED` carrying the captured stderr tail in `context.stderr`.
|
||||
|
||||
#### Scenario: decode does not buffer full output
|
||||
|
||||
- **WHEN** the runner module is instrumented to observe `Buffer.concat` calls and a 50 MiB WAV is decoded
|
||||
- **THEN** zero `Buffer.concat` calls observe the full output payload — output is appended chunk-by-chunk
|
||||
|
||||
#### Scenario: ffmpeg failure throws DECODE_FAILED with stderr context
|
||||
|
||||
- **WHEN** `decode` is called with bytes that ffmpeg rejects (e.g. a truncated MP3 header)
|
||||
- **THEN** it throws `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED"` with the captured stderr tail in `context.stderr`
|
||||
|
||||
**Verified by:** `src/decode/decode.test.ts::"decode appends chunks without concatenating the full output"` AND `src/decode/decode.test.ts::"decode throws AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED on non-zero exit"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Encode pipeline streams chunk-by-chunk
|
||||
|
||||
`encode(pcm, opts)` SHALL interleave the per-channel `Float32Array[]` PCM through a fixed-size working buffer (≤ 64 KiB) into ffmpeg's `stdin`, drain `stdout` chunk-by-chunk, and emit a `Uint8Array` whose bytes form a complete file of `opts.format`. Encode SHALL validate `pcm` shape (every channel same length, length > 0) and throw `AudioCodecFfmpegNativeError` with code `AUDIO_CODEC_FFMPEG_NATIVE.INVALID_PCM` for malformed input. A non-zero ffmpeg exit code SHALL throw `AUDIO_CODEC_FFMPEG_NATIVE.ENCODE_FAILED`.
|
||||
|
||||
#### Scenario: encode round-trips through decode
|
||||
|
||||
- **WHEN** `encode` is called on the `DecodeResult` of a 16-bit WAV and the resulting bytes are decoded again
|
||||
- **THEN** the re-decoded `channelData` matches the original within ±1e-4 per sample
|
||||
|
||||
#### Scenario: ragged PCM throws INVALID_PCM
|
||||
|
||||
- **WHEN** `encode` is called with `pcm = [Float32Array(100), Float32Array(99)]`
|
||||
- **THEN** it throws `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.INVALID_PCM"` before any subprocess is spawned
|
||||
|
||||
**Verified by:** `src/encode/encode.test.ts::"encode then decode round-trips PCM within tolerance"` AND `src/encode/encode.test.ts::"encode rejects ragged channel arrays with INVALID_PCM"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Streaming decoder factory
|
||||
|
||||
`createDecoder(opts)` SHALL return a `StreamingDecoder` exposing `write(chunk: Uint8Array): Promise<DecodeResult | undefined>`, `flush(): Promise<DecodeResult | undefined>`, and a `readable: ReadableStream<AudioPage>` accessor. Successive `write` calls SHALL forward chunks to ffmpeg's `stdin` and yield `AudioPage`s as `stdout` produces frames. The decoder SHALL expose a `@suaveplan/streams` `TransformStream<Uint8Array, AudioPage>` via a `createDecodeTransform()` helper so callers can compose it with `pipeThrough`.
|
||||
|
||||
#### Scenario: streamed decode total equals one-shot decode
|
||||
|
||||
- **WHEN** a 1 MiB WAV is split into 64 KiB chunks and fed through `createDecoder` while the same payload is decoded via one-shot `decode`
|
||||
- **THEN** the concatenated streamed `channelData` is byte-equal to the one-shot `channelData`
|
||||
|
||||
#### Scenario: TransformStream composes into a streams pipeline
|
||||
|
||||
- **WHEN** `createDecodeTransform("wav")` is piped between a `ReadableStream<Uint8Array>` source and a `WritableStream<AudioPage>` sink
|
||||
- **THEN** the pipeline drains without buffering the full source and the sink receives one or more non-empty `AudioPage` chunks
|
||||
|
||||
**Verified by:** `src/stream/stream-decoder.test.ts::"chunked write yields the same channelData as one-shot decode"` AND `src/stream/stream-decoder.test.ts::"createDecodeTransform composes into a @suaveplan/streams pipeline"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Streaming encoder factory
|
||||
|
||||
`createEncoder(opts)` SHALL return a `StreamingEncoder` exposing `write(pcm: Float32Array[]): Promise<Uint8Array | undefined>`, `flush(): Promise<Uint8Array | undefined>`, and a `readable: ReadableStream<Uint8Array>` accessor. The package SHALL also export `createEncodeTransform(opts)` returning a `@suaveplan/streams` `TransformStream<Float32Array[], Uint8Array>`. Encoding SHALL stream output chunk-by-chunk; the encoder SHALL NOT buffer the full encoded output before yielding the first chunk.
|
||||
|
||||
#### Scenario: streamed encode yields output before flush
|
||||
|
||||
- **WHEN** a 60-second MP3 encode runs via `createEncoder` and the first `write` is followed by 5 seconds of additional `write` calls before `flush`
|
||||
- **THEN** at least one non-empty `Uint8Array` chunk is yielded from the readable before `flush` resolves
|
||||
|
||||
**Verified by:** `src/stream/stream-encoder.test.ts::"streamed encode yields output before flush is called"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal propagation kills the subprocess
|
||||
|
||||
Every public method (`decode`, `encode`, `createDecoder`, `createEncoder`) SHALL accept an optional `signal: AbortSignal`. A signal already aborted at call time SHALL short-circuit before any subprocess is spawned and throw `AudioCodecFfmpegNativeError` with code `AUDIO_CODEC_FFMPEG_NATIVE.ABORTED`. A signal aborted mid-operation SHALL send `SIGTERM` to the child, start a 1-second grace timer, send `SIGKILL` if the child has not exited when the timer fires, release the semaphore slot, and reject with `AUDIO_CODEC_FFMPEG_NATIVE.ABORTED` carrying the abort `reason` in `context.reason`.
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits before spawn
|
||||
|
||||
- **WHEN** `decode` is called with an `AbortSignal` whose `aborted === true`
|
||||
- **THEN** it throws `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.ABORTED"` and no subprocess is spawned
|
||||
|
||||
#### Scenario: mid-decode abort escalates SIGTERM to SIGKILL
|
||||
|
||||
- **WHEN** a long-running decode is aborted, the child ignores `SIGTERM`, and 1 second elapses
|
||||
- **THEN** the child receives `SIGKILL`, the semaphore slot is released, and the call rejects with `AUDIO_CODEC_FFMPEG_NATIVE.ABORTED`
|
||||
|
||||
**Verified by:** `src/abort/abort.test.ts::"pre-aborted signal short-circuits before spawn"` AND `src/abort/abort.test.ts::"mid-decode abort escalates SIGTERM to SIGKILL after 1s grace"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Concurrency limit via in-package semaphore
|
||||
|
||||
The codec SHALL enforce a per-instance concurrency limit (default `4`, override via `options.concurrencyLimit`) using an in-package semaphore — no external dependency. Calls that exceed the limit SHALL queue and acquire a slot in FIFO order; slot release SHALL be guaranteed in a `finally` block so errors and aborts never leak slots. The codec SHALL NOT import `p-limit`, `async-sema`, or any other concurrency library.
|
||||
|
||||
#### Scenario: concurrency limit bounds active subprocesses
|
||||
|
||||
- **WHEN** 10 `decode` calls are issued in parallel against a codec with `concurrencyLimit: 2`
|
||||
- **THEN** at any instant the number of live child processes is ≤ 2, and all 10 calls eventually resolve
|
||||
|
||||
#### Scenario: errored call releases its slot
|
||||
|
||||
- **WHEN** a `decode` call fails with `_DECODE_FAILED` while 3 other calls are queued behind the semaphore
|
||||
- **THEN** the next queued call begins within one tick of the failure, proving the slot was released
|
||||
|
||||
**Verified by:** `src/semaphore/semaphore.test.ts::"concurrency limit caps live subprocess count"` AND `src/semaphore/semaphore.test.ts::"failed call releases its semaphore slot"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Timeout enforcement
|
||||
|
||||
Every subprocess call SHALL be bounded by `options.timeoutMs` (default `30000`). When the timer elapses before the child exits, the runner SHALL send `SIGTERM`, start a 1-second grace timer, send `SIGKILL` on grace expiry, and reject with `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT"` carrying `timeoutMs` in `context`. Timeouts SHALL release the semaphore slot in the same `finally` path as aborts.
|
||||
|
||||
#### Scenario: timeout kills the child and rejects with TIMEOUT
|
||||
|
||||
- **WHEN** a codec is constructed with `timeoutMs: 50` and given a synthetic input that causes ffmpeg to hang
|
||||
- **THEN** the call rejects with `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT"` and the child process exits
|
||||
|
||||
**Verified by:** `src/runner/runner.test.ts::"timeout kills hung child and rejects with AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Every error code is reachable from a call site
|
||||
|
||||
The package SHALL export `AudioCodecFfmpegNativeErrorCode`, `AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA`, and `registerAudioCodecFfmpegNativeErrorCodes()`, registering the codes `AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING`, `_SPAWN_FAILED`, `_DECODE_FAILED`, `_ENCODE_FAILED`, `_TIMEOUT`, `_ABORTED`, `_UNSUPPORTED_CODEC`, and `_INVALID_PCM` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioCodecFfmpegNativeError` extending `SuaveplanError` and carry one of these codes, a `cause`, and a `context`. Every registered code SHALL be reachable from a real call site exercised by tests — no code may be declared and unused.
|
||||
|
||||
#### Scenario: every registered code is asserted thrown by tests
|
||||
|
||||
- **WHEN** the test suite runs
|
||||
- **THEN** each code in `AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA` is asserted thrown by at least one test, including `_SPAWN_FAILED` (via a `binaryProvider` returning a non-executable path) and `_UNSUPPORTED_CODEC` (via `wma` encode)
|
||||
|
||||
**Verified by:** `src/error-codes.test.ts::"every AUDIO_CODEC_FFMPEG_NATIVE code is reachable from a call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation
|
||||
|
||||
The package SHALL instrument its public entry points via `@opentelemetry/api`: spans `audio-codec-ffmpeg-native.decode` and `audio-codec-ffmpeg-native.encode` carrying `format`, `sampleRate`, `channels`, and `byteLength` attributes; a counter `audio_codec_ffmpeg_native_invocations_total` with `op` and `format` labels; a histogram `audio_codec_ffmpeg_native_duration_ms`; and an up/down counter `audio_codec_ffmpeg_native_inflight` tracking live subprocess count. Instrumentation SHALL be inert when no exporter is registered and SHALL NOT run as an import-time side effect.
|
||||
|
||||
#### Scenario: a decode records span, counter, histogram, and inflight gauge
|
||||
|
||||
- **WHEN** `decode` runs against a real in-memory `MeterProvider` and `TracerProvider`
|
||||
- **THEN** one `audio-codec-ffmpeg-native.decode` span is recorded with `format` attribute, the invocations counter increments by 1, the duration histogram records one observation, and the inflight gauge reaches 1 during the call and returns to 0 on completion
|
||||
|
||||
#### Scenario: import is side-effect-free
|
||||
|
||||
- **WHEN** the package is imported and never used
|
||||
- **THEN** no metric or span is emitted and no global OTel state is mutated
|
||||
|
||||
**Verified by:** `src/otel/otel.test.ts::"decode records span, counter, histogram, and inflight gauge"` AND `tests/purity.test.ts::"importing the package mutates no global OTel state"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary validation
|
||||
|
||||
The public options objects — `FfmpegNativeCodecOptions` and `EncodeOpts` — SHALL have Zod schemas exported from sibling `*.schemas.ts` files. The codec constructor SHALL validate `FfmpegNativeCodecOptions` against `FfmpegNativeCodecOptionsSchema`, and `encode` / `createEncoder` SHALL validate `EncodeOpts` against `EncodeOptsSchema`. A validation failure SHALL throw `AudioCodecFfmpegNativeError` with code `AUDIO_CODEC_FFMPEG_NATIVE.INVALID_PCM` (for PCM-shape failures) or `AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC` (for an unknown `format`) carrying the Zod issue list in `context.issues`.
|
||||
|
||||
#### Scenario: negative concurrencyLimit is rejected at construction
|
||||
|
||||
- **WHEN** `new FfmpegNativeCodec({ concurrencyLimit: -1 })` is called
|
||||
- **THEN** it throws an error whose `cause` is a Zod issue listing the failing path
|
||||
|
||||
#### Scenario: unknown format throws UNSUPPORTED_CODEC
|
||||
|
||||
- **WHEN** `encode(pcm, { format: "xyz", sampleRate: 44100 })` is called
|
||||
- **THEN** it throws `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC"` with the issues in `context.issues`
|
||||
|
||||
**Verified by:** `src/codec/codec.test.ts::"constructor rejects invalid options via Zod"` AND `src/encode/encode.test.ts::"unknown format throws AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per feature module
|
||||
|
||||
`package.json` SHALL declare one `exports` entry per feature module — `./codec/codec`, `./binary/binary`, `./runner/runner`, `./semaphore/semaphore`, `./decode/decode`, `./encode/encode`, `./stream/stream-decoder`, `./stream/stream-encoder`, `./abort/abort`, `./otel/otel`, `./register/register`, `./error-codes` — in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`. Feature-module barrels SHALL use explicit named re-exports — no `export *` (rule 10 / §6a).
|
||||
|
||||
#### Scenario: each feature module is independently importable
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/audio-codec-ffmpeg-native/register/register`
|
||||
- **THEN** the import resolves to the register module without pulling the runner, semaphore, or OTel modules
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry and is independently importable"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: register helper installs the codec into a CodecRegistry
|
||||
|
||||
The package SHALL export `register(registry: CodecRegistry, options?: FfmpegNativeCodecOptions): void`, which constructs a single `FfmpegNativeCodec` instance with the given options and calls `registry.register(codec)`. The helper SHALL be idempotent — calling it twice on the same registry with the same options SHALL leave the registry holding the most-recently-registered instance (per `CodecRegistry.register` replace semantics). The helper SHALL be the canonical one-line install path documented in the README.
|
||||
|
||||
#### Scenario: register installs the codec under the ffmpeg-native name
|
||||
|
||||
- **WHEN** `register(registry)` is called against a fresh `CodecRegistry`
|
||||
- **THEN** `registry.get("ffmpeg-native")` returns an `FfmpegNativeCodec` instance, and `registry.detect(wavBytes)` returns `"ffmpeg-native"` when no other codec is registered
|
||||
|
||||
**Verified by:** `src/register/register.test.ts::"register installs FfmpegNativeCodec under the ffmpeg-native name"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Declared format support matrix
|
||||
|
||||
The codec SHALL support the following operations exactly: decode for `wav`, `mp3`, `ogg`, `opus`, `flac`, `aac`, `m4a`, `webm`, `wma`; encode for `wav`, `mp3`, `ogg`, `opus`, `flac`, `aac`, `m4a`, `webm`; streaming decode for every decodable format; streaming encode for every encodable format. `encode(pcm, { format: "wma" })` SHALL throw `AudioCodecFfmpegNativeError` with code `AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC` regardless of binary capability. When the underlying ffmpeg binary lacks a codec listed as supported (e.g. an `aac` encoder build without `libfdk_aac` or `aac`), the runner SHALL surface a `AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC` error rather than a generic `_ENCODE_FAILED`.
|
||||
|
||||
#### Scenario: WMA encode is rejected by contract
|
||||
|
||||
- **WHEN** `encode(pcm, { format: "wma", sampleRate: 44100 })` is called
|
||||
- **THEN** it throws `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC"` and no subprocess is spawned
|
||||
|
||||
#### Scenario: round-trip across declared encodable formats
|
||||
|
||||
- **WHEN** a 1-second sine-wave `Float32Array[]` is encoded then decoded for each of `wav`, `mp3`, `ogg`, `opus`, `flac`, `aac`, `m4a`, `webm`
|
||||
- **THEN** every round trip succeeds and the decoded PCM length matches the input length within one frame of tolerance
|
||||
|
||||
**Verified by:** `src/codec/codec.test.ts::"WMA encode throws AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC without spawning"` AND `tests/integration.test.ts::"round-trip across every declared encodable format"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user