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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 21:13:18 +02:00
1 parent d3a4cb7d0e
commit 071f194326
8 files changed
+542 -9

No files matched your search

+45 -2
View File
@@ -18,7 +18,7 @@ import {
import type { MixBus } from '../../src/core/music/mixer';
import type { SequencePlay } from '../../src/core/music/playbacks';
import type { ScheduledNote } from '../../src/core/music/Scheduler';
import type { PitchOptions } from '../../src/viewport/fxWorklet';
import type { LfoOptions, PitchOptions } from '../../src/viewport/fxWorklet';
import { saturationCurve } from '../../src/core/soundfx/saturation';
import { SoundLibrary, stageNodes } from '../../src/viewport/SoundLibrary';
@@ -341,7 +341,7 @@ beforeEach(() => {
? 'crush'
: name === 'chip-player'
? 'chip'
: name === 'phaser' || name === 'flanger' || name === 'limiter'
: name === 'phaser' || name === 'flanger' || name === 'limiter' || name === 'lfo'
? name
: name === 'pitch-shifter'
? 'shifter'
@@ -2385,4 +2385,47 @@ describe('the stages of #318 to #328', () => {
[0.75, 'value'],
]);
});
it('starts the LFO of a Vibrato with its voice and delays around its center, the voice that much earlier (#320)', async () => {
const library = new SoundLibrary();
const vibrato: FxStagePlan = {
kind: 'vibrato',
shape: 'square',
rate: { value: 0.5 },
depth: { value: 4 },
};
library.playNote(
'instrument:i',
[
{
pitch: [],
stages: [vibrato],
source: {
file: FILE,
volume: 1,
loop: false,
loopStart: 0,
loopEnd: 0,
gate: true,
},
},
],
{},
1,
'',
0,
{ at: 2 },
);
await settle();
await arrive();
const center = ((2 ** (4 / 12) - 1) * 0.25) / 0.5;
const options = worklets[0]!.options.processorOptions as unknown as LfoOptions & { vibrato: boolean };
expect(options).toMatchObject({ shape: 2, startAt: 2 - center, vibrato: true });
expect(options.center).toBeCloseTo(center, 4);
expect(sources[0]!.startedAt).toBeCloseTo(2 - center, 4);
expect([...worklets[0]!.parameters.entries()].map(([name, p]) => [name, p.value])).toEqual([
['rate', 0.5],
['depth', 4],
]);
});
});