186 lines
8.5 KiB
Markdown
186 lines
8.5 KiB
Markdown
# 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.
|