Files
feuerwerk/tests/editor/modulation-view.test.ts
T
adminandClaude Opus 5.5 f9a953f3d3
game-maker / gate (push) Successful in 4m24s
game-maker / suite (push) Failing after 16m35s
Keep the Flanger's comb calm and smooth where its teeth crowd (#398)
High up the Flanger's comb flickered and looked pixelated. Each column
of its screen took six readings, and its teeth pass there faster than
frames are drawn, up to 0.4 of a tooth a frame at 15 kHz by default.
Each frame caught other teeth, and each column was drawn as a sharp
rectangle.

- flangerColumns reckons each column exactly. The response only hangs
  on the phase, and between two multiples of pi it runs one way. So a
  column holds its lowest and highest at its ends or on the peaks and
  notches inside it.
- Each frame is blurred over the delays of the last 1/20 s
  (RESPONSE_BLUR), about what an eye blends. Where teeth pass faster
  than that, they become a calm band from the deepest notch to the
  highest peak. Further down, where they can be followed, they stay
  sharp and move.
- The response is drawn as a band between two smooth lines through
  the columns, no longer as one rectangle per column.
- responseColumnsOf picks the Phaser's readings or the Flanger's exact
  columns. responseDb had no caller left and is gone.

Measured before choosing the blur, as the mean change of a column from
one frame to the next on the default Flanger (2-5 / 5-10 / 10-20 kHz):

  readings as before   5.6 / 10.6 / 14.3 dB
  exact, no blur       5.5 / 10.2 / 12.9 dB
  exact, 1/60 s        5.0 /  7.6 /  4.0 dB
  exact, 1/20 s        3.9 /  2.6 /  0.04 dB

Under 5 kHz most of what is left is the teeth really moving.

Tests:
  Unit modulation-view (stein2): 24 tests, 2 new. The exact columns
    hold what 4000 readings a column find there, and a fast swing
    leaves the same band over 16 kHz in three frames in a row.
  Counter-check: without the peaks and notches inside a column, both
    new tests fail.
  e2e 34 (atx): ALL PASSED, 142 checks.
  Gate (atx): CHECK EXIT 0, 291 files, 4469 tests.
  Pictures of the default Flanger and of one sweeping to 20 ms, near
    and far, two of each 40 ms apart: smooth edges, the band the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:15:53 +02:00

