Files
suaveplan/openspec/specs/audio-cli/spec.md
2026-05-27 23:29:53 +01:00

15 KiB

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 <input> ... save <output> 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<string, unknown>]>. 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.<command>, 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/<command> 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"