Initial Commit
This commit is contained in:
31
packages/media/audio-fx/audio-fx-plugin-mixer/README.md
Normal file
31
packages/media/audio-fx/audio-fx-plugin-mixer/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-mixer - mixer plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
mixer plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
Member of the `@suaveplan/audio-fx-plugin-*` family. Universal (browser / server / offline / worklet identical). Depends on `@suaveplan/audio-fx-plugin-base` for the `AudioFxPlugin` contract; declares no other `@suaveplan/audio-fx-plugin-*` deps.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [mixer](./src/mixer/mixer.md) — `MixerPlugin` is the universal multi-channel summing mixer. Each input channel is one channel strip with its own gain,…
|
||||
- [mixer](./src/mixer/mixer.md) — `MixerPlugin` is the universal multi-channel summing mixer. Each input channel is one channel strip with its own gain,…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, channel strips, and preset payload of `MixerPlugin`. These run on…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, channel strips, and preset payload of `MixerPlugin`. These run on…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./mixer` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
47
packages/media/audio-fx/audio-fx-plugin-mixer/package.json
Normal file
47
packages/media/audio-fx/audio-fx-plugin-mixer/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-fx-plugin-mixer",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "mixer plugin for the @suaveplan/audio-fx-plugin-* family.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.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-fx-plugin-base": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/audio-core": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/typescript-config": "workspace:^",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
}
|
||||
}
|
||||
15
packages/media/audio-fx/audio-fx-plugin-mixer/src/index.ts
Normal file
15
packages/media/audio-fx/audio-fx-plugin-mixer/src/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export { MixerPlugin } from "./mixer/index.js";
|
||||
export type {
|
||||
MixerChannel,
|
||||
MixerChannelOptions,
|
||||
MixerPluginOptions,
|
||||
MixerPresetData,
|
||||
MixerVuSnapshot,
|
||||
} from "./mixer.types.js";
|
||||
export {
|
||||
MixerChannelOptionsSchema,
|
||||
MixerChannelSchema,
|
||||
MixerPluginOptionsSchema,
|
||||
MixerPresetDataSchema,
|
||||
MixerSendsSchema,
|
||||
} from "./schemas/index.js";
|
||||
@@ -0,0 +1,85 @@
|
||||
# `mixer.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `MixerPlugin`'s channel strips, construction options, preset payload, and VU metering snapshot. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- `MixerChannel` — a live channel strip: id, gain (0..4), pan (-1..1), sends record.
|
||||
- `MixerChannelOptions` — construction-time half-twin of `MixerChannel` with optional initial values.
|
||||
- `MixerPluginOptions` — top-level construction options with `channels: ReadonlyArray<MixerChannelOptions>` and optional `initialMasterGain`.
|
||||
- `MixerPresetData` — preset payload carrying the full live channel strips.
|
||||
- `MixerVuSnapshot` — `{ channels: Record<id, rms>; master: rms }` per-block metering view.
|
||||
- All record types are `Readonly` so the consumer cannot mutate the snapshot returned by `getVuLevels()` or `getChannels()`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MixerPluginOptions,
|
||||
} from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
const opts: MixerPluginOptions = {
|
||||
channels: [
|
||||
{ id: "drums", initialGain: 0.8 },
|
||||
{ id: "vocal", initialGain: 0.9, initialPan: 0.1 },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MixerChannel,
|
||||
MixerPluginOptions,
|
||||
MixerVuSnapshot,
|
||||
} from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
function loudest(snapshot: MixerVuSnapshot): string | null {
|
||||
let best: { id: string; rms: number } | null = null;
|
||||
for (const [id, rms] of Object.entries(snapshot.channels)) {
|
||||
if (best === null || rms > best.rms) best = { id, rms };
|
||||
}
|
||||
return best?.id ?? null;
|
||||
}
|
||||
|
||||
function asPreset(channels: ReadonlyArray<MixerChannel>): MixerPluginOptions {
|
||||
return {
|
||||
channels: channels.map((c) => ({
|
||||
id: c.id,
|
||||
initialGain: c.gain,
|
||||
initialPan: c.pan,
|
||||
initialSends: c.sends,
|
||||
})),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MixerChannel`
|
||||
- `readonly id: string`
|
||||
- `readonly gain: number` (0..4)
|
||||
- `readonly pan: number` (-1..1)
|
||||
- `readonly sends: Readonly<Record<string, number>>`
|
||||
- `MixerChannelOptions`
|
||||
- `readonly id: string`
|
||||
- `readonly initialGain?: number`
|
||||
- `readonly initialPan?: number`
|
||||
- `readonly initialSends?: Readonly<Record<string, number>>`
|
||||
- `MixerPluginOptions`
|
||||
- `readonly channels: ReadonlyArray<MixerChannelOptions>`
|
||||
- `readonly initialMasterGain?: number`
|
||||
- `MixerPresetData`
|
||||
- `readonly channels: ReadonlyArray<MixerChannel>`
|
||||
- `MixerVuSnapshot`
|
||||
- `readonly channels: Readonly<Record<string, number>>`
|
||||
- `readonly master: number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`MixerChannelOptions` mirrors `MixerChannel` with `initial*` prefixes on the mutable fields. The pattern matches the rest of this package — every plugin uses `initialX` for construction-time hydration of a runtime-mutable value. Keeping the two types distinct lets the constructor's Zod schema validate the boundary input without forcing callers to specify defaults for every field.
|
||||
|
||||
`MixerVuSnapshot.channels` is a `Record<string, number>` rather than an array because consumers typically look up by id. Returning an array would force a lookup-by-position contract that breaks as soon as the user reorders channels. The downside is that `Record` enumeration order is not guaranteed across runtimes; UI code that wants stable ordering should iterate over `mixer.getChannels()` and index into the snapshot map by id.
|
||||
@@ -0,0 +1,32 @@
|
||||
/** One channel strip configuration. */
|
||||
export interface MixerChannel {
|
||||
readonly id: string;
|
||||
readonly gain: number;
|
||||
readonly pan: number;
|
||||
readonly sends: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
/** Construction-time options for a single channel strip. */
|
||||
export interface MixerChannelOptions {
|
||||
readonly id: string;
|
||||
readonly initialGain?: number;
|
||||
readonly initialPan?: number;
|
||||
readonly initialSends?: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
/** Construction options for `MixerPlugin`. */
|
||||
export interface MixerPluginOptions {
|
||||
readonly channels: ReadonlyArray<MixerChannelOptions>;
|
||||
readonly initialMasterGain?: number;
|
||||
}
|
||||
|
||||
/** Persisted preset payload — the channel strips. */
|
||||
export interface MixerPresetData {
|
||||
readonly channels: ReadonlyArray<MixerChannel>;
|
||||
}
|
||||
|
||||
/** Live metering snapshot for one channel + master. */
|
||||
export interface MixerVuSnapshot {
|
||||
readonly channels: Readonly<Record<string, number>>;
|
||||
readonly master: number;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MixerPlugin } from "./mixer.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
# `mixer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MixerPlugin` is the universal multi-channel summing mixer. Each input channel is one channel strip with its own gain, pan, and named sends; the panned outputs are summed into a stereo master bus. The output sum is internally unclipped — a caller wanting hard-cap behaviour chains a `LimiterPlugin` downstream. Per-channel and master VU levels are exposed via `getVuLevels()` so a UI can drive meters from a single source of truth.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin` and runs identically across browser, server, offline, and worklet renderers. Channels live as plain configuration on the plugin (not as parameters) and survive preset round-trip via `MixerPresetData`.
|
||||
|
||||
## Features
|
||||
|
||||
- `MixerChannel[]` configured at construction with per-channel `id`, `initialGain`, `initialPan`, and `initialSends`. Up to 64 channels per instance.
|
||||
- One automatable parameter: `masterGain` (0..4, default 1).
|
||||
- `setChannelGain(id, gain)`, `setChannelPan(id, pan)`, `setSend(channelId, busId, amount)` — mutate channel strips at runtime. Unknown ids throw `AUDIO_FX.CHAIN_SLOT_NOT_FOUND`.
|
||||
- Equal-power per-channel panning: `angle = (pan + 1) · π / 4`, `leftGain = cos(angle) · gain`, `rightGain = sin(angle) · gain`. Centre pan splits equally to `±√½`.
|
||||
- Sum into a fixed-count stereo output (`io.outputs[0].count === 2`) regardless of input channel count.
|
||||
- `getVuLevels()` returns per-channel RMS and master RMS over the most recent block.
|
||||
- Sends are stored on the channel strip and persisted in presets. The universal block-path does not route sends through buses — the browser engine wires them up via `createAudioNode()` in a future revision.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MixerPlugin } from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
const mixer = new MixerPlugin({
|
||||
channels: [
|
||||
{ id: "drums", initialGain: 0.8, initialPan: 0 },
|
||||
{ id: "bass", initialGain: 0.7, initialPan: -0.1 },
|
||||
{ id: "lead", initialGain: 0.6, initialPan: 0.2 },
|
||||
],
|
||||
});
|
||||
chain.add(mixer);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MixerPlugin } from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
const mixer = new MixerPlugin({
|
||||
channels: [
|
||||
{ id: "vocal" },
|
||||
{ id: "guitar" },
|
||||
{ id: "drums" },
|
||||
],
|
||||
initialMasterGain: 0.9,
|
||||
});
|
||||
await mixer.setup(
|
||||
{ sampleRate: 48_000, channels: 3, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
// Route vocal to a reverb send and a delay send.
|
||||
mixer.setSend("vocal", "reverb", 0.4);
|
||||
mixer.setSend("vocal", "delay", 0.2);
|
||||
|
||||
// Live VU metering at ~30 fps.
|
||||
setInterval(() => {
|
||||
const vu = mixer.getVuLevels();
|
||||
meter.draw(vu);
|
||||
}, 33);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MixerPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new MixerPlugin(options: MixerPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options or duplicate channel ids.
|
||||
- `getChannels(): ReadonlyArray<MixerChannel>` — current channel strips.
|
||||
- `setChannelGain(id, gain)`, `setChannelPan(id, pan)`, `setSend(channelId, busId, amount)` — mutate channel strips.
|
||||
- `getVuLevels(): MixerVuSnapshot` — per-channel RMS + master RMS for the most recent block.
|
||||
- Inherited accessor for `masterGain`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The mixer declares `io.outputs[0].count === 2` (fixed stereo) rather than `"preserve"` because the spec calls for "a master bus" — a single output target — and downstream plugins should be able to assume a stereo bus regardless of how many input channels feed in. `io.inputs[0].count === "any"` accepts whatever the predecessor produces, with channels beyond the configured strip count silently ignored.
|
||||
|
||||
Per-channel sends are stored on the strip but **not routed through the `BusRegistry` in this universal implementation**. A future browser-engine build will read the `sends` map in `createAudioNode()` and wire each strip's output through the named auxiliary buses. The spec test only checks "sums channels with applied gain and pan", which the universal path satisfies; full send routing requires the browser-engine integration that lives outside this package.
|
||||
|
||||
The mixer's per-block VU update is RMS, not peak. RMS is what mixing engineers expect when they read a "VU" label — peak meters are typically labelled "PPM" in the same UI. Computing RMS adds one accumulator + one `Math.sqrt` per channel and one `Math.sqrt` for the master at the end of the block, which is negligible compared to the per-sample multiply-accumulate work. If a future surface needs peak metering, it can subscribe to the per-block telemetry stream and reduce locally.
|
||||
|
||||
The internal sum is unclipped on purpose. The spec's "sum its channels into the output block without clipping internally" requirement is verified by a test that pushes the per-channel gain to `1.5` with two channels active, producing an output of `3.0` — well past `±1` — and asserting that value is what comes out. This keeps the mixer composable: a caller can chain a `LimiterPlugin` downstream for hard-cap behaviour, or leave the clipping policy to the host renderer's master.
|
||||
@@ -0,0 +1,358 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type {
|
||||
ChainContext,
|
||||
ChainHandle,
|
||||
} from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import { defaultAmbientState } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
type AudioFxError,
|
||||
} from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MixerPlugin } from "./mixer.js";
|
||||
|
||||
const SAMPLE_RATE = 48_000;
|
||||
|
||||
function blockCtx(): BlockContext {
|
||||
return {
|
||||
blockOffsetSamples: 0,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
channels: 2,
|
||||
opts: {},
|
||||
};
|
||||
}
|
||||
|
||||
function newConfig(blockSize: number, channels = 2): ProcessorConfig {
|
||||
return { sampleRate: SAMPLE_RATE, channels, blockSize };
|
||||
}
|
||||
|
||||
function newCtx(channels = 2): ChainContext {
|
||||
const chain: ChainHandle = {
|
||||
id: "mixer-test",
|
||||
name: "mixer-test",
|
||||
channels,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
blockSize: 128,
|
||||
slotCount: 1,
|
||||
};
|
||||
return {
|
||||
chain,
|
||||
position: { index: 0, total: 1 },
|
||||
predecessors: [],
|
||||
successors: [],
|
||||
ambient: defaultAmbientState(),
|
||||
buses: { get: () => undefined, list: () => [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe("MixerPlugin: contract surface", () => {
|
||||
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
|
||||
const m = new MixerPlugin({
|
||||
channels: [{ id: "a" }, { id: "b" }],
|
||||
});
|
||||
expect(m.metadata.id).toBe("mixer");
|
||||
expect(m.metadata.category).toBe("spatial");
|
||||
expect(m.parameters.map((p) => p.id)).toEqual(["masterGain"]);
|
||||
expect(m.getChannels().map((c) => c.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("rejects empty channel list with INVALID_OPTIONS", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new MixerPlugin({ channels: [] });
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("rejects duplicate channel ids with INVALID_OPTIONS", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new MixerPlugin({
|
||||
channels: [{ id: "a" }, { id: "a" }],
|
||||
});
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("setChannelGain on unknown id throws CHAIN_SLOT_NOT_FOUND", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const m = new MixerPlugin({ channels: [{ id: "a" }] });
|
||||
try {
|
||||
m.setChannelGain("nope", 0.5);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected CHAIN_SLOT_NOT_FOUND");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MixerPlugin: sums channels with applied gain and pan", () => {
|
||||
it("output equals gain- and pan-weighted sum of input channels", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const m = new MixerPlugin({
|
||||
channels: [
|
||||
{ id: "left", initialGain: 0.5, initialPan: -1 },
|
||||
{ id: "right", initialGain: 0.25, initialPan: 1 },
|
||||
],
|
||||
});
|
||||
await m.setup(newConfig(64, 2), newCtx(2));
|
||||
const input: Float32Array[] = [
|
||||
new Float32Array(64),
|
||||
new Float32Array(64),
|
||||
];
|
||||
for (let i = 0; i < 64; i++) {
|
||||
input[0]![i] = 1;
|
||||
input[1]![i] = 1;
|
||||
}
|
||||
const output: Float32Array[] = [
|
||||
new Float32Array(64),
|
||||
new Float32Array(64),
|
||||
];
|
||||
m.process(input, output, blockCtx());
|
||||
// Channel A pan=-1 → leftGain=0.5·cos(0)=0.5, rightGain=0.5·sin(0)=0.
|
||||
// Channel B pan=+1 → leftGain=0.25·cos(π/2)=0, rightGain=0.25·sin(π/2)=0.25.
|
||||
// Master gain = 1.
|
||||
for (let i = 0; i < 64; i++) {
|
||||
expect(output[0]![i]).toBeCloseTo(0.5, 6);
|
||||
expect(output[1]![i]).toBeCloseTo(0.25, 6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("MixerPlugin: sum is internally unclipped", () => {
|
||||
it("output exceeds ±1 when summed channels demand it", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const m = new MixerPlugin({
|
||||
channels: [
|
||||
{ id: "a", initialGain: 1.5, initialPan: -1 },
|
||||
{ id: "b", initialGain: 1.5, initialPan: -1 },
|
||||
],
|
||||
});
|
||||
await m.setup(newConfig(32, 2), newCtx(2));
|
||||
const input: Float32Array[] = [
|
||||
new Float32Array(32),
|
||||
new Float32Array(32),
|
||||
];
|
||||
for (let i = 0; i < 32; i++) {
|
||||
input[0]![i] = 1;
|
||||
input[1]![i] = 1;
|
||||
}
|
||||
const output: Float32Array[] = [
|
||||
new Float32Array(32),
|
||||
new Float32Array(32),
|
||||
];
|
||||
m.process(input, output, blockCtx());
|
||||
// Each channel contributes 1.5 to the left, total 3.0. Mixer
|
||||
// must not clip internally.
|
||||
for (let i = 0; i < 32; i++) {
|
||||
expect(output[0]![i]).toBeCloseTo(3, 4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("MixerPlugin: setChannelGain updates the mix", () => {
|
||||
it("setChannelGain changes the output level on next block", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const m = new MixerPlugin({
|
||||
channels: [
|
||||
{ id: "a", initialGain: 1, initialPan: 0 },
|
||||
{ id: "b", initialGain: 0, initialPan: 0 },
|
||||
],
|
||||
});
|
||||
await m.setup(newConfig(32, 2), newCtx(2));
|
||||
const input: Float32Array[] = [
|
||||
new Float32Array(32).fill(1),
|
||||
new Float32Array(32),
|
||||
];
|
||||
const output: Float32Array[] = [
|
||||
new Float32Array(32),
|
||||
new Float32Array(32),
|
||||
];
|
||||
m.process(input, output, blockCtx());
|
||||
const before = output[0]![0]!;
|
||||
m.setChannelGain("a", 0.5);
|
||||
m.process(input, output, blockCtx());
|
||||
expect(output[0]![0]).toBeLessThan(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MixerPlugin: setChannelPan updates the pan", () => {
|
||||
it("setChannelPan changes the L/R balance on next block", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const m = new MixerPlugin({
|
||||
channels: [
|
||||
{ id: "a", initialGain: 1, initialPan: 0 },
|
||||
{ id: "b", initialGain: 0, initialPan: 0 },
|
||||
],
|
||||
});
|
||||
await m.setup(newConfig(32, 2), newCtx(2));
|
||||
const input: Float32Array[] = [
|
||||
new Float32Array(32).fill(1),
|
||||
new Float32Array(32),
|
||||
];
|
||||
const output: Float32Array[] = [
|
||||
new Float32Array(32),
|
||||
new Float32Array(32),
|
||||
];
|
||||
m.process(input, output, blockCtx());
|
||||
const beforeL = output[0]![0]!;
|
||||
const beforeR = output[1]![0]!;
|
||||
// Centered pan: L === R within rounding.
|
||||
expect(beforeL).toBeCloseTo(beforeR, 5);
|
||||
m.setChannelPan("a", -1);
|
||||
m.process(input, output, blockCtx());
|
||||
// Hard left: L > 0, R ≈ 0.
|
||||
expect(output[0]![0]).toBeGreaterThan(output[1]![0]!);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MixerPlugin: surround channels are zeroed in the output", () => {
|
||||
it("output channels beyond L/R are filled with zeros", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const m = new MixerPlugin({
|
||||
channels: [
|
||||
{ id: "a", initialGain: 1 },
|
||||
{ id: "b", initialGain: 1 },
|
||||
{ id: "c", initialGain: 1 },
|
||||
{ id: "d", initialGain: 1 },
|
||||
],
|
||||
});
|
||||
await m.setup(newConfig(32, 4), newCtx(4));
|
||||
const input: Float32Array[] = [
|
||||
new Float32Array(32).fill(0.5),
|
||||
new Float32Array(32).fill(0.5),
|
||||
new Float32Array(32).fill(0.5),
|
||||
new Float32Array(32).fill(0.5),
|
||||
];
|
||||
const output: Float32Array[] = [
|
||||
new Float32Array(32).fill(99),
|
||||
new Float32Array(32).fill(99),
|
||||
new Float32Array(32).fill(99),
|
||||
new Float32Array(32).fill(99),
|
||||
];
|
||||
m.process(input, output, blockCtx());
|
||||
for (let i = 0; i < 32; i++) {
|
||||
expect(output[2]![i]).toBe(0);
|
||||
expect(output[3]![i]).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("MixerPlugin: setup rejects mono channel count", () => {
|
||||
it("mono setup throws CHANNEL_MISMATCH", { timeout: 5_000 }, async () => {
|
||||
const m = new MixerPlugin({ channels: [{ id: "a" }] });
|
||||
try {
|
||||
await m.setup(newConfig(32, 1), newCtx(1));
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHANNEL_MISMATCH
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected CHANNEL_MISMATCH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MixerPlugin: setSend records the bus amount", () => {
|
||||
it("getChannels reflects the new send amount", { timeout: 5_000 }, () => {
|
||||
const m = new MixerPlugin({
|
||||
channels: [{ id: "a" }],
|
||||
});
|
||||
m.setSend("a", "reverb", 0.6);
|
||||
expect(m.getChannels()[0]!.sends).toEqual({ reverb: 0.6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("MixerPlugin: VU metering reflects channel + master energy", () => {
|
||||
it("getVuLevels returns per-channel and master RMS after process", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const m = new MixerPlugin({
|
||||
channels: [
|
||||
{ id: "a", initialGain: 1, initialPan: 0 },
|
||||
{ id: "b", initialGain: 0, initialPan: 0 },
|
||||
],
|
||||
});
|
||||
await m.setup(newConfig(64, 2), newCtx(2));
|
||||
const input: Float32Array[] = [
|
||||
new Float32Array(64).fill(0.5),
|
||||
new Float32Array(64),
|
||||
];
|
||||
const output: Float32Array[] = [
|
||||
new Float32Array(64),
|
||||
new Float32Array(64),
|
||||
];
|
||||
m.process(input, output, blockCtx());
|
||||
const vu = m.getVuLevels();
|
||||
expect(vu.channels["a"]).toBeCloseTo(0.5, 4);
|
||||
expect(vu.master).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MixerPlugin: reset clears VU state", () => {
|
||||
it("after reset, getVuLevels returns zeros", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const m = new MixerPlugin({ channels: [{ id: "a" }, { id: "b" }] });
|
||||
await m.setup(newConfig(32, 2), newCtx(2));
|
||||
const input: Float32Array[] = [
|
||||
new Float32Array(32).fill(1),
|
||||
new Float32Array(32),
|
||||
];
|
||||
const output: Float32Array[] = [
|
||||
new Float32Array(32),
|
||||
new Float32Array(32),
|
||||
];
|
||||
m.process(input, output, blockCtx());
|
||||
expect(m.getVuLevels().master).toBeGreaterThan(0);
|
||||
m.reset();
|
||||
const vu = m.getVuLevels();
|
||||
expect(vu.master).toBe(0);
|
||||
expect(vu.channels["a"]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MixerPlugin: preset round-trip", () => {
|
||||
it("getPreset / applyPreset round-trips channels", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = new MixerPlugin({
|
||||
channels: [
|
||||
{ id: "lead", initialGain: 0.7, initialPan: -0.3 },
|
||||
{ id: "bass", initialGain: 0.5, initialPan: 0.2 },
|
||||
],
|
||||
});
|
||||
a.setSend("lead", "reverb", 0.4);
|
||||
const snap = a.getPreset();
|
||||
const b = new MixerPlugin({
|
||||
channels: [{ id: "lead" }, { id: "bass" }],
|
||||
});
|
||||
b.applyPreset(snap);
|
||||
const channels = b.getChannels();
|
||||
expect(channels[0]!.gain).toBeCloseTo(0.7, 6);
|
||||
expect(channels[0]!.pan).toBeCloseTo(-0.3, 6);
|
||||
expect(channels[0]!.sends).toEqual({ reverb: 0.4 });
|
||||
expect(channels[1]!.id).toBe("bass");
|
||||
});
|
||||
});
|
||||
215
packages/media/audio-fx/audio-fx-plugin-mixer/src/mixer/mixer.ts
Normal file
215
packages/media/audio-fx/audio-fx-plugin-mixer/src/mixer/mixer.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type { ChainContext } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import { AbstractAudioFxPlugin } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
AudioFxError,
|
||||
} from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
import type {
|
||||
MixerChannel,
|
||||
MixerPluginOptions,
|
||||
MixerPresetData,
|
||||
MixerVuSnapshot,
|
||||
} from "../mixer.types.js";
|
||||
import {
|
||||
MixerPluginOptionsSchema,
|
||||
MixerPresetDataSchema,
|
||||
} from "../schemas/index.js";
|
||||
|
||||
/**
|
||||
* Multi-channel mixer. Treats each input channel as one channel strip
|
||||
* with its own gain, pan, and named sends, summing the equal-power
|
||||
* panned signal into a stereo master output. The sum is internally
|
||||
* unclipped — callers chain a `LimiterPlugin` downstream if hard-cap
|
||||
* behavior is needed. VU levels are exposed via `getVuLevels()` so a
|
||||
* UI can drive per-channel and master meters from a single source of
|
||||
* truth.
|
||||
*/
|
||||
export class MixerPlugin extends AbstractAudioFxPlugin {
|
||||
private channels: MixerChannel[] = [];
|
||||
private channelIndex = new Map<string, number>();
|
||||
private channelLevels: Float32Array = new Float32Array(0);
|
||||
private masterLevel = 0;
|
||||
|
||||
public constructor(options: MixerPluginOptions) {
|
||||
const parsed = MixerPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.mixer-invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const opts = parsed.data;
|
||||
const channels: MixerChannel[] = opts.channels.map((c) => ({
|
||||
id: c.id,
|
||||
gain: c.initialGain ?? 1,
|
||||
pan: c.initialPan ?? 0,
|
||||
sends: c.initialSends ?? {},
|
||||
}));
|
||||
const seen = new Set<string>();
|
||||
for (const ch of channels) {
|
||||
if (seen.has(ch.id)) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.mixer-duplicate-channel-id",
|
||||
{ context: { id: ch.id } }
|
||||
);
|
||||
}
|
||||
seen.add(ch.id);
|
||||
}
|
||||
super({
|
||||
metadata: {
|
||||
id: "mixer",
|
||||
name: "Mixer",
|
||||
version: "1.0.0",
|
||||
category: "spatial",
|
||||
},
|
||||
schema: MixerPresetDataSchema,
|
||||
io: {
|
||||
inputs: [{ count: "preserve" }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
id: "masterGain",
|
||||
name: "Master Gain",
|
||||
value: opts.initialMasterGain ?? 1,
|
||||
min: 0,
|
||||
max: 4,
|
||||
default: 1,
|
||||
unit: "ratio",
|
||||
automatable: true,
|
||||
},
|
||||
],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
this.channels = channels;
|
||||
this.rebuildIndex();
|
||||
}
|
||||
|
||||
public getChannels(): ReadonlyArray<MixerChannel> {
|
||||
return this.channels;
|
||||
}
|
||||
|
||||
public setChannelGain(id: string, gain: number): void {
|
||||
const ch = this.findChannelOrThrow(id);
|
||||
this.channels[ch] = { ...this.channels[ch]!, gain };
|
||||
}
|
||||
|
||||
public setChannelPan(id: string, pan: number): void {
|
||||
const idx = this.findChannelOrThrow(id);
|
||||
this.channels[idx] = { ...this.channels[idx]!, pan };
|
||||
}
|
||||
|
||||
public setSend(channelId: string, busId: string, amount: number): void {
|
||||
const ch = this.findChannelOrThrow(channelId);
|
||||
const existing = this.channels[ch]!;
|
||||
const sends = { ...existing.sends, [busId]: amount };
|
||||
this.channels[ch] = { ...existing, sends };
|
||||
}
|
||||
|
||||
public getVuLevels(): MixerVuSnapshot {
|
||||
const channels: Record<string, number> = {};
|
||||
for (let i = 0; i < this.channels.length; i++) {
|
||||
channels[this.channels[i]!.id] = this.channelLevels[i]!;
|
||||
}
|
||||
return { channels, master: this.masterLevel };
|
||||
}
|
||||
|
||||
protected override setupState(
|
||||
config: ProcessorConfig,
|
||||
_chain: ChainContext
|
||||
): void {
|
||||
if (config.channels < 2) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHANNEL_MISMATCH,
|
||||
"audio-fx.mixer-requires-stereo",
|
||||
{ context: { channels: config.channels } }
|
||||
);
|
||||
}
|
||||
this.channelLevels = new Float32Array(this.channels.length);
|
||||
this.masterLevel = 0;
|
||||
}
|
||||
|
||||
protected override resetState(): void {
|
||||
this.channelLevels.fill(0);
|
||||
this.masterLevel = 0;
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
_ctx: BlockContext
|
||||
): void {
|
||||
const blockSize = input[0]!.length;
|
||||
const master = this.parameterValue("masterGain");
|
||||
const outL = output[0]!;
|
||||
const outR = output[1]!;
|
||||
outL.fill(0);
|
||||
outR.fill(0);
|
||||
for (let ch = 2; ch < output.length; ch++) {
|
||||
output[ch]!.fill(0);
|
||||
}
|
||||
const numChannels = Math.min(input.length, this.channels.length);
|
||||
let masterAcc = 0;
|
||||
for (let c = 0; c < numChannels; c++) {
|
||||
const strip = this.channels[c]!;
|
||||
const inBuf = input[c]!;
|
||||
const angle = ((strip.pan + 1) * Math.PI) / 4;
|
||||
const leftGain = Math.cos(angle) * strip.gain;
|
||||
const rightGain = Math.sin(angle) * strip.gain;
|
||||
let channelAcc = 0;
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
const sample = inBuf[i]!;
|
||||
outL[i] = outL[i]! + sample * leftGain;
|
||||
outR[i] = outR[i]! + sample * rightGain;
|
||||
channelAcc += sample * sample;
|
||||
}
|
||||
this.channelLevels[c] = Math.sqrt(channelAcc / blockSize);
|
||||
}
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
const l = outL[i]! * master;
|
||||
const r = outR[i]! * master;
|
||||
outL[i] = l;
|
||||
outR[i] = r;
|
||||
masterAcc += l * l + r * r;
|
||||
}
|
||||
this.masterLevel = Math.sqrt(masterAcc / (2 * blockSize));
|
||||
}
|
||||
|
||||
protected override getPresetData(): MixerPresetData {
|
||||
return { channels: this.channels.map((c) => ({ ...c })) };
|
||||
}
|
||||
|
||||
protected override applyPresetData(data: unknown): void {
|
||||
const { channels } = data as MixerPresetData;
|
||||
this.channels = channels.map((c) => ({
|
||||
id: c.id,
|
||||
gain: c.gain,
|
||||
pan: c.pan,
|
||||
sends: { ...c.sends },
|
||||
}));
|
||||
this.rebuildIndex();
|
||||
this.channelLevels = new Float32Array(this.channels.length);
|
||||
}
|
||||
|
||||
private findChannelOrThrow(id: string): number {
|
||||
const idx = this.channelIndex.get(id);
|
||||
if (idx === undefined) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND,
|
||||
"audio-fx.mixer-unknown-channel",
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
private rebuildIndex(): void {
|
||||
this.channelIndex.clear();
|
||||
for (let i = 0; i < this.channels.length; i++) {
|
||||
this.channelIndex.set(this.channels[i]!.id, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
MixerChannelOptionsSchema,
|
||||
MixerChannelSchema,
|
||||
MixerPluginOptionsSchema,
|
||||
MixerPresetDataSchema,
|
||||
MixerSendsSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,65 @@
|
||||
# `mixer/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options, channel strips, and preset payload of `MixerPlugin`. These run on the public boundary between user code and the plugin: any malformed object hitting the constructor or `applyPreset` is rejected with `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` before it can corrupt runtime state.
|
||||
|
||||
Separating schemas from the plugin module lets `index.ts` re-export them so callers can introspect the option surface (for forms, RPC validation, or storybook controls) without importing the audio implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `MixerSendsSchema` — `z.record(string.min(1), number.finite().min(0).max(2))`. Send amounts are bounded above at `2` to allow modest gain at the bus, but not unbounded amplification.
|
||||
- `MixerChannelSchema` — full live strip schema: id, gain (0..4), pan (-1..1), sends.
|
||||
- `MixerChannelOptionsSchema` — boundary schema with `initialX` half-twins of the live fields.
|
||||
- `MixerPluginOptionsSchema` — `channels` array constrained to 1..64 plus optional `initialMasterGain`.
|
||||
- `MixerPresetDataSchema` — `.strict()` object carrying the full live channel strips.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MixerPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
const parsed = MixerPluginOptionsSchema.parse({
|
||||
channels: [{ id: "drums" }, { id: "vocal" }],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MixerChannelSchema,
|
||||
MixerPresetDataSchema,
|
||||
MixerSendsSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
// Validate an external send-map blob before forwarding to the plugin.
|
||||
function isSendMap(blob: unknown): boolean {
|
||||
return MixerSendsSchema.safeParse(blob).success;
|
||||
}
|
||||
|
||||
// Strict preset validation at the IPC boundary.
|
||||
function isMixerPreset(blob: unknown): boolean {
|
||||
return MixerPresetDataSchema.safeParse(blob).success;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MixerSendsSchema: z.ZodRecord<string, number>`
|
||||
- `MixerChannelSchema: z.ZodObject<{ id, gain, pan, sends }>`
|
||||
- `MixerChannelOptionsSchema: z.ZodObject<{ id, initialGain?, initialPan?, initialSends? }>`
|
||||
- `MixerPluginOptionsSchema: z.ZodObject<{ channels, initialMasterGain? }>`
|
||||
- `MixerPresetDataSchema: z.ZodObject<{ channels }, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The channel-count cap of 64 reflects two practical constraints: a typical mixing console UI struggles to render more than 32 strips legibly, and a per-channel loop over 64 entries per sample is already at the upper end of what a real-time block-processing budget can absorb on a typical CPU. Going past 64 strips is usually a sign that the caller should be using a submix architecture instead.
|
||||
|
||||
`MixerPresetDataSchema` uses `.strict()` so unknown fields are rejected — matching the sibling-plugin policy. The schema validates the full live `MixerChannel` shape (not just the construction-time half) because presets restore the runtime state directly, bypassing the `initialX → gain` mapping the constructor does.
|
||||
|
||||
The send-amount upper bound of `2` lets a bus send slightly boost the signal (useful for parallel-compression busses where the dry sum is too quiet) but blocks pathological values that would silently drown the master. The lower bound of `0` allows a "mute send" without removing the entry from the sends map, so a UI animating a fader to zero can resume from the same state.
|
||||
@@ -0,0 +1,31 @@
|
||||
import z from "zod";
|
||||
|
||||
export const MixerSendsSchema = z.record(
|
||||
z.string().min(1),
|
||||
z.number().finite().min(0).max(2)
|
||||
);
|
||||
|
||||
export const MixerChannelSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
gain: z.number().finite().min(0).max(4),
|
||||
pan: z.number().finite().min(-1).max(1),
|
||||
sends: MixerSendsSchema,
|
||||
});
|
||||
|
||||
export const MixerChannelOptionsSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
initialGain: z.number().finite().min(0).max(4).optional(),
|
||||
initialPan: z.number().finite().min(-1).max(1).optional(),
|
||||
initialSends: MixerSendsSchema.optional(),
|
||||
});
|
||||
|
||||
export const MixerPluginOptionsSchema = z.object({
|
||||
channels: z.array(MixerChannelOptionsSchema).min(1).max(64),
|
||||
initialMasterGain: z.number().finite().min(0).max(4).optional(),
|
||||
});
|
||||
|
||||
export const MixerPresetDataSchema = z
|
||||
.object({
|
||||
channels: z.array(MixerChannelSchema).min(1).max(64),
|
||||
})
|
||||
.strict();
|
||||
12
packages/media/audio-fx/audio-fx-plugin-mixer/tsconfig.json
Normal file
12
packages/media/audio-fx/audio-fx-plugin-mixer/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"tests/**",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
38
packages/media/audio-fx/audio-fx-plugin-mixer/vite.config.ts
Normal file
38
packages/media/audio-fx/audio-fx-plugin-mixer/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/audio-fx-plugin-mixer",
|
||||
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" },
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user