Files
feuerwerk/tests/editor/modulation-view.test.ts
T
adminandClaude Opus 5.5 b6749cc43d Show Tremolo, Vibrato, Phaser and Flanger as devices that swing (#398)
The four effects that swing were still drawn as rows of numbers. Like
the Delay, the Saturation and the Limiter, each node now shows a device
of a fixed size. Its screen draws what it does over the first seconds
of a sound, and a light plays that in real time, over and over.

- One device for all four (ModulationPanel, ModulationFace and
  modulationView), as KnobPanel is for Gain, Pitch, Mix and Pan. A short
  description gives each its title, plate, buttons and knobs.
- Tremolo, 640x280 in cream: a wave its volume shapes, the volume as
  its outline. It says DOWN TO 50%, EVERY 200 ms and DIPS -6 dB.
- Vibrato, 640x280 in plum: the pitch in semitones, cool below and warm
  above. Under a wet below 1 the dry pitch stands beside it. It says
  how late the voice is (LATE 0.93 ms).
- Phaser, 760x280 in mustard, and Flanger, 680x280 in steel blue: the
  response from 20 Hz to 20 kHz, and under it a lane of the turn or the
  delay over time. The response is drawn where the light is, so the
  notches swirl and the comb jets. A comb finer than a column fills it.
- The light loops over 1, 2, 4 or 8 s, two LFO swings at least, for a
  Flanger as long as its sweep takes to reach 20 ms. The face only
  keeps the editor drawing while something swings and is heard.
- A Rate lamp beside each name goes from dark to lit with the swing.
- Nothing is listened to. The pictures use the worklets' own numbers:
  lfoAt, vibratoSpeedUp (now exported from lfo.ts), the allpass chain
  with its feedback, and the comb, reckoned at 48 kHz.
- Phaser and Flanger take 4 preset columns. Phased Pad was looked at
  in a picture, nothing overlaps.
- Exported from nodes.ts: LFO_SHAPE, lfoShapeOf, TREMOLO_ROWS,
  VIBRATO_ROWS, PHASER_STAGE_ROW and phaserStagesOf. FLANGER_MAX_MS is
  exported from phaser.ts.

Decided here, as the request left the look open:

- A notch counts where the response falls below -3 dB, not some dB
  under the loudest point. Feedback rings peaks far above 0 dB, and the
  first rule counted 7 notches on a Phaser of 12 stages.
- Rate and Depth of Tremolo and Vibrato are big knobs, Wet is small.

Tests:
  Unit modulation-view (stein2): 22 tests, new. Phaser and Flanger
    give the dB of their worklets on a sine within 0.005 dB, the
    Tremolo its samples, the Vibrato the pitch it reads at.
  Counter-check: with the sign of the Phaser's feedback flipped in one
    place, exactly the worklet test fails.
  e2e 34 (atx): ALL PASSED, 142 checks, 4 new. Each device has its
    size, its ports outside, a light that runs by itself, and a button
    and a knob as one undo step each. Picking 8 stages on the Phaser
    draws 4 notches.
  Gate (atx): CHECK EXIT 0, 291 files, 4467 tests.

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

279 lines
11 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,
flangerDb,
flangerDelayAt,
idleModKeys,
lampOf,
lfoSpan,
loopAt,
MOD_DEVICES,
MOD_LAYOUTS,
type ModDevice,
modTexts,
modValues,
notchesOf,
PHASER_DEVICE,
phaserDb,
responseColumns,
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('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');
});
});