/** The Phaser's and the Flanger's samples (#341), as their worklet makes them. */ import { describe, expect, it } from 'vitest'; import { Flanger, type FlangerNumbers, Phaser, type PhaserNumbers } from '../../src/core/soundfx/phaser'; const RATE = 48000; const one = (value: number) => new Float32Array([value]); function flange( input: Float32Array, numbers: Partial>, flanger = new Flanger(RATE), ) { const out = new Float32Array(input.length); const all = { offset: 0, sweep: 0, rate: 0, depth: 0, feedback: 0, mix: 1, ...numbers }; flanger.render( [input], [out], Object.fromEntries(Object.entries(all).map(([k, v]) => [k, one(v)])) as FlangerNumbers, ); return out; } function phase(input: Float32Array, numbers: Partial>, stages = 4) { const out = new Float32Array(input.length); const all = { frequency: 1000, rate: 0, depth: 0, feedback: 0, mix: 1, ...numbers }; new Phaser(RATE, stages).render( [input], [out], Object.fromEntries(Object.entries(all).map(([k, v]) => [k, one(v)])) as PhaserNumbers, ); return out; } const impulse = (length: number, at = 0) => { const samples = new Float32Array(length); samples[at] = 1; return samples; }; const sine = (hz: number, seconds: number) => Float32Array.from({ length: Math.round(seconds * RATE) }, (_, i) => Math.sin((2 * Math.PI * hz * i) / RATE), ); const rms = (samples: Float32Array, from = 0, to = samples.length) => { let sum = 0; for (let i = from; i < to; i++) sum += samples[i]! ** 2; return Math.sqrt(sum / (to - from)); }; /** Where an impulse comes out again, after its own sample: the samples with anything in them. */ const heard = (samples: Float32Array, from = 1) => [...samples].flatMap((v, i) => (i >= from && Math.abs(v) > 1e-6 ? [[i, +v.toFixed(4)]] : [])); describe('the Flanger (#341)', () => { it('adds the sound offset ms late, half and half at mix 1, and leaves it be at mix 0', () => { const out = flange(impulse(200), { offset: 1 }); expect(out[0]).toBeCloseTo(0.5); expect(heard(out)).toEqual([[48, 0.5]]); expect([...flange(impulse(200), { offset: 1, mix: 0 })]).toEqual([...impulse(200)]); }); it('feeds the copy back, every echo one offset after the last', () => { expect(heard(flange(impulse(200), { offset: 1, feedback: 0.5 }))).toEqual([ [48, 0.5], [96, 0.25], [144, 0.125], [192, 0.0625], ]); }); it('sweeps the delay on from the start of the sound, and mirrors it at 0 as sfxr does', () => { // 100 ms/s from 0: after 0.1 s the copy is 10 ms late and still moving, so an impulse then // comes back at j = 480 + 0.1 j, 533.3 samples on. const flanger = new Flanger(RATE); flange(new Float32Array(4800), { sweep: 100 }, flanger); const later = heard(flange(impulse(800), { sweep: 100 }, flanger)); expect(later.map(([i]) => i)).toEqual([533, 534]); // Read 0.9 samples a sample while the line grows: the copy is drawn out, by 1/0.9 on average. const drawn = later.reduce((sum, [, v]) => sum + v!, 0); expect(drawn).toBeGreaterThan(0.5); expect(drawn).toBeLessThan(0.6); // From 1 ms down at 100 ms/s: 20 ms in, the delay has passed 0 and is 1 ms again, growing. const down = new Flanger(RATE); flange(new Float32Array(960), { offset: 1, sweep: -100 }, down); const back = flange(impulse(200), { offset: 1, sweep: -100 }, down); expect(back[0]).toBeCloseTo(0.5); expect(heard(back).map(([i]) => i)).toEqual([53, 54]); }); it('swings the delay up to depth ms with its LFO, and never past 20 ms', () => { // A quarter of a 10 Hz swing is 25 ms in: half way up from 1 ms towards 1 + 4 ms. const flanger = new Flanger(RATE); flange(new Float32Array(1200), { offset: 1, rate: 10, depth: 4 }, flanger); const out = flange(impulse(400), { offset: 1, rate: 10, depth: 4 }, flanger); const at = heard(out)[0]![0]!; expect(at).toBeGreaterThan(48 * 2.5); expect(at).toBeLessThan(48 * 3.6); const far = new Flanger(RATE); flange(new Float32Array(48000), { sweep: 125 }, far); expect(heard(flange(impulse(2000), { sweep: 125 }, far))).toEqual([[960, 0.5]]); }); }); describe('the Phaser (#341)', () => { it('cuts a notch at its frequency with two stages, where four make a peak', () => { const tone = sine(1000, 0.2); expect(rms(phase(tone, {}, 2), 4800)).toBeLessThan(0.01); expect(rms(phase(tone, {}, 4), 4800)).toBeCloseTo(Math.SQRT1_2, 2); // Far below the notch the two stages hardly turn the phase: the sound stays. expect(rms(phase(sine(50, 0.4), {}, 2), 4800)).toBeGreaterThan(0.69); }); it('leaves the sound be at mix 0', () => { const tone = sine(1000, 0.05); expect([...phase(tone, { mix: 0 }, 2)]).toEqual([...tone]); }); it('moves the notch with its LFO, depth octaves wide', () => { // At 1 Hz over 4 octaves the notch passes 1 kHz twice a second and is two octaves off between, // where two stages turn it by 2 * 2 * atan(1/4): cos(28°) of it is left, 0.62 of the RMS. const out = phase(sine(1000, 1), { rate: 1, depth: 4 }, 2); const windows = Array.from({ length: 40 }, (_, w) => rms(out, w * 1200, (w + 1) * 1200)); expect(Math.min(...windows)).toBeLessThan(0.15); expect(Math.max(...windows)).toBeGreaterThan(0.58); expect(Math.max(...windows)).toBeLessThan(0.63); }); it('stays bounded at the most feedback there is', () => { const noise = Float32Array.from({ length: RATE }, (_, i) => Math.sin(i * 12.9898) % 1); const out = phase(noise, { feedback: 0.95, rate: 2, depth: 4 }, 12); expect(Math.max(...out.map(Math.abs))).toBeLessThan(20); expect(out.every(Number.isFinite)).toBe(true); }); });