Initial Commit
This commit is contained in:
104
packages/media/audio/audio-cli/README.md
Normal file
104
packages/media/audio/audio-cli/README.md
Normal 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.
|
||||
|
||||
85
packages/media/audio/audio-cli/package.json
Normal file
85
packages/media/audio/audio-cli/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
59
packages/media/audio/audio-cli/src/bin/bin.md
Normal file
59
packages/media/audio/audio-cli/src/bin/bin.md
Normal 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.
|
||||
46
packages/media/audio/audio-cli/src/bin/bin.test.ts
Normal file
46
packages/media/audio/audio-cli/src/bin/bin.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
51
packages/media/audio/audio-cli/src/bin/bin.ts
Normal file
51
packages/media/audio/audio-cli/src/bin/bin.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
4
packages/media/audio/audio-cli/src/bin/index.ts
Normal file
4
packages/media/audio/audio-cli/src/bin/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createSigintHandler,
|
||||
main,
|
||||
} from "./bin.js";
|
||||
76
packages/media/audio/audio-cli/src/cli/cli.md
Normal file
76
packages/media/audio/audio-cli/src/cli/cli.md
Normal 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`.
|
||||
361
packages/media/audio/audio-cli/src/cli/cli.test.ts
Normal file
361
packages/media/audio/audio-cli/src/cli/cli.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
414
packages/media/audio/audio-cli/src/cli/cli.ts
Normal file
414
packages/media/audio/audio-cli/src/cli/cli.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
6
packages/media/audio/audio-cli/src/cli/index.ts
Normal file
6
packages/media/audio/audio-cli/src/cli/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type { AudioCliOptions } from "./cli.js";
|
||||
export {
|
||||
AudioCLI,
|
||||
createDefaultCodecRegistry,
|
||||
detectCodecForOutput,
|
||||
} from "./cli.js";
|
||||
63
packages/media/audio/audio-cli/src/commands/analyze.md
Normal file
63
packages/media/audio/audio-cli/src/commands/analyze.md
Normal 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.
|
||||
@@ -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();
|
||||
97
packages/media/audio/audio-cli/src/commands/analyze.test.ts
Normal file
97
packages/media/audio/audio-cli/src/commands/analyze.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
91
packages/media/audio/audio-cli/src/commands/analyze.ts
Normal file
91
packages/media/audio/audio-cli/src/commands/analyze.ts
Normal 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;
|
||||
}
|
||||
20
packages/media/audio/audio-cli/src/commands/analyze.types.ts
Normal file
20
packages/media/audio/audio-cli/src/commands/analyze.types.ts
Normal 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;
|
||||
}
|
||||
70
packages/media/audio/audio-cli/src/commands/batch.md
Normal file
70
packages/media/audio/audio-cli/src/commands/batch.md
Normal 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.
|
||||
12
packages/media/audio/audio-cli/src/commands/batch.schemas.ts
Normal file
12
packages/media/audio/audio-cli/src/commands/batch.schemas.ts
Normal 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();
|
||||
167
packages/media/audio/audio-cli/src/commands/batch.test.ts
Normal file
167
packages/media/audio/audio-cli/src/commands/batch.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
133
packages/media/audio/audio-cli/src/commands/batch.ts
Normal file
133
packages/media/audio/audio-cli/src/commands/batch.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
29
packages/media/audio/audio-cli/src/commands/batch.types.ts
Normal file
29
packages/media/audio/audio-cli/src/commands/batch.types.ts
Normal 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[];
|
||||
}
|
||||
63
packages/media/audio/audio-cli/src/commands/convert.md
Normal file
63
packages/media/audio/audio-cli/src/commands/convert.md
Normal 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.
|
||||
@@ -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();
|
||||
61
packages/media/audio/audio-cli/src/commands/convert.test.ts
Normal file
61
packages/media/audio/audio-cli/src/commands/convert.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
33
packages/media/audio/audio-cli/src/commands/convert.ts
Normal file
33
packages/media/audio/audio-cli/src/commands/convert.ts
Normal 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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ConvertOptions {
|
||||
readonly format?: string;
|
||||
readonly bitDepth?: 8 | 16 | 24 | 32;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
21
packages/media/audio/audio-cli/src/commands/index.ts
Normal file
21
packages/media/audio/audio-cli/src/commands/index.ts
Normal 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";
|
||||
70
packages/media/audio/audio-cli/src/commands/info.md
Normal file
70
packages/media/audio/audio-cli/src/commands/info.md
Normal 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.
|
||||
@@ -0,0 +1,5 @@
|
||||
import z from "zod";
|
||||
|
||||
export const InfoOptionsSchema = z
|
||||
.object({ signal: z.instanceof(AbortSignal).optional() })
|
||||
.strict();
|
||||
65
packages/media/audio/audio-cli/src/commands/info.test.ts
Normal file
65
packages/media/audio/audio-cli/src/commands/info.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
64
packages/media/audio/audio-cli/src/commands/info.ts
Normal file
64
packages/media/audio/audio-cli/src/commands/info.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
15
packages/media/audio/audio-cli/src/commands/info.types.ts
Normal file
15
packages/media/audio/audio-cli/src/commands/info.types.ts
Normal 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;
|
||||
}
|
||||
60
packages/media/audio/audio-cli/src/commands/play.md
Normal file
60
packages/media/audio/audio-cli/src/commands/play.md
Normal 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.
|
||||
14
packages/media/audio/audio-cli/src/commands/play.schemas.ts
Normal file
14
packages/media/audio/audio-cli/src/commands/play.schemas.ts
Normal 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();
|
||||
103
packages/media/audio/audio-cli/src/commands/play.test.ts
Normal file
103
packages/media/audio/audio-cli/src/commands/play.test.ts
Normal 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, {});
|
||||
});
|
||||
});
|
||||
72
packages/media/audio/audio-cli/src/commands/play.ts
Normal file
72
packages/media/audio/audio-cli/src/commands/play.ts
Normal 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" },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
15
packages/media/audio/audio-cli/src/commands/play.types.ts
Normal file
15
packages/media/audio/audio-cli/src/commands/play.types.ts
Normal 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;
|
||||
}
|
||||
66
packages/media/audio/audio-cli/src/commands/process.md
Normal file
66
packages/media/audio/audio-cli/src/commands/process.md
Normal 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.
|
||||
@@ -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();
|
||||
184
packages/media/audio/audio-cli/src/commands/process.test.ts
Normal file
184
packages/media/audio/audio-cli/src/commands/process.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
116
packages/media/audio/audio-cli/src/commands/process.ts
Normal file
116
packages/media/audio/audio-cli/src/commands/process.ts
Normal 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 },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
12
packages/media/audio/audio-cli/src/commands/process.types.ts
Normal file
12
packages/media/audio/audio-cli/src/commands/process.types.ts
Normal 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;
|
||||
}
|
||||
62
packages/media/audio/audio-cli/src/commands/record.md
Normal file
62
packages/media/audio/audio-cli/src/commands/record.md
Normal 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.
|
||||
@@ -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();
|
||||
106
packages/media/audio/audio-cli/src/commands/record.test.ts
Normal file
106
packages/media/audio/audio-cli/src/commands/record.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
82
packages/media/audio/audio-cli/src/commands/record.ts
Normal file
82
packages/media/audio/audio-cli/src/commands/record.ts
Normal 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 },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
20
packages/media/audio/audio-cli/src/commands/record.types.ts
Normal file
20
packages/media/audio/audio-cli/src/commands/record.types.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/media/audio/audio-cli/src/error-codes/index.ts
Normal file
12
packages/media/audio/audio-cli/src/error-codes/index.ts
Normal 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";
|
||||
53
packages/media/audio/audio-cli/src/index.ts
Normal file
53
packages/media/audio/audio-cli/src/index.ts
Normal 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";
|
||||
2
packages/media/audio/audio-cli/src/pipeline/index.ts
Normal file
2
packages/media/audio/audio-cli/src/pipeline/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { ParsedPipeline } from "./parser.js";
|
||||
export { parsePipeline, validatePipeline } from "./parser.js";
|
||||
69
packages/media/audio/audio-cli/src/pipeline/parser.md
Normal file
69
packages/media/audio/audio-cli/src/pipeline/parser.md
Normal 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.
|
||||
114
packages/media/audio/audio-cli/src/pipeline/parser.test.ts
Normal file
114
packages/media/audio/audio-cli/src/pipeline/parser.test.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
177
packages/media/audio/audio-cli/src/pipeline/parser.ts
Normal file
177
packages/media/audio/audio-cli/src/pipeline/parser.ts
Normal 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(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
packages/media/audio/audio-cli/tests/exports.test.ts
Normal file
65
packages/media/audio/audio-cli/tests/exports.test.ts
Normal 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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
27
packages/media/audio/audio-cli/tests/helpers.ts
Normal file
27
packages/media/audio/audio-cli/tests/helpers.ts
Normal 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);
|
||||
}
|
||||
73
packages/media/audio/audio-cli/tests/integration.test.ts
Normal file
73
packages/media/audio/audio-cli/tests/integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
packages/media/audio/audio-cli/tests/property.test.ts
Normal file
49
packages/media/audio/audio-cli/tests/property.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
91
packages/media/audio/audio-cli/tests/purity.test.ts
Normal file
91
packages/media/audio/audio-cli/tests/purity.test.ts
Normal 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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
12
packages/media/audio/audio-cli/tsconfig.json
Normal file
12
packages/media/audio/audio-cli/tsconfig.json
Normal 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"]
|
||||
}
|
||||
18
packages/media/audio/audio-cli/tsconfig.lib.json
Normal file
18
packages/media/audio/audio-cli/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
52
packages/media/audio/audio-cli/vite.config.ts
Normal file
52
packages/media/audio/audio-cli/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
6
packages/media/audio/audio-cli/vitest.config.ts
Normal file
6
packages/media/audio/audio-cli/vitest.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
testTimeout: 30_000,
|
||||
additionalCoverageExcludes: ["src/bin/bin.ts"],
|
||||
});
|
||||
Reference in New Issue
Block a user