Files
feuerwerk/tests/core/lfo.test.ts
T
adminandClaude Opus 5.5 071f194326 Swing the volume or the pitch with Tremolo and Vibrato (#320)
Alarms, sirens and wobbly retro sounds need a volume or a pitch that
swings. Two new nodes do that, on a voice and on a bus alike.

- Tremolo: Rate (0 to 20 Hz), Depth (0 to 100 %), Shape (Sine,
  Triangle, Square, Saw). The volume swings between full and
  1 - depth.
- Vibrato: Rate (0.5 to 20 Hz), Depth (0 to 4 semitones), Shape. The
  shape is the curve of the pitch, a Square jumps between two pitches.
- As the issue decided: two nodes, phase 0 on the first sample of the
  voice, no fade-in time. Both run on the FX worklet, the classes are
  in lfo.ts and tested in Node.
- Deviation from the issue text, as in the plan: the Vibrato is a delay
  that swings, not playbackRate. A bus has no playbackRate, and the
  sound keeps its length.
- The delay swings around a center taken from the start values. The
  center counts as the latency of the node, so a voice starts that
  much earlier and a Mix and a bus make up for it.
- Known limit, in the node text and the wiki: a depth turned up or a
  rate turned down after the start asks for a delay below 0. The lower
  half of the swing then flattens.

Finding: the first e2e run was red at the edge of a window. The phase
is summed up, so the square flips one sample later than 2400 steps of
10/48000 suggest. The check keeps 10 samples away from the edges.

Tests:
- Unit lfo (new) 5, soundfx 53 (2 new), sound-library 102 (1 new),
  green.
- Counter-checks, each turns one test red: the swing turned over (up
  and down swapped), the start time not passed to the worklet.
- e2e 34 (melektaus-atx): ALL PASSED, 80 checks (1 new). Chrome
  offline: Tremolo square silent before its voice, then 0.5 and 0 for
  50 ms each. Vibrato square at 5 Hz, 1 semitone: 1059.5 Hz and
  940.5 Hz.
- The gate runs once on the last commit of this series.

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

78 lines
3.5 KiB
TypeScript

/** 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);
});
});