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, orAudioBuffer
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 noFloat32Arrayblock-processing loop is implemented inaudio-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 --versionis invoked through thebinshim - THEN the process prints the package version to stdout and exits
0
Scenario: Programmatic AudioCLI mirrors the CLI
- WHEN a caller imports
AudioCLIand callscli.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
AudioCliErrorwhosecode === "AUDIO_CLI.UNKNOWN_OP"with the offending op name incontext
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.flacruns against a real temp WAV file - THEN
output.flacexists, decodes to a 30-second FLAC stream, and the process exits0
Scenario: Unrecognised output extension is rejected
- WHEN the output path carries an extension no registered codec claims
- THEN the command throws
AudioCliErrorwhosecode === "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 --lufsruns - THEN the report values are produced by the imported
@suaveplan/audio-analysis-*ops, not by logic local toaudio-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 --helpruns - THEN stdout contains a usage line and an entry for each of
process,batch,analyze,convert,info,play, andrecord, and the process exits0
Scenario: Unknown command prints usage and exits non-zero
- WHEN
audio frobnicateruns - 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.wavruns against a path that does not exist - THEN the process exits
3, stderr carries anAUDIO_CLI.INPUT_NOT_FOUNDdiagnostic, and no output file is created
Scenario: Every error code has a distinct non-zero exit mapping
- WHEN the
EXIT_CODEStable is enumerated - THEN every
AUDIO_CLI_*code maps to a non-zero integer and0is 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
AudioCliErrorwhosecode === "AUDIO_CLI.INVALID_ARGS"with the Zod issues incontext, and the process exits2
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_METADATAis 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.flacruns against a real in-memory OTel provider - THEN a span
audio_cli.convertis recorded,audio_cli_commands_total{command="convert",outcome="success"}increments by 1, andaudio_cli_command_duration_msrecords 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
processrun receivesSIGINTmid-stream - THEN the command throws
AUDIO_CLI.ABORTED, the partially written output file is removed, and the process exits130
Scenario: Pre-aborted signal short-circuits a programmatic call
- WHEN
AudioCLI.processis called with an already-aborted signal - THEN it throws
AudioCliErrorwhosecode === "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
parsePipelinewithout pulling the command registry or thebinentry
Scenario: Importing the root barrel does not parse argv
- WHEN a consumer imports
@suaveplan/audio-cli - THEN no command dispatch runs and
process.argvis 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"