Files
feuerwerk/tests/editor/modulation-view.test.ts
T
adminandClaude Opus 5.5 b145e1f288
game-maker / gate (push) Successful in 4m8s
game-maker / suite (push) Failing after 16m2s
Draw the Flanger's comb as a thin line with a fading trail (#398)
The comb was still drawn too heavy. Each frame showed the whole band
the comb had swept over the last 1/20 s, edged by two lines 1.5 wide,
and the faster the delay moved, the thicker that band got.

- flangerLine draws the comb where the light is as one line, 1 wide.
  It runs through the ends of each column and the peaks and notches
  in it, where they are. A comb finer than a column zigzags over it.
- Behind the line a faint trail shows what the comb swept over the
  last 1/20 s, and over the last 1/40 s once more, so it fades with
  age. A Flanger that stands still has none.
- The line fades out where its teeth move more than a quarter of a
  tooth within the trail (clearAt), and is gone from half a tooth on.
  There only the calm band of the trail is left, so nothing flickers.
- responseFrame gives the screen the line, how clear each point is,
  the sharp columns for the notches and the trail. It replaces
  responseColumnsOf, and RESPONSE_BLUR is now RESPONSE_TRAIL.
- The notches are counted on the sharp comb again, not on the band.
- The Phaser's response is drawn as the same thin line, without a
  trail.

Tests:
  Unit modulation-view (stein2): 26 tests, 2 new. The line holds the
    comb's dB at each point and every notch. On a fast swing it fades
    out over 8 kHz, stays clear under 1 kHz, and the trail is the same
    band over 16 kHz in three frames. A still Flanger has no trail.
  Counter-check: with clearAt always 1, the fade test fails.
  e2e 34 (atx): ALL PASSED, 142 checks.
  Gate (atx): CHECK EXIT 0, 291 files, 4471 tests.
  Pictures of the default Flanger, a fast one (2 Hz, 5 ms) and one
    sweeping to 20 ms, and of the Phaser: a thin line, the trail
    behind it, a calm band high up.

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

354 lines
15 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 {
clearAt,
FLANGER_DEVICE,
flangerColumns,
flangerDb,
flangerDelayAt,
flangerLine,
hzAt,
idleModKeys,
lampOf,
lfoSpan,
loopAt,
MOD_DEVICES,
MOD_LAYOUTS,
type ModDevice,
modTexts,
modValues,
notchesOf,
PHASER_DEVICE,
phaserDb,
RESPONSE_FLOOR,
responseColumns,
responseFrame,
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('draws the comb as a thin line through its ends, peaks and notches', () => {
const values = modValues(FLANGER_DEVICE, { feedback: 0.5 });
const line = flangerLine(values, 2, 640);
for (const point of line) expect(point.db).toBeCloseTo(flangerDb(values, 2, hzAt(point.share)), 9);
for (let i = 1; i < line.length; i++) expect(line[i]!.share).toBeGreaterThan(line[i - 1]!.share);
// Every notch under the columns' width is on the line, as deep as it goes.
const notch = flangerDb(values, 1, 500);
for (const k of [1, 3, 21])
expect(
line.some((p) => Math.abs(p.share - shareOfHz(k * 250)) < 1e-9 && Math.abs(p.db - notch) < 1e-9),
).toBe(true);
});
it('fades the line where its teeth pass faster than frames, the trail behind it a calm band', () => {
// The default swings 1 to 3 ms every 4 s, at 0.9 s about its fastest: high up, the trail 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) => responseFrame(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 frame of frames) {
expect(frame.trail).toHaveLength(2);
for (const column of frame.trail[0]!.slice(high)) {
expect(column.high).toBeCloseTo(peak, 6);
expect(column.low).toBeCloseTo(notch, 6);
}
frame.line.forEach((point, i) => {
if (point.share >= shareOfHz(8000)) expect(frame.clear[i]).toBe(0);
if (point.share <= shareOfHz(1000)) expect(frame.clear[i]).toBe(1);
});
}
// Below the first notch it stays a sharp line that moves, its trail a sliver.
const low = Math.floor(640 * shareOfHz(100));
for (const frame of frames)
expect(frame.trail[0]![low]!.high - frame.trail[0]![low]!.low).toBeLessThan(0.5);
expect(frames[0]!.columns[low]!.high).not.toBeCloseTo(frames[2]!.columns[low]!.high, 3);
});
it('draws a still Flanger as a clear line without a trail', () => {
const frame = responseFrame(FLANGER_DEVICE, modValues(FLANGER_DEVICE, { rate: 0 }), 0.9, 640);
expect(frame.trail).toEqual([]);
expect(frame.clear.every((c) => c === 1)).toBe(true);
expect(clearAt(20000, 0)).toBe(1);
});
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');
});
});