/** * 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) => [...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) => 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'); }); });