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>
This commit is contained in:
1 parent
28909f0adf
commit
b6749cc43d
12 files changed
+1860
-11
No files matched your search
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user