324 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The Tremolo, the Vibrato, the Phaser and the Flanger as devices (#398): every part on its plate
* and none over another, how their knobs turn, and that what their screens draw is what their
* worklets do – the volume, the pitch, and what is left of each frequency.
*/
import { describe, expect, it } from 'vitest';
import { Tremolo, Vibrato, vibratoDelay } from '../../src/core/soundfx/lfo';
import { Flanger, Phaser } from '../../src/core/soundfx/phaser';
import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView';
import {
FLANGER_DEVICE,
flangerColumns,
flangerDb,
flangerDelayAt,
idleModKeys,
lampOf,
lfoSpan,
loopAt,
MOD_DEVICES,
MOD_LAYOUTS,
type ModDevice,
modTexts,
modValues,
notchesOf,
PHASER_DEVICE,
phaserDb,
RESPONSE_FLOOR,
responseColumns,
responseColumnsOf,
shareOfHz,
SHOWN_RATE,
swingPicture,
swingValueAt,
TREMOLO_DEVICE,
VIBRATO_DEVICE,
} from '../../src/editor/soundfx/modulationView';
import { knobPosition } from '../../src/ui/knob';
const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
const knob = (device: ModDevice, key: string) => device.knobs.find((k) => k.key === key)!;
const RATE = SHOWN_RATE;
const one = (value: number) => new Float32Array([value]);
/** How loud a sine at `hz` comes out of `render` once it has settled, in dB against what went in. */
function measuredDb(render: (input: Float32Array[], output: Float32Array[]) => void, hz: number): number {
const length = RATE;
const input = Float32Array.from({ length }, (_, i) => Math.sin((2 * Math.PI * hz * i) / RATE));
const output = new Float32Array(length);
for (let at = 0; at < length; at += 128)
render([input.subarray(at, at + 128)], [output.subarray(at, at + 128)]);
const half = length / 2;
const rms = (a: Float32Array) => Math.sqrt(a.subarray(half).reduce((s, v) => s + v * v, 0) / half);
return 20 * Math.log10(rms(output) / rms(input));
}
describe('the plates', () => {
for (const device of MOD_DEVICES) {
const L = MOD_LAYOUTS[device.kind];
it(`keeps the ${device.title.toLowerCase()}'s screen, buttons and knobs on the plate, clear of each other and the screws`, () => {
const screws = L.screws.map((s) => ({ x: s.x - 7, y: s.y - 7, w: 14, h: 14 }));
const lamp = { x: L.lamp.x - L.lamp.r, y: L.lamp.y - L.lamp.r, w: 2 * L.lamp.r, h: 2 * L.lamp.r };
const parts: Box[] = [
L.bezel,
...(L.pickGroup ? [L.pickGroup] : []),
...L.picks,
...L.groups.map((g) => g.box),
...device.knobs.map((k) => knobBox(L.knobs[k.key]!)),
lamp,
L.title,
];
for (const part of parts) {
expect(part.x >= 0 && part.y >= 0).toBe(true);
expect(part.x + part.w <= L.panel.w && part.y + part.h <= L.panel.h).toBe(true);
for (const screw of screws) expect(overlap(part, screw)).toBe(false);
}
for (let i = 0; i < parts.length; i++)
for (let j = i + 1; j < parts.length; j++) expect(overlap(parts[i]!, parts[j]!)).toBe(false);
});
}
it('has a button for every option it picks from', () => {
expect(MOD_LAYOUTS.tremolo.picks).toHaveLength(4);
expect(MOD_LAYOUTS.phaser.picks).toHaveLength(5);
expect(MOD_LAYOUTS.flanger.picks).toHaveLength(0);
expect(PHASER_DEVICE.pick!.options.map((o) => o.caption)).toEqual(['2', '4', '6', '8', '12']);
expect(TREMOLO_DEVICE.pick!.options.map((o) => o.caption)).toEqual(['SINE', 'TRIANGLE', 'SQUARE', 'SAW']);
});
});
describe('the knobs', () => {
const position = (device: ModDevice, key: string, value: number) => {
const k = knob(device, key);
return knobPosition(value, { min: k.row.min!, max: k.row.max!, step: k.row.step!, ...k });
};
it('gives the slow end of a rate and the low end of a frequency most of the turn, and turns a sweep round 0', () => {
expect(position(TREMOLO_DEVICE, 'rate', 5)).toBeCloseTo(0.5);
expect(position(PHASER_DEVICE, 'frequency', 800)).toBeCloseTo(0.44, 2);
expect(position(FLANGER_DEVICE, 'sweep', 0)).toBeCloseTo(0.5);
});
it('names what each number is', () => {
expect(knob(TREMOLO_DEVICE, 'rate').format(5)).toBe('5 Hz');
expect(knob(FLANGER_DEVICE, 'rate').format(0.25)).toBe('0.25 Hz');
expect(knob(TREMOLO_DEVICE, 'depth').format(50)).toBe('50%');
expect(knob(VIBRATO_DEVICE, 'depth').format(0.5)).toBe('0.5 st');
expect(knob(PHASER_DEVICE, 'frequency').format(1200)).toBe('1.2 kHz');
expect(knob(PHASER_DEVICE, 'depth').format(2)).toBe('2 oct');
expect(knob(FLANGER_DEVICE, 'sweep').format(5)).toBe('+5 ms/s');
expect(knob(FLANGER_DEVICE, 'sweep').format(-2.5)).toBe('-2.5 ms/s');
expect(knob(FLANGER_DEVICE, 'offset').format(1)).toBe('1 ms');
});
it('greys all but the wet at wet 0, all but the depth of a Tremolo at depth 0, the rate of a Phaser without depth', () => {
const idle = (device: ModDevice, params: Record<string, number | string>) =>
[...idleModKeys(device, modValues(device, params))].sort();
expect(idle(TREMOLO_DEVICE, {})).toEqual([]);
expect(idle(TREMOLO_DEVICE, { wet: 0 })).toEqual(['depth', 'rate', 'shape']);
expect(idle(TREMOLO_DEVICE, { depth: 0 })).toEqual(['rate', 'shape', 'wet']);
expect(idle(PHASER_DEVICE, { depth: 0 })).toEqual(['rate']);
expect(idle(FLANGER_DEVICE, { mix: 0 })).toEqual(['depth', 'feedback', 'offset', 'rate', 'sweep']);
});
});
describe('the light', () => {
it('spans two swings at least, in whole seconds of 1, 2, 4 or 8', () => {
expect(lfoSpan(5)).toBe(1);
expect(lfoSpan(0.5)).toBe(4);
expect(lfoSpan(0.25)).toBe(8);
expect(lfoSpan(0.1)).toBe(8);
expect(lfoSpan(0)).toBe(1);
});
it('spans a Flanger until its sweep has the delay at its longest', () => {
const values = modValues(FLANGER_DEVICE, { offset: 1, sweep: 5, depth: 0 });
expect(swingPicture(FLANGER_DEVICE, values).span).toBe(4);
expect(swingPicture(FLANGER_DEVICE, values).highest).toBe(20);
});
it('runs round the span in real time', () => {
expect(loopAt(4, 9)).toBe(1);
expect(loopAt(4, -1)).toBe(3);
});
it('lights the lamp from dark at the bottom of the swing to full at its top, not at all when nothing moves', () => {
const picture = swingPicture(TREMOLO_DEVICE, modValues(TREMOLO_DEVICE, {}));
expect(lampOf(picture, 0.5)).toBe(0);
expect(lampOf(picture, 1)).toBe(1);
expect(lampOf(swingPicture(TREMOLO_DEVICE, modValues(TREMOLO_DEVICE, { rate: 0 })), 1)).toBe(0);
});
});
describe('the Tremolo screen', () => {
it('draws the volume its worklet lets through', () => {
const values = modValues(TREMOLO_DEVICE, { shape: 'triangle', rate: 3, depth: 80 });
const tremolo = new Tremolo('triangle', RATE);
const input = new Float32Array(RATE).fill(1);
const output = new Float32Array(RATE);
tremolo.render([input], [output], { rate: one(3), depth: one(0.8) });
for (const i of [0, 1000, 8000, 20000, 47000])
expect(output[i]).toBeCloseTo(swingValueAt(TREMOLO_DEVICE, values, i / RATE), 4);
});
it('goes down by the depth as far as the wet lets it, and says so', () => {
const picture = (params: Record<string, number>) =>
swingPicture(TREMOLO_DEVICE, modValues(TREMOLO_DEVICE, params));
expect(picture({}).lowest).toBeCloseTo(0.5);
expect(picture({ wet: 0.5 }).lowest).toBeCloseTo(0.75);
const values = modValues(TREMOLO_DEVICE, {});
expect(modTexts(TREMOLO_DEVICE, values, picture({}))).toEqual({
side: 'DOWN TO 50%',
left: 'EVERY 200 ms',
right: 'DIPS -6 dB',
});
});
});
describe('the Vibrato screen', () => {
it('draws the pitch its worklet reads the sound at', () => {
const params = { shape: 'sine', rate: 4, depth: 2 };
const values = modValues(VIBRATO_DEVICE, params);
const vibrato = new Vibrato('sine', RATE, vibratoDelay('sine', 4, 2));
// A ramp in comes out as where the line is read, so its steps are how fast: the pitch.
const input = Float32Array.from({ length: RATE }, (_, i) => i);
const output = new Float32Array(RATE);
vibrato.render([input], [output], { rate: one(4), depth: one(2) });
for (const i of [6000, 9000, 15000, 30000]) {
const pitch = 12 * Math.log2(output[i + 1]! - output[i]!);
expect(pitch).toBeCloseTo(swingValueAt(VIBRATO_DEVICE, values, i / RATE), 1);
}
});
it('swings the depth up and a little more down, and says how late the voice is', () => {
const values = modValues(VIBRATO_DEVICE, {});
const picture = swingPicture(VIBRATO_DEVICE, values);
expect(picture.highest).toBeCloseTo(0.5);
expect(picture.lowest).toBeCloseTo(12 * Math.log2(2 - 2 ** (0.5 / 12)));
expect(picture.high).toBe(1);
expect(modTexts(VIBRATO_DEVICE, values, picture)).toEqual({
side: 'UP 0.5 st DOWN 0.51 st',
left: 'EVERY 200 ms',
right: 'LATE 0.93 ms',
});
});
});
describe('the Phaser screen', () => {
it('draws what its worklet leaves of a sine', () => {
const values = modValues(PHASER_DEVICE, { rate: 0, frequency: 800, feedback: 0.6 });
const numbers = {
frequency: one(800),
rate: one(0),
depth: one(2),
feedback: one(0.6),
mix: one(1),
};
for (const hz of [150, 500, 2000, 6000]) {
const phaser = new Phaser(RATE, 4);
const db = measuredDb((input, output) => phaser.render(input, output, numbers), hz);
expect(db).toBeCloseTo(phaserDb(values, 800, hz), 2);
}
});
it('cuts a notch for every two stages', () => {
for (const stages of ['2', '4', '6', '8']) {
const values = modValues(PHASER_DEVICE, { stages });
const columns = responseColumns((hz) => phaserDb(values, 800, hz), 300);
expect(notchesOf(columns)).toHaveLength(Number(stages) / 2);
}
});
it('says how many notches, where they turn and how often', () => {
const values = modValues(PHASER_DEVICE, {});
expect(modTexts(PHASER_DEVICE, values, swingPicture(PHASER_DEVICE, values))).toEqual({
side: '2 NOTCHES',
left: 'TURNS 400 Hz TO 1.6 kHz',
right: 'EVERY 2 s',
});
});
});
describe('the Flanger screen', () => {
it('draws what its worklet leaves of a sine: a notch at 500 Hz for 1 ms, a peak at 1 kHz', () => {
const values = modValues(FLANGER_DEVICE, { offset: 1, depth: 0, feedback: 0.5 });
const numbers = {
offset: one(1),
sweep: one(0),
rate: one(0),
depth: one(0),
feedback: one(0.5),
mix: one(1),
};
for (const hz of [300, 500, 1000, 1700]) {
const flanger = new Flanger(RATE);
const db = measuredDb((input, output) => flanger.render(input, output, numbers), hz);
expect(db).toBeCloseTo(flangerDb(values, 1, hz), 2);
}
expect(flangerDb(values, 1, 1000)).toBeCloseTo(20 * Math.log10(1.5));
expect(flangerDb(values, 1, 500)).toBeLessThan(-15);
});
it('holds in each column what a fine reading of the comb finds there', () => {
// A notch without feedback goes down to nothing, where no reading lands: compare what is shown.
const shown = (db: number) => Math.max(RESPONSE_FLOOR, db);
for (const [feedback, mix] of [
[0, 1],
[0.5, 1],
[0.9, 0.4],
] as const) {
const values = modValues(FLANGER_DEVICE, { feedback, mix });
for (const ms of [0.3, 3, 12]) {
const columns = flangerColumns(values, ms, ms, 200);
const fine = responseColumns((hz) => flangerDb(values, ms, hz), 200, 4000);
columns.forEach((column, c) => {
expect(column.low).toBeLessThanOrEqual(fine[c]!.low + 1e-9);
expect(column.high).toBeGreaterThanOrEqual(fine[c]!.high - 1e-9);
expect(shown(column.low)).toBeCloseTo(shown(fine[c]!.low), 1);
expect(column.high).toBeCloseTo(fine[c]!.high, 1);
});
}
}
});
it('blurs the teeth that pass faster than frames into the same band, so they do not flicker', () => {
// The default swings 1 to 3 ms every 4 s, at 0.9 s about its fastest: high up, the time of a
// blur lets more than a tooth pass, low down a sliver of one.
const values = modValues(FLANGER_DEVICE, { feedback: 0.5 });
const frames = [0, 1, 2].map((k) => responseColumnsOf(FLANGER_DEVICE, values, 0.9 + k / 60, 640));
const peak = 20 * Math.log10(1.5);
const notch = flangerDb(values, 1, 500);
const high = Math.ceil(640 * shareOfHz(16000));
for (const columns of frames)
for (const column of columns.slice(high)) {
expect(column.high).toBeCloseTo(peak, 6);
expect(column.low).toBeCloseTo(notch, 6);
}
// Below the first notch it stays a sharp line that moves.
const low = Math.floor(640 * shareOfHz(100));
for (const columns of frames) expect(columns[low]!.high - columns[low]!.low).toBeLessThan(0.5);
expect(frames[0]![low]!.high).not.toBeCloseTo(frames[2]![low]!.high, 3);
});
it('sweeps the delay down through 0 and up again, the LFO on top', () => {
const values = modValues(FLANGER_DEVICE, { offset: 1, sweep: -2, depth: 0 });
expect(flangerDelayAt(values, 0.5)).toBeCloseTo(0);
expect(flangerDelayAt(values, 1)).toBeCloseTo(1);
const swinging = modValues(FLANGER_DEVICE, { offset: 1, rate: 0.25, depth: 2 });
expect(flangerDelayAt(swinging, 2)).toBeCloseTo(3);
});
it('says how late the copy gets, its LFO and its sweep, or that it is dry', () => {
const values = modValues(FLANGER_DEVICE, {});
expect(modTexts(FLANGER_DEVICE, values, swingPicture(FLANGER_DEVICE, values))).toEqual({
side: 'DELAY 1 ms TO 3 ms',
left: 'LFO EVERY 4 s',
right: 'NO SWEEP',
});
const dry = modValues(FLANGER_DEVICE, { mix: 0 });
expect(modTexts(FLANGER_DEVICE, dry, swingPicture(FLANGER_DEVICE, dry)).side).toBe('DRY');
});
});