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>
354 lines
15 KiB
TypeScript
354 lines
15 KiB
TypeScript
/**
|
||
* 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');
|
||
});
|
||
});
|