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

+157
View File
@@ -0,0 +1,157 @@
/**
* Tremolo and Vibrato (#320): an LFO that starts at phase 0 with its voice and moves the volume, or
* the pitch. The Vibrato moves the pitch through a delay line whose time swings: a delay that gets
* shorter plays faster, so higher. That works on a bus as well, where there is no source to speed
* up, and the sound keeps its length. Both run on the FX worklet and are plain classes, so they are
* tested in Node.
*/
export type LfoShape = 'sine' | 'triangle' | 'square' | 'saw';
export const LFO_SHAPES: { value: LfoShape; label: string }[] = [
{ value: 'sine', label: 'Sine' },
{ value: 'triangle', label: 'Triangle' },
{ value: 'square', label: 'Square' },
{ value: 'saw', label: 'Saw' },
];
/** The four shapes by a number, as the worklet takes them. */
export const LFO_SHAPE_INDEX: Record<LfoShape, number> = { sine: 0, triangle: 1, square: 2, saw: 3 };
/** An LFO at `phase` (0 to 1, one period): -1 to 1, each shape starting at 0 or rising from it. */
export function lfoAt(shape: LfoShape, phase: number): number {
const p = phase - Math.floor(phase);
switch (shape) {
case 'sine':
return Math.sin(2 * Math.PI * p);
case 'triangle':
return p < 0.25 ? 4 * p : p < 0.75 ? 2 - 4 * p : 4 * p - 4;
case 'square':
return p < 0.5 ? 1 : -1;
case 'saw':
return p < 0.5 ? 2 * p : 2 * p - 2;
}
}
/** Steps of one period the swing of a Vibrato's delay is tabled in. */
const STEPS = 4096;
/**
* How the delay of a Vibrato swings over one period, in periods: the LFO summed up, less its mean,
* so it swings around 0. Its slope is the LFO turned over, which is what moves the pitch.
*/
const swings = new Map<LfoShape, Float64Array>();
function swingTable(shape: LfoShape): Float64Array {
let table = swings.get(shape);
if (table) return table;
table = new Float64Array(STEPS + 1);
let sum = 0;
for (let i = 0; i <= STEPS; i++) {
table[i] = sum;
sum += (lfoAt(shape, (i + 0.5) / STEPS) / STEPS) * -1;
}
const mean = table.subarray(0, STEPS).reduce((all, v) => all + v, 0) / STEPS;
for (let i = 0; i <= STEPS; i++) table[i]! -= mean;
swings.set(shape, table);
return table;
}
/** The swing at `phase`, read between the steps of its table. */
function swingAt(shape: LfoShape, phase: number): number {
const table = swingTable(shape);
const at = (phase - Math.floor(phase)) * STEPS;
const i = Math.floor(at);
return table[i]! + (table[i + 1]! - table[i]!) * (at - i);
}
/** The furthest the swing of a shape goes from 0, in periods. */
export function lfoSwing(shape: LfoShape): number {
return swingTable(shape).reduce((most, v) => Math.max(most, Math.abs(v)), 0);
}
/** How much faster the sound is read at the top of a Vibrato `depth` semitones deep. */
const speedUp = (depth: number) => 2 ** (Math.max(0, depth) / 12) - 1;
/** The longest a Vibrato's delay may get, and so what its line holds. */
export const VIBRATO_MAX_DELAY = 0.5;
/**
* Seconds a Vibrato holds the sound back on average: as far as its delay swings out at the rate and
* depth it starts with, so it never has to go below 0. That is how late the voice is.
*/
export function vibratoDelay(shape: LfoShape, rate: number, depth: number): number {
if (!(rate > 0)) return 0;
return Math.min(VIBRATO_MAX_DELAY / 2, (speedUp(depth) * lfoSwing(shape)) / rate);
}
const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
/** Rate in Hz and depth, as the worklet hands them in: one value, or one per sample. */
export type LfoNumbers = Record<'rate' | 'depth', Float32Array>;
/** A Tremolo: the volume goes from full down to `1 - depth` and back, depth 0 to 1. */
export class Tremolo {
private phase = 0;
constructor(
private readonly shape: LfoShape,
private readonly sampleRate: number,
) {}
render(inputs: readonly Float32Array[], outputs: Float32Array[], numbers: LfoNumbers): void {
const length = outputs[0]?.length ?? 0;
for (let i = 0; i < length; i++) {
const depth = Math.max(0, Math.min(1, at(numbers.depth, i)));
const level = 1 - (depth * (1 - lfoAt(this.shape, this.phase))) / 2;
outputs.forEach((out, c) => {
out[i] = (inputs[c]?.[i] ?? inputs[0]?.[i] ?? 0) * level;
});
this.phase += Math.max(0, at(numbers.rate, i)) / this.sampleRate;
if (this.phase >= 1) this.phase -= Math.floor(this.phase);
}
}
}
/**
* A Vibrato: the sound through a delay line `center` seconds long on average, swinging so the pitch
* moves `depth` semitones up and as far down. The swing is the LFO summed up, so its slope is the
* LFO – a Square jumps between two pitches. A delay that would leave the line is held to it.
*/
export class Vibrato {
private readonly lines: Float32Array[] = [];
private readonly size: number;
private written = 0;
private phase = 0;
constructor(
private readonly shape: LfoShape,
private readonly sampleRate: number,
private readonly center: number,
) {
this.size = Math.ceil(VIBRATO_MAX_DELAY * sampleRate) + 2;
}
render(inputs: readonly Float32Array[], outputs: Float32Array[], numbers: LfoNumbers): void {
const length = outputs[0]?.length ?? 0;
while (this.lines.length < outputs.length) this.lines.push(new Float32Array(this.size));
for (let i = 0; i < length; i++) {
const rate = Math.max(1e-3, at(numbers.rate, i));
const swing = (speedUp(at(numbers.depth, i)) / rate) * swingAt(this.shape, this.phase);
const delay = Math.max(0, Math.min(VIBRATO_MAX_DELAY, this.center + swing)) * this.sampleRate;
const read = this.written - delay;
const from = Math.floor(read);
const share = read - from;
outputs.forEach((out, c) => {
const line = this.lines[c]!;
line[this.written % this.size] = inputs[c]?.[i] ?? inputs[0]?.[i] ?? 0;
const a = line[((from % this.size) + this.size) % this.size]!;
const b = line[(((from + 1) % this.size) + this.size) % this.size]!;
out[i] = a + (b - a) * share;
});
this.written += 1;
this.phase += rate / this.sampleRate;
if (this.phase >= 1) this.phase -= Math.floor(this.phase);
}
}
}
+88 -4
View File
@@ -6,6 +6,7 @@ import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time';
import type { FlatDef } from '../properties/PropertySchema';
import type { ChipShape, ChipWave } from './chip';
import { COMPRESSOR_LATENCY, compressorMakeup, limiterLatency } from './dynamics';
import { LFO_SHAPES, type LfoShape, vibratoDelay } from './lfo';
import {
SATURATION_TYPES,
saturationAutoGain,
@@ -41,7 +42,17 @@ export type FxStage =
| { kind: 'pan'; pan: number; mod?: FxMod }
| FxCompress
| FxLimit
| FxSaturate;
| FxSaturate
| FxLfo<'tremolo'>
| FxLfo<'vibrato'>;
/** A Tremolo or a Vibrato (#320): its shape, its rate in Hz, its depth (0 to 1, or semitones). */
export interface FxLfo<K extends 'tremolo' | 'vibrato' = 'tremolo' | 'vibrato'> {
kind: K;
shape: LfoShape;
rate: number;
depth: number;
}
/**
* A Saturation (#323): the curve of its type, the drive in dB in front of it, the cutoff its tone
@@ -276,7 +287,9 @@ export type FxStagePlan =
| ({ kind: 'saturate'; curve: SaturationType } & Record<
'drive' | 'tone' | 'output' | 'autoGain' | 'mix',
FxAmount
>);
>)
| { kind: 'tremolo'; shape: LfoShape; rate: FxAmount; depth: FxAmount }
| { kind: 'vibrato'; shape: LfoShape; rate: FxAmount; depth: FxAmount };
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
export type FxPlan =
@@ -905,6 +918,61 @@ export const SoundFxSaturation: SoundFxNodeDef = {
},
};
const LFO_SHAPE = {
key: 'shape',
label: 'Shape',
kind: 'enum' as const,
default: 'sine',
options: LFO_SHAPES,
};
export function lfoShapeOf(settings: PropertyBag): LfoShape {
return LFO_SHAPES.find((option) => option.value === settings[LFO_SHAPE.key])?.value ?? 'sine';
}
const TREMOLO_RATE = { ...numberRow('rate', 'Rate (Hz)', 0, 20, 0.01), default: 5 };
const TREMOLO_DEPTH = { ...numberRow('depth', 'Depth (%)', 0, 100, 1), default: 50 };
const VIBRATO_RATE = { ...numberRow('rate', 'Rate (Hz)', 0.5, 20, 0.01), default: 5 };
const VIBRATO_DEPTH = { ...numberRow('depth', 'Depth (st)', 0, 4, 0.01), default: 0.5 };
export const TREMOLO_ROWS = { rate: TREMOLO_RATE, depth: TREMOLO_DEPTH };
export const VIBRATO_ROWS = { rate: VIBRATO_RATE, depth: VIBRATO_DEPTH };
export const SoundFxTremolo: SoundFxNodeDef = {
type: 'SoundFxTremolo',
label: 'Tremolo',
category: 'sound',
description:
'Makes the volume swing, "rate" times a second: from full down by "depth" percent and back. "Shape" is how it swings: Sine and Triangle smoothly, Square on and off, Saw up and then down at once. It starts at the same point of its swing every time a sound starts. Rate and depth can change while the sound plays.',
inputs: [audioIn, numberIn(TREMOLO_RATE), numberIn(TREMOLO_DEPTH)],
outputs: [audioOut],
params: [LFO_SHAPE],
emit: (sounds, amounts, settings) => {
const depth = held(amounts.depth, TREMOLO_DEPTH);
if (isNone(depth)) return arriving(sounds);
return withStages(arriving(sounds), [
{ kind: 'tremolo', shape: lfoShapeOf(settings), rate: held(amounts.rate, TREMOLO_RATE), depth },
]);
},
};
export const SoundFxVibrato: SoundFxNodeDef = {
type: 'SoundFxVibrato',
label: 'Vibrato',
category: 'sound',
description:
'Makes the pitch swing, "rate" times a second, "depth" semitones up and as far down. "Shape" is how it swings: Sine and Triangle smoothly, Square jumps between two pitches, Saw glides up and drops. It starts at the same point of its swing every time a sound starts, and the sound keeps its length. The pitch is moved by a short delay that swings: the voice is late by as long as it swings out at the rate and depth it starts with, at 5 Hz and half a semitone under a millisecond, which a voice, a Mix and a bus make up for. Rate and depth can change while the sound plays; turning the depth up or the rate down later than the start can flatten the lower half of the swing.',
inputs: [audioIn, numberIn(VIBRATO_RATE), numberIn(VIBRATO_DEPTH)],
outputs: [audioOut],
params: [LFO_SHAPE],
emit: (sounds, amounts, settings) => {
const depth = held(amounts.depth, VIBRATO_DEPTH);
if (isNone(depth)) return arriving(sounds);
return withStages(arriving(sounds), [
{ kind: 'vibrato', shape: lfoShapeOf(settings), rate: held(amounts.rate, VIBRATO_RATE), depth },
]);
},
};
/** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */
const UNDAMPED_HZ = 20000;
const DAMPED_HZ = 500;
@@ -929,6 +997,8 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxCompressor,
SoundFxLimiter,
SoundFxSaturation,
SoundFxTremolo,
SoundFxVibrato,
];
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
@@ -1083,6 +1153,16 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] {
});
continue;
}
if (stage.kind === 'tremolo') {
const lfo = { shape: stage.shape, rate: amountOf(stage.rate, params) };
stages.push({ kind: 'tremolo', ...lfo, depth: amountOf(stage.depth, params) / 100 });
continue;
}
if (stage.kind === 'vibrato') {
const lfo = { shape: stage.shape, rate: amountOf(stage.rate, params) };
stages.push({ kind: 'vibrato', ...lfo, depth: amountOf(stage.depth, params) });
continue;
}
if (stage.kind === 'pan') {
const pan = amountOf(stage.pan, params);
const mod = modOf(stage.pan, params);
@@ -1167,7 +1247,7 @@ function chipOf(plan: FxChipPlan, params: FxParams): FxChip {
}
/** The stages that sound on after their input ends: an echo, and what holds the sound back on the worklet. */
const RINGING: ReadonlySet<FxStagePlan['kind']> = new Set(['delay', 'limit']);
const RINGING: ReadonlySet<FxStagePlan['kind']> = new Set(['delay', 'limit', 'vibrato']);
/** Whether a plan can ring on after its sources end (#324): it has an echo somewhere, or a Limiter. */
export function fxRings(plan: FxPlan): boolean {
@@ -1188,11 +1268,15 @@ export function fxTailOf(chain: FxChain): number {
return chain.stages.reduce((sum, stage) => sum + (stage.kind === 'delay' ? delayTail(stage) : 0), ways);
}
/** Seconds a stage holds the sound back (#326): a Compressor's look-ahead, a Limiter's, a Saturation's oversampling. */
/**
* Seconds a stage holds the sound back (#326): a Compressor's look-ahead, a Limiter's, a
* Saturation's oversampling, the delay a Vibrato swings around.
*/
export function stageLatency(stage: FxStage, sampleRate: number): number {
if (stage.kind === 'compress') return COMPRESSOR_LATENCY;
if (stage.kind === 'limit') return limiterLatency(sampleRate);
if (stage.kind === 'saturate') return SHAPER_LATENCY_FRAMES / sampleRate;
if (stage.kind === 'vibrato') return vibratoDelay(stage.shape, stage.rate, stage.depth);
return 0;
}
+36 -2
View File
@@ -12,6 +12,7 @@ import {
type FxDelay,
type FxEnvelope,
type FxFlanger,
type FxLfo,
type FxMod,
type FxPass,
type FxPhaser,
@@ -36,9 +37,10 @@ import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
import { DEFAULT_TEMPO } from '../core/music/time';
import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/model/sound';
import { shifterLatency } from '../core/soundfx/pitchPlayer';
import { LFO_SHAPES } from '../core/soundfx/lfo';
import { driveGain, saturationCurve, SHAPER_LATENCY_FRAMES } from '../core/soundfx/saturation';
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
import type { ChipOptions, FlangerOptions, PhaserOptions, PitchOptions } from './fxWorklet';
import type { ChipOptions, FlangerOptions, LfoOptions, PhaserOptions, PitchOptions } from './fxWorklet';
import fxWorkletUrl from './fxWorklet.ts?worker&url';
import { wake } from './wake';
@@ -1956,7 +1958,14 @@ interface LastVoice {
}
/** The stages that run on the worklet. */
const ON_WORKLET: ReadonlySet<FxStagePlan['kind']> = new Set(['crush', 'phaser', 'flanger', 'limit']);
const ON_WORKLET: ReadonlySet<FxStagePlan['kind']> = new Set([
'crush',
'phaser',
'flanger',
'limit',
'tremolo',
'vibrato',
]);
/** Whether a plan has a Pitch, a Chip or a stage of the worklet anywhere, and so needs the worklet. */
function needsWorklet(plan: FxPlan): boolean {
@@ -2055,6 +2064,7 @@ export function stageNodes(
if (stage.kind === 'flanger') return flangerNodes(context, stage, at, follow);
if (stage.kind === 'compress') return compressNodes(context, stage);
if (stage.kind === 'saturate') return saturateNodes(context, stage);
if (stage.kind === 'tremolo' || stage.kind === 'vibrato') return lfoNodes(context, stage, at);
if (stage.kind === 'limit')
return workletNodes(context, 'limiter', [
{ name: 'threshold', value: stage.threshold, even: 'value' },
@@ -2205,6 +2215,29 @@ function compressNodes(context: BaseAudioContext, stage: FxCompress): StageNodes
};
}
/**
* A Tremolo or a Vibrato (#320) on the worklet, its LFO at phase 0 when its voice starts at `at`.
* A Vibrato's delay swings around the center its start values give, which is how late it is.
*/
function lfoNodes(context: BaseAudioContext, stage: FxLfo, at: number): StageNodes {
const options: LfoOptions & { vibrato: boolean } = {
shape: LFO_SHAPES.findIndex((option) => option.value === stage.shape),
startAt: at,
center: stage.kind === 'vibrato' ? stageLatency(stage, context.sampleRate) : 0,
vibrato: stage.kind === 'vibrato',
};
return workletNodes(
context,
'lfo',
[
{ name: 'rate', value: stage.rate, even: 'ratio' },
{ name: 'depth', value: stage.depth, even: 'value' },
],
undefined,
options,
);
}
/** Below what a Saturation takes out the DC its bent sound gained (#323). */
const DC_BLOCK_HZ = 10;
@@ -2535,6 +2568,7 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
else if (stage.kind === 'compress')
into.push(stage.threshold, stage.knee, stage.ratio, stage.attack, stage.release, stage.gain);
else if (stage.kind === 'limit') into.push(stage.threshold, stage.ceiling, stage.release, stage.autoGain);
else if (stage.kind === 'tremolo' || stage.kind === 'vibrato') into.push(stage.rate, stage.depth);
else if (stage.kind === 'saturate')
into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix);
else if (stage.kind !== 'envelope') into.push(stage.gain);
+63 -1
View File
@@ -3,12 +3,13 @@
* plays one sound through a `PitchPlayer`, its pitch the AudioParam `pitch` in semitones, so it
* glides, ramps and eases like any other knob. A Bitcrusher (#322) runs a `Crusher`, a Chip
* (#329) a `ChipPlayer`, a Phaser and a Flanger (#341) theirs, the pitch of a bus (#346) a
* `PitchShifter`, a Limiter (#326) a `Limiter`. Loaded into the context once by the sound library; nothing on the page
* `PitchShifter`, a Limiter (#326) a `Limiter`, a Tremolo and a Vibrato (#320) theirs. Loaded into the context once by the sound library; nothing on the page
* imports it.
*/
import { type ChipShape, ChipPlayer } from '../core/soundfx/chip';
import { Crusher } from '../core/soundfx/crusher';
import { Limiter, type LimiterNumbers } from '../core/soundfx/dynamics';
import { LFO_SHAPES, type LfoNumbers, Tremolo, Vibrato } from '../core/soundfx/lfo';
import { Flanger, type FlangerNumbers, Phaser, type PhaserNumbers } from '../core/soundfx/phaser';
import { PitchPlayer, PitchShifter } from '../core/soundfx/pitchPlayer';
@@ -274,6 +275,67 @@ class LimitProcessor extends AudioWorkletProcessor {
registerProcessor('limiter', LimitProcessor);
export interface LfoOptions {
/** The shape by its place in `LFO_SHAPES`. */
shape: number;
/** The second of the audio clock its voice starts at: the LFO is at phase 0 on that sample. */
startAt: number;
/** A Vibrato's average delay in seconds. */
center: number;
}
/** A Tremolo or a Vibrato (#320) between two stages; its LFO starts with its voice. */
class LfoProcessor extends AudioWorkletProcessor {
private readonly effect: Tremolo | Vibrato;
private stopped = false;
private startAt: number;
constructor({ processorOptions }: { processorOptions: LfoOptions & { vibrato: boolean } }) {
super();
const shape = LFO_SHAPES[processorOptions.shape]?.value ?? 'sine';
this.effect = processorOptions.vibrato
? new Vibrato(shape, sampleRate, processorOptions.center)
: new Tremolo(shape, sampleRate);
this.startAt = processorOptions.startAt;
this.port.onmessage = () => {
this.stopped = true;
};
}
process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>,
): boolean {
if (this.stopped) return false;
let input = inputs[0] ?? [];
let out = outputs[0]!;
let numbers = parameters as LfoNumbers;
// Nothing is heard before the voice starts, and the LFO waits for its first sample.
if (this.startAt > currentTime) {
const wait = Math.round((this.startAt - currentTime) * sampleRate);
for (const channel of out) channel.fill(0, 0, wait);
if (wait >= (out[0]?.length ?? 0)) return true;
const from = (values: Float32Array) => (values.length > 1 ? values.subarray(wait) : values);
input = input.map(from);
out = out.map((channel) => channel.subarray(wait));
numbers = { rate: from(numbers.rate), depth: from(numbers.depth) };
this.startAt = 0;
}
this.effect.render(input, out, numbers);
return true;
}
static get parameterDescriptors() {
return [
{ name: 'rate', defaultValue: 0 },
{ name: 'depth', defaultValue: 0 },
];
}
}
registerProcessor('lfo', LfoProcessor);
export interface ChipOptions extends ChipShape {
/** The second of the audio clock its first sample is heard at; before it, silence. */
startAt: number;
+77
View File
@@ -0,0 +1,77 @@
/** 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);
});
});
+30
View File
@@ -131,6 +131,8 @@ describe('the sound FX asset', () => {
'SoundFxCompressor',
'SoundFxLimiter',
'SoundFxSaturation',
'SoundFxTremolo',
'SoundFxVibrato',
]);
f.edit.addNode('SoundFxInput');
f.edit.addNode('SoundFxOutput');
@@ -148,6 +150,8 @@ describe('the sound FX asset', () => {
'SoundFxCompressor',
'SoundFxLimiter',
'SoundFxSaturation',
'SoundFxTremolo',
'SoundFxVibrato',
]);
const behavior = makeBehavior(f, 'Logic');
@@ -494,6 +498,32 @@ describe('a Saturation in a sound FX graph (#323)', () => {
});
});
describe('a Tremolo and a Vibrato in a sound FX graph (#320)', () => {
const at = (type: string, params?: Record<string, string | number>) =>
fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages;
it('swing at their defaults, the depth of a Tremolo from percent, and are left out at a typed-in depth of 0', () => {
expect(at('SoundFxTremolo')).toEqual([{ kind: 'tremolo', shape: 'sine', rate: 5, depth: 0.5 }]);
expect(at('SoundFxTremolo', { shape: 'square', depth: 150 })).toEqual([
{ kind: 'tremolo', shape: 'square', rate: 5, depth: 1 },
]);
expect(at('SoundFxVibrato', { rate: 0, depth: 9 })).toEqual([
{ kind: 'vibrato', shape: 'sine', rate: 0.5, depth: 4 },
]);
expect(at('SoundFxVibrato', { depth: 0 })).toEqual([]);
expect(at('SoundFxTremolo', { depth: 0 })).toEqual([]);
});
it('a Vibrato is late by its delay and rings that long, a Tremolo is neither', () => {
const [vibrato] = at('SoundFxVibrato', { shape: 'square', rate: 0.5, depth: 4 });
const [tremolo] = at('SoundFxTremolo');
expect(stageLatency(vibrato!, 48000)).toBeCloseTo(0.13, 2);
expect(stageLatency(tremolo!, 48000)).toBe(0);
expect(fxRings(compileSoundFx(line([{ type: 'SoundFxVibrato' }]).fx.graph).compiled!)).toBe(true);
expect(fxRings(compileSoundFx(line([{ type: 'SoundFxTremolo' }]).fx.graph).compiled!)).toBe(false);
});
});
describe('the tail of a voice (#324)', () => {
const echo = (time: number, feedback: number, mix: number): FxStage => ({
kind: 'delay',
+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],
]);
});
});
+46
View File
@@ -1099,6 +1099,52 @@ export async function sound(t) {
Math.abs(saturated.autoGain) < 0.5,
JSON.stringify(saturated),
);
// #320: Tremolo and Vibrato on the worklet, rendered offline. A Tremolo square at 10 Hz, depth 1,
// on a steady 0.5 for a voice starting at 0.1 s: silence before it, then 0.5 for 50 ms and
// 0 for 50 ms. A Vibrato square at 5 Hz, one semitone deep, on a 1 kHz sine: the first
// half of a period at 1059.5 Hz, the second at 940.5 Hz.
const swung = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (stage, fill, at = 0) => {
const context = new OfflineAudioContext(1, RATE, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const sound = context.createBuffer(1, RATE, RATE);
fill(sound.getChannelData(0));
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, stage, at);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const steady = (data) => data.fill(0.5);
const sine = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE); };
const levels = (out, from, to) => [...new Set(out.subarray(from, to))].map((v) => +v.toFixed(3));
const tremolo = await render({ kind: 'tremolo', shape: 'square', rate: 10, depth: 1 }, steady, 0.1);
const hz = (out, from, to) => {
const at = [];
for (let i = Math.round(from * RATE) + 1; i < to * RATE; i++)
if (out[i - 1] < 0 && out[i] >= 0) at.push(i - out[i] / (out[i] - out[i - 1]));
return +(((at.length - 1) * RATE) / (at.at(-1) - at[0])).toFixed(1);
};
const vibrato = await render({ kind: 'vibrato', shape: 'square', rate: 5, depth: 1 }, sine);
return {
before: levels(tremolo, 0, 4800),
on: levels(tremolo, 4810, 7190),
off: levels(tremolo, 7210, 9590),
up: hz(vibrato, 0.41, 0.49),
down: hz(vibrato, 0.51, 0.59),
};
})()`);
check(
'a Tremolo starts its swing with its voice, a Vibrato jumps a semitone up and as far down',
JSON.stringify([swung.before, swung.on, swung.off]) === JSON.stringify([[0], [0.5], [0]]) &&
Math.abs(swung.up - 1059.5) < 1 &&
Math.abs(swung.down - 940.5) < 1,
JSON.stringify(swung),
);
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty
// of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a
// quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of