diff --git a/src/core/soundfx/lfo.ts b/src/core/soundfx/lfo.ts new file mode 100644 index 00000000..5c9bec9b --- /dev/null +++ b/src/core/soundfx/lfo.ts @@ -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 = { 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(); + +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); + } + } +} diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 244d80ef..04c494da 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -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 { + 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 = new Set(['delay', 'limit']); +const RINGING: ReadonlySet = 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; } diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 34f6ee92..da6070df 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -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 = new Set(['crush', 'phaser', 'flanger', 'limit']); +const ON_WORKLET: ReadonlySet = 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); diff --git a/src/viewport/fxWorklet.ts b/src/viewport/fxWorklet.ts index 71f90175..1f4cf3dc 100644 --- a/src/viewport/fxWorklet.ts +++ b/src/viewport/fxWorklet.ts @@ -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, + ): 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; diff --git a/tests/core/lfo.test.ts b/tests/core/lfo.test.ts new file mode 100644 index 00000000..0544117d --- /dev/null +++ b/tests/core/lfo.test.ts @@ -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); + }); +}); diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index 4ddd8117..66dc7e65 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -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) => + 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', diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 6caa9150..1825ee87 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -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], + ]); + }); }); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 1e13d5de..9a8bd3d3 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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