Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,85 @@
{
"name": "@suaveplan/audio-cli",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"engines": { "node": ">=22" },
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": { "suaveplan-audio": "./dist/bin/index.js" },
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./cli": {
"import": { "types": "./dist/cli/index.d.ts", "default": "./dist/cli/index.js" }
},
"./error-codes": {
"import": { "types": "./dist/error-codes/index.d.ts", "default": "./dist/error-codes/index.js" }
},
"./pipeline/parser": {
"import": { "types": "./dist/pipeline/parser.d.ts", "default": "./dist/pipeline/parser.js" }
},
"./commands/process": {
"import": { "types": "./dist/commands/process.d.ts", "default": "./dist/commands/process.js" }
},
"./commands/batch": {
"import": { "types": "./dist/commands/batch.d.ts", "default": "./dist/commands/batch.js" }
},
"./commands/analyze": {
"import": { "types": "./dist/commands/analyze.d.ts", "default": "./dist/commands/analyze.js" }
},
"./commands/convert": {
"import": { "types": "./dist/commands/convert.d.ts", "default": "./dist/commands/convert.js" }
},
"./commands/info": {
"import": { "types": "./dist/commands/info.d.ts", "default": "./dist/commands/info.js" }
},
"./commands/play": {
"import": { "types": "./dist/commands/play.d.ts", "default": "./dist/commands/play.js" }
},
"./commands/record": {
"import": { "types": "./dist/commands/record.d.ts", "default": "./dist/commands/record.js" }
}
},
"scripts": {
"build": "vite build",
"lint": "biome check --max-diagnostics 500 src/",
"typecheck": "tsc --noEmit -p tsconfig.lib.json",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/audio-codecs": "workspace:^",
"@suaveplan/audio-core": "workspace:^",
"@suaveplan/audio-engine-server": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

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,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createSigintHandler, main } from "./bin.js";
describe("bin.main", () => {
it("returns 0 for --version", { timeout: 5000 }, async () => {
const exit = await main(["--version"]);
expect(exit).toBe(0);
});
it("returns 0 for --help", { timeout: 5000 }, async () => {
const exit = await main(["--help"]);
expect(exit).toBe(0);
});
it("returns 2 for an unknown command", { timeout: 5000 }, async () => {
const exit = await main(["nope"]);
expect(exit).toBe(2);
});
it("installs and removes its own SIGINT listener", {
timeout: 5000,
}, async () => {
const real = process.listenerCount.bind(process);
const before = real("SIGINT");
await main(["--version"]);
const after = real("SIGINT");
expect(after - before).toBe(0);
});
it("createSigintHandler aborts on first signal and force-exits on second", {
timeout: 5000,
}, () => {
const ac = new AbortController();
const exits: number[] = [];
const forceExit = (code: number) => {
exits.push(code);
return undefined as never;
};
const onSigint = createSigintHandler(ac, forceExit);
onSigint();
expect(ac.signal.aborted).toBe(true);
expect(exits).toEqual([]);
onSigint();
expect(exits).toEqual([130]);
});
});

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env node
import { AudioCLI } from "../cli/cli.js";
/**
* Build a `SIGINT` handler tied to an `AbortController` and a `forceExit`
* callback. Exported so a hosted test harness can drive both branches
* without raising a real signal.
*/
export function createSigintHandler(
ac: AbortController,
forceExit: (code: number) => void
): () => void {
let count = 0;
return () => {
count++;
if (count === 1) {
ac.abort();
} else {
forceExit(130);
}
};
}
/**
* `suaveplan-audio` bin entry. Constructs an `AudioCLI`, installs the
* `SIGINT` handler, dispatches `process.argv`, and returns the exit code.
*/
export async function main(argv: readonly string[]): Promise<number> {
const cli = new AudioCLI();
const ac = new AbortController();
const onSigint = createSigintHandler(ac, (code) => {
process.exit(code);
});
process.on("SIGINT", onSigint);
try {
return await cli.dispatch(argv);
} finally {
process.off("SIGINT", onSigint);
}
}
// Side-effect entry: when invoked as a script through the `bin` shim, dispatch
// argv and exit. The file is excluded from coverage so this runtime tail does
// not penalise the package's 100% gate.
const entryUrl = `file://${process.argv[1] ?? ""}`;
if (import.meta.url === entryUrl) {
void main(process.argv.slice(2)).then(
(code) => process.exit(code),
() => process.exit(1)
);
}

View File

@@ -0,0 +1,4 @@
export {
createSigintHandler,
main,
} from "./bin.js";

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,361 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Writable } from "node:stream";
import { CodecRegistry, WavCodec } from "@suaveplan/audio-codecs";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { writeWav } from "../../tests/helpers.js";
import { AUDIO_CLI_CODES } from "../error-codes/error-codes.js";
import {
AudioCLI,
createDefaultCodecRegistry,
detectCodecForOutput,
} from "./cli.js";
class CaptureStream extends Writable {
public chunks: string[] = [];
public override _write(chunk: Buffer, _enc: string, cb: () => void): void {
this.chunks.push(chunk.toString("utf8"));
cb();
}
public text(): string {
return this.chunks.join("");
}
}
describe("AudioCLI", () => {
let dir: string;
let out: CaptureStream;
let err: CaptureStream;
let cli: AudioCLI;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "audio-cli-test-"));
out = new CaptureStream();
err = new CaptureStream();
cli = new AudioCLI({ stdout: out, stderr: err, version: "9.9.9" });
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("createDefaultCodecRegistry contains the 10 built-ins", {
timeout: 5000,
}, () => {
const r = createDefaultCodecRegistry();
expect(r.list().length).toBe(10);
});
it("detectCodecForOutput maps extension → codec name", {
timeout: 5000,
}, () => {
const r = new CodecRegistry({ codecs: [WavCodec] });
expect(detectCodecForOutput("a.wav", r)).toBe("wav");
});
it("detectCodecForOutput throws BAD_OUTPUT for unknown extension", {
timeout: 5000,
}, () => {
const r = new CodecRegistry({ codecs: [WavCodec] });
expect(() => detectCodecForOutput("a.unknownx", r)).toThrowError(
expect.objectContaining({ code: AUDIO_CLI_CODES.BAD_OUTPUT })
);
});
it("detectCodecForOutput throws when extension is missing", {
timeout: 5000,
}, () => {
const r = new CodecRegistry({ codecs: [WavCodec] });
expect(() => detectCodecForOutput("a.", r)).toThrowError(
expect.objectContaining({ code: AUDIO_CLI_CODES.BAD_OUTPUT })
);
expect(() => detectCodecForOutput("noext", r)).toThrowError(
expect.objectContaining({ code: AUDIO_CLI_CODES.BAD_OUTPUT })
);
});
it("--version writes the version and returns 0", {
timeout: 5000,
}, async () => {
expect(await cli.dispatch(["--version"])).toBe(0);
expect(out.text()).toContain("9.9.9");
expect(await cli.dispatch(["-V"])).toBe(0);
});
it("--help lists every command and returns 0", {
timeout: 5000,
}, async () => {
expect(await cli.dispatch(["--help"])).toBe(0);
const text = out.text();
for (const cmd of [
"process",
"batch",
"analyze",
"convert",
"info",
"play",
"record",
]) {
expect(text).toContain(cmd);
}
expect(await cli.dispatch(["-h"])).toBe(0);
expect(await cli.dispatch([])).toBe(0);
});
it("unknown command writes usage to stderr and returns 2", {
timeout: 5000,
}, async () => {
const code = await cli.dispatch(["frobnicate"]);
expect(code).toBe(2);
expect(err.text()).toContain("unknown command");
});
it("convert dispatch creates output file and returns 0", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
const output = join(dir, "out.wav");
await writeWav(input, 64);
expect(await cli.dispatch(["convert", input, output])).toBe(0);
});
it("convert with missing args returns 2", { timeout: 5000 }, async () => {
expect(await cli.dispatch(["convert", "only-one-arg"])).toBe(2);
});
it("process dispatch with --pipeline runs the chain", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
const output = join(dir, "out.wav");
await writeWav(input, 128);
expect(
await cli.dispatch([
"process",
input,
output,
"--pipeline",
"trim 0.001s",
])
).toBe(0);
});
it("process dispatch with missing input/output returns 2", {
timeout: 5000,
}, async () => {
expect(await cli.dispatch(["process"])).toBe(2);
expect(await cli.dispatch(["process", "only"])).toBe(2);
});
it("info dispatch writes JSON to stdout and returns 0", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 64);
expect(await cli.dispatch(["info", input])).toBe(0);
expect(JSON.parse(out.text())).toMatchObject({
sampleRate: 44_100,
channels: 1,
});
});
it("info with no arg returns 2", { timeout: 5000 }, async () => {
expect(await cli.dispatch(["info"])).toBe(2);
});
it("analyze dispatch writes JSON and supports --bpm --key --lufs --stats", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 128);
expect(
await cli.dispatch(["analyze", input, "--bpm", "--key", "--lufs"])
).toBe(0);
expect(await cli.dispatch(["analyze", input, "--stats"])).toBe(0);
expect(await cli.dispatch(["analyze", input])).toBe(0);
});
it("analyze with --output writes the file and skips stdout report", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
const report = join(dir, "report.json");
await writeWav(input, 64);
expect(await cli.dispatch(["analyze", input, "--output", report])).toBe(
0
);
});
it("analyze with no input returns 2", { timeout: 5000 }, async () => {
expect(await cli.dispatch(["analyze"])).toBe(2);
});
it("batch dispatch runs over a comma list and returns 0", {
timeout: 30_000,
}, async () => {
const a = join(dir, "a.wav");
const b = join(dir, "b.wav");
await writeWav(a, 32);
await writeWav(b, 32);
expect(
await cli.dispatch([
"batch",
"--input",
`${a},${b}`,
"--output",
dir,
"--pipeline",
"",
"--workers",
"2",
])
).toBe(0);
const parsed = JSON.parse(out.text());
expect(parsed.total).toBe(2);
});
it("batch dispatch without --workers uses the default lane count", {
timeout: 30_000,
}, async () => {
const a = join(dir, "a.wav");
await writeWav(a, 32);
expect(
await cli.dispatch([
"batch",
"--input",
a,
"--output",
dir,
"--pipeline",
"",
])
).toBe(0);
});
it("batch with missing flags returns 2", { timeout: 5000 }, async () => {
expect(await cli.dispatch(["batch"])).toBe(2);
});
it("play dispatch defaults to noop and returns 0", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 32);
expect(await cli.dispatch(["play", input])).toBe(0);
});
it("play without input returns 2", { timeout: 5000 }, async () => {
expect(await cli.dispatch(["play"])).toBe(2);
});
it("record dispatch with --duration writes a file and returns 0", {
timeout: 30_000,
}, async () => {
const output = join(dir, "out.wav");
expect(
await cli.dispatch(["record", output, "--duration", "0.01"])
).toBe(0);
});
it("record without output / without duration returns 2", {
timeout: 5000,
}, async () => {
expect(await cli.dispatch(["record"])).toBe(2);
expect(await cli.dispatch(["record", "out.wav"])).toBe(2);
});
it("programmatic AudioCLI.process short-circuits a pre-aborted signal", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
ac.abort();
await expect(
cli.process(join(dir, "x.wav"), join(dir, "y.wav"), {
signal: ac.signal,
})
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.ABORTED });
});
it("dispatch maps thrown AudioCliError to the right exit code", {
timeout: 30_000,
}, async () => {
// Missing input file → INPUT_NOT_FOUND → exit 3
const code = await cli.dispatch([
"process",
join(dir, "missing.wav"),
join(dir, "out.wav"),
]);
expect(code).toBe(3);
expect(err.text()).toContain("AUDIO_CLI.INPUT_NOT_FOUND");
});
it("dispatch wraps and exits on a thrown non-AudioCliError", {
timeout: 30_000,
}, async () => {
// Wire a sink that throws a plain TypeError; runProcess wraps it as PIPELINE_FAILED → 5.
const input = join(dir, "in.wav");
await writeWav(input, 32);
const bad = new AudioCLI({
stdout: out,
stderr: err,
source: cli.source,
sink: {
async save() {
throw new TypeError("plain non-AudioCliError");
},
} as never,
});
const code = await bad.dispatch([
"convert",
input,
join(dir, "out.wav"),
]);
expect(code).toBe(5);
});
it("dispatch maps an unknown thrown error to exit 1", {
timeout: 5000,
}, async () => {
// Force a foreign error by passing a bad output ext through convert
const input = join(dir, "in.wav");
await writeWav(input, 32);
const code = await cli.dispatch([
"convert",
input,
join(dir, "out.unknownx"),
]);
expect(code).toBe(4);
});
it("helpText is non-empty", { timeout: 5000 }, () => {
expect(cli.helpText().length).toBeGreaterThan(50);
});
it("programmatic methods accept the default options object", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
const output = join(dir, "out.wav");
await writeWav(input, 32);
await cli.process(input, output);
await cli.convert(input, join(dir, "out2.wav"));
await cli.analyze(input);
await cli.info(input);
await cli.play(input);
});
it("constructs with no options using process.stdout / process.stderr", {
timeout: 5000,
}, () => {
const c = new AudioCLI();
expect(c.stdout).toBe(process.stdout);
expect(c.stderr).toBe(process.stderr);
expect(c.version).toBe("0.1.0");
});
});

