# Capability Spec Delta: audio-cli (audio-cli) **Package**: `@suaveplan/audio-cli` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Server-tier purity The `@suaveplan/audio-cli` package SHALL be a server-tier application package: it MAY import Node APIs (`node:fs`, `node:path`, `node:process`, `node:stream`, `Buffer`) and MUST NOT import browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`). All audio processing SHALL be delegated to the universal `@suaveplan/audio-core`, `@suaveplan/audio-codecs`, and `@suaveplan/audio-engine-server` packages — the CLI itself SHALL contain no DSP, codec, or page-management logic. Its `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/audio-codecs`, `@suaveplan/audio-engine-server`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/audio-analysis-*`, `@suaveplan/cli`, `@suaveplan/queue`, `@suaveplan/streams`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`. #### Scenario: Built dist references no browser globals - **WHEN** the built `dist/` is grep'd - **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, or `AudioBuffer` #### Scenario: No DSP logic lives in the CLI package - **WHEN** the package source is audited - **THEN** every audio transform is reached through an imported `@suaveplan/audio-*` symbol, and no `Float32Array` block-processing loop is implemented in `audio-cli` **Verified by:** `tests/purity.test.ts::"dist references no browser globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace packages and zod"` --- ### Requirement: The `audio` CLI entry point and `bin` declaration The package SHALL ship an executable entry module that registers the `audio` command-line tool. `package.json` SHALL declare `"bin": { "audio": "./dist/index.js" }` and the entry module SHALL begin with a `#!/usr/bin/env bun` shebang. The entry point SHALL construct an `AudioCLI` instance, register every subcommand, parse `process.argv`, dispatch the matched command, and call `process.exit` with the resolved exit code. The package SHALL also export the `AudioCLI` class from its root barrel for programmatic use without spawning a subprocess. #### Scenario: Installed `bin` resolves and runs - **WHEN** the package is installed and `audio --version` is invoked through the `bin` shim - **THEN** the process prints the package version to stdout and exits `0` #### Scenario: Programmatic AudioCLI mirrors the CLI - **WHEN** a caller imports `AudioCLI` and calls `cli.process(input, output, opts)` directly - **THEN** the produced output file is byte-identical to running the equivalent `audio ... save ` invocation **Verified by:** `tests/integration.test.ts::"audio --version runs through the bin shim and exits 0"` AND `src/cli.test.ts::"programmatic AudioCLI.process matches the CLI invocation"` --- ### Requirement: Argument and pipeline parsing The package SHALL export a `parsePipeline(spec: string)` function that converts a Unix-pipe-style operation chain (e.g. `"trim 30s | normalize -16lufs | fade 0.5s 1s"`) and the positional bare-op form (`audio input.wav trim 30s normalize -16lufs save output.flac`) into an ordered `Array<[opName: string, params: Record]>`. The parser SHALL support quoted arguments, backslash escaping, numeric literals, and unit suffixes (`s` seconds, `ms` milliseconds, `lufs`, `db`, `hz`). The resolved op chain SHALL map one-to-one onto `AudioEdit` records passed to `AudioDocument.run`. A chain referencing an op name not present in the composed `OpRegistry` SHALL throw `AudioCliError` with code `AUDIO_CLI.UNKNOWN_OP` naming the offending op. #### Scenario: Pipe-string and bare-op forms produce the same edit chain - **WHEN** `parsePipeline("trim 30s | normalize")` and the positional form `["trim", "30s", "normalize"]` are both parsed - **THEN** both yield the same `[["trim", { start: 30 }], ["normalize", {}]]` edit chain #### Scenario: Unknown op name is rejected - **WHEN** a pipeline string names an op absent from the registry - **THEN** parsing throws `AudioCliError` whose `code === "AUDIO_CLI.UNKNOWN_OP"` with the offending op name in `context` **Verified by:** `src/pipeline/parser.test.ts::"pipe-string and bare-op forms parse to the same edit chain"` AND `src/pipeline/parser.test.ts::"rejects an unknown op name with AUDIO_CLI.UNKNOWN_OP"` --- ### Requirement: Input → ops chain → output processing flow The `process` and bare-op commands SHALL execute the pipeline `input → ops chain → output`: load the input via `@suaveplan/audio-engine-server`'s `FileSourceAdapter` (or stdin), append the parsed ops as `AudioEdit` records to an `AudioDocument`, stream the compiled plan through `@suaveplan/audio-core`'s `PlanEngine` + `ProcessPipeline`, and encode the result to the output path via `@suaveplan/audio-codecs` and `FileSinkAdapter` (or stdout). The output codec SHALL be inferred from the output file extension; an unrecognised extension SHALL throw `AudioCliError` with code `AUDIO_CLI.BAD_OUTPUT`. Processing SHALL be streaming end-to-end — the CLI MUST NOT materialise the whole decoded file in memory when the codec supports progressive decode. #### Scenario: Trim then normalize round-trips to a new format - **WHEN** `audio input.wav trim 30s normalize -16lufs save output.flac` runs against a real temp WAV file - **THEN** `output.flac` exists, decodes to a 30-second FLAC stream, and the process exits `0` #### Scenario: Unrecognised output extension is rejected - **WHEN** the output path carries an extension no registered codec claims - **THEN** the command throws `AudioCliError` whose `code === "AUDIO_CLI.BAD_OUTPUT"` and exits non-zero **Verified by:** `src/commands/process.test.ts::"trim then normalize writes a 30s FLAC and exits 0"` AND `src/commands/process.test.ts::"rejects an unrecognised output extension with AUDIO_CLI.BAD_OUTPUT"` --- ### Requirement: Streaming through audio-core, audio-codecs, and audio-engine-server Every command that reads or writes audio SHALL compose the three lower packages without re-implementing their behaviour: `@suaveplan/audio-engine-server` for file/stdin/stdout adapters, `@suaveplan/audio-codecs` for decode/encode, and `@suaveplan/audio-core` for the `AudioDocument` + `PlanEngine` + `ProcessPipeline` render path. The `analyze` command SHALL additionally compose the `@suaveplan/audio-analysis-*` ops, and pipeline FX SHALL be resolved from `@suaveplan/audio-fx-plugin-base`. The CLI SHALL pass decoded `Float32Array[]` blocks through `@suaveplan/streams` so backpressure is honoured between decode, process, and encode stages. #### Scenario: Decode, process, and encode stages are backpressure-linked - **WHEN** a large input is processed and the sink stage is artificially slowed - **THEN** the decode stage pauses rather than buffering the whole file, and peak memory stays bounded #### Scenario: Analyze composes analysis ops without re-implementing them - **WHEN** `audio analyze input.mp3 --bpm --key --lufs` runs - **THEN** the report values are produced by the imported `@suaveplan/audio-analysis-*` ops, not by logic local to `audio-cli` **Verified by:** `tests/integration.test.ts::"decode/process/encode stages honour backpressure"` AND `src/commands/analyze.test.ts::"analyze delegates to the audio-analysis ops"` --- ### Requirement: `--help` and `--version` flags The CLI SHALL handle `--help` / `-h` and `--version` / `-V` at both the top level and per subcommand. `--version` SHALL print the `@suaveplan/audio-cli` package version to stdout and exit `0`. `--help` SHALL print usage, the command table, and per-command option descriptions to stdout and exit `0`. An unknown top-level command or an unknown flag SHALL print a short usage hint to stderr and exit with a non-zero code. #### Scenario: Top-level help lists every command - **WHEN** `audio --help` runs - **THEN** stdout contains a usage line and an entry for each of `process`, `batch`, `analyze`, `convert`, `info`, `play`, and `record`, and the process exits `0` #### Scenario: Unknown command prints usage and exits non-zero - **WHEN** `audio frobnicate` runs - **THEN** stderr carries a usage hint, stdout is empty, and the exit code is non-zero **Verified by:** `src/cli.test.ts::"--help lists every registered command and exits 0"` AND `src/cli.test.ts::"unknown command prints usage to stderr and exits non-zero"` --- ### Requirement: Exit codes mapped from error codes The CLI SHALL exit `0` on success and a documented non-zero code on failure. Every `AUDIO_CLI_*` error code SHALL map to a stable, non-zero exit code via an exported `EXIT_CODES` table: usage/argument errors → `2`, input-not-found / unreadable → `3`, bad-output / encode failure → `4`, pipeline / processing failure → `5`, aborted (SIGINT) → `130`. An uncaught error of unknown shape SHALL exit `1`. A failed command SHALL write a single human-readable diagnostic line to stderr carrying the error `code` and `message` before exiting. #### Scenario: Missing input file exits with the input-error code - **WHEN** `audio missing.wav normalize save out.wav` runs against a path that does not exist - **THEN** the process exits `3`, stderr carries an `AUDIO_CLI.INPUT_NOT_FOUND` diagnostic, and no output file is created #### Scenario: Every error code has a distinct non-zero exit mapping - **WHEN** the `EXIT_CODES` table is enumerated - **THEN** every `AUDIO_CLI_*` code maps to a non-zero integer and `0` is reserved for success **Verified by:** `src/commands/process.test.ts::"missing input exits 3 with an AUDIO_CLI.INPUT_NOT_FOUND diagnostic"` AND `src/error-codes.test.ts::"every AUDIO_CLI code maps to a distinct non-zero exit code"` --- ### Requirement: Zod boundary validation of parsed arguments Every command's resolved option object — `ProcessOptions`, `BatchOptions`, `AnalyzeOptions`, `ConvertOptions`, `InfoOptions`, `PlayOptions`, `RecordOptions` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and each command SHALL validate its parsed arguments against the schema before doing any I/O. A validation failure SHALL throw `AudioCliError` with code `AUDIO_CLI.INVALID_ARGS` carrying the Zod issue list in `context`, and the CLI SHALL surface it as exit code `2`. #### Scenario: Invalid argument is rejected at the boundary - **WHEN** `audio batch --workers -1 ...` is invoked with a negative worker count - **THEN** the command throws `AudioCliError` whose `code === "AUDIO_CLI.INVALID_ARGS"` with the Zod issues in `context`, and the process exits `2` **Verified by:** `src/commands/batch.test.ts::"rejects a negative --workers value with AUDIO_CLI.INVALID_ARGS and exit 2"` --- ### Requirement: Structured error codes The package SHALL export `AudioCliErrorCode`, `AUDIO_CLI_ERROR_METADATA`, and `registerAudioCliErrorCodes()`, registering the codes `AUDIO_CLI.INVALID_ARGS`, `AUDIO_CLI.UNKNOWN_OP`, `AUDIO_CLI.INPUT_NOT_FOUND`, `AUDIO_CLI.BAD_OUTPUT`, `AUDIO_CLI.PIPELINE_FAILED`, and `AUDIO_CLI.ABORTED` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioCliError` extending `SuaveplanError` carrying one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests running the real CLI against real temp files. #### Scenario: Every registered code is thrown by a call site - **WHEN** the test suite runs - **THEN** each code in `AUDIO_CLI_ERROR_METADATA` is asserted thrown by at least one test invoking the real CLI **Verified by:** `src/error-codes.test.ts::"every AUDIO_CLI code is reachable from a call site"` --- ### Requirement: OpenTelemetry instrumentation The package SHALL instrument its commands via `@suaveplan/opentelemetry`: a span per command named `audio_cli.`, a counter `audio_cli_files_processed_total{command,outcome}`, a counter `audio_cli_commands_total{command,outcome}`, and a histogram `audio_cli_command_duration_ms{command,outcome}`. The `outcome` attribute SHALL be one of `success`/`error`/`aborted`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect. #### Scenario: A successful command records its span and counters - **WHEN** `audio convert in.wav out.flac` runs against a real in-memory OTel provider - **THEN** a span `audio_cli.convert` is recorded, `audio_cli_commands_total{command="convert",outcome="success"}` increments by 1, and `audio_cli_command_duration_ms` records one observation #### Scenario: Instrumentation is inert without an exporter - **WHEN** the package is imported and a command runs with no OTel exporter registered - **THEN** no error is thrown and no global state is mutated at import time **Verified by:** `src/commands/convert.test.ts::"convert records a span and counters with an exporter registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"` --- ### Requirement: AbortSignal and SIGINT handling The CLI SHALL install a `SIGINT` handler that aborts an `AbortController` whose signal is threaded into every long-running command (`process`, `batch`, `analyze`, `convert`, `record`) and into the underlying `audio-core` stream and `audio-engine-server` adapter calls. On abort the command SHALL stop at the next block boundary, clean up any partially written output file, throw `AudioCliError` with code `AUDIO_CLI.ABORTED`, and exit `130`. A second `SIGINT` SHALL force-exit immediately. The programmatic `AudioCLI` methods SHALL accept an `AbortSignal` via their options object so the same path is exercisable without a real signal. #### Scenario: SIGINT aborts and removes the partial output - **WHEN** a long `process` run receives `SIGINT` mid-stream - **THEN** the command throws `AUDIO_CLI.ABORTED`, the partially written output file is removed, and the process exits `130` #### Scenario: Pre-aborted signal short-circuits a programmatic call - **WHEN** `AudioCLI.process` is called with an already-aborted signal - **THEN** it throws `AudioCliError` whose `code === "AUDIO_CLI.ABORTED"` before opening the input file **Verified by:** `tests/integration.test.ts::"SIGINT aborts a process run, removes partial output, exits 130"` AND `src/cli.test.ts::"AudioCLI.process short-circuits a pre-aborted signal"` --- ### Requirement: Subpath exports for reusable modules `package.json` SHALL declare subpath `exports` for the reusable non-`bin` modules — `./cli` (the `AudioCLI` class), `./pipeline/parser` (`parsePipeline`), and `./commands/` for each command's programmatic entry — in addition to the root `.` barrel and the `bin` entry. `sideEffects` SHALL be `false`. The shebang entry module SHALL NOT be re-exported from the root barrel so importing the package never triggers `process.argv` parsing. #### Scenario: The pipeline parser is independently importable - **WHEN** a consumer imports `@suaveplan/audio-cli/pipeline/parser` - **THEN** the import resolves to `parsePipeline` without pulling the command registry or the `bin` entry #### Scenario: Importing the root barrel does not parse argv - **WHEN** a consumer imports `@suaveplan/audio-cli` - **THEN** no command dispatch runs and `process.argv` is not read at import time **Verified by:** `tests/exports.test.ts::"every reusable module has a subpath export entry"` AND `tests/exports.test.ts::"importing the root barrel does not parse process.argv"`