Initial Commit
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# `glados.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `GLaDOSPlugin`'s preset enum, stage configuration, construction options, and preset payload. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
|
||||
|
||||
`GLaDOSPreset` is the canonical preset-name enum; saved presets travel by name (not numeric index) so a future revision adding new presets does not break stored data.
|
||||
|
||||
## Features
|
||||
|
||||
- `GLaDOSPreset` — textual enum covering the six built-in presets.
|
||||
- `GLaDOSConfig` — six numeric stage values, all `readonly`.
|
||||
- `GLaDOSPluginOptions` — `preset?` selects a starting preset; `config?` supplies a `Partial<GLaDOSConfig>` overlay applied on top.
|
||||
- `GLaDOSPresetData` — preset payload carrying both `preset` (intent) and the full `config` (effective state) so restore is deterministic.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
GLaDOSConfig,
|
||||
GLaDOSPluginOptions,
|
||||
GLaDOSPreset,
|
||||
} from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
const opts: GLaDOSPluginOptions = { preset: "wheatley" };
|
||||
const preset: GLaDOSPreset = opts.preset ?? "glados-mainline";
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
GLaDOSConfig,
|
||||
GLaDOSPluginOptions,
|
||||
GLaDOSPreset,
|
||||
} from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
function blend(a: GLaDOSConfig, b: GLaDOSConfig, t: number): GLaDOSConfig {
|
||||
return {
|
||||
pitchShift: a.pitchShift + (b.pitchShift - a.pitchShift) * t,
|
||||
formantShift: a.formantShift + (b.formantShift - a.formantShift) * t,
|
||||
roboticIntensity:
|
||||
a.roboticIntensity + (b.roboticIntensity - a.roboticIntensity) * t,
|
||||
distortionAmount:
|
||||
a.distortionAmount + (b.distortionAmount - a.distortionAmount) * t,
|
||||
lowPassCutoff: a.lowPassCutoff + (b.lowPassCutoff - a.lowPassCutoff) * t,
|
||||
reverbAmount: a.reverbAmount + (b.reverbAmount - a.reverbAmount) * t,
|
||||
};
|
||||
}
|
||||
|
||||
const opts: GLaDOSPluginOptions = {
|
||||
preset: "robotic-generic",
|
||||
config: { roboticIntensity: 0.9, reverbAmount: 0.3 },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GLaDOSPreset` = `"glados-mainline" | "glados-test-chamber" | "glados-cake-deception" | "wheatley" | "cave-johnson" | "robotic-generic"`
|
||||
- `GLaDOSConfig`
|
||||
- `readonly pitchShift: number` (-12..12 semitones)
|
||||
- `readonly formantShift: number` (-12..12 semitones)
|
||||
- `readonly roboticIntensity: number` (0..1)
|
||||
- `readonly distortionAmount: number` (0..1)
|
||||
- `readonly lowPassCutoff: number` (20..20 000 Hz)
|
||||
- `readonly reverbAmount: number` (0..1)
|
||||
- `GLaDOSPluginOptions`
|
||||
- `readonly preset?: GLaDOSPreset`
|
||||
- `readonly config?: Partial<GLaDOSConfig>`
|
||||
- `GLaDOSPresetData`
|
||||
- `readonly preset: GLaDOSPreset`
|
||||
- `readonly config: GLaDOSConfig`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`GLaDOSPresetData` encodes both the preset name *and* the live config because the user can apply a preset and then override individual stages — restoring the preset name alone would lose those overrides. Storing both keeps restore deterministic. The pattern matches the audio-analyzer plugin's `AudioAnalyzerPresetData` which encodes both the preset name and the toggle state for the same reason.
|
||||
|
||||
`GLaDOSPluginOptions.config` is a `Partial<GLaDOSConfig>` overlay applied on top of the selected preset (or the default config when no preset is given). This lets callers express things like "start with the test-chamber preset but with extra reverb" in a single options object without two function calls.
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Built-in GLaDOS-family preset names. */
|
||||
export type GLaDOSPreset =
|
||||
| "glados-mainline"
|
||||
| "glados-test-chamber"
|
||||
| "glados-cake-deception"
|
||||
| "wheatley"
|
||||
| "cave-johnson"
|
||||
| "robotic-generic";
|
||||
|
||||
/** Six stage values configuring the voice chain. */
|
||||
export interface GLaDOSConfig {
|
||||
readonly pitchShift: number;
|
||||
readonly formantShift: number;
|
||||
readonly roboticIntensity: number;
|
||||
readonly distortionAmount: number;
|
||||
readonly lowPassCutoff: number;
|
||||
readonly reverbAmount: number;
|
||||
}
|
||||
|
||||
/** Construction options for `GLaDOSPlugin`. */
|
||||
export interface GLaDOSPluginOptions {
|
||||
readonly preset?: GLaDOSPreset;
|
||||
readonly config?: Partial<GLaDOSConfig>;
|
||||
}
|
||||
|
||||
/** Persisted preset payload — the live stage configuration + preset name. */
|
||||
export interface GLaDOSPresetData {
|
||||
readonly preset: GLaDOSPreset;
|
||||
readonly config: GLaDOSConfig;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# `glados`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GLaDOSPlugin` is the universal voice-effect insert. It runs the input signal through a fixed six-stage chain — granular pitch shift → formant tilt → robotic-intensity tremolo → soft-clip distortion → low-pass filter → short feedback reverb — and exposes a typed setter for each stage plus six named presets. The plugin is an insert (`io.inputs[0].count === io.outputs[0].count === "preserve"`), so it accepts any input channel count and emits the same shape.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin`, runs identically across browser / server / worklet renderers, and validates every setter at the boundary: out-of-range values throw `AUDIO_FX.INVALID_OPTIONS` with the offending value attached. Six built-in presets cover the GLaDOS family (mainline, test chamber, cake deception) plus Wheatley, Cave Johnson, and a generic robotic voice.
|
||||
|
||||
## Features
|
||||
|
||||
- Six chained stages, each with a typed setter: `setPitchShift`, `setFormantShift`, `setRoboticIntensity`, `setDistortionAmount`, `setLowPassCutoff`, `setReverbAmount`.
|
||||
- Six built-in presets (`glados-mainline`, `glados-test-chamber`, `glados-cake-deception`, `wheatley`, `cave-johnson`, `robotic-generic`) selected via the construction `preset` option or `applyGladosPreset(name)`.
|
||||
- `getGLaDOSPresets()` exposes the read-only preset table so editors can render preset dropdowns and preview the values.
|
||||
- Per-stage range validation: `pitchShift`/`formantShift` ∈ [-12, 12] semitones, `roboticIntensity`/`distortionAmount`/`reverbAmount` ∈ [0, 1], `lowPassCutoff` ∈ [20, 20 000] Hz.
|
||||
- `applyGladosPreset` is named distinctly from `AbstractAudioFxPlugin.applyPreset(snapshot)` so the two methods do not collide.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GLaDOSPlugin } from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
const glados = new GLaDOSPlugin({ preset: "glados-mainline" });
|
||||
chain.add(glados);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { GLaDOSPlugin, getGLaDOSPresets } from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
const presetNames = Object.keys(getGLaDOSPresets()) as ReadonlyArray<
|
||||
keyof ReturnType<typeof getGLaDOSPresets>
|
||||
>;
|
||||
const voice = new GLaDOSPlugin({ preset: "wheatley" });
|
||||
await voice.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
// Customise the wheatley preset with extra reverb.
|
||||
voice.setReverbAmount(0.4);
|
||||
voice.setLowPassCutoff(5_500);
|
||||
|
||||
// Preview every preset on the same line of dialogue.
|
||||
for (const name of presetNames) {
|
||||
voice.applyGladosPreset(name);
|
||||
await player.playDemo();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GLaDOSPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new GLaDOSPlugin(options?: GLaDOSPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `applyGladosPreset(name: GLaDOSPreset): void`, `getCurrentPreset(): GLaDOSPreset`, `getGladosConfig(): GLaDOSConfig`.
|
||||
- `setPitchShift(-12..12)`, `setFormantShift(-12..12)`, `setRoboticIntensity(0..1)`, `setDistortionAmount(0..1)`, `setLowPassCutoff(20..20000)`, `setReverbAmount(0..1)` — each throws `AUDIO_FX.INVALID_OPTIONS` on out-of-range.
|
||||
- `getGLaDOSPresets()` — read-only view of the six built-in presets.
|
||||
- Types: `GLaDOSPreset` literal union, `GLaDOSConfig`, `GLaDOSPluginOptions`, `GLaDOSPresetData`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The pitch-shift stage is a per-channel granular ring buffer with linear-interpolated fractional read — the same shape used by `PitchShifterPlugin` but with a smaller window because vocal content does not need the larger windows that drum or instrumental material benefits from. For pitch shifts within the GLaDOS-typical range (-5 to +5 semitones) this produces an audibly clean result. Going further out is supported but the perceptual artefacts increase.
|
||||
|
||||
The formant-tilt stage is a simple gain shift (`10^(formantShift / 8)`) rather than a true formant analyzer. A real formant-shift implementation needs spectral analysis + cepstrum manipulation, which is much heavier and would not measurably improve the GLaDOS character. The simple gain matches what most game-audio implementations use for "robotic voice" effects.
|
||||
|
||||
The robotic-intensity stage is a 30 Hz tremolo modulating amplitude — fast enough to read as "robotic clicking" rather than "vibrato". The depth is mapped linearly from the `roboticIntensity` knob so the user-facing range `[0, 1]` translates to `0` (no tremolo) through `1` (full chopping). The 30 Hz rate is faster than typical tremolo because that's what makes the voice sound mechanical rather than emotional.
|
||||
|
||||
The distortion stage is a `tanh` soft-clip with parallel dry/wet routing — same shape as the `DistortionPlugin`'s waveshaper mode but with a fixed drive that scales with the user's `distortionAmount` knob.
|
||||
|
||||
The low-pass is a one-pole filter (the same shape used by `MicrophoneSimulationPlugin`), and the reverb is a short feedback delay (`REVERB_DELAY_MS = 30 ms` per channel). Both are intentionally simpler than their dedicated sibling-plugin counterparts because the GLaDOS character does not need surgical control — the user wants "more or less" of each colour, not a fully parametric filter and convolution.
|
||||
|
||||
Preset application replaces the *entire* config in one shot via `{ ...PRESETS[preset] }`. Individual setter overrides applied after `applyGladosPreset` will stick; the next preset application replaces them. The preset payload encodes both the preset name and the live config so `applyPreset(snap)` restores any per-stage overrides made after the preset was originally applied.
|
||||
@@ -0,0 +1,299 @@
|
||||
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 { GLaDOSPlugin, getGLaDOSPresets } from "./glados.js";
|
||||
|
||||
const SAMPLE_RATE = 48_000;
|
||||
|
||||
function blockCtx(): BlockContext {
|
||||
return {
|
||||
blockOffsetSamples: 0,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
channels: 1,
|
||||
opts: {},
|
||||
};
|
||||
}
|
||||
|
||||
function buffers(
|
||||
size: number,
|
||||
channels = 1
|
||||
): { input: Float32Array[]; output: Float32Array[] } {
|
||||
const input: Float32Array[] = [];
|
||||
const output: Float32Array[] = [];
|
||||
for (let c = 0; c < channels; c++) {
|
||||
input.push(new Float32Array(size));
|
||||
output.push(new Float32Array(size));
|
||||
}
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
function newConfig(blockSize: number, channels = 1): ProcessorConfig {
|
||||
return { sampleRate: SAMPLE_RATE, channels, blockSize };
|
||||
}
|
||||
|
||||
function newCtx(channels = 1): ChainContext {
|
||||
const chain: ChainHandle = {
|
||||
id: "glados-test",
|
||||
name: "glados-test",
|
||||
channels,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
blockSize: 256,
|
||||
slotCount: 1,
|
||||
};
|
||||
return {
|
||||
chain,
|
||||
position: { index: 0, total: 1 },
|
||||
predecessors: [],
|
||||
successors: [],
|
||||
ambient: defaultAmbientState(),
|
||||
buses: { get: () => undefined, list: () => [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe("GLaDOSPlugin: contract surface", () => {
|
||||
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
|
||||
const g = new GLaDOSPlugin();
|
||||
expect(g.metadata.id).toBe("glados");
|
||||
expect(g.metadata.category).toBe("voice");
|
||||
});
|
||||
|
||||
it("rejects malformed options with INVALID_OPTIONS", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new GLaDOSPlugin({ config: { pitchShift: 99 } });
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("getGLaDOSPresets exposes the six built-in presets", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const presets = getGLaDOSPresets();
|
||||
expect(Object.keys(presets).sort()).toEqual([
|
||||
"cave-johnson",
|
||||
"glados-cake-deception",
|
||||
"glados-mainline",
|
||||
"glados-test-chamber",
|
||||
"robotic-generic",
|
||||
"wheatley",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GLaDOSPlugin: applyGladosPreset('wheatley') sets every stage to the wheatley preset values", () => {
|
||||
it("config after preset application equals the documented preset record", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const g = new GLaDOSPlugin();
|
||||
g.applyGladosPreset("wheatley");
|
||||
const cfg = g.getGladosConfig();
|
||||
const expected = getGLaDOSPresets().wheatley;
|
||||
expect(cfg).toEqual(expected);
|
||||
expect(g.getCurrentPreset()).toBe("wheatley");
|
||||
});
|
||||
|
||||
it("constructor with `preset` initialises from the named preset", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const g = new GLaDOSPlugin({ preset: "cave-johnson" });
|
||||
expect(g.getCurrentPreset()).toBe("cave-johnson");
|
||||
expect(g.getGladosConfig()).toEqual(getGLaDOSPresets()["cave-johnson"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GLaDOSPlugin: glados-mainline preset colours the input audibly", () => {
|
||||
it("output differs from input on a non-silent block (per-sample RMS difference > 1e-3)", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const g = new GLaDOSPlugin();
|
||||
await g.setup(newConfig(SAMPLE_RATE / 2), newCtx());
|
||||
const { input, output } = buffers(SAMPLE_RATE / 2);
|
||||
for (let i = 0; i < SAMPLE_RATE / 2; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
|
||||
}
|
||||
g.process(input, output, blockCtx());
|
||||
let acc = 0;
|
||||
// Skip the first quarter to let the pitch ring fill.
|
||||
for (let i = SAMPLE_RATE / 8; i < SAMPLE_RATE / 2; i++) {
|
||||
const d = output[0]![i]! - input[0]![i]!;
|
||||
acc += d * d;
|
||||
}
|
||||
const rmsDiff = Math.sqrt(acc / (SAMPLE_RATE / 2 - SAMPLE_RATE / 8));
|
||||
expect(rmsDiff).toBeGreaterThan(1e-3);
|
||||
for (let i = 0; i < SAMPLE_RATE / 2; i++) {
|
||||
expect(Number.isFinite(output[0]![i]!)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GLaDOSPlugin: setRoboticIntensity rejects out-of-range values with AUDIO_FX.INVALID_OPTIONS", () => {
|
||||
it("setRoboticIntensity(99) throws", { timeout: 5_000 }, () => {
|
||||
const g = new GLaDOSPlugin();
|
||||
try {
|
||||
g.setRoboticIntensity(99);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("setPitchShift(-99) throws", { timeout: 5_000 }, () => {
|
||||
const g = new GLaDOSPlugin();
|
||||
try {
|
||||
g.setPitchShift(-99);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("setFormantShift(NaN) throws", { timeout: 5_000 }, () => {
|
||||
const g = new GLaDOSPlugin();
|
||||
try {
|
||||
g.setFormantShift(Number.NaN);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("setDistortionAmount(99) throws", { timeout: 5_000 }, () => {
|
||||
const g = new GLaDOSPlugin();
|
||||
try {
|
||||
g.setDistortionAmount(99);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("setLowPassCutoff(0) throws", { timeout: 5_000 }, () => {
|
||||
const g = new GLaDOSPlugin();
|
||||
try {
|
||||
g.setLowPassCutoff(0);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("setReverbAmount(99) throws", { timeout: 5_000 }, () => {
|
||||
const g = new GLaDOSPlugin();
|
||||
try {
|
||||
g.setReverbAmount(99);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GLaDOSPlugin: in-range setters update the live config", () => {
|
||||
it("every setter updates its corresponding field", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const g = new GLaDOSPlugin();
|
||||
g.setPitchShift(5);
|
||||
g.setFormantShift(-3);
|
||||
g.setRoboticIntensity(0.4);
|
||||
g.setDistortionAmount(0.8);
|
||||
g.setLowPassCutoff(2_000);
|
||||
g.setReverbAmount(0.7);
|
||||
const cfg = g.getGladosConfig();
|
||||
expect(cfg.pitchShift).toBe(5);
|
||||
expect(cfg.formantShift).toBe(-3);
|
||||
expect(cfg.roboticIntensity).toBeCloseTo(0.4, 6);
|
||||
expect(cfg.distortionAmount).toBeCloseTo(0.8, 6);
|
||||
expect(cfg.lowPassCutoff).toBe(2_000);
|
||||
expect(cfg.reverbAmount).toBeCloseTo(0.7, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GLaDOSPlugin: preset round-trip", () => {
|
||||
it("getPreset / applyPreset round-trips the preset name + config", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = new GLaDOSPlugin({ preset: "glados-test-chamber" });
|
||||
a.setDistortionAmount(0.55);
|
||||
const snap = a.getPreset();
|
||||
const b = new GLaDOSPlugin();
|
||||
b.applyPreset(snap);
|
||||
expect(b.getCurrentPreset()).toBe("glados-test-chamber");
|
||||
expect(b.getGladosConfig().distortionAmount).toBeCloseTo(0.55, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GLaDOSPlugin: reset clears chain state", () => {
|
||||
it("after reset(), processing an empty input produces an empty output", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const g = new GLaDOSPlugin();
|
||||
await g.setup(newConfig(2048), newCtx());
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) input[0]![i] = 0.5;
|
||||
g.process(input, output, blockCtx());
|
||||
g.reset();
|
||||
const { input: in2, output: out2 } = buffers(2048);
|
||||
g.process(in2, out2, blockCtx());
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
expect(out2[0]![i]).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GLaDOSPlugin: stereo input runs the chain on both channels", () => {
|
||||
it("two channels of identical input produce two non-silent channels of output", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const g = new GLaDOSPlugin();
|
||||
await g.setup(newConfig(SAMPLE_RATE / 4, 2), newCtx(2));
|
||||
const { input, output } = buffers(SAMPLE_RATE / 4, 2);
|
||||
for (let i = 0; i < SAMPLE_RATE / 4; i++) {
|
||||
const v = 0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
|
||||
input[0]![i] = v;
|
||||
input[1]![i] = v;
|
||||
}
|
||||
g.process(input, output, blockCtx());
|
||||
let lE = 0;
|
||||
let rE = 0;
|
||||
for (let i = SAMPLE_RATE / 8; i < SAMPLE_RATE / 4; i++) {
|
||||
lE += output[0]![i]! * output[0]![i]!;
|
||||
rE += output[1]![i]! * output[1]![i]!;
|
||||
}
|
||||
expect(lE).toBeGreaterThan(0);
|
||||
expect(rE).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
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 {
|
||||
GLaDOSConfig,
|
||||
GLaDOSPluginOptions,
|
||||
GLaDOSPreset,
|
||||
GLaDOSPresetData,
|
||||
} from "../glados.types.js";
|
||||
import {
|
||||
GLaDOSPluginOptionsSchema,
|
||||
GLaDOSPresetDataSchema,
|
||||
} from "../schemas/index.js";
|
||||
|
||||
const TWO_PI = 2 * Math.PI;
|
||||
const REVERB_DELAY_MS = 30;
|
||||
|
||||
const DEFAULT_CONFIG: GLaDOSConfig = {
|
||||
pitchShift: -2,
|
||||
formantShift: 1,
|
||||
roboticIntensity: 0.6,
|
||||
distortionAmount: 0.2,
|
||||
lowPassCutoff: 6_000,
|
||||
reverbAmount: 0.2,
|
||||
};
|
||||
|
||||
const PRESETS: Readonly<Record<GLaDOSPreset, GLaDOSConfig>> = {
|
||||
"glados-mainline": {
|
||||
pitchShift: -2,
|
||||
formantShift: 1,
|
||||
roboticIntensity: 0.6,
|
||||
distortionAmount: 0.2,
|
||||
lowPassCutoff: 6_000,
|
||||
reverbAmount: 0.2,
|
||||
},
|
||||
"glados-test-chamber": {
|
||||
pitchShift: -2,
|
||||
formantShift: 1,
|
||||
roboticIntensity: 0.7,
|
||||
distortionAmount: 0.15,
|
||||
lowPassCutoff: 5_500,
|
||||
reverbAmount: 0.6,
|
||||
},
|
||||
"glados-cake-deception": {
|
||||
pitchShift: -3,
|
||||
formantShift: 2,
|
||||
roboticIntensity: 0.8,
|
||||
distortionAmount: 0.35,
|
||||
lowPassCutoff: 4_500,
|
||||
reverbAmount: 0.3,
|
||||
},
|
||||
wheatley: {
|
||||
pitchShift: 3,
|
||||
formantShift: -1,
|
||||
roboticIntensity: 0.3,
|
||||
distortionAmount: 0.05,
|
||||
lowPassCutoff: 7_500,
|
||||
reverbAmount: 0.1,
|
||||
},
|
||||
"cave-johnson": {
|
||||
pitchShift: -5,
|
||||
formantShift: -2,
|
||||
roboticIntensity: 0.1,
|
||||
distortionAmount: 0.4,
|
||||
lowPassCutoff: 3_500,
|
||||
reverbAmount: 0.25,
|
||||
},
|
||||
"robotic-generic": {
|
||||
pitchShift: 0,
|
||||
formantShift: 0,
|
||||
roboticIntensity: 1,
|
||||
distortionAmount: 0.5,
|
||||
lowPassCutoff: 5_000,
|
||||
reverbAmount: 0.15,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Voice-effect insert plugin. Runs the input through a fixed signal
|
||||
* chain of pitch shift (granular), formant tilt, robotic-intensity
|
||||
* tremolo, soft-clip distortion, low-pass filter, and short feedback
|
||||
* reverb. Six built-in presets cover the GLaDOS family plus the
|
||||
* generic "robotic" voice; per-stage setters override preset values.
|
||||
*/
|
||||
export class GLaDOSPlugin extends AbstractAudioFxPlugin {
|
||||
private gladosConfig: GLaDOSConfig;
|
||||
private currentPreset: GLaDOSPreset;
|
||||
private lpY: Float32Array = new Float32Array(0);
|
||||
private reverbBuf: Float32Array = new Float32Array(0);
|
||||
private reverbWrite = 0;
|
||||
private tremoloPhase = 0;
|
||||
private pitchRing: Float32Array = new Float32Array(0);
|
||||
private pitchWrite = 0;
|
||||
private pitchRead = 0;
|
||||
|
||||
public constructor(options: GLaDOSPluginOptions = {}) {
|
||||
const parsed = GLaDOSPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.glados-invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const opts = parsed.data;
|
||||
const preset = opts.preset ?? "glados-mainline";
|
||||
super({
|
||||
metadata: {
|
||||
id: "glados",
|
||||
name: "GLaDOS",
|
||||
version: "1.0.0",
|
||||
category: "voice",
|
||||
},
|
||||
schema: GLaDOSPresetDataSchema,
|
||||
io: {
|
||||
inputs: [{ count: "preserve" }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
},
|
||||
parameters: [],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
this.currentPreset = preset;
|
||||
const base =
|
||||
opts.preset !== undefined ? PRESETS[preset] : DEFAULT_CONFIG;
|
||||
this.gladosConfig = mergeConfig(
|
||||
base,
|
||||
(opts.config ?? {}) as Partial<GLaDOSConfig>
|
||||
);
|
||||
}
|
||||
|
||||
public applyGladosPreset(preset: GLaDOSPreset): void {
|
||||
this.currentPreset = preset;
|
||||
this.gladosConfig = { ...PRESETS[preset] };
|
||||
}
|
||||
|
||||
public getGladosConfig(): GLaDOSConfig {
|
||||
return this.gladosConfig;
|
||||
}
|
||||
|
||||
public getCurrentPreset(): GLaDOSPreset {
|
||||
return this.currentPreset;
|
||||
}
|
||||
|
||||
public setPitchShift(v: number): void {
|
||||
this.assertRange("pitchShift", v, -12, 12);
|
||||
this.gladosConfig = { ...this.gladosConfig, pitchShift: v };
|
||||
}
|
||||
public setFormantShift(v: number): void {
|
||||
this.assertRange("formantShift", v, -12, 12);
|
||||
this.gladosConfig = { ...this.gladosConfig, formantShift: v };
|
||||
}
|
||||
public setRoboticIntensity(v: number): void {
|
||||
this.assertRange("roboticIntensity", v, 0, 1);
|
||||
this.gladosConfig = { ...this.gladosConfig, roboticIntensity: v };
|
||||
}
|
||||
public setDistortionAmount(v: number): void {
|
||||
this.assertRange("distortionAmount", v, 0, 1);
|
||||
this.gladosConfig = { ...this.gladosConfig, distortionAmount: v };
|
||||
}
|
||||
public setLowPassCutoff(v: number): void {
|
||||
this.assertRange("lowPassCutoff", v, 20, 20_000);
|
||||
this.gladosConfig = { ...this.gladosConfig, lowPassCutoff: v };
|
||||
}
|
||||
public setReverbAmount(v: number): void {
|
||||
this.assertRange("reverbAmount", v, 0, 1);
|
||||
this.gladosConfig = { ...this.gladosConfig, reverbAmount: v };
|
||||
}
|
||||
|
||||
protected override setupState(
|
||||
config: ProcessorConfig,
|
||||
_chain: ChainContext
|
||||
): void {
|
||||
this.lpY = new Float32Array(config.channels);
|
||||
const reverbSamples =
|
||||
Math.ceil((REVERB_DELAY_MS / 1000) * config.sampleRate) *
|
||||
config.channels;
|
||||
this.reverbBuf = new Float32Array(reverbSamples);
|
||||
this.reverbWrite = 0;
|
||||
this.tremoloPhase = 0;
|
||||
const pitchSamples = config.sampleRate * 0.05 * config.channels;
|
||||
this.pitchRing = new Float32Array(Math.ceil(pitchSamples));
|
||||
this.pitchWrite = 0;
|
||||
this.pitchRead = 0;
|
||||
}
|
||||
|
||||
protected override resetState(): void {
|
||||
this.lpY.fill(0);
|
||||
this.reverbBuf.fill(0);
|
||||
this.reverbWrite = 0;
|
||||
this.tremoloPhase = 0;
|
||||
this.pitchRing.fill(0);
|
||||
this.pitchWrite = 0;
|
||||
this.pitchRead = 0;
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
ctx: BlockContext
|
||||
): void {
|
||||
const cfg = this.gladosConfig;
|
||||
const sr = ctx.sampleRate;
|
||||
const blockSize = input[0]!.length;
|
||||
const channels = input.length;
|
||||
const pitchRatio = 2 ** (cfg.pitchShift / 12);
|
||||
const formantTilt = 10 ** (cfg.formantShift / 8);
|
||||
const tremoloInc = (TWO_PI * 30) / sr;
|
||||
const tremoloDepth = cfg.roboticIntensity;
|
||||
const distDrive = 1 + cfg.distortionAmount * 6;
|
||||
const distMix = cfg.distortionAmount;
|
||||
const alpha = onePoleAlpha(cfg.lowPassCutoff, sr);
|
||||
const reverbFb = cfg.reverbAmount * 0.6;
|
||||
const reverbWet = cfg.reverbAmount;
|
||||
const reverbSize = this.reverbBuf.length / channels;
|
||||
const pitchSize = this.pitchRing.length / channels;
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
const tremolo =
|
||||
1 -
|
||||
tremoloDepth *
|
||||
0.5 *
|
||||
(1 - Math.sin(this.tremoloPhase + tremoloInc * i));
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
// Pitch-shift via ring buffer + fractional read.
|
||||
const ringIdxBase = ch * pitchSize;
|
||||
this.pitchRing[ringIdxBase + (this.pitchWrite % pitchSize)] =
|
||||
input[ch]![i]!;
|
||||
const readPos = this.pitchRead;
|
||||
const readBase =
|
||||
((readPos % pitchSize) + pitchSize) % pitchSize;
|
||||
const i0 = Math.floor(readBase);
|
||||
const i1 = (i0 + 1) % pitchSize;
|
||||
const frac = readBase - i0;
|
||||
const pitched =
|
||||
this.pitchRing[ringIdxBase + i0]! * (1 - frac) +
|
||||
this.pitchRing[ringIdxBase + i1]! * frac;
|
||||
|
||||
// Formant tilt (output gain).
|
||||
const tilted = pitched * formantTilt;
|
||||
|
||||
// Tremolo (robotic intensity).
|
||||
const robotic = tilted * tremolo;
|
||||
|
||||
// Distortion.
|
||||
const drySat = robotic;
|
||||
const wetSat = Math.tanh(robotic * distDrive);
|
||||
const distorted = drySat * (1 - distMix) + wetSat * distMix;
|
||||
|
||||
// One-pole low-pass.
|
||||
this.lpY[ch] =
|
||||
this.lpY[ch]! + alpha * (distorted - this.lpY[ch]!);
|
||||
const filtered = this.lpY[ch]!;
|
||||
|
||||
// Short feedback reverb.
|
||||
const reverbBase = ch * reverbSize;
|
||||
const reverbIdx = this.reverbWrite % reverbSize;
|
||||
const tail = this.reverbBuf[reverbBase + reverbIdx]!;
|
||||
this.reverbBuf[reverbBase + reverbIdx] =
|
||||
filtered + tail * reverbFb;
|
||||
output[ch]![i] = filtered + tail * reverbWet;
|
||||
}
|
||||
this.pitchWrite = (this.pitchWrite + 1) % pitchSize;
|
||||
this.pitchRead += pitchRatio;
|
||||
this.reverbWrite = (this.reverbWrite + 1) % reverbSize;
|
||||
}
|
||||
this.tremoloPhase =
|
||||
(this.tremoloPhase + tremoloInc * blockSize) % TWO_PI;
|
||||
}
|
||||
|
||||
protected override getPresetData(): GLaDOSPresetData {
|
||||
return { preset: this.currentPreset, config: this.gladosConfig };
|
||||
}
|
||||
|
||||
protected override applyPresetData(data: unknown): void {
|
||||
const { preset, config } = data as GLaDOSPresetData;
|
||||
this.currentPreset = preset;
|
||||
this.gladosConfig = config;
|
||||
}
|
||||
|
||||
private assertRange(
|
||||
field: string,
|
||||
value: number,
|
||||
lo: number,
|
||||
hi: number
|
||||
): void {
|
||||
if (!Number.isFinite(value) || value < lo || value > hi) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
`audio-fx.glados-invalid-${field}`,
|
||||
{ context: { field, value, min: lo, max: hi } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeConfig(
|
||||
base: GLaDOSConfig,
|
||||
overlay: Partial<GLaDOSConfig>
|
||||
): GLaDOSConfig {
|
||||
return { ...base, ...overlay };
|
||||
}
|
||||
|
||||
function onePoleAlpha(cutoffHz: number, sr: number): number {
|
||||
const omega = (TWO_PI * cutoffHz) / sr;
|
||||
return omega / (omega + 1);
|
||||
}
|
||||
|
||||
/** Public read-only view of the built-in presets. */
|
||||
export function getGLaDOSPresets(): Readonly<
|
||||
Record<GLaDOSPreset, GLaDOSConfig>
|
||||
> {
|
||||
return PRESETS;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
GLaDOSPlugin,
|
||||
getGLaDOSPresets,
|
||||
} from "./glados.js";
|
||||
13
packages/media/audio-fx/audio-fx-plugin-glados/src/index.ts
Normal file
13
packages/media/audio-fx/audio-fx-plugin-glados/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { GLaDOSPlugin, getGLaDOSPresets } from "./glados/index.js";
|
||||
export type {
|
||||
GLaDOSConfig,
|
||||
GLaDOSPluginOptions,
|
||||
GLaDOSPreset,
|
||||
GLaDOSPresetData,
|
||||
} from "./glados.types.js";
|
||||
export {
|
||||
GLaDOSConfigSchema,
|
||||
GLaDOSPluginOptionsSchema,
|
||||
GLaDOSPresetDataSchema,
|
||||
GLaDOSPresetSchema,
|
||||
} from "./schemas/index.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
GLaDOSConfigSchema,
|
||||
GLaDOSPluginOptionsSchema,
|
||||
GLaDOSPresetDataSchema,
|
||||
GLaDOSPresetSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
# `glados/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options, stage configuration, preset enum, and preset payload of `GLaDOSPlugin`. These run on the public boundary so any malformed object 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
|
||||
|
||||
- `GLaDOSPresetSchema` — `z.enum([...six preset names])` reused in options + preset payload.
|
||||
- `GLaDOSConfigSchema` — validates the six stage values with their documented ranges.
|
||||
- `GLaDOSPluginOptionsSchema` — `preset?: enum, config?: Partial<GLaDOSConfig>` (via `.partial()`).
|
||||
- `GLaDOSPresetDataSchema` — `.strict()` object carrying the preset name + full config.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GLaDOSConfigSchema } from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
const parsed = GLaDOSConfigSchema.partial().parse({
|
||||
pitchShift: -3,
|
||||
roboticIntensity: 0.6,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
GLaDOSPresetDataSchema,
|
||||
GLaDOSPresetSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
// Render a dropdown of presets from the schema's options.
|
||||
const names = GLaDOSPresetSchema.options;
|
||||
for (const name of names) ui.addOption(name);
|
||||
|
||||
// Strict preset validation at the IPC boundary.
|
||||
function isGladosPreset(blob: unknown): boolean {
|
||||
return GLaDOSPresetDataSchema.safeParse(blob).success;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GLaDOSPresetSchema: z.ZodEnum<[...six]>`
|
||||
- `GLaDOSConfigSchema: z.ZodObject<{ pitchShift, formantShift, roboticIntensity, distortionAmount, lowPassCutoff, reverbAmount }>`
|
||||
- `GLaDOSPluginOptionsSchema: z.ZodObject<{ preset?, config? }>`
|
||||
- `GLaDOSPresetDataSchema: z.ZodObject<{ preset, config }, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`GLaDOSPresetSchema` is exported as a named schema (not inlined) so the same value is reused by `GLaDOSPluginOptionsSchema.preset` and `GLaDOSPresetDataSchema.preset`. That guarantees both validation paths agree byte-for-byte on the legal preset set — adding a seventh preset is a one-line change in `schemas.ts`.
|
||||
|
||||
`GLaDOSPresetDataSchema` is `.strict()` so unknown fields are rejected — matching the sibling-plugin policy. The preset payload carries both the preset name and the live config; storing only the name would lose per-stage overrides made after the preset was originally applied. The schema validates the full config so a forwards-incompatible payload is caught at the boundary.
|
||||
@@ -0,0 +1,28 @@
|
||||
import z from "zod";
|
||||
|
||||
export const GLaDOSPresetSchema = z.enum([
|
||||
"glados-mainline",
|
||||
"glados-test-chamber",
|
||||
"glados-cake-deception",
|
||||
"wheatley",
|
||||
"cave-johnson",
|
||||
"robotic-generic",
|
||||
]);
|
||||
|
||||
export const GLaDOSConfigSchema = z.object({
|
||||
pitchShift: z.number().finite().min(-12).max(12),
|
||||
formantShift: z.number().finite().min(-12).max(12),
|
||||
roboticIntensity: z.number().finite().min(0).max(1),
|
||||
distortionAmount: z.number().finite().min(0).max(1),
|
||||
lowPassCutoff: z.number().finite().min(20).max(20_000),
|
||||
reverbAmount: z.number().finite().min(0).max(1),
|
||||
});
|
||||
|
||||
export const GLaDOSPluginOptionsSchema = z.object({
|
||||
preset: GLaDOSPresetSchema.optional(),
|
||||
config: GLaDOSConfigSchema.partial().optional(),
|
||||
});
|
||||
|
||||
export const GLaDOSPresetDataSchema = z
|
||||
.object({ preset: GLaDOSPresetSchema, config: GLaDOSConfigSchema })
|
||||
.strict();
|
||||
Reference in New Issue
Block a user