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>
78 lines
3.5 KiB
TypeScript
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);
|
|
});
|
|
});
|