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:
1 parent
d3a4cb7d0e
commit
071f194326
8 files changed
+542
-9
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user