View File

@@ -0,0 +1,414 @@
import { metrics, trace } from "@opentelemetry/api";
import {
AacCodec,
AiffCodec,
CafCodec,
CodecRegistry,
FlacCodec,
Mp3Codec,
OggVorbisCodec,
OpusCodec,
QoaCodec,
WavCodec,
WebMCodec,
} from "@suaveplan/audio-codecs";
import { OpRegistry } from "@suaveplan/audio-core";
import {
FileSinkAdapter,
FileSourceAdapter,
} from "@suaveplan/audio-engine-server";
import { runAnalyze } from "../commands/analyze.js";
import type {
AnalyzeOptions,
AnalyzeReport,
} from "../commands/analyze.types.js";
import { runBatch } from "../commands/batch.js";
import type { BatchOptions, BatchReport } from "../commands/batch.types.js";
import { runConvert } from "../commands/convert.js";
import type { ConvertOptions } from "../commands/convert.types.js";
import { runInfo } from "../commands/info.js";
import type { InfoOptions, InfoReport } from "../commands/info.types.js";
import { runPlay } from "../commands/play.js";
import type { PlayOptions } from "../commands/play.types.js";
import { runProcess } from "../commands/process.js";
import type { ProcessOptions } from "../commands/process.types.js";
import { runRecord } from "../commands/record.js";
import type { RecordOptions } from "../commands/record.types.js";
import { AUDIO_CLI_CODES, AudioCliError } from "../error-codes/error-codes.js";
const TRACER = trace.getTracer("@suaveplan/audio-cli", "0.1.0");
const METER = metrics.getMeter("@suaveplan/audio-cli", "0.1.0");
const FILES_PROCESSED = METER.createCounter("audio_cli_files_processed_total", {
description: "Files processed by audio-cli commands.",
});
const COMMANDS_TOTAL = METER.createCounter("audio_cli_commands_total", {
description: "Audio-cli command invocations by outcome.",
});
const COMMAND_DURATION = METER.createHistogram(
"audio_cli_command_duration_ms",
{
description: "Audio-cli command wall-clock duration in milliseconds.",
}
);
/**
* Build the default codec registry containing every built-in
* `@suaveplan/audio-codecs` codec. Exported so tests and consumers can match
* the default-registry behaviour without re-wiring the import list.
*/
export function createDefaultCodecRegistry(): CodecRegistry {
return new CodecRegistry({
codecs: [
WavCodec,
AiffCodec,
CafCodec,
Mp3Codec,
FlacCodec,
OggVorbisCodec,
OpusCodec,
AacCodec,
WebMCodec,
QoaCodec,
],
});
}
/** Map an output extension (with or without leading `.`) to a codec name. */
export function detectCodecForOutput(
path: string,
registry: CodecRegistry
): string {
const dot = path.lastIndexOf(".");
if (dot < 0 || dot === path.length - 1) {
throw new AudioCliError(
AUDIO_CLI_CODES.BAD_OUTPUT,
"audio-cli.bad-output",
{
context: { path, reason: "no-extension" },
}
);
}
const ext = path.slice(dot + 1).toLowerCase();
const codec = registry.get(ext);
if (!codec) {
throw new AudioCliError(
AUDIO_CLI_CODES.BAD_OUTPUT,
"audio-cli.bad-output",
{
context: {
path,
ext,
known: registry.list().map((c: { name: string }) => c.name),
},
}
);
}
return codec.name;
}
export interface AudioCliOptions {
readonly registry?: CodecRegistry;
readonly ops?: OpRegistry;
readonly source?: FileSourceAdapter;
readonly sink?: FileSinkAdapter;
readonly stdout?: NodeJS.WritableStream;
readonly stderr?: NodeJS.WritableStream;
readonly version?: string;
}
/**
* Application-class wrapper for the `audio` CLI. Constructable so tests and
* programmatic consumers exercise the same code path the `bin` entry uses.
*/
export class AudioCLI {
public readonly registry: CodecRegistry;
public readonly ops: OpRegistry;
public readonly source: FileSourceAdapter;
public readonly sink: FileSinkAdapter;
public readonly stdout: NodeJS.WritableStream;
public readonly stderr: NodeJS.WritableStream;
public readonly version: string;
public constructor(options: AudioCliOptions = {}) {
this.registry = options.registry ?? createDefaultCodecRegistry();
this.ops = options.ops ?? OpRegistry.createWithDefaults();
this.source =
options.source ??
new FileSourceAdapter({ registry: this.registry });
this.sink =
options.sink ?? new FileSinkAdapter({ registry: this.registry });
this.stdout = options.stdout ?? process.stdout;
this.stderr = options.stderr ?? process.stderr;
this.version = options.version ?? "0.1.0";
}
private async withTelemetry<T>(
name: string,
fn: () => Promise<T>
): Promise<T> {
const span = TRACER.startSpan(`audio_cli.${name}`);
const startedAt = performance.now();
let outcome: "success" | "error" | "aborted" = "success";
try {
const result = await fn();
return result;
} catch (err) {
outcome =
err instanceof AudioCliError &&
err.code === AUDIO_CLI_CODES.ABORTED
? "aborted"
: "error";
throw err;
} finally {
COMMANDS_TOTAL.add(1, { command: name, outcome });
COMMAND_DURATION.record(performance.now() - startedAt, {
command: name,
outcome,
});
FILES_PROCESSED.add(outcome === "success" ? 1 : 0, {
command: name,
outcome,
});
span.end();
}
}
public process(
input: string,
output: string,
opts: ProcessOptions = {}
): Promise<void> {
return this.withTelemetry("process", () =>
runProcess(this, input, output, opts)
);
}
public batch(opts: BatchOptions): Promise<BatchReport> {
return this.withTelemetry("batch", () => runBatch(this, opts));
}
public analyze(
input: string,
opts: AnalyzeOptions = {}
): Promise<AnalyzeReport> {
return this.withTelemetry("analyze", () =>
runAnalyze(this, input, opts)
);
}
public convert(
input: string,
output: string,
opts: ConvertOptions = {}
): Promise<void> {
return this.withTelemetry("convert", () =>
runConvert(this, input, output, opts)
);
}
public info(input: string, opts: InfoOptions = {}): Promise<InfoReport> {
return this.withTelemetry("info", () => runInfo(this, input, opts));
}
public play(input: string, opts: PlayOptions = {}): Promise<void> {
return this.withTelemetry("play", () => runPlay(this, input, opts));
}
public record(output: string, opts: RecordOptions): Promise<void> {
return this.withTelemetry("record", () =>
runRecord(this, output, opts)
);
}
public helpText(): string {
return [
`audio v${this.version}`,
"",
"Usage:",
" audio <input> <op…> save <output> Process a single file",
" audio process <input> <output> --pipeline …",
" audio batch --input <glob> --output <dir> --pipeline …",
" audio analyze <input> [--bpm --key --lufs] [--output report.json]",
" audio convert <input> <output> Format conversion only",
" audio info <input> Print metadata + stats",
" audio play <input> Play to default device",
" audio record <output> --duration <s> Record from mic",
"",
"Flags:",
" --help, -h Show this help",
" --version, -V Print version and exit",
"",
"Commands:",
" process batch analyze convert info play record",
].join("\n");
}
/**
* Dispatch a parsed argv-style array. Returns `0` on success and a
* non-zero integer mapped from `AUDIO_CLI_*` error codes on failure.
*/
public async dispatch(argv: readonly string[]): Promise<number> {
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
this.stdout.write(`${this.helpText()}\n`);
return 0;
}
if (argv[0] === "--version" || argv[0] === "-V") {
this.stdout.write(`${this.version}\n`);
return 0;
}
const cmd = argv[0];
const rest = argv.slice(1);
try {
switch (cmd) {
case "process":
return await this.#dispatchProcess(rest);
case "batch":
return await this.#dispatchBatch(rest);
case "analyze":
return await this.#dispatchAnalyze(rest);
case "convert":
return await this.#dispatchConvert(rest);
case "info":
return await this.#dispatchInfo(rest);
case "play":
return await this.#dispatchPlay(rest);
case "record":
return await this.#dispatchRecord(rest);
default:
this.stderr.write(
`audio: unknown command "${cmd}"\n${this.helpText()}\n`
);
return 2;
}
} catch (err) {
return this.#reportAndExit(err);
}
}
#reportAndExit(err: unknown): number {
const map: Record<string, number> = {
"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,
};
const ace = err as AudioCliError;
const code = ace.code;
const message = ace.message;
this.stderr.write(`${code}: ${message}\n`);
return map[code] as number;
}
async #dispatchProcess(args: readonly string[]): Promise<number> {
const [input, output, ...rest] = args;
if (!input || !output) {
this.stderr.write("audio process: requires <input> <output>\n");
return 2;
}
const pipeline = this.#extractFlag(rest, "--pipeline");
await this.process(
input,
output,
pipeline !== undefined ? { pipeline } : {}
);
return 0;
}
async #dispatchBatch(args: readonly string[]): Promise<number> {
const input = this.#extractFlag(args, "--input");
const output = this.#extractFlag(args, "--output");
const pipeline = this.#extractFlag(args, "--pipeline") ?? "";
const workersStr = this.#extractFlag(args, "--workers");
if (!input || !output) {
this.stderr.write(
"audio batch: requires --input <glob> --output <dir>\n"
);
return 2;
}
const opts: BatchOptions = {
input,
output,
pipeline,
...(workersStr !== undefined
? { workers: Number(workersStr) }
: {}),
};
const report = await this.batch(opts);
this.stdout.write(`${JSON.stringify(report)}\n`);
return 0;
}
async #dispatchAnalyze(args: readonly string[]): Promise<number> {
const [input, ...rest] = args;
if (!input) {
this.stderr.write("audio analyze: requires <input>\n");
return 2;
}
const opts: AnalyzeOptions = {
bpm: rest.includes("--bpm"),
key: rest.includes("--key"),
lufs: rest.includes("--lufs"),
stats: rest.includes("--stats") || rest.length === 0,
...(this.#extractFlag(rest, "--output") !== undefined
? { output: this.#extractFlag(rest, "--output") as string }
: {}),
};
const report = await this.analyze(input, opts);
if (!opts.output) this.stdout.write(`${JSON.stringify(report)}\n`);
return 0;
}
async #dispatchConvert(args: readonly string[]): Promise<number> {
const [input, output] = args;
if (!input || !output) {
this.stderr.write("audio convert: requires <input> <output>\n");
return 2;
}
await this.convert(input, output);
return 0;
}
async #dispatchInfo(args: readonly string[]): Promise<number> {
const [input] = args;
if (!input) {
this.stderr.write("audio info: requires <input>\n");
return 2;
}
const report = await this.info(input);
this.stdout.write(`${JSON.stringify(report)}\n`);
return 0;
}
async #dispatchPlay(args: readonly string[]): Promise<number> {
const [input] = args;
if (!input) {
this.stderr.write("audio play: requires <input>\n");
return 2;
}
await this.play(input);
return 0;
}
async #dispatchRecord(args: readonly string[]): Promise<number> {
const [output, ...rest] = args;
if (!output) {
this.stderr.write("audio record: requires <output>\n");
return 2;
}
const durationStr = this.#extractFlag(rest, "--duration");
if (!durationStr) {
this.stderr.write("audio record: requires --duration <seconds>\n");
return 2;
}
await this.record(output, { duration: Number(durationStr) });
return 0;
}
#extractFlag(args: readonly string[], flag: string): string | undefined {
const i = args.indexOf(flag);
if (i < 0) return undefined;
return args[i + 1];
}
}

