8.5 KiB
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 §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 theanalyzecommand.@suaveplan/cli— command registry, argument parsing, help-text rendering.@suaveplan/queue— batch job distribution forbatch --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:
audio input.wav trim 30s normalize -16lufs fade 0.5s 1s save output.flac
Pipe-string form — for scripting and batch --pipeline:
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.
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.
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
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
AudioCLIagainst real temp files (node:fsmkdtemp), 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-serverpackages. - Streaming end-to-end. Decode → process → encode are linked through
@suaveplan/streamsso 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/queueand--workerscontrols 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-climeans 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 inaudio-coreor a plugin. - Two surface forms, one internal chain. The bare-op positional form is the ergonomic default;
the pipe-string form exists because
batch --pipelineneeds 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_CODESis 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-clicarries no architecture spec. Unlikeaudio-core, this package's design is entirely local — it references §1–§4 of the core design rather than restating the ecosystem model.