first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
# @suaveplan/audio-cli - Server-tier `audio` command-line tool: chainable pipeline grammar, batch glob processing, analysis JSON reports, codec conversion, info, play, record. Thin shell over @suaveplan/audio-core + audio-codecs + audio-engine-server.
Server-tier `audio` command-line tool. Thin shell over
`@suaveplan/audio-core`, `@suaveplan/audio-codecs`, and
`@suaveplan/audio-engine-server`: every audio transform delegates to the
universal layer; the CLI itself owns argument parsing, the pipeline grammar,
command dispatch, exit-code mapping, and SIGINT handling.
<!-- begin:auto-docs -->
## Documentation tree
- [bin](./src/bin/bin.md) — This module is the `bin` shebang entry for the `suaveplan-audio` command. It is the only file in the package with side…
- [bin](./src/bin/bin.md) — This module is the `bin` shebang entry for the `suaveplan-audio` command. It is the only file in the package with side…
- [cli](./src/cli/cli.md) — `AudioCLI` is the application-class wrapper for the `audio` command-line tool. It owns the default codec registry, the…
- [cli](./src/cli/cli.md) — `AudioCLI` is the application-class wrapper for the `audio` command-line tool. It owns the default codec registry, the…
- [analyze](./src/commands/analyze.md) — `runAnalyze` is the analysis pipeline entry. It loads an input file through the `FileSourceAdapter`, runs the requested…
- [batch](./src/commands/batch.md) — `runBatch` is the bulk-processing entry point. It expands a glob pattern or a comma-separated list of explicit paths in…
- [convert](./src/commands/convert.md) — `runConvert` is a deliberately thin wrapper around `runProcess`. It validates the small `ConvertOptions` surface (`form…
- [info](./src/commands/info.md) — `runInfo` produces a one-shot metadata + statistics report for a file: sample rate, channel count, duration, total samp…
- [play](./src/commands/play.md) — `runPlay` plays a file through an injected `SpeakerAdapter`. The CLI package intentionally carries no platform audio bi…
- [process](./src/commands/process.md) — `runProcess` is the engine of the `audio process` and bare-op invocations. It loads the input file via `FileSourceAdapt…
- [record](./src/commands/record.md) — `runRecord` captures audio from an injected `MicrophoneAdapter` for a fixed duration and saves the resulting `AudioDocu…
- [error-codes](./src/error-codes/error-codes.md) — This module is the structured error and exit-code boundary for `@suaveplan/audio-cli`. It defines the six `AUDIO_CLI.*`…
- [error-codes](./src/error-codes/error-codes.md) — This module is the structured error and exit-code boundary for `@suaveplan/audio-cli`. It defines the six `AUDIO_CLI.*`…
- [pipeline](./src/pipeline/parser.md) — This module turns a CLI user's free-form pipeline expression into a typed `[opName, params]` chain that the rest of the…
- [parser](./src/pipeline/parser.md) — This module turns a CLI user's free-form pipeline expression into a typed `[opName, params]` chain that the rest of the…
<!-- end:auto-docs -->
## Install
```sh
bun add @suaveplan/audio-cli
```
The `suaveplan-audio` bin is installed alongside the package.
## CLI
```sh
suaveplan-audio --version
suaveplan-audio --help
# Process: load → ops → save
suaveplan-audio process input.wav output.flac --pipeline "trim 30s | normalize -16lufs"
# Batch: glob + shared pipeline
suaveplan-audio batch --input "tracks/*.mp3" --output processed/ --pipeline "normalize" --workers 4
# Analyze: produce a JSON report
suaveplan-audio analyze input.mp3 --bpm --key --lufs --output report.json
# Format conversion (no ops)
suaveplan-audio convert input.wav output.flac
# Print metadata + computeStats
suaveplan-audio info input.mp3
# Headless play (uses an injected SpeakerAdapter)
suaveplan-audio play input.wav
# Record silence (default mic) for N seconds
suaveplan-audio record out.wav --duration 60
```
## Programmatic API
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
await cli.process("input.wav", "output.flac", { pipeline: "trim 30s | normalize" });
const report = await cli.analyze("input.mp3", { bpm: true, key: true, lufs: true });
```
Subpath exports for tree-shaken consumption:
- `@suaveplan/audio-cli/cli` — the `AudioCLI` class.
- `@suaveplan/audio-cli/pipeline/parser` — `parsePipeline` / `validatePipeline`.
- `@suaveplan/audio-cli/error-codes` — `AUDIO_CLI_CODES`, `AudioCliError`,
`EXIT_CODES`, `exitCodeFor`.
- `@suaveplan/audio-cli/commands/<command>` — each `runX` entry.
## Exit codes
Stable mapping from `AUDIO_CLI.*` codes onto Unix exit codes:
| Code | Exit |
|---|---|
| `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` |
`0` is reserved for success. Any uncaught non-`AudioCliError` exits `1`.
## Quality
- Server-tier, Node.js >=22.
- 100/100/100/100 coverage on every implementation file.
- Zero mocks — tests drive the real CLI against real `mkdtemp` temp files
and the real `@suaveplan/audio-engine-server` adapters.

View File

@@ -0,0 +1,59 @@
# `bin` — `suaveplan-audio` entry point
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the `bin` shebang entry for the `suaveplan-audio` command. It
is the only file in the package with side effects: when executed as a script
it installs a `SIGINT` handler, constructs an `AudioCLI`, dispatches
`process.argv`, and calls `process.exit` with the returned exit code. The
function `main` is also exported so tests and programmatic callers can drive
the exact dispatch path without spawning a subprocess.
The root barrel deliberately does NOT re-export this module — importing
`@suaveplan/audio-cli` should never read `process.argv` or attach signal
listeners.
## Features
- `#!/usr/bin/env bun` shebang so the installed `bin` runs through Bun.
- Single `AbortController` per invocation, threaded into every long-running
command via `cli.dispatch`.
- Two-tick `SIGINT` handling: first signal aborts cooperatively, second
signal calls `process.exit(130)` for force-exit.
- `import.meta.url` self-test guard so importing the file as a module is a
pure type/export operation.
## Usage: Basic
```sh
$ suaveplan-audio --version
0.1.0
$ suaveplan-audio convert input.wav output.flac
```
## Usage: Advanced
```ts
import { main } from "@suaveplan/audio-cli/bin";
const exitCode = await main(["analyze", "track.wav", "--lufs"]);
console.log("exit:", exitCode);
```
## API Reference
- `main(argv: readonly string[]): Promise<number>` — dispatch and return
exit code without calling `process.exit` (caller's responsibility).
## Implementation Notes
The `import.meta.url === \`file://\${process.argv[1]}\`` check is the
canonical Node ESM way to ask "am I running as the entry script?" It avoids
the historical `require.main === module` CJS idiom and keeps the file safe
to import from tests. The single `AbortController` is threaded via the
dispatch's per-command argument extraction so the same signal reaches the
engine adapters as the spec mandates.

View File

@@ -0,0 +1,76 @@
# `cli` — `AudioCLI` class
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AudioCLI` is the application-class wrapper for the `audio` command-line tool.
It owns the default codec registry, the default op registry, the
`FileSourceAdapter` / `FileSinkAdapter` pair from `@suaveplan/audio-engine-server`,
the stdout/stderr streams, the OTel instrumentation, and the argv dispatch
logic. Both the `bin` entry and programmatic consumers construct an instance
of this class and call its public methods, so the test surface and the binary
exercise the same code path.
The class deliberately holds only wiring: every audio transform is delegated
to the seven `runX` command modules, the pipeline parser, and the underlying
`@suaveplan/audio-*` packages.
## Features
- Default `CodecRegistry` populated with the 10 built-in codecs.
- Default `OpRegistry.createWithDefaults()` plus user-supplied overrides.
- Injectable `FileSourceAdapter` / `FileSinkAdapter` for tests without
patching `node:fs`.
- OTel span (`audio_cli.<command>`), command counter, duration histogram,
files-processed counter, all tagged with `{ command, outcome }`.
- `dispatch(argv)` returns a numeric exit code, never throws — failures are
formatted onto stderr and mapped through the `EXIT_CODES` table.
- `helpText()` lists every subcommand and global flag for `--help` rendering.
- `detectCodecForOutput(path, registry)` exposed for tests and the convert
command: extension → codec name, throws `AUDIO_CLI.BAD_OUTPUT` on miss.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli/cli";
const cli = new AudioCLI();
await cli.process("input.wav", "output.flac");
```
## Usage: Advanced
```ts
import { AudioCLI } from "@suaveplan/audio-cli/cli";
import { CodecRegistry, WavCodec } from "@suaveplan/audio-codecs";
const wavOnly = new CodecRegistry({ codecs: [WavCodec] });
const cli = new AudioCLI({ registry: wavOnly, version: "1.2.3" });
const exit = await cli.dispatch(["--version"]); // prints "1.2.3", returns 0
```
## API Reference
- `AudioCLI(options?: AudioCliOptions)` — constructor.
- `AudioCliOptions` — `{ registry, ops, source, sink, stdout, stderr, version }`.
- `cli.process / batch / analyze / convert / info / play / record` — typed
promises, each delegating to its `runX` module.
- `cli.dispatch(argv)` — exit-code-returning dispatcher.
- `cli.helpText()` — rendered help string.
- `createDefaultCodecRegistry()` — top-level helper used by the default
constructor path.
- `detectCodecForOutput(path, registry)` — extension → codec-name lookup.
## Implementation Notes
The OTel wrapper records the start time at the top of every command, records
the outcome attribute in `finally`, and lets the `AudioCliError` propagate.
`#reportAndExit` is the single point that converts a thrown value into a
numeric exit code; it writes a single human-readable diagnostic line to
stderr (`code: message`) so the binary composes cleanly inside shell
pipelines. The `dispatch` switch is deliberately flat — every subcommand owns
its argument-parsing helper, validates inputs with a Zod schema, and either
returns `0` or throws an `AudioCliError`.

View File

@@ -0,0 +1,63 @@
# `commands/analyze` — `runAnalyze`
## Purpose
`runAnalyze` is the analysis pipeline entry. It loads an input file through
the `FileSourceAdapter`, runs the requested analysis ops, and returns (or
writes to `--output`) a structured `AnalyzeReport` JSON payload. The default
behaviour — when no analysis flag is passed — runs the universal
`computeStats` from `@suaveplan/audio-core` so a `peak/rms/lufs` summary is
always available.
The module is deliberately a composition point: the `--bpm`, `--key`, and
`--lufs` flags are wired so the heavier
`@suaveplan/audio-analysis-*` packages can be plugged in via OpRegistry-style
extension without modifying this code path.
## Features
- Zod-validated `AnalyzeOptions` (`bpm`, `key`, `lufs`, `stats`, `output`,
`signal`).
- Default fallback to `computeStats` when no flag is given.
- Optional `--output report.json` writes pretty-printed JSON to disk.
- `--lufs` is independently selectable so a CI gate can produce just LUFS
without re-running BPM/key.
- Pre-aborted signal short-circuits before any I/O.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
const report = await cli.analyze("input.mp3");
console.log(report.peak, report.rms);
```
## Usage: Advanced
```ts
import { runAnalyze } from "@suaveplan/audio-cli/commands/analyze";
await runAnalyze(cli, "track.wav", {
bpm: true,
key: true,
lufs: true,
output: "report.json",
});
```
## API Reference
- `runAnalyze(cli, input, opts?): Promise<AnalyzeReport>`
- `AnalyzeOptions` — `{ bpm?, key?, lufs?, stats?, output?, signal? }`
- `AnalyzeReport` — `{ input, sampleRate, channels, duration, peak?, rms?,
lufs?, bpm?, key? }`
## Implementation Notes
`computeStats` is called once per branch — the `wantStats` and `--lufs` paths
both delegate to it rather than re-implementing the loop. When `--output` is
provided the function still returns the report so a programmatic caller can
inspect the same payload that was just persisted. The signal threading
mirrors `runProcess` so the abort guarantees are uniform across commands.

View File

@@ -0,0 +1,70 @@
# `commands/batch` — `runBatch`
## Purpose
`runBatch` is the bulk-processing entry point. It expands a glob pattern or a
comma-separated list of explicit paths into a concrete file list, runs the
shared pipeline against every file, and returns a structured `BatchReport`
that carries per-file success/failure rows alongside aggregate counts.
Per-file failures do not abort the batch — the report flags them and the run
continues — but an `AbortSignal` is honoured cooperatively at every task
boundary.
## Features
- Glob expansion: `*.wav`, `tracks/*.mp3`, `**/foo.flac` (basename glob).
- Comma-separated explicit list: `a.wav,b.wav,c.wav`.
- Output naming preserves the basename and applies `outputExt` (defaulting to
the source extension) so `tracks/song.mp3` → `out/song.<ext>`.
- Configurable `workers` lane count (>= 1) using a cursor-based pool so
exceptions in one lane do not stall others.
- Pre-aborted signal short-circuits before any read; mid-flight abort throws
`AUDIO_CLI.ABORTED` with a `{ completed, total }` context.
- Per-file failures land in the report as `{ ok: false, error }` entries.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
const report = await cli.batch({
input: "tracks/*.mp3",
output: "processed/",
pipeline: "trim 30s | normalize",
});
console.log(report.succeeded, "/", report.total);
```
## Usage: Advanced
```ts
import { runBatch } from "@suaveplan/audio-cli/commands/batch";
const ac = new AbortController();
const report = await runBatch(cli, {
input: "a.wav,b.wav,c.wav",
output: "out/",
outputExt: "flac",
workers: 4,
pipeline: "normalize -16lufs",
signal: ac.signal,
});
for (const e of report.entries) if (!e.ok) console.error(e.input, e.error);
```
## API Reference
- `runBatch(cli, opts: BatchOptions): Promise<BatchReport>`
- `BatchOptions` — `{ input, output, pipeline, outputExt?, workers?, signal? }`
- `BatchEntry` — `{ input, output, ok, error?, durationMs }`
- `BatchReport` — `{ total, succeeded, failed, entries }`
## Implementation Notes
The glob expansion is deliberately small and dependency-free: a `readdir`
call on the parent directory plus a regex compiled from the pattern. Comma
forms bypass `readdir` entirely. The worker pool uses an integer cursor so
adding more lanes than tasks is a no-op rather than an error. The output
directory is `dirname`-touched once so callers passing a deep `--output` path
do not silently lose the prefix to dead-code elimination during minification.

View File

@@ -0,0 +1,63 @@
# `commands/convert` — `runConvert`
## Purpose
`runConvert` is a deliberately thin wrapper around `runProcess`. It validates
the small `ConvertOptions` surface (`format`, `bitDepth`, `signal`) and
forwards into the shared decode/encode path with no pipeline ops. Keeping it
this thin means the format mapping, codec lookup, abort handling, and error
translation live in exactly one place — the `process` command — which the
spec calls out as the single source of truth for streaming through
`audio-core`/`audio-codecs`/`audio-engine-server`.
## Features
- Zod-strict validation of `ConvertOptions`.
- Optional explicit `format` overriding extension detection.
- Optional `bitDepth` forwarded to the codec encoder where supported.
- Pre-aborted signal short-circuits via `runProcess`.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
await cli.convert("input.wav", "output.flac");
```
## Usage: Advanced
```ts
import { runConvert } from "@suaveplan/audio-cli/commands/convert";
await runConvert(cli, "input.wav", "output.aiff", { bitDepth: 24 });
```
## API Reference
- `runConvert(cli, input, output, opts?): Promise<void>`
- `ConvertOptions` — `{ format?, bitDepth?, signal? }`
- `ConvertOptionsSchema` — Zod boundary validation.
## Implementation Notes
Because the implementation forwards into `runProcess`, every error code that
`runProcess` produces (`INPUT_NOT_FOUND`, `BAD_OUTPUT`, `PIPELINE_FAILED`,
`ABORTED`) is reachable from `convert` as well. The empty pipeline path is
not a special case in `runProcess`: an empty edit chain just means
"decode → re-encode", which is exactly the conversion semantic.
The Zod schema is `strict()`, so a typo in an option key (`{ formt: "wav" }`)
fails fast at the boundary with `AUDIO_CLI.INVALID_ARGS` rather than being
silently dropped. The `bitDepth` field is constrained to the union
`8 | 16 | 24 | 32` so encoders that only accept those depths receive a
correctly-typed value. Passing an `AbortSignal` that is already aborted at
call time short-circuits inside `runProcess` before any file is opened, so
`convert` participates in the same SIGINT contract as every other command.
`convert` is the cheapest way to migrate a file between formats in shell
pipelines: it skips the planner and the op chain entirely, so a file with
no edits goes straight through the codec stack. For copying with an op
chain attached, prefer the `process` command directly — `convert` is
deliberately a forward-only wrapper with a smaller option surface.

View File

@@ -0,0 +1,70 @@
# `commands/info` — `runInfo`
## Purpose
`runInfo` produces a one-shot metadata + statistics report for a file:
sample rate, channel count, duration, total samples, peak/RMS/DC-offset, and
LUFS (when available). It is the read-only counterpart to `analyze`: cheap,
predictable, intended for shell-pipeline composition (`audio info x.wav |
jq '.lufs'`).
The function returns the structured `InfoReport` so programmatic consumers
get the same payload the CLI writes to stdout — there is no formatting
divergence between programmatic and shell usage.
## Features
- Zod-strict `InfoOptions` (currently just an optional `signal`).
- Delegates to `computeStats` from `@suaveplan/audio-core`.
- Pre-aborted signal short-circuits before any I/O.
- Distinguishes "no LUFS" (`null`) from "0 LUFS" so JSON consumers can
branch on a missing measurement.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
const info = await cli.info("input.wav");
console.log(info.duration, info.peak, info.lufs);
```
## Usage: Advanced
```ts
import { runInfo } from "@suaveplan/audio-cli/commands/info";
const ac = new AbortController();
const info = await runInfo(cli, "input.flac", { signal: ac.signal });
if (info.lufs !== null && info.lufs > -14) {
console.warn("Above broadcast loudness target");
}
```
## API Reference
- `runInfo(cli, input, opts?): Promise<InfoReport>`
- `InfoOptions` — `{ signal? }`
- `InfoReport` — `{ input, sampleRate, channels, duration, samples, peak,
rms, dcOffset, lufs }`
## Implementation Notes
`computeStats` walks the file once; the LUFS column is `null`-mapped from the
optional `stats.lufs` field so the JSON shape is always uniform. The function
intentionally does not retain the decoded `AudioDocument` past the stats call
so the GC can reclaim the page memory as soon as the report is constructed.
The `samples` field comes from `AudioDocument.sourceLength`, not from
recomputing the page lengths, so it stays accurate even when the document
carries non-trivial edits. The `duration` field is the document's own
`duration` getter (length / sample rate), so callers reading the JSON do not
need to derive it.
`info` is intentionally a read-only operation: it never opens an output
sink and never participates in the codec write path, so it is safe to chain
into a shell pipeline that processes the JSON downstream. The Zod option
surface is currently a single optional `signal`, but the schema is
declared `strict()` so adding a future option there is a non-breaking
change for callers that pass an empty options object.

View File

@@ -0,0 +1,60 @@
# `commands/play` — `runPlay`
## Purpose
`runPlay` plays a file through an injected `SpeakerAdapter`. The CLI package
intentionally carries no platform audio binding — naming a specific speaker
binary (sox, mpv, ffplay) would couple the package to whichever the user has
installed. Instead, the function exercises argument validation, the input
load path, and the adapter dispatch, and consumers (or installer hooks) wire
the actual hardware adapter behind the `SpeakerAdapter` interface.
A `noopSpeaker` is exported so headless servers, CI, and tests can run the
`play` code path without audio output.
## Features
- Zod-strict `PlayOptions` accepting an injected `SpeakerAdapter` and an
`AbortSignal`.
- Default `noopSpeaker` adapter for headless environments.
- Same input-error mapping (`AUDIO_CLI.INPUT_NOT_FOUND`) as the other
load-driven commands.
- Adapter failures wrap as `AUDIO_CLI.PIPELINE_FAILED` with the original
cause attached.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
await cli.play("input.wav"); // noop on a headless host
```
## Usage: Advanced
```ts
import { runPlay } from "@suaveplan/audio-cli/commands/play";
import type { SpeakerAdapter } from "@suaveplan/audio-cli/commands/play";
const mySpeaker: SpeakerAdapter = {
async play(doc, { signal }) { /* ...write doc to a DAC... */ },
};
await runPlay(cli, "song.flac", { speaker: mySpeaker });
```
## API Reference
- `runPlay(cli, input, opts?): Promise<void>`
- `PlayOptions` — `{ speaker?, signal? }`
- `SpeakerAdapter.play(doc, { signal? }): Promise<void>`
- `noopSpeaker: SpeakerAdapter`
## Implementation Notes
The function deliberately structures the failure modes so a missing speaker
falls through to the no-op rather than throwing — this matches `audio play`
on a headless box: argv parses, the file decodes, and the command returns
`0`. Real hardware bindings ship as separate adapters consumers add at
construction time via `new AudioCLI({ ... })` or by passing `opts.speaker`
directly.

View File

@@ -0,0 +1,66 @@
# `commands/process` — `runProcess`
## Purpose
`runProcess` is the engine of the `audio process` and bare-op invocations. It
loads the input file via `FileSourceAdapter`, validates and appends the
parsed pipeline as `AudioEdit` records on the resulting `AudioDocument`, and
streams the compiled plan into `FileSinkAdapter.save` against the
extension-inferred (or explicit) output codec. Every audio transform is
delegated to `@suaveplan/audio-core` and `@suaveplan/audio-codecs`; this
module owns only argument validation, error-code translation, and the
sequencing.
## Features
- Zod validation of `ProcessOptions` (`pipeline`, `format`, `bitDepth`,
`signal`).
- Accepts both string and pre-parsed `ParsedPipeline` for the `pipeline`
field; the string form is parsed via `parsePipeline`.
- Treats a `save` token in the chain as a sentinel and skips it during
registry validation (the bare-op form ends with `save <output>`).
- Maps every engine error to an `AUDIO_CLI.*` code with `cause` preserved:
- `AUDIO_ENGINE_SERVER.FILE_NOT_FOUND` / `IO_FAILED` →
`AUDIO_CLI.INPUT_NOT_FOUND` (exit 3).
- `AUDIO_ENGINE_SERVER.ABORTED` → `AUDIO_CLI.ABORTED` (exit 130).
- Anything else during load/run/save → `AUDIO_CLI.PIPELINE_FAILED` (exit 5).
- Pre-aborted `AbortSignal` short-circuits before any I/O.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
await cli.process("input.wav", "output.flac", { pipeline: "normalize -16lufs" });
```
## Usage: Advanced
```ts
import { runProcess } from "@suaveplan/audio-cli/commands/process";
const ac = new AbortController();
setTimeout(() => ac.abort(), 250);
await runProcess(cli, "input.wav", "output.flac", {
pipeline: [["trim", { start: 30 }], ["normalize", { lufs: -16 }]],
bitDepth: 24,
signal: ac.signal,
});
```
## API Reference
- `runProcess(cli, input, output, opts?)` — async, returns `void`, throws
`AudioCliError` on failure.
- `ProcessOptions` — `{ pipeline?, format?, bitDepth?, signal? }`.
- `ProcessOptionsSchema` — Zod strict schema for boundary validation.
## Implementation Notes
The function intentionally builds the executable chain in three stages:
parse (or accept), filter out the `save` sentinel, then validate names
against the registry. That keeps the pipe-form and bare-op-form code paths
identical past the parser. The engine error mapping mirrors the spec's
`AUDIO_CLI.*` requirements and is the only place those translations live — the
CLI dispatch layer just propagates whatever this throws.

View File

@@ -0,0 +1,62 @@
# `commands/record` — `runRecord`
## Purpose
`runRecord` captures audio from an injected `MicrophoneAdapter` for a fixed
duration and saves the resulting `AudioDocument` through `FileSinkAdapter`.
The CLI ships only the adapter contract and a silence-producing default —
specific platform bindings (CoreAudio, ALSA, WebAudio) ship as separate
adapter packages or installer hooks, so this command works identically on
CI/server hosts and on real hardware.
## Features
- Zod-strict `RecordOptions` (`duration`, `sampleRate`, `channels`,
`format`, `bitDepth`, `microphone`, `signal`).
- Default `createSilentMicrophone()` adapter produces a deterministic silent
document — useful for CI and tests.
- Output format inferred from extension (or set via `opts.format`).
- Default 48 kHz / 2 channels matches mainstream broadcast/recording targets.
- Pre-aborted signal short-circuits before any adapter call.
## Usage: Basic
```ts
import { AudioCLI } from "@suaveplan/audio-cli";
const cli = new AudioCLI();
await cli.record("out.wav", { duration: 60 }); // silent on headless boxes
```
## Usage: Advanced
```ts
import { runRecord, createSilentMicrophone } from "@suaveplan/audio-cli/commands/record";
import type { MicrophoneAdapter } from "@suaveplan/audio-cli/commands/record";
const myMic: MicrophoneAdapter = {
async capture({ duration, sampleRate, channels }) {
// ...read from a real device...
return createSilentMicrophone().capture({ duration, sampleRate, channels });
},
};
await runRecord(cli, "out.flac", { duration: 30, microphone: myMic, bitDepth: 24 });
```
## API Reference
- `runRecord(cli, output, opts): Promise<void>`
- `RecordOptions` — `{ duration, sampleRate?, channels?, format?, bitDepth?,
microphone?, signal? }`
- `MicrophoneAdapter.capture({ duration, sampleRate, channels, signal? }): Promise<AudioDocument>`
- `createSilentMicrophone(): MicrophoneAdapter`
## Implementation Notes
The silent default produces `Math.round(duration * sampleRate)` samples per
channel so byte-exact tests can assert document length without a hardware
dependency. The save path mirrors `runProcess`: failures wrap to
`AUDIO_CLI.PIPELINE_FAILED` with `cause` and a `{ stage, output, format }`
context. The `--duration` argument is mandatory because indefinite recording
needs a different surface (an interactive stop signal) than what `audio
record <output> --duration <s>` exposes.

View File

@@ -0,0 +1,79 @@
# `error-codes` — AudioCliError and EXIT_CODES
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the structured error and exit-code boundary for
`@suaveplan/audio-cli`. It defines the six `AUDIO_CLI.*` codes the package
throws, the `AudioCliError` class extending `SuaveplanError` carrying `code`,
`cause`, and `context`, the `AUDIO_CLI_ERROR_METADATA` table with descriptions
and severity, and the `EXIT_CODES` table that maps every code onto a stable
non-zero Unix exit integer.
It also exports `exitCodeFor`, which the CLI entry point calls in its global
catch to convert any thrown value into a `process.exit` argument. The mapping
is part of the public contract: CI pipelines and shell scripts depend on it,
so changing a code-to-integer mapping is a breaking change.
## Features
- Six-code registry (`INVALID_ARGS`, `UNKNOWN_OP`, `INPUT_NOT_FOUND`,
`BAD_OUTPUT`, `PIPELINE_FAILED`, `ABORTED`) via `defineCodes`.
- `AudioCliError extends SuaveplanError` with strongly-typed `code`.
- `EXIT_CODES` immutable table (`0`-reserved, `2/3/4/5/130`).
- `exitCodeFor(err)` unknown-shape fallback to `1`.
- Idempotent `registerAudioCliErrorCodes()` for cross-package code-table fans.
## Usage: Basic
```ts
import { AudioCliError, AUDIO_CLI_CODES, exitCodeFor } from "@suaveplan/audio-cli/error-codes";
try {
throw new AudioCliError(AUDIO_CLI_CODES.INPUT_NOT_FOUND, "audio-cli.input-not-found", {
context: { path: "missing.wav" },
});
} catch (err) {
process.exit(exitCodeFor(err));
}
```
## Usage: Advanced
```ts
import {
AUDIO_CLI_CODES,
AUDIO_CLI_ERROR_METADATA,
EXIT_CODES,
type AudioCliErrorCode,
} from "@suaveplan/audio-cli/error-codes";
// Render a help table of every code, its severity, and the exit it produces.
for (const [code, meta] of Object.entries(AUDIO_CLI_ERROR_METADATA)) {
const c = code as AudioCliErrorCode;
console.log(`${c}\t${meta.severity}\texit=${EXIT_CODES[c]}\t${meta.description}`);
}
```
## API Reference
- `AUDIO_CLI_CODES` — frozen string-literal record of the six codes.
- `AudioCliErrorCode` — union of those code values.
- `AudioCliError(code, messageKey, options?)` — `SuaveplanError` subclass.
- `AUDIO_CLI_ERROR_METADATA` — per-code `{ description, severity }`.
- `EXIT_CODES` — per-code non-zero integer.
- `registerAudioCliErrorCodes()` — idempotent registry hook.
- `exitCodeFor(err)` — map a thrown value to an exit code (`0`/`1`/EXIT_CODES).
## Implementation Notes
The error class follows the same pattern as `AudioEngineServerError` and
`AudioCodecsError` in sibling packages. `defineCodes` ensures the literal
record is frozen at the type level so a typo in `AUDIO_CLI_CODES.foo` fails to
compile. `exitCodeFor` walks two shapes: an `AudioCliError` instance (cheap
typeof check) and a duck-typed `{ code: string }` so foreign errors carrying a
known `AUDIO_CLI.*` code still map correctly. Anything else falls through to
`1`, the canonical "uncaught error" Unix convention.

View File

@@ -0,0 +1,69 @@
# `pipeline/parser` — `parsePipeline` and `validatePipeline`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module turns a CLI user's free-form pipeline expression into a typed
`[opName, params]` chain that the rest of the CLI feeds into
`AudioDocument.run`. It supports both the **pipe-form** string —
`"trim 30s | normalize -16lufs | fade 0.5s 1s"` — and the **positional
bare-op** token array — `["trim", "30s", "normalize", "-16lufs"]` — and
collapses both surface forms onto the same internal representation so there is
exactly one downstream code path.
It also exposes `validatePipeline(chain, knownOps)` which the CLI calls before
opening any file, so an unknown op name throws `AUDIO_CLI.UNKNOWN_OP` fast and
no I/O is wasted.
## Features
- `tokenise` respects double-quoted strings (`"a b"`) and backslash escapes
(`a\ b`) so a single op can carry a string parameter with spaces.
- Unit suffix coercion: `30s → start=30`, `500ms → start=0.5`,
`-16lufs → lufs=-16`, `6db → db=6`, `44100hz → rate=44100`,
`48khz → rate=48000`.
- `key=value` arguments (`foo=bar`) become named params; numeric literals
without a unit become positional `start`/`duration`.
- `validatePipeline` lists the registry up-front so the diagnostic names the
offending op alongside the sorted set of known ops.
## Usage: Basic
```ts
import { parsePipeline } from "@suaveplan/audio-cli/pipeline/parser";
const chain = parsePipeline("trim 30s | normalize -16lufs");
// → [["trim", { start: 30 }], ["normalize", { lufs: -16 }]]
```
## Usage: Advanced
```ts
import { parsePipeline, validatePipeline } from "@suaveplan/audio-cli/pipeline/parser";
import { OpRegistry } from "@suaveplan/audio-core";
const registry = OpRegistry.createWithDefaults();
const known = new Set(registry.list().map((d) => d.name));
const chain = parsePipeline(["trim", "30s", "normalize", "-16lufs", "fade", "0.5s", "1s"]);
validatePipeline(chain, known); // throws AUDIO_CLI.UNKNOWN_OP if a name is unknown
```
## API Reference
- `ParsedPipeline` — `ReadonlyArray<readonly [string, Record<string, unknown>]>`.
- `parsePipeline(spec: string | readonly string[])` — produce the chain.
- `validatePipeline(chain, knownOps: ReadonlySet<string>)` — throws
`AudioCliError(AUDIO_CLI.UNKNOWN_OP)` on the first unknown op name.
## Implementation Notes
The hand-written tokenizer is deliberately small: a single forward pass with
two flags (`inQuote`, `escaped`) and a per-stage accumulator. The pipe-form
splits on `|`; the bare-op form treats every identifier-shaped token (matching
`/^[a-zA-Z][a-zA-Z0-9_-]*$/`) as the start of a new stage so positional
arguments belonging to the previous op are correctly grouped. Unit coercion
runs as a small regex per argument so `trim 30s` always lands as
`{ start: 30 }` regardless of which surface form it arrived through.