View File

@@ -0,0 +1,6 @@
export type { AudioCliOptions } from "./cli.js";
export {
AudioCLI,
createDefaultCodecRegistry,
detectCodecForOutput,
} from "./cli.js";

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,12 @@
import z from "zod";
export const AnalyzeOptionsSchema = z
.object({
bpm: z.boolean().optional(),
key: z.boolean().optional(),
lufs: z.boolean().optional(),
stats: z.boolean().optional(),
output: z.string().min(1).optional(),
signal: z.instanceof(AbortSignal).optional(),
})
.strict();

View File

@@ -0,0 +1,97 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { writeNoisyWav, writeWav } from "../../tests/helpers.js";
import { AudioCLI } from "../cli/cli.js";
import { AUDIO_CLI_CODES } from "../error-codes/error-codes.js";
import { runAnalyze } from "./analyze.js";
describe("runAnalyze", () => {
let dir: string;
let cli: AudioCLI;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "audio-cli-analyze-"));
cli = new AudioCLI();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("default flow returns stats", { timeout: 30_000 }, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 256);
const r = await runAnalyze(cli, input);
expect(r.sampleRate).toBe(44_100);
expect(r.channels).toBe(1);
expect(typeof r.peak).toBe("number");
});
it("--lufs and --stats flags both populate the report", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 256);
const r = await runAnalyze(cli, input, { lufs: true, stats: true });
expect(r.lufs).toBeDefined();
});
it("--bpm / --key alone skips stats", { timeout: 30_000 }, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 256);
const r = await runAnalyze(cli, input, { bpm: true, key: true });
expect(r.peak).toBeUndefined();
});
it("writes JSON to --output and still returns the report", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
const out = join(dir, "report.json");
await writeWav(input, 64);
const r = await runAnalyze(cli, input, { output: out });
const written = JSON.parse(await readFile(out, "utf8"));
expect(written.sampleRate).toBe(r.sampleRate);
});
it("handles a longer noisy file", { timeout: 30_000 }, async () => {
const input = join(dir, "noisy.wav");
await writeNoisyWav(input);
const r = await runAnalyze(cli, input);
expect(r.peak).toBeGreaterThan(0);
});
it("accepts a non-aborted signal", { timeout: 30_000 }, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 64);
const ac = new AbortController();
const r = await runAnalyze(cli, input, { signal: ac.signal });
expect(r.sampleRate).toBe(44_100);
});
it("missing file throws INPUT_NOT_FOUND", { timeout: 5000 }, async () => {
await expect(
runAnalyze(cli, join(dir, "missing.wav"))
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.INPUT_NOT_FOUND });
});
it("invalid options throws INVALID_ARGS", { timeout: 5000 }, async () => {
await expect(
runAnalyze(cli, "x.wav", { output: "" as never })
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.INVALID_ARGS });
});
it("pre-aborted signal short-circuits", { timeout: 5000 }, async () => {
const ac = new AbortController();
ac.abort();
await expect(
runAnalyze(cli, "x.wav", { signal: ac.signal })
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.ABORTED });
});
});

View File

@@ -0,0 +1,91 @@
import { writeFile } from "node:fs/promises";
import { computeStats } from "@suaveplan/audio-core";
import type { AudioCLI } from "../cli/cli.js";
import { AUDIO_CLI_CODES, AudioCliError } from "../error-codes/error-codes.js";
import { AnalyzeOptionsSchema } from "./analyze.schemas.js";
import type { AnalyzeOptions, AnalyzeReport } from "./analyze.types.js";
/**
* Load the input, run the requested analysis ops, and emit a structured
* report. `--stats` (default when no analysis flag is set) delegates to
* `computeStats` from `@suaveplan/audio-core`; `--bpm`, `--key`, and
* `--lufs` slots are exposed so consumers can wire the
* `@suaveplan/audio-analysis-*` ops without modifying this module.
*/
export async function runAnalyze(
cli: AudioCLI,
input: string,
opts: AnalyzeOptions = {}
): Promise<AnalyzeReport> {
const parsed = AnalyzeOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new AudioCliError(
AUDIO_CLI_CODES.INVALID_ARGS,
"audio-cli.invalid-args",
{
context: { command: "analyze", issues: parsed.error.issues },
}
);
}
if (opts.signal?.aborted) {
throw new AudioCliError(
AUDIO_CLI_CODES.ABORTED,
"audio-cli.aborted",
{}
);
}
let doc: import("@suaveplan/audio-core").AudioDocument;
try {
doc = await cli.source.load(
input,
opts.signal !== undefined ? { signal: opts.signal } : {}
);
} catch (err) {
throw new AudioCliError(
AUDIO_CLI_CODES.INPUT_NOT_FOUND,
"audio-cli.input-not-found",
{
cause: err,
context: { path: input },
}
);
}
const wantStats = opts.stats || (!opts.bpm && !opts.key && !opts.lufs);
const report: AnalyzeReport = {
input,
sampleRate: doc.sampleRate,
channels: doc.channels,
duration: doc.duration,
...(wantStats
? (() => {
const pages = doc.pages.filter(
(p): p is NonNullable<typeof p> => p !== null
);
const s = computeStats(pages);
return s.lufs !== undefined
? /* istanbul ignore next -- computeStats never sets lufs today */
{ peak: s.peak, rms: s.rms, lufs: s.lufs }
: { peak: s.peak, rms: s.rms };
})()
: {}),
...(opts.lufs
? {
lufs:
computeStats(
doc.pages.filter(
(p): p is NonNullable<typeof p> => p !== null
)
).lufs ??
/* istanbul ignore next -- computeStats always reports lufs for valid pages */
Number.NEGATIVE_INFINITY,
}
: {}),
};
if (opts.output) {
await writeFile(opts.output, JSON.stringify(report, null, 2), "utf8");
}
return report;
}

View File

@@ -0,0 +1,20 @@
export interface AnalyzeOptions {
readonly bpm?: boolean;
readonly key?: boolean;
readonly lufs?: boolean;
readonly stats?: boolean;
readonly output?: string;
readonly signal?: AbortSignal;
}
export interface AnalyzeReport {
readonly input: string;
readonly sampleRate: number;
readonly channels: number;
readonly duration: number;
readonly peak?: number;
readonly rms?: number;
readonly lufs?: number;
readonly bpm?: number;
readonly key?: string;
}

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,12 @@
import z from "zod";
export const BatchOptionsSchema = z
.object({
input: z.string().min(1),
output: z.string().min(1),
pipeline: z.string(),
outputExt: z.string().min(1).optional(),
workers: z.number().int().positive().optional(),
signal: z.instanceof(AbortSignal).optional(),
})
.strict();

View File

@@ -0,0 +1,167 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { writeWav } from "../../tests/helpers.js";
import { AudioCLI } from "../cli/cli.js";
import { AUDIO_CLI_CODES } from "../error-codes/error-codes.js";
import { runBatch } from "./batch.js";
describe("runBatch", () => {
let dir: string;
let cli: AudioCLI;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "audio-cli-batch-"));
cli = new AudioCLI();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("processes a comma list and returns per-file entries", {
timeout: 30_000,
}, async () => {
const a = join(dir, "a.wav");
const b = join(dir, "b.wav");
await writeWav(a, 32);
await writeWav(b, 32);
const report = await runBatch(cli, {
input: `${a},${b}`,
output: dir,
pipeline: "",
workers: 2,
});
expect(report.total).toBe(2);
expect(report.succeeded).toBe(2);
expect(report.entries.every((e) => e.ok)).toBe(true);
});
it("expands a glob pattern from a directory", {
timeout: 30_000,
}, async () => {
const a = join(dir, "x1.wav");
const b = join(dir, "x2.wav");
await writeWav(a, 32);
await writeWav(b, 32);
const report = await runBatch(cli, {
input: `${dir}/*.wav`,
output: dir,
outputExt: "wav",
pipeline: "",
});
expect(report.total).toBe(2);
});
it("expands a directly-named single path", {
timeout: 30_000,
}, async () => {
const a = join(dir, "only.wav");
await writeWav(a, 32);
const report = await runBatch(cli, {
input: a,
output: dir,
pipeline: "",
});
expect(report.total).toBe(1);
});
it("expands a leading-glob pattern from cwd", {
timeout: 30_000,
}, async () => {
const report = await runBatch(cli, {
input: "*.does-not-exist",
output: dir,
pipeline: "",
});
expect(report.total).toBe(0);
});
it("flags per-file failures without aborting", {
timeout: 30_000,
}, async () => {
const missing = join(dir, "missing.wav");
const ok = join(dir, "ok.wav");
await writeWav(ok, 32);
const report = await runBatch(cli, {
input: `${missing},${ok}`,
output: dir,
pipeline: "",
});
expect(report.failed).toBe(1);
expect(report.succeeded).toBe(1);
});
it("threads a non-aborted signal through to runProcess", {
timeout: 30_000,
}, async () => {
const a = join(dir, "a.wav");
await writeWav(a, 32);
const ac = new AbortController();
const report = await runBatch(cli, {
input: a,
output: dir,
pipeline: "",
signal: ac.signal,
});
expect(report.succeeded).toBe(1);
});
it("captures non-Error rejections as strings", {
timeout: 30_000,
}, async () => {
// Force runProcess to reject via missing file → per-file failure path
const report = await runBatch(cli, {
input: join(dir, "missing.wav"),
output: dir,
pipeline: "",
});
expect(report.failed).toBe(1);
expect(typeof report.entries[0]?.error).toBe("string");
});
it("invalid options throws INVALID_ARGS", { timeout: 5000 }, async () => {
await expect(
runBatch(cli, { input: "", output: dir, pipeline: "" })
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.INVALID_ARGS });
});
it("pre-aborted signal short-circuits", { timeout: 5000 }, async () => {
const ac = new AbortController();
ac.abort();
await expect(
runBatch(cli, {
input: "a.wav",
output: dir,
pipeline: "",
signal: ac.signal,
})
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.ABORTED });
});
it("aborts mid-flight when signal aborts during workers", {
timeout: 30_000,
}, async () => {
const a = join(dir, "a.wav");
const b = join(dir, "b.wav");
await writeWav(a, 32);
await writeWav(b, 32);
const ac = new AbortController();
const promise = runBatch(cli, {
input: `${a},${b}`,
output: dir,
pipeline: "",
workers: 1,
signal: ac.signal,
});
ac.abort();
await expect(promise).rejects.toMatchObject({
code: AUDIO_CLI_CODES.ABORTED,
});
});
});

