Files
suaveplan/openspec/archive/2026-05-23-enterprise-audio-cli/design.md
2026-05-27 23:29:53 +01:00

186 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design: enterprise-audio-cli
**Change ID**: enterprise-audio-cli
**Package**: `@suaveplan/audio-cli`
`@suaveplan/audio-cli` is the `audio` command-line tool — the server-tier application entry point of
the `@suaveplan/audio-*` family. It is deliberately **thin**: it owns argument parsing, the pipeline
grammar, command dispatch, exit-code mapping, and signal handling, and nothing else. Every audio
transform is delegated to the universal and platform packages below it.
---
## 1. Relationship to the ecosystem architecture
The cross-cutting architecture for the whole audio family — the three-layer separation, the six
plugin slots, the canonical `Segment` shape, the universal data model, and the error hierarchy —
lives in [`enterprise-audio-core/design.md`](../enterprise-audio-core/design.md) §1–§4 and is **not
duplicated here**. `audio-cli` sits in the Application layer of that architecture (the topmost box
in §1.6's diagram). It composes:
- `@suaveplan/audio-core` — `AudioDocument`, `OpRegistry`, `PlanEngine`, `ProcessPipeline`.
- `@suaveplan/audio-codecs` — `CodecRegistry`, `decodeSource`, `encodeTo`.
- `@suaveplan/audio-engine-server` — `FileSourceAdapter`, `FileSinkAdapter`, stdin/stdout adapters.
- `@suaveplan/audio-fx-plugin-base` — FX plugins resolvable by name inside a pipeline.
- `@suaveplan/audio-analysis-*` — analysis ops for the `analyze` command.
- `@suaveplan/cli` — command registry, argument parsing, help-text rendering.
- `@suaveplan/queue` — batch job distribution for `batch --queue`.
The CLI implements **no DSP, no codec, and no page management**. If a behaviour belongs to audio
processing, it belongs in one of the packages above; `audio-cli` only wires them together and maps
the result onto an Unix process contract (argv in, exit code out, stdout/stderr).
---
## 2. Command grammar
The tool accepts two interchangeable surface forms that resolve to the same internal op chain.
**Bare-op positional form** — the primary, ergonomic form:
```sh
audio input.wav trim 30s normalize -16lufs fade 0.5s 1s save output.flac
```
**Pipe-string form** — for scripting and `batch --pipeline`:
```sh
audio batch --input "./tracks/*.mp3" --pipeline "trim 30s | normalize | fade 0.5s 1s" --output "./processed/"
```
Both forms parse to `Array<[opName, params]>`. Unit suffixes are normalised at parse time: `30s` →
seconds `30`, `500ms` → seconds `0.5`, `-16lufs` → `{ lufs: -16 }`, `6db` → `{ db: 6 }`,
`44100hz` → `{ rate: 44100 }`. Quoted arguments and backslash escaping let a single op carry a
string parameter containing spaces.
| Command | Description |
|---|---|
| `audio <input> <op…> save <output>` | Process a single file through an op chain |
| `audio process` | Same as bare-op, with an explicit `--pipeline` flag |
| `audio batch` | Glob input, optional `@suaveplan/queue` fan-out, shared pipeline |
| `audio analyze` | Run `audio-analysis-*` ops, emit a JSON report |
| `audio convert` | Format conversion only (decode → encode, no ops) |
| `audio info` | Print container metadata and `computeStats` output |
| `audio play` | Play a file (delegates to a speaker adapter; no-op-tested) |
| `audio record` | Record from a mic adapter to a file |
---
## 3. Pipeline-construction strategy
`parsePipeline` is a small hand-written tokenizer (split on `|` for the pipe form, on whitespace for
the bare-op form, respecting quotes/escapes), followed by a per-op argument coercion pass. The
resolved chain is validated against the composed `OpRegistry` — built from
`OpRegistry.createWithDefaults()` plus the FX plugins from `@suaveplan/audio-fx-plugin-base` — so an
unknown op name fails fast with `AUDIO_CLI.UNKNOWN_OP` before any file is opened. Each
`[opName, params]` pair becomes an `AudioEdit` appended to the `AudioDocument` via `run`; the
document stays non-destructive and the actual work is deferred to the stream.
```ts
const ops = parsePipeline(spec) // [["trim", {start:30}], ["normalize", {lufs:-16}]]
const doc = await source.load(input) // FileSourceAdapter → AudioDocument
for (const [type, opts] of ops) doc.run({ type, opts })
const sink = new FileSinkAdapter()
await sink.save(doc, output, detectCodec(output)) // streams compiled plan → encoder
```
`detectCodec` maps the output extension onto a `@suaveplan/audio-codecs` codec; an unknown
extension throws `AUDIO_CLI.BAD_OUTPUT`.
---
## 4. Process contract: exit codes and signals
The CLI's job is to translate audio outcomes into a stable Unix process contract.
```ts
export const EXIT_CODES = {
AUDIO_CLI.INVALID_ARGS: 2,
AUDIO_CLI.UNKNOWN_OP: 2,
AUDIO_CLI.INPUT_NOT_FOUND: 3,
AUDIO_CLI.BAD_OUTPUT: 4,
AUDIO_CLI.PIPELINE_FAILED: 5,
AUDIO_CLI.ABORTED: 130,
} as const
```
`0` is success; an uncaught error of unknown shape exits `1`. Every failure writes one diagnostic
line — `code: message` — to stderr and nothing to stdout, so the tool composes cleanly in shell
pipelines.
A single `AbortController` is created per invocation. `SIGINT` aborts its signal, which is threaded
into the `audio-core` stream, the `audio-engine-server` adapters, and (for `batch`) the
`@suaveplan/queue` jobs. On abort the command stops at the next block boundary, unlinks any
partially written output, throws `AUDIO_CLI.ABORTED`, and exits `130`. A second `SIGINT`
force-exits. The programmatic `AudioCLI` methods accept an `AbortSignal` directly so the abort path
is testable without a real signal.
---
## 5. File layout
```text
packages/media/audio/audio-cli/
package.json # bin: { "audio": "./dist/index.js" }, subpath exports
tsconfig.json
vitest.config.ts
src/
index.ts # #!/usr/bin/env bun — constructs AudioCLI, dispatches, process.exit
cli.ts # AudioCLI class — command registry, dispatch, exit-code mapping
cli.md cli.test.ts
error-codes.ts # AudioCliErrorCode, AUDIO_CLI_ERROR_METADATA, EXIT_CODES
error-codes.test.ts
pipeline/
parser.ts parser.md parser.types.ts parser.test.ts index.ts
commands/
process.ts batch.ts analyze.ts convert.ts info.ts play.ts record.ts
(+ .md + .types.ts + .test.ts each) index.ts
__benchmarks__/
cli.bench.ts
tests/
integration.test.ts property.test.ts purity.test.ts exports.test.ts
```
---
## 6. Integration notes
- **Tests run the real CLI.** The repo bans mocks, so command tests spawn or invoke the real
`AudioCLI` against real temp files (`node:fs` `mkdtemp`), assert on the produced bytes, the exit
code, and the stderr diagnostic. Codec and engine behaviour is exercised through the real
`@suaveplan/audio-codecs` / `@suaveplan/audio-engine-server` packages.
- **Streaming end-to-end.** Decode → process → encode are linked through `@suaveplan/streams` so a
slow sink applies backpressure to the decoder rather than buffering the whole file.
- **`play` / `record`.** These require a speaker/mic adapter. They are wired through the engine
package's adapter interfaces; tests cover argument validation and adapter dispatch, not real audio
hardware.
- **`batch --queue`.** Without `--queue`, batch runs files sequentially with a progress bar. With
`--queue`, jobs are submitted to `@suaveplan/queue` and `--workers` controls fan-out.
---
## 7. Benchmarks
- `cli.bench.ts` — batch-process 100 short files through a 3-op pipeline; analyze 100 files for
BPM + LUFS. Measures dispatch overhead and confirms the CLI layer adds negligible cost on top of
the underlying packages.
---
## 8. Decisions and trade-offs
- **The CLI is thin by mandate.** Keeping zero DSP/codec logic in `audio-cli` means the same
processing is provably the code that runs in the browser tier — the CLI is "just a shell". Any
temptation to inline a quick transform here is a design violation; it belongs in `audio-core` or a
plugin.
- **Two surface forms, one internal chain.** The bare-op positional form is the ergonomic default;
the pipe-string form exists because `batch --pipeline` needs a single quotable argument. Both
collapse to the same `[opName, params][]` so there is exactly one code path to test and reason
about.
- **Exit codes are part of the public surface.** `EXIT_CODES` is exported and spec'd because CI
pipelines branch on it. Changing a mapping is a breaking change, the same as changing a function
signature.
- **`audio-cli` carries no architecture spec.** Unlike `audio-core`, this package's design is
entirely local — it references §1–§4 of the core design rather than restating the ecosystem
model.