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

8.5 KiB
Raw Blame History

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 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:

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 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.