Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,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.

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,4 @@
export {
GLaDOSPlugin,
getGLaDOSPresets,
} from "./glados.js";

View 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";

View File

@@ -0,0 +1,6 @@
export {
GLaDOSConfigSchema,
GLaDOSPluginOptionsSchema,
GLaDOSPresetDataSchema,
GLaDOSPresetSchema,
} from "./schemas.js";

View File

@@ -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.

View File

@@ -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();