/** Tremolo and Vibrato (#320): the LFO shapes and the two classes of the FX worklet. */ import { describe, expect, it } from 'vitest'; import { lfoAt, lfoSwing, type LfoNumbers, Tremolo, Vibrato, vibratoDelay } from '../../src/core/soundfx/lfo'; const RATE = 48000; const numbers = (rate: number, depth: number): LfoNumbers => ({ rate: new Float32Array([rate]), depth: new Float32Array([depth]), }); /** Runs a mono sound through in blocks of 128, as the worklet does. */ function run(effect: Tremolo | Vibrato, sound: Float32Array, set: LfoNumbers): Float32Array { const out = new Float32Array(sound.length); for (let from = 0; from < sound.length; from += 128) { const to = Math.min(from + 128, sound.length); effect.render([sound.subarray(from, to)], [out.subarray(from, to)], set); } return out; } /** Cycles a second between `from` and `to` seconds, counted by upward zero crossings. */ function hz(out: Float32Array, from: number, to: number): number { const crossings: number[] = []; for (let i = Math.round(from * RATE) + 1; i < to * RATE; i++) if (out[i - 1]! < 0 && out[i]! >= 0) crossings.push(i - out[i]! / (out[i]! - out[i - 1]!)); return ((crossings.length - 1) * RATE) / (crossings.at(-1)! - crossings[0]!); } describe('the LFO of a Tremolo or a Vibrato', () => { it('starts every shape at phase 0', () => { expect([0, 0.25, 0.5, 0.75].map((p) => lfoAt('sine', p))).toEqual([0, 1, expect.closeTo(0, 12), -1]); expect([0, 0.25, 0.5, 0.75].map((p) => lfoAt('triangle', p))).toEqual([0, 1, 0, -1]); expect([0, 0.49, 0.5, 0.99].map((p) => lfoAt('square', p))).toEqual([1, 1, -1, -1]); expect([0, 0.25, 0.5, 0.75].map((p) => lfoAt('saw', p))).toEqual([0, 0.5, -1, -0.5]); }); it('swings a delay as far as the LFO summed up goes from its mean', () => { expect(lfoSwing('sine')).toBeCloseTo(1 / (2 * Math.PI), 4); expect(lfoSwing('square')).toBeCloseTo(0.25, 4); expect(lfoSwing('saw')).toBeCloseTo(1 / 6, 4); // 4 semitones at 0.5 Hz with a square: about 130 ms, as the plan said. expect(vibratoDelay('square', 0.5, 4)).toBeCloseTo(0.13, 2); expect(vibratoDelay('sine', 5, 0)).toBe(0); }); }); describe('a Tremolo', () => { it('moves the volume between full and 1 - depth, starting halfway down for a sine', () => { const out = run(new Tremolo('sine', RATE), new Float32Array(RATE).fill(1), numbers(4, 0.6)); expect(out[0]).toBeCloseTo(0.7, 6); expect(Math.max(...out)).toBeCloseTo(1, 4); expect(Math.min(...out)).toBeCloseTo(0.4, 4); // A quarter period in, at the top. expect(out[RATE / 16]).toBeCloseTo(1, 4); }); }); describe('a Vibrato', () => { const sine = (seconds: number) => Float32Array.from({ length: RATE * seconds }, (_, i) => Math.sin((2 * Math.PI * 1000 * i) / RATE)); it('jumps between two pitches with a square, as far up as down', () => { const center = vibratoDelay('square', 5, 1); const out = run(new Vibrato('square', RATE, center), sine(1), numbers(5, 1)); const up = 1000 * 2 ** (1 / 12); // The first half of every period is the upper pitch, the second the lower. expect(hz(out, 0.21, 0.29)).toBeCloseTo(up, 0); expect(hz(out, 0.31, 0.39)).toBeCloseTo(1000 - (up - 1000), 0); }); it('is the sound itself at depth 0, late by its center', () => { const sound = sine(0.1); expect(run(new Vibrato('sine', RATE, 0), sound, numbers(5, 0))).toEqual(sound); const late = run(new Vibrato('sine', RATE, 0.001), sound, numbers(5, 0)); expect(late[100]).toBeCloseTo(sound[52]!, 6); }); });