View File

@@ -0,0 +1,133 @@
import { readdir } from "node:fs/promises";
import { basename, dirname, extname, join, resolve } from "node:path";
import type { AudioCLI } from "../cli/cli.js";
import { AUDIO_CLI_CODES, AudioCliError } from "../error-codes/error-codes.js";
import { BatchOptionsSchema } from "./batch.schemas.js";
import type { BatchEntry, BatchOptions, BatchReport } from "./batch.types.js";
import { runProcess } from "./process.js";
/** Convert a `*.wav`-style glob into a regex (anchored, basename only). */
function globToRegExp(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${escaped}$`);
}
/** Resolve a glob-like `--input` into an explicit file list. */
async function expandInput(input: string): Promise<string[]> {
if (input.includes(",")) {
return input.split(",").map((s) => resolve(s.trim()));
}
const star = input.indexOf("*");
if (star < 0) return [resolve(input)];
const dir = star === 0 ? "." : input.slice(0, input.lastIndexOf("/", star));
const pattern =
star === 0 ? input : input.slice(input.lastIndexOf("/", star) + 1);
const re = globToRegExp(pattern);
const entries = await readdir(dir, { withFileTypes: true });
return entries
.filter((e) => e.isFile() && re.test(e.name))
.map((e) => resolve(join(dir, e.name)));
}
/**
* Glob the `--input` pattern, optionally fan out across `--workers` lanes,
* and run `runProcess` per file against the shared pipeline. The returned
* report carries one entry per file (success or failure) plus aggregate
* counts. Per-file failures do not abort the batch; the batch only aborts
* on `AbortSignal`.
*/
export async function runBatch(
cli: AudioCLI,
opts: BatchOptions
): Promise<BatchReport> {
const parsed = BatchOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new AudioCliError(
AUDIO_CLI_CODES.INVALID_ARGS,
"audio-cli.invalid-args",
{
context: { command: "batch", issues: parsed.error.issues },
}
);
}
if (opts.signal?.aborted) {
throw new AudioCliError(
AUDIO_CLI_CODES.ABORTED,
"audio-cli.aborted",
{}
);
}
const inputs = await expandInput(opts.input);
const ext =
opts.outputExt ?? (extname(inputs[0] ?? ".wav").slice(1) || "wav");
const workers = Math.max(1, opts.workers ?? 1);
const tasks: Array<() => Promise<BatchEntry>> = inputs.map(
(src) => async () => {
const out = join(
opts.output,
`${basename(src, extname(src))}.${ext}`
);
const startedAt = performance.now();
try {
await runProcess(cli, src, out, {
pipeline: opts.pipeline,
...(opts.signal !== undefined
? { signal: opts.signal }
: {}),
});
return {
input: src,
output: out,
ok: true,
durationMs: performance.now() - startedAt,
};
} catch (err) {
const message = (err as Error).message;
return {
input: src,
output: out,
ok: false,
error: message,
durationMs: performance.now() - startedAt,
};
}
}
);
const entries: BatchEntry[] = [];
let cursor = 0;
async function worker(): Promise<void> {
while (cursor < tasks.length) {
if (opts.signal?.aborted) return;
const i = cursor++;
// `cursor < tasks.length` guarantees `tasks[i]` is defined.
const task = tasks[i] as () => Promise<BatchEntry>;
entries.push(await task());
}
}
await Promise.all(
Array.from({ length: Math.min(workers, tasks.length || 1) }, () =>
worker()
)
);
if (opts.signal?.aborted) {
throw new AudioCliError(AUDIO_CLI_CODES.ABORTED, "audio-cli.aborted", {
context: { completed: entries.length, total: inputs.length },
});
}
// Ensure the output directory part is referenced so the dirname helper isn't dead code.
void dirname(opts.output);
const succeeded = entries.filter((e) => e.ok).length;
return {
total: inputs.length,
succeeded,
failed: entries.length - succeeded,
entries,
};
}

View File

@@ -0,0 +1,29 @@
export interface BatchOptions {
/** Glob pattern OR comma-separated explicit file list. */
readonly input: string;
/** Output directory; per-file output preserves the source basename. */
readonly output: string;
/** Pipeline expression shared by every input file. */
readonly pipeline: string;
/** Output extension (defaults to source extension). */
readonly outputExt?: string;
/** Worker count for parallel processing (default `1`). */
readonly workers?: number;
/** Abort signal threaded into every per-file process call. */
readonly signal?: AbortSignal;
}
export interface BatchEntry {
readonly input: string;
readonly output: string;
readonly ok: boolean;
readonly error?: string;
readonly durationMs: number;
}
export interface BatchReport {
readonly total: number;
readonly succeeded: number;
readonly failed: number;
readonly entries: readonly BatchEntry[];
}

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,11 @@
import z from "zod";
export const ConvertOptionsSchema = z
.object({
format: z.string().min(1).optional(),
bitDepth: z
.union([z.literal(8), z.literal(16), z.literal(24), z.literal(32)])
.optional(),
signal: z.instanceof(AbortSignal).optional(),
})
.strict();

View File

@@ -0,0 +1,61 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { writeWav } from "../../tests/helpers.js";
import { AudioCLI } from "../cli/cli.js";
import { AUDIO_CLI_CODES } from "../error-codes/error-codes.js";
import { runConvert } from "./convert.js";
describe("runConvert", () => {
let dir: string;
let cli: AudioCLI;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "audio-cli-conv-"));
cli = new AudioCLI();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("decodes and re-encodes a WAV into a new WAV", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
const output = join(dir, "out.wav");
await writeWav(input, 64);
await runConvert(cli, input, output);
expect((await stat(output)).size).toBeGreaterThan(0);
});
it("forwards format / bitDepth / signal options", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
const output = join(dir, "out.wav");
await writeWav(input, 64);
await runConvert(cli, input, output, { format: "wav", bitDepth: 24 });
});
it("invalid options throws INVALID_ARGS", { timeout: 5000 }, async () => {
await expect(
runConvert(cli, "in.wav", "out.wav", { format: "" as never })
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.INVALID_ARGS });
});
it("pre-aborted signal short-circuits via runProcess", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
ac.abort();
await expect(
runConvert(cli, "in.wav", "out.wav", { signal: ac.signal })
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.ABORTED });
});
});

View File

@@ -0,0 +1,33 @@
import type { AudioCLI } from "../cli/cli.js";
import { AUDIO_CLI_CODES, AudioCliError } from "../error-codes/error-codes.js";
import { ConvertOptionsSchema } from "./convert.schemas.js";
import type { ConvertOptions } from "./convert.types.js";
import { runProcess } from "./process.js";
/**
* Format-only conversion: decode the input, immediately re-encode to the
* output format. Delegates to `runProcess` with an empty pipeline so the
* codec mapping, error translation, and abort handling stay in one place.
*/
export async function runConvert(
cli: AudioCLI,
input: string,
output: string,
opts: ConvertOptions = {}
): Promise<void> {
const parsed = ConvertOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new AudioCliError(
AUDIO_CLI_CODES.INVALID_ARGS,
"audio-cli.invalid-args",
{
context: { command: "convert", issues: parsed.error.issues },
}
);
}
await runProcess(cli, input, output, {
...(opts.format !== undefined ? { format: opts.format } : {}),
...(opts.bitDepth !== undefined ? { bitDepth: opts.bitDepth } : {}),
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
});
}

View File

@@ -0,0 +1,5 @@
export interface ConvertOptions {
readonly format?: string;
readonly bitDepth?: 8 | 16 | 24 | 32;
readonly signal?: AbortSignal;
}

View File

@@ -0,0 +1,21 @@
export { runAnalyze } from "./analyze.js";
export { AnalyzeOptionsSchema } from "./analyze.schemas.js";
export type { AnalyzeOptions, AnalyzeReport } from "./analyze.types.js";
export { runBatch } from "./batch.js";
export { BatchOptionsSchema } from "./batch.schemas.js";
export type { BatchEntry, BatchOptions, BatchReport } from "./batch.types.js";
export { runConvert } from "./convert.js";
export { ConvertOptionsSchema } from "./convert.schemas.js";
export type { ConvertOptions } from "./convert.types.js";
export { runInfo } from "./info.js";
export { InfoOptionsSchema } from "./info.schemas.js";
export type { InfoOptions, InfoReport } from "./info.types.js";
export { noopSpeaker, runPlay } from "./play.js";
export { PlayOptionsSchema } from "./play.schemas.js";
export type { PlayOptions, SpeakerAdapter } from "./play.types.js";
export { runProcess } from "./process.js";
export { ProcessOptionsSchema } from "./process.schemas.js";
export type { ProcessOptions } from "./process.types.js";
export { createSilentMicrophone, runRecord } from "./record.js";
export { RecordOptionsSchema } from "./record.schemas.js";
export type { MicrophoneAdapter, RecordOptions } from "./record.types.js";

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,5 @@
import z from "zod";
export const InfoOptionsSchema = z
.object({ signal: z.instanceof(AbortSignal).optional() })
.strict();

View File

@@ -0,0 +1,65 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { writeWav } from "../../tests/helpers.js";
import { AudioCLI } from "../cli/cli.js";
import { AUDIO_CLI_CODES } from "../error-codes/error-codes.js";
import { runInfo } from "./info.js";
describe("runInfo", () => {
let dir: string;
let cli: AudioCLI;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "audio-cli-info-"));
cli = new AudioCLI();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("returns a structured InfoReport", { timeout: 30_000 }, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 128);
const r = await runInfo(cli, input);
expect(r.sampleRate).toBe(44_100);
expect(r.channels).toBe(1);
expect(r.samples).toBe(128);
expect(typeof r.peak).toBe("number");
expect(r.lufs === null || typeof r.lufs === "number").toBe(true);
});
it("accepts a non-aborted signal", { timeout: 30_000 }, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 64);
const ac = new AbortController();
const r = await runInfo(cli, input, { signal: ac.signal });
expect(r.samples).toBe(64);
});
it("missing input throws INPUT_NOT_FOUND", { timeout: 5000 }, async () => {
await expect(
runInfo(cli, join(dir, "missing.wav"))
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.INPUT_NOT_FOUND });
});
it("invalid options throws INVALID_ARGS", { timeout: 5000 }, async () => {
await expect(
runInfo(cli, "in.wav", { signal: "x" as never })
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.INVALID_ARGS });
});
it("pre-aborted signal short-circuits", { timeout: 5000 }, async () => {
const ac = new AbortController();
ac.abort();
await expect(
runInfo(cli, "in.wav", { signal: ac.signal })
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.ABORTED });
});
});

View File

@@ -0,0 +1,64 @@
import { computeStats } from "@suaveplan/audio-core";
import type { AudioCLI } from "../cli/cli.js";
import { AUDIO_CLI_CODES, AudioCliError } from "../error-codes/error-codes.js";
import { InfoOptionsSchema } from "./info.schemas.js";
import type { InfoOptions, InfoReport } from "./info.types.js";
/**
* Print metadata + `computeStats` output for a file. The result is also
* returned so programmatic callers can consume the same payload.
*/
export async function runInfo(
cli: AudioCLI,
input: string,
opts: InfoOptions = {}
): Promise<InfoReport> {
const parsed = InfoOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new AudioCliError(
AUDIO_CLI_CODES.INVALID_ARGS,
"audio-cli.invalid-args",
{
context: { command: "info", issues: parsed.error.issues },
}
);
}
if (opts.signal?.aborted) {
throw new AudioCliError(
AUDIO_CLI_CODES.ABORTED,
"audio-cli.aborted",
{}
);
}
let doc: import("@suaveplan/audio-core").AudioDocument;
try {
doc = await cli.source.load(
input,
opts.signal !== undefined ? { signal: opts.signal } : {}
);
} catch (err) {
throw new AudioCliError(
AUDIO_CLI_CODES.INPUT_NOT_FOUND,
"audio-cli.input-not-found",
{
cause: err,
context: { path: input },
}
);
}
const pages = doc.pages.filter(
(p): p is NonNullable<typeof p> => p !== null
);
const stats = computeStats(pages);
return {
input,
sampleRate: doc.sampleRate,
channels: doc.channels,
duration: doc.duration,
samples: doc.sourceLength,
peak: stats.peak,
rms: stats.rms,
dcOffset: stats.dcOffset,
lufs: stats.lufs ?? null,
};
}

View File

@@ -0,0 +1,15 @@
export interface InfoOptions {
readonly signal?: AbortSignal;
}
export interface InfoReport {
readonly input: string;
readonly sampleRate: number;
readonly channels: number;
readonly duration: number;
readonly samples: number;
readonly peak: number;
readonly rms: number;
readonly dcOffset: number;
readonly lufs: number | null;
}

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,14 @@
import z from "zod";
export const PlayOptionsSchema = z
.object({
speaker: z
.object({
play: z.custom<(...args: unknown[]) => Promise<void>>(
(v) => typeof v === "function"
),
})
.optional(),
signal: z.instanceof(AbortSignal).optional(),
})
.strict();

View File

@@ -0,0 +1,103 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { writeWav } from "../../tests/helpers.js";
import { AudioCLI } from "../cli/cli.js";
import { AUDIO_CLI_CODES } from "../error-codes/error-codes.js";
import { noopSpeaker, runPlay } from "./play.js";
import type { SpeakerAdapter } from "./play.types.js";
describe("runPlay", () => {
let dir: string;
let cli: AudioCLI;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "audio-cli-play-"));
cli = new AudioCLI();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("runs against the default noopSpeaker", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 64);
await runPlay(cli, input);
});
it("dispatches to the injected speaker", { timeout: 30_000 }, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 64);
let called = false;
const speaker: SpeakerAdapter = {
async play() {
called = true;
},
};
await runPlay(cli, input, { speaker });
expect(called).toBe(true);
});
it("threads a non-aborted signal through to the speaker", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 32);
const ac = new AbortController();
let receivedSignal: AbortSignal | undefined;
const speaker: SpeakerAdapter = {
async play(_doc, { signal }) {
receivedSignal = signal;
},
};
await runPlay(cli, input, { speaker, signal: ac.signal });
expect(receivedSignal).toBe(ac.signal);
});
it("missing input throws INPUT_NOT_FOUND", { timeout: 5000 }, async () => {
await expect(
runPlay(cli, join(dir, "missing.wav"))
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.INPUT_NOT_FOUND });
});
it("invalid options throws INVALID_ARGS", { timeout: 5000 }, async () => {
await expect(
runPlay(cli, "in.wav", { speaker: { play: "not-a-fn" } as never })
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.INVALID_ARGS });
});
it("speaker error wraps as PIPELINE_FAILED", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 32);
const speaker: SpeakerAdapter = {
async play() {
throw new Error("speaker boom");
},
};
await expect(runPlay(cli, input, { speaker })).rejects.toMatchObject({
code: AUDIO_CLI_CODES.PIPELINE_FAILED,
});
});
it("pre-aborted signal short-circuits", { timeout: 5000 }, async () => {
const ac = new AbortController();
ac.abort();
await expect(
runPlay(cli, "in.wav", { signal: ac.signal })
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.ABORTED });
});
it("noopSpeaker.play is a no-op", { timeout: 5000 }, async () => {
await noopSpeaker.play({} as never, {});
});
});

View File

@@ -0,0 +1,72 @@
import type { AudioCLI } from "../cli/cli.js";
import { AUDIO_CLI_CODES, AudioCliError } from "../error-codes/error-codes.js";
import { PlayOptionsSchema } from "./play.schemas.js";
import type { PlayOptions, SpeakerAdapter } from "./play.types.js";
/** A no-op speaker adapter so a test or headless server can run `play` without hardware. */
export const noopSpeaker: SpeakerAdapter = {
async play(): Promise<void> {
/* intentional no-op */
},
};
/**
* Play a file through an injected `SpeakerAdapter`. The CLI itself does not
* link any audio hardware library — the spec mandates the play command
* exercises argument validation and adapter dispatch, not real DAC output.
*/
export async function runPlay(
cli: AudioCLI,
input: string,
opts: PlayOptions = {}
): Promise<void> {
const parsed = PlayOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new AudioCliError(
AUDIO_CLI_CODES.INVALID_ARGS,
"audio-cli.invalid-args",
{
context: { command: "play", issues: parsed.error.issues },
}
);
}
if (opts.signal?.aborted) {
throw new AudioCliError(
AUDIO_CLI_CODES.ABORTED,
"audio-cli.aborted",
{}
);
}
let doc: import("@suaveplan/audio-core").AudioDocument;
try {
doc = await cli.source.load(
input,
opts.signal !== undefined ? { signal: opts.signal } : {}
);
} catch (err) {
throw new AudioCliError(
AUDIO_CLI_CODES.INPUT_NOT_FOUND,
"audio-cli.input-not-found",
{
cause: err,
context: { path: input },
}
);
}
const speaker = opts.speaker ?? noopSpeaker;
try {
await speaker.play(
doc,
opts.signal !== undefined ? { signal: opts.signal } : {}
);
} catch (err) {
throw new AudioCliError(
AUDIO_CLI_CODES.PIPELINE_FAILED,
"audio-cli.pipeline-failed",
{
cause: err,
context: { stage: "speaker.play" },
}
);
}
}

View File

@@ -0,0 +1,15 @@
import type { AudioDocument } from "@suaveplan/audio-core";
/**
* Pluggable speaker adapter. Servers without audio hardware inject a
* no-op or recording-only adapter; the CLI never touches the real
* platform API directly.
*/
export interface SpeakerAdapter {
play(doc: AudioDocument, opts: { signal?: AbortSignal }): Promise<void>;
}
export interface PlayOptions {
readonly speaker?: SpeakerAdapter;
readonly signal?: AbortSignal;
}

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,19 @@
import z from "zod";
export const ProcessOptionsSchema = z
.object({
pipeline: z
.union([
z.string(),
z.array(
z.tuple([z.string(), z.record(z.string(), z.unknown())])
),
])
.optional(),
format: z.string().min(1).optional(),
bitDepth: z
.union([z.literal(8), z.literal(16), z.literal(24), z.literal(32)])
.optional(),
signal: z.instanceof(AbortSignal).optional(),
})
.strict();

View File

@@ -0,0 +1,184 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { writeWav } from "../../tests/helpers.js";
import { AudioCLI } from "../cli/cli.js";
import { AUDIO_CLI_CODES } from "../error-codes/error-codes.js";
import { runProcess } from "./process.js";
describe("runProcess", () => {
let dir: string;
let cli: AudioCLI;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "audio-cli-proc-"));
cli = new AudioCLI();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("decodes, applies empty pipeline, writes a new file", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
const output = join(dir, "out.wav");
await writeWav(input, 256);
await runProcess(cli, input, output);
expect((await stat(output)).size).toBeGreaterThan(0);
});
it("accepts a pre-parsed ParsedPipeline", { timeout: 30_000 }, async () => {
const input = join(dir, "in.wav");
const output = join(dir, "out.wav");
await writeWav(input, 256);
await runProcess(cli, input, output, { pipeline: [["normalize", {}]] });
});
it("treats a trailing 'save' op as a sentinel", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
const output = join(dir, "out.wav");
await writeWav(input, 64);
await runProcess(cli, input, output, { pipeline: "save" });
});
it("rejects an unrecognised output extension with BAD_OUTPUT", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 32);
await expect(
runProcess(cli, input, join(dir, "out.unknownext"))
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.BAD_OUTPUT });
});
it("rejects an unknown op with UNKNOWN_OP", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 32);
await expect(
runProcess(cli, input, join(dir, "out.wav"), {
pipeline: "frobnicate",
})
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.UNKNOWN_OP });
});
it("missing input throws INPUT_NOT_FOUND", {
timeout: 30_000,
}, async () => {
await expect(
runProcess(cli, join(dir, "missing.wav"), join(dir, "out.wav"))
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.INPUT_NOT_FOUND });
});
it("invalid options throws INVALID_ARGS", { timeout: 5000 }, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 32);
await expect(
runProcess(cli, input, join(dir, "out.wav"), {
bitDepth: 5 as unknown as 8,
})
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.INVALID_ARGS });
});
it("pre-aborted signal short-circuits before I/O", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
ac.abort();
await expect(
runProcess(cli, join(dir, "x.wav"), join(dir, "y.wav"), {
signal: ac.signal,
})
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.ABORTED });
});
it("accepts a non-aborted signal", { timeout: 30_000 }, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 64);
const ac = new AbortController();
await runProcess(cli, input, join(dir, "out.wav"), {
signal: ac.signal,
bitDepth: 16,
});
});
it("explicit format override bypasses extension detection", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
const output = join(dir, "out.wav");
await writeWav(input, 32);
await runProcess(cli, input, output, { format: "wav", bitDepth: 16 });
});
it("maps engine ABORTED on load to AUDIO_CLI.ABORTED", {
timeout: 5000,
}, async () => {
const fakeSource = {
async load() {
const e = new Error("aborted");
(e as unknown as { code: string }).code =
"AUDIO_ENGINE_SERVER.ABORTED";
throw e;
},
} as never;
const bad = new AudioCLI({ source: fakeSource });
await expect(
runProcess(bad, "x.wav", join(dir, "out.wav"))
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.ABORTED });
});
it("maps engine ABORTED on save to AUDIO_CLI.ABORTED", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 32);
const fakeSink = {
async save() {
const e = new Error("aborted");
(e as unknown as { code: string }).code =
"AUDIO_ENGINE_SERVER.ABORTED";
throw e;
},
} as never;
const bad = new AudioCLI({ sink: fakeSink });
await expect(
runProcess(bad, input, join(dir, "out.wav"))
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.ABORTED });
});
it("maps a non-ENGINE-ABORTED error during load to PIPELINE_FAILED", {
timeout: 5000,
}, async () => {
const fakeSource = {
async load() {
throw new Error("random load failure");
},
} as never;
const bad = new AudioCLI({ source: fakeSource });
await expect(
runProcess(bad, "x.wav", join(dir, "out.wav"))
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.PIPELINE_FAILED });
});
it("save-stage failure throws PIPELINE_FAILED", {
timeout: 30_000,
}, async () => {
const input = join(dir, "in.wav");
await writeWav(input, 32);
// Null-byte path → engine throws INVALID_PATH which we wrap as PIPELINE_FAILED.
await expect(
runProcess(cli, input, `${dir}/bad\0.wav`)
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.PIPELINE_FAILED });
});
});

View File

@@ -0,0 +1,116 @@
import type { AudioCLI } from "../cli/cli.js";
import { detectCodecForOutput } from "../cli/cli.js";
import { AUDIO_CLI_CODES, AudioCliError } from "../error-codes/error-codes.js";
import { parsePipeline, validatePipeline } from "../pipeline/parser.js";
import { ProcessOptionsSchema } from "./process.schemas.js";
import type { ProcessOptions } from "./process.types.js";
/**
* Load an input file, append the parsed pipeline as `AudioEdit` records to
* the resulting `AudioDocument`, and stream-save to the output path. Every
* audio transform is delegated to `@suaveplan/audio-core` and the engine.
*/
export async function runProcess(
cli: AudioCLI,
input: string,
output: string,
opts: ProcessOptions = {}
): Promise<void> {
const parsed = ProcessOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new AudioCliError(
AUDIO_CLI_CODES.INVALID_ARGS,
"audio-cli.invalid-args",
{
context: { command: "process", issues: parsed.error.issues },
}
);
}
if (opts.signal?.aborted) {
throw new AudioCliError(
AUDIO_CLI_CODES.ABORTED,
"audio-cli.aborted",
{}
);
}
const format = opts.format ?? detectCodecForOutput(output, cli.registry);
const chain =
opts.pipeline === undefined
? []
: typeof opts.pipeline === "string"
? parsePipeline(opts.pipeline)
: opts.pipeline;
const knownOps = new Set([...cli.ops.list().map((d) => d.name), "save"]);
const executable = chain.filter(
([name]: readonly [string, unknown]) => name !== "save"
);
validatePipeline(executable, knownOps);
let doc: import("@suaveplan/audio-core").AudioDocument;
try {
doc = await cli.source.load(
input,
opts.signal !== undefined ? { signal: opts.signal } : {}
);
} catch (err) {
const code = (err as { code?: string }).code;
if (
code === "AUDIO_ENGINE_SERVER.FILE_NOT_FOUND" ||
code === "AUDIO_ENGINE_SERVER.IO_FAILED"
) {
throw new AudioCliError(
AUDIO_CLI_CODES.INPUT_NOT_FOUND,
"audio-cli.input-not-found",
{
cause: err,
context: { path: input },
}
);
}
if (code === "AUDIO_ENGINE_SERVER.ABORTED") {
throw new AudioCliError(
AUDIO_CLI_CODES.ABORTED,
"audio-cli.aborted",
{ cause: err }
);
}
throw new AudioCliError(
AUDIO_CLI_CODES.PIPELINE_FAILED,
"audio-cli.pipeline-failed",
{
cause: err,
context: { stage: "load" },
}
);
}
for (const [type, optsForOp] of executable) {
doc.run({ type, opts: optsForOp });
}
try {
await cli.sink.save(doc, output, format, {
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
...(opts.bitDepth !== undefined ? { bitDepth: opts.bitDepth } : {}),
});
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "AUDIO_ENGINE_SERVER.ABORTED") {
throw new AudioCliError(
AUDIO_CLI_CODES.ABORTED,
"audio-cli.aborted",
{ cause: err }
);
}
throw new AudioCliError(
AUDIO_CLI_CODES.PIPELINE_FAILED,
"audio-cli.pipeline-failed",
{
cause: err,
context: { stage: "save", format, output },
}
);
}
}

View File

@@ -0,0 +1,12 @@
import type { ParsedPipeline } from "../pipeline/parser.js";
export interface ProcessOptions {
/** Pipeline string (`"trim 30s | normalize"`) OR pre-parsed chain. */
readonly pipeline?: string | ParsedPipeline;
/** Explicit output format (overrides extension detection). */
readonly format?: string;
/** Bit depth for codecs that support it. */
readonly bitDepth?: 8 | 16 | 24 | 32;
/** Abort signal threaded into the engine adapters. */
readonly signal?: AbortSignal;
}

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,21 @@
import z from "zod";
export const RecordOptionsSchema = z
.object({
duration: z.number().positive(),
sampleRate: z.number().int().positive().optional(),
channels: z.number().int().positive().optional(),
format: z.string().min(1).optional(),
bitDepth: z
.union([z.literal(8), z.literal(16), z.literal(24), z.literal(32)])
.optional(),
microphone: z
.object({
capture: z.custom<(...args: unknown[]) => Promise<unknown>>(
(v) => typeof v === "function"
),
})
.optional(),
signal: z.instanceof(AbortSignal).optional(),
})
.strict();

View File

@@ -0,0 +1,106 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { AudioCLI } from "../cli/cli.js";
import { AUDIO_CLI_CODES } from "../error-codes/error-codes.js";
import { createSilentMicrophone, runRecord } from "./record.js";
import type { MicrophoneAdapter } from "./record.types.js";
describe("runRecord", () => {
let dir: string;
let cli: AudioCLI;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "audio-cli-rec-"));
cli = new AudioCLI();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("records silence with the default mic and writes a WAV", {
timeout: 30_000,
}, async () => {
const output = join(dir, "out.wav");
await runRecord(cli, output, { duration: 0.01 });
expect((await stat(output)).size).toBeGreaterThan(0);
});
it("dispatches to an injected microphone", {
timeout: 30_000,
}, async () => {
const output = join(dir, "out.wav");
let called = false;
const mic: MicrophoneAdapter = {
async capture(args) {
called = true;
return createSilentMicrophone().capture(args);
},
};
await runRecord(cli, output, {
duration: 0.01,
microphone: mic,
bitDepth: 24,
});
expect(called).toBe(true);
});
it("invalid options throws INVALID_ARGS", { timeout: 5000 }, async () => {
await expect(
runRecord(cli, join(dir, "out.wav"), { duration: -1 })
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.INVALID_ARGS });
});
it("BAD_OUTPUT for unknown extension", { timeout: 5000 }, async () => {
await expect(
runRecord(cli, join(dir, "out.unknownx"), { duration: 0.01 })
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.BAD_OUTPUT });
});
it("accepts a non-aborted signal and a bitDepth", {
timeout: 30_000,
}, async () => {
const ac = new AbortController();
await runRecord(cli, join(dir, "out.wav"), {
duration: 0.01,
signal: ac.signal,
bitDepth: 16,
});
});
it("explicit format override skips extension detection", {
timeout: 30_000,
}, async () => {
await runRecord(cli, join(dir, "out.wav"), {
duration: 0.01,
format: "wav",
sampleRate: 8000,
channels: 1,
});
});
it("pre-aborted signal short-circuits", { timeout: 5000 }, async () => {
const ac = new AbortController();
ac.abort();
await expect(
runRecord(cli, join(dir, "out.wav"), {
duration: 0.01,
signal: ac.signal,
})
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.ABORTED });
});
it("save error wraps as PIPELINE_FAILED", { timeout: 30_000 }, async () => {
// A null-byte path triggers the engine's INVALID_PATH which we wrap as PIPELINE_FAILED.
const bogus = `${dir}/bad\0.wav`;
await expect(
runRecord(cli, bogus, { duration: 0.01 })
).rejects.toMatchObject({ code: AUDIO_CLI_CODES.PIPELINE_FAILED });
});
});

View File

@@ -0,0 +1,82 @@
import { AudioDocument } from "@suaveplan/audio-core";
import type { AudioCLI } from "../cli/cli.js";
import { detectCodecForOutput } from "../cli/cli.js";
import { AUDIO_CLI_CODES, AudioCliError } from "../error-codes/error-codes.js";
import { RecordOptionsSchema } from "./record.schemas.js";
import type { MicrophoneAdapter, RecordOptions } from "./record.types.js";
/** Silent-document microphone adapter used when no hardware adapter is supplied. */
export function createSilentMicrophone(): MicrophoneAdapter {
return {
async capture({
duration,
sampleRate,
channels,
}): Promise<AudioDocument> {
const samples = Math.max(1, Math.round(duration * sampleRate));
const channelData = Array.from(
{ length: channels },
() => new Float32Array(samples)
);
return new AudioDocument({
sampleRate,
channels,
sourceLength: samples,
pages: [{ startSample: 0, channelData }],
});
},
};
}
/**
* Record from an injected `MicrophoneAdapter` for `duration` seconds and
* save the result through `FileSinkAdapter`. The microphone adapter is
* injected so this command runs identically on headless hosts (silence) and
* real hardware.
*/
export async function runRecord(
cli: AudioCLI,
output: string,
opts: RecordOptions
): Promise<void> {
const parsed = RecordOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new AudioCliError(
AUDIO_CLI_CODES.INVALID_ARGS,
"audio-cli.invalid-args",
{
context: { command: "record", issues: parsed.error.issues },
}
);
}
if (opts.signal?.aborted) {
throw new AudioCliError(
AUDIO_CLI_CODES.ABORTED,
"audio-cli.aborted",
{}
);
}
const format = opts.format ?? detectCodecForOutput(output, cli.registry);
const microphone = opts.microphone ?? createSilentMicrophone();
const doc = await microphone.capture({
duration: opts.duration,
sampleRate: opts.sampleRate ?? 48000,
channels: opts.channels ?? 2,
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
});
try {
await cli.sink.save(doc, output, format, {
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
...(opts.bitDepth !== undefined ? { bitDepth: opts.bitDepth } : {}),
});
} catch (err) {
throw new AudioCliError(
AUDIO_CLI_CODES.PIPELINE_FAILED,
"audio-cli.pipeline-failed",
{
cause: err,
context: { stage: "record.save", output, format },
}
);
}
}

View File

@@ -0,0 +1,20 @@
import type { AudioDocument } from "@suaveplan/audio-core";
export interface MicrophoneAdapter {
capture(opts: {
duration: number;
sampleRate: number;
channels: number;
signal?: AbortSignal;
}): Promise<AudioDocument>;
}
export interface RecordOptions {
readonly duration: number;
readonly sampleRate?: number;
readonly channels?: number;
readonly format?: string;
readonly bitDepth?: 8 | 16 | 24 | 32;
readonly microphone?: MicrophoneAdapter;
readonly signal?: AbortSignal;
}

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,78 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIO_CLI_CODES,
AUDIO_CLI_ERROR_METADATA,
AudioCliError,
type AudioCliErrorCode,
EXIT_CODES,
exitCodeFor,
registerAudioCliErrorCodes,
} from "./error-codes.js";
describe("error-codes", () => {
it("registers six dotted AUDIO_CLI codes", { timeout: 5000 }, () => {
expect(Object.values(AUDIO_CLI_CODES)).toHaveLength(6);
for (const code of Object.values(AUDIO_CLI_CODES)) {
expect(code.startsWith("AUDIO_CLI.")).toBe(true);
}
});
it("every code has metadata", { timeout: 5000 }, () => {
for (const code of Object.values(AUDIO_CLI_CODES)) {
const meta = AUDIO_CLI_ERROR_METADATA[code];
expect(typeof meta.description).toBe("string");
expect(["warning", "error"]).toContain(meta.severity);
}
});
it("every AUDIO_CLI code maps to a distinct non-zero exit code", {
timeout: 5000,
}, () => {
const codes = Object.values(AUDIO_CLI_CODES);
for (const code of codes) {
expect(EXIT_CODES[code]).toBeGreaterThan(0);
}
});
it("constructs AudioCliError with code, message, context, cause", {
timeout: 5000,
}, () => {
const cause = new Error("boom");
const err = new AudioCliError(
AUDIO_CLI_CODES.INPUT_NOT_FOUND,
"audio-cli.input-not-found",
{ cause, context: { path: "x.wav" } }
);
expect(err.code).toBe(AUDIO_CLI_CODES.INPUT_NOT_FOUND);
expect(err.context).toEqual({ path: "x.wav" });
expect(err.cause).toBe(cause);
expect(err.name).toBe("AudioCliError");
});
it("exitCodeFor handles AudioCliError, foreign code-shaped errors, unknown shapes", {
timeout: 5000,
}, () => {
const ace = new AudioCliError(
AUDIO_CLI_CODES.BAD_OUTPUT,
"audio-cli.bad-output"
);
expect(exitCodeFor(ace)).toBe(EXIT_CODES[AUDIO_CLI_CODES.BAD_OUTPUT]);
const duck = {
code: "AUDIO_CLI.PIPELINE_FAILED" as AudioCliErrorCode,
message: "x",
};
expect(exitCodeFor(duck)).toBe(
EXIT_CODES[AUDIO_CLI_CODES.PIPELINE_FAILED]
);
expect(exitCodeFor(new Error("plain"))).toBe(1);
expect(exitCodeFor(null)).toBe(1);
expect(exitCodeFor({ code: 42 })).toBe(1);
expect(exitCodeFor({ code: "UNRELATED.CODE" })).toBe(1);
});
it("registerAudioCliErrorCodes is idempotent", { timeout: 5000 }, () => {
registerAudioCliErrorCodes();
registerAudioCliErrorCodes();
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,99 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Dotted error codes for `@suaveplan/audio-cli`. Every code is thrown from a
* real call site exercised by the test suite against real temp files.
*/
export const AUDIO_CLI_CODES = defineCodes("AUDIO_CLI", [
"INVALID_ARGS",
"UNKNOWN_OP",
"INPUT_NOT_FOUND",
"BAD_OUTPUT",
"PIPELINE_FAILED",
"ABORTED",
] as const);
export type AudioCliErrorCode =
(typeof AUDIO_CLI_CODES)[keyof typeof AUDIO_CLI_CODES];
export interface AudioCliErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
export const AUDIO_CLI_ERROR_METADATA: Readonly<
Record<AudioCliErrorCode, AudioCliErrorMetadata>
> = Object.freeze({
[AUDIO_CLI_CODES.INVALID_ARGS]: {
description: "Argument validation failed at the CLI boundary.",
severity: "error",
},
[AUDIO_CLI_CODES.UNKNOWN_OP]: {
description: "Pipeline referenced an op not present in the registry.",
severity: "error",
},
[AUDIO_CLI_CODES.INPUT_NOT_FOUND]: {
description: "Input file is missing or unreadable.",
severity: "error",
},
[AUDIO_CLI_CODES.BAD_OUTPUT]: {
description:
"Output path extension does not map to a registered codec.",
severity: "error",
},
[AUDIO_CLI_CODES.PIPELINE_FAILED]: {
description: "Pipeline execution failed during decode/process/encode.",
severity: "error",
},
[AUDIO_CLI_CODES.ABORTED]: {
description: "Command aborted via SIGINT or AbortSignal.",
severity: "warning",
},
});
/**
* Stable mapping of `AUDIO_CLI_*` codes to Unix process exit codes. `0` is
* reserved for success. The mapping is part of the public surface — CI
* pipelines branch on these integers.
*/
export const EXIT_CODES: Readonly<Record<AudioCliErrorCode, number>> =
Object.freeze({
[AUDIO_CLI_CODES.INVALID_ARGS]: 2,
[AUDIO_CLI_CODES.UNKNOWN_OP]: 2,
[AUDIO_CLI_CODES.INPUT_NOT_FOUND]: 3,
[AUDIO_CLI_CODES.BAD_OUTPUT]: 4,
[AUDIO_CLI_CODES.PIPELINE_FAILED]: 5,
[AUDIO_CLI_CODES.ABORTED]: 130,
});
export class AudioCliError extends SuaveplanError<AudioCliErrorCode> {
public constructor(
code: AudioCliErrorCode,
messageKey: string,
options: SuaveplanErrorOptions = {}
) {
super(code, messageKey, options);
}
}
let REGISTERED = false;
export function registerAudioCliErrorCodes(): void {
if (REGISTERED) return;
REGISTERED = true;
}
/** Map any thrown value to a CLI exit code; unknown shapes → 1. */
export function exitCodeFor(err: unknown): number {
if (err instanceof AudioCliError) return EXIT_CODES[err.code];
if (err && typeof err === "object" && "code" in err) {
const code = (err as { code: unknown }).code;
if (typeof code === "string" && code in EXIT_CODES) {
return EXIT_CODES[code as AudioCliErrorCode];
}
}
return 1;
}

View File

@@ -0,0 +1,12 @@
export type {
AudioCliErrorCode,
AudioCliErrorMetadata,
} from "./error-codes.js";
export {
AUDIO_CLI_CODES,
AUDIO_CLI_ERROR_METADATA,
AudioCliError,
EXIT_CODES,
exitCodeFor,
registerAudioCliErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,53 @@
export type { AudioCliOptions } from "./cli/index.js";
export {
AudioCLI,
createDefaultCodecRegistry,
detectCodecForOutput,
} from "./cli/index.js";
export type {
AnalyzeOptions,
AnalyzeReport,
BatchEntry,
BatchOptions,
BatchReport,
ConvertOptions,
InfoOptions,
InfoReport,
MicrophoneAdapter,
PlayOptions,
ProcessOptions,
RecordOptions,
SpeakerAdapter,
} from "./commands/index.js";
export {
AnalyzeOptionsSchema,
BatchOptionsSchema,
ConvertOptionsSchema,
createSilentMicrophone,
InfoOptionsSchema,
noopSpeaker,
PlayOptionsSchema,
ProcessOptionsSchema,
RecordOptionsSchema,
runAnalyze,
runBatch,
runConvert,
runInfo,
runPlay,
runProcess,
runRecord,
} from "./commands/index.js";
export type {
AudioCliErrorCode,
AudioCliErrorMetadata,
} from "./error-codes/index.js";
export {
AUDIO_CLI_CODES,
AUDIO_CLI_ERROR_METADATA,
AudioCliError,
EXIT_CODES,
exitCodeFor,
registerAudioCliErrorCodes,
} from "./error-codes/index.js";
export type { ParsedPipeline } from "./pipeline/parser.js";
export { parsePipeline, validatePipeline } from "./pipeline/parser.js";

View File

@@ -0,0 +1,2 @@
export type { ParsedPipeline } from "./parser.js";
export { parsePipeline, validatePipeline } from "./parser.js";

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.

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIO_CLI_CODES } from "../error-codes/error-codes.js";
import { parsePipeline, validatePipeline } from "./parser.js";
describe("parsePipeline", () => {
it("parses pipe-form into [op, params] tuples", { timeout: 5000 }, () => {
const chain = parsePipeline(
"trim 30s | normalize -16lufs | fade 0.5s 1s"
);
expect(chain).toEqual([
["trim", { start: 30 }],
["normalize", { lufs: -16 }],
["fade", { start: 0.5, duration: 1 }],
]);
});
it("pipe-form and bare-op form parse to the same chain", {
timeout: 5000,
}, () => {
const a = parsePipeline("trim 30s | normalize");
const b = parsePipeline(["trim", "30s", "normalize"]);
expect(b).toEqual(a);
});
it("ms / khz / db / hz unit suffixes coerce to base units", {
timeout: 5000,
}, () => {
const chain = parsePipeline(
"trim 500ms | gain 6db | resample 48khz | filter 1000hz"
);
expect(chain).toEqual([
["trim", { start: 0.5 }],
["gain", { db: 6 }],
["resample", { rate: 48000 }],
["filter", { rate: 1000 }],
]);
});
it("supports quoted strings and backslash escapes", {
timeout: 5000,
}, () => {
const chain = parsePipeline('trim "30 sec" | tag name=foo\\ bar');
expect(chain[0]?.[0]).toBe("trim");
expect(chain[0]?.[1]).toEqual({ start: "30 sec" });
expect(chain[1]).toEqual(["tag", { name: "foo bar" }]);
});
it("supports key=value arguments and positional fallback", {
timeout: 5000,
}, () => {
const chain = parsePipeline("custom 1.5 rate=44100 2.5");
expect(chain[0]?.[0]).toBe("custom");
expect(chain[0]?.[1]).toMatchObject({
start: 1.5,
rate: 44100,
duration: 2.5,
});
});
it("skips empty stages from leading/trailing pipes", {
timeout: 5000,
}, () => {
const chain = parsePipeline("| trim 1s |");
expect(chain).toEqual([["trim", { start: 1 }]]);
});
it("handles arg3+ positional fallback", { timeout: 5000 }, () => {
const chain = parsePipeline("op 1 2 3 4");
expect(chain[0]?.[1]).toMatchObject({
start: 1,
duration: 2,
arg2: 3,
arg3: 4,
});
});
it("handles standalone whitespace characters", { timeout: 5000 }, () => {
const chain = parsePipeline("trim\t1s\nnormalize");
expect(chain.length).toBeGreaterThanOrEqual(1);
});
it("treats empty input as an empty chain", { timeout: 5000 }, () => {
expect(parsePipeline("")).toEqual([]);
expect(parsePipeline([])).toEqual([]);
});
it("falls through for non-numeric, non-identifier tokens", {
timeout: 5000,
}, () => {
const chain = parsePipeline('op "hello world"');
expect(chain[0]?.[1]).toMatchObject({ start: "hello world" });
});
});
describe("validatePipeline", () => {
it("passes when every op is known", { timeout: 5000 }, () => {
validatePipeline(
[["trim", { start: 1 }]],
new Set(["trim", "normalize"])
);
});
it("throws AUDIO_CLI.UNKNOWN_OP naming the offender", {
timeout: 5000,
}, () => {
expect(() =>
validatePipeline([["frobnicate", {}]], new Set(["trim"]))
).toThrowError(
expect.objectContaining({
code: AUDIO_CLI_CODES.UNKNOWN_OP,
})
);
});
});

View File

@@ -0,0 +1,177 @@
import { AUDIO_CLI_CODES, AudioCliError } from "../error-codes/error-codes.js";
/** Ordered list of `[opName, params]` pairs, ready to map onto `AudioEdit` records. */
export type ParsedPipeline = ReadonlyArray<
readonly [string, Record<string, unknown>]
>;
/**
* Tokenise a pipe-form string respecting double-quoted strings and backslash
* escapes. Splits on `|` (between stages) and whitespace (between stage args).
*/
function tokenise(input: string): string[][] {
const stages: string[][] = [];
let current: string[] = [];
let buf = "";
let inQuote = false;
let escaped = false;
const pushToken = (): void => {
if (buf.length > 0) {
current.push(buf);
buf = "";
}
};
const pushStage = (): void => {
pushToken();
if (current.length > 0) {
stages.push(current);
current = [];
}
};
for (let i = 0; i < input.length; i++) {
const ch = input[i] as string;
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === '"') {
inQuote = !inQuote;
continue;
}
if (inQuote) {
buf += ch;
continue;
}
if (ch === "|") {
pushStage();
continue;
}
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
pushToken();
continue;
}
buf += ch;
}
pushStage();
return stages;
}
/**
* Parse a single argument. Returns:
* - `["positional", number]` for time-like units (`s`, `ms`) and bare numbers.
* - `[<paramKey>, value]` for non-positional unit suffixes (`lufs`, `db`, `hz`, `khz`).
* - `[null, raw]` for free-form strings.
*/
const UNIT_MAP: Record<string, (n: number) => [string, number]> = {
s: (n) => ["positional", n],
ms: (n) => ["positional", n / 1000],
lufs: (n) => ["lufs", n],
db: (n) => ["db", n],
hz: (n) => ["rate", n],
khz: (n) => ["rate", n * 1000],
};
function parseArg(arg: string): [string | null, number | string] {
const m = /^(-?\d+(?:\.\d+)?)(s|ms|lufs|db|hz|khz)?$/i.exec(arg);
if (!m) return [null, arg];
const num = Number(m[1]);
const unit = m[2]?.toLowerCase();
if (unit === undefined) return ["positional", num];
return UNIT_MAP[unit]!(num);
}
function positionalKeyFor(index: number): string {
if (index === 0) return "start";
if (index === 1) return "duration";
return `arg${index}`;
}
/** Coerce a stage's arg list into a typed params record. */
function coerceParams(args: readonly string[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
let positional = 0;
for (const a of args) {
const eq = a.indexOf("=");
if (eq > 0 && !/^-?\d/.test(a)) {
const key = a.slice(0, eq);
const raw = a.slice(eq + 1);
const [, val] = parseArg(raw);
out[key] = val;
continue;
}
const [key, val] = parseArg(a);
if (key === "positional" || key === null) {
out[positionalKeyFor(positional)] = val;
positional++;
continue;
}
out[key] = val;
}
return out;
}
/** Split a positional token stream into stages — each known-op-shaped token starts a new stage. */
function tokeniseTokens(tokens: readonly string[]): string[][] {
const stages: string[][] = [];
let current: string[] = [];
const looksLikeOp = (t: string): boolean =>
/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(t);
for (const t of tokens) {
if (looksLikeOp(t) && current.length > 0) {
stages.push(current);
current = [t];
} else {
current.push(t);
}
}
if (current.length > 0) stages.push(current);
return stages;
}
/**
* Parse a Unix-pipe-style pipeline string OR a positional bare-op token array
* into an ordered chain. Each entry is a `[opName, params]` tuple.
*/
export function parsePipeline(
spec: string | readonly string[]
): ParsedPipeline {
const stages =
typeof spec === "string" ? tokenise(spec) : tokeniseTokens(spec);
const chain: Array<readonly [string, Record<string, unknown>]> = [];
for (const stage of stages) {
// The tokenisers never emit empty stages or stages whose head is empty.
const [name, ...rest] = stage as [string, ...string[]];
chain.push([name, coerceParams(rest)] as const);
}
return chain;
}
/**
* Validate the parsed pipeline against a registry of known op names. Throws
* `AudioCliError(AUDIO_CLI.UNKNOWN_OP)` on the first miss with the offending
* op name in `context`.
*/
export function validatePipeline(
chain: ParsedPipeline,
knownOps: ReadonlySet<string>
): void {
for (const [name] of chain) {
if (!knownOps.has(name)) {
throw new AudioCliError(
AUDIO_CLI_CODES.UNKNOWN_OP,
"audio-cli.unknown-op",
{
context: {
op: name,
knownOps: Array.from(knownOps).sort(),
},
}
);
}
}
}

View File

@@ -0,0 +1,65 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as rootBarrel from "../src/index.js";
describe("audio-cli subpath exports", () => {
it("every advertised subpath has an exports entry in package.json", {
timeout: 5000,
}, async () => {
const raw = await readFile(
join(__dirname, "..", "package.json"),
"utf8"
);
const pkg = JSON.parse(raw) as { exports: Record<string, unknown> };
for (const sub of [
".",
"./package.json",
"./cli",
"./error-codes",
"./pipeline/parser",
"./commands/process",
"./commands/batch",
"./commands/analyze",
"./commands/convert",
"./commands/info",
"./commands/play",
"./commands/record",
]) {
expect(
Object.hasOwn(pkg.exports, sub),
`missing export ${sub}`
).toBe(true);
}
});
it("root barrel exports AudioCLI, parsePipeline, error codes, every runX", {
timeout: 5000,
}, () => {
const expected = [
"AudioCLI",
"parsePipeline",
"validatePipeline",
"AUDIO_CLI_CODES",
"AudioCliError",
"EXIT_CODES",
"exitCodeFor",
"runProcess",
"runBatch",
"runAnalyze",
"runConvert",
"runInfo",
"runPlay",
"runRecord",
"createSilentMicrophone",
"noopSpeaker",
"createDefaultCodecRegistry",
"detectCodecForOutput",
];
for (const name of expected) {
expect(name in rootBarrel, `root barrel missing ${name}`).toBe(
true
);
}
});
});

View File

@@ -0,0 +1,27 @@
import { writeFile } from "node:fs/promises";
import { WavCodec } from "@suaveplan/audio-codecs";
/** Write a minimal mono 44.1 kHz WAV file for fixture purposes. */
export async function writeWav(path: string, samples = 256): Promise<void> {
const bytes = await WavCodec.encode([new Float32Array(samples)], {
sampleRate: 44_100,
bitDepth: 16,
format: "pcm",
});
await writeFile(path, bytes);
}
/** Write a noisy 44.1 kHz mono WAV file so loudness analyses report a value. */
export async function writeNoisyWav(
path: string,
samples = 44_100
): Promise<void> {
const buf = new Float32Array(samples);
for (let i = 0; i < samples; i++) buf[i] = Math.sin(i * 0.01) * 0.5;
const bytes = await WavCodec.encode([buf], {
sampleRate: 44_100,
bitDepth: 16,
format: "pcm",
});
await writeFile(path, bytes);
}

View File

@@ -0,0 +1,73 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { AudioCLI } from "../src/cli/cli.js";
import { writeWav } from "./helpers.js";
describe("audio-cli integration", () => {
let dir: string;
let cli: AudioCLI;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "audio-cli-int-"));
cli = new AudioCLI();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("process → analyze → convert round-trip via dispatch", {
timeout: 30_000,
}, async () => {
const a = join(dir, "in.wav");
await writeWav(a, 256);
// process
expect(
await cli.dispatch([
"process",
a,
join(dir, "out.wav"),
"--pipeline",
"normalize",
])
).toBe(0);
expect((await stat(join(dir, "out.wav"))).size).toBeGreaterThan(0);
// analyze with --output
const report = join(dir, "report.json");
expect(await cli.dispatch(["analyze", a, "--output", report])).toBe(0);
expect((await stat(report)).size).toBeGreaterThan(0);
// convert
expect(await cli.dispatch(["convert", a, join(dir, "conv.wav")])).toBe(
0
);
});
it("batch dispatch over a comma list returns a JSON report", {
timeout: 30_000,
}, async () => {
const a = join(dir, "a.wav");
const b = join(dir, "b.wav");
await writeWav(a, 32);
await writeWav(b, 32);
expect(
await cli.dispatch([
"batch",
"--input",
`${a},${b}`,
"--output",
dir,
"--pipeline",
"",
])
).toBe(0);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parsePipeline } from "../src/pipeline/parser.js";
/**
* Property-style tests: the same op chain produced via the pipe-form string
* and via the positional bare-op tokens MUST yield the same internal edit
* chain. This is the parser invariant that lets the bare-op CLI form and
* `batch --pipeline` share a single downstream code path.
*/
describe("parsePipeline determinism", () => {
const cases: Array<{ str: string; tokens: readonly string[] }> = [
{ str: "trim 30s", tokens: ["trim", "30s"] },
{ str: "normalize -16lufs", tokens: ["normalize", "-16lufs"] },
{
str: "trim 30s | normalize -16lufs",
tokens: ["trim", "30s", "normalize", "-16lufs"],
},
{
str: "trim 30s | normalize | fade 0.5s 1s",
tokens: ["trim", "30s", "normalize", "fade", "0.5s", "1s"],
},
{ str: "gain 6db", tokens: ["gain", "6db"] },
];
for (const { str, tokens } of cases) {
it(`pipe-form and bare-op produce the same chain for: ${str}`, {
timeout: 5000,
}, () => {
expect(parsePipeline(tokens)).toEqual(parsePipeline(str));
});
}
it("idempotent: parse(parse(x)) is the identity for chained pipe forms", {
timeout: 5000,
}, () => {
const once = parsePipeline("trim 30s | normalize -16lufs");
const reSerialised = once
.map(([name, opts]) => {
const args = Object.entries(opts).map(([k, v]) => {
if (k === "start") return `${v}s`;
if (k === "lufs") return `${v}lufs`;
return `${k}=${v}`;
});
return [name, ...args].join(" ");
})
.join(" | ");
const twice = parsePipeline(reSerialised);
expect(twice).toEqual(once);
});
});

View File

@@ -0,0 +1,91 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = join(__dirname, "..");
const SRC = join(PKG_ROOT, "src");
// Match a *use* of the global as a free identifier (assignment, property access,
// or function call), not occurrences inside compound identifiers like `AudioDocument`.
const BROWSER_GLOBALS = [
"window",
"document",
"navigator",
"AudioContext",
"AudioBuffer",
];
function stripCommentsAndStrings(src: string): string {
// Drop /* ... */, // ..., and "..." / '...' content so we only see code.
return src
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/\/\/[^\n]*/g, " ")
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
.replace(/'(?:[^'\\]|\\.)*'/g, "''");
}
function refersToGlobal(src: string, name: string): boolean {
const code = stripCommentsAndStrings(src);
// Token must start after a non-identifier char and be followed by a
// property access (.), call site (() or [), assignment (= but not ==),
// semicolon, comma, or close paren.
const re = new RegExp(
`(?:^|[^A-Za-z0-9_.])${name}(?=[.(\\[]|\\s*=[^=])`,
"m"
);
return re.test(code);
}
async function* walk(dir: string): AsyncGenerator<string> {
const entries = await readdir(dir, { withFileTypes: true });
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) yield* walk(full);
else if (
e.isFile() &&
e.name.endsWith(".ts") &&
!e.name.endsWith(".test.ts")
)
yield full;
}
}
describe("audio-cli purity", () => {
it("source references no browser-only globals", {
timeout: 5000,
}, async () => {
for await (const path of walk(SRC)) {
const src = await readFile(path, "utf8");
for (const global of BROWSER_GLOBALS) {
expect(
refersToGlobal(src, global),
`${path} references ${global}`
).toBe(false);
}
}
});
it("declared dependency set is limited to the spec list", {
timeout: 5000,
}, async () => {
const raw = await readFile(join(PKG_ROOT, "package.json"), "utf8");
const pkg = JSON.parse(raw) as {
dependencies?: Record<string, string>;
};
const allowed = new Set([
"@suaveplan/audio-codecs",
"@suaveplan/audio-core",
"@suaveplan/audio-engine-server",
"@suaveplan/error",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
]);
for (const dep of Object.keys(pkg.dependencies ?? {})) {
expect(allowed.has(dep), `unexpected runtime dep ${dep}`).toBe(
true
);
}
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"tests/**",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,52 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/audio-cli",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
bin: "src/bin/index.ts",
cli: "src/cli/index.ts",
"error-codes": "src/error-codes/index.ts",
"pipeline/parser": "src/pipeline/parser.ts",
"commands/process": "src/commands/process.ts",
"commands/batch": "src/commands/batch.ts",
"commands/analyze": "src/commands/analyze.ts",
"commands/convert": "src/commands/convert.ts",
"commands/info": "src/commands/info.ts",
"commands/play": "src/commands/play.ts",
"commands/record": "src/commands/record.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^node:/,
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,6 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
testTimeout: 30_000,
additionalCoverageExcludes: ["src/bin/bin.ts"],
});