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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user