/** * The pitch of a sound FX in real time (#317): a sound played higher or lower keeps its length, also * while the pitch moves. Measured on a sine, whose pitch is the number of times it rises through zero * per second. */ import { describe, expect, it } from 'vitest'; import { PitchPlayer } from '../../src/core/soundfx/pitchPlayer'; const RATE = 48000; const BLOCK = 128; /** A frame at 48 kHz is 2048 samples, and a frame takes the pitch at the sample it starts on. */ const HALF_FRAME = 1024; function sine(frequency: number, seconds: number, rate = RATE): Float32Array { const samples = new Float32Array(Math.round(rate * seconds)); for (let i = 0; i < samples.length; i++) samples[i] = Math.sin((2 * Math.PI * frequency * i) / rate); return samples; } /** Plays the sound to its end block by block, as a worklet does, the pitch from `semitones(sample)`. */ function render( sound: Float32Array, semitones: number | ((at: number) => number), { step = 1, loop = false, blocks = Infinity, }: { step?: number; loop?: boolean | { start: number; end: number }; blocks?: number } = {}, ): Float32Array { const player = new PitchPlayer([sound], step, RATE, loop); const out: number[] = []; const block = new Float32Array(BLOCK); const pitch = new Float32Array(BLOCK); for (let b = 0; b < blocks; b++) { for (let i = 0; i < BLOCK; i++) pitch[i] = typeof semitones === 'number' ? semitones : semitones(out.length + i); const going = player.render([block], typeof semitones === 'number' ? [semitones] : pitch); out.push(...block); if (!going) break; } return Float32Array.from(out); } /** Rises through zero per second between two samples. */ function frequencyOf( samples: Float32Array, from = samples.length / 4, to = (samples.length * 3) / 4, ): number { let rises = 0; for (let i = Math.floor(from) + 1; i < to; i++) if (samples[i - 1]! < 0 && samples[i]! >= 0) rises++; return rises / ((to - from) / RATE); } const loudest = (samples: Float32Array) => samples.reduce((max, v) => Math.max(max, Math.abs(v)), 0); /** The last sample that is not silence, plus one: how long the sound is heard. */ function heard(samples: Float32Array): number { let last = samples.length; while (last > 0 && Math.abs(samples[last - 1]!) <= 1e-4) last--; return last; } describe('the pitch player', () => { it.each([ [12, 880], [-12, 220], [7, 440 * 2 ** (7 / 12)], [0.5, 440 * 2 ** (0.5 / 12)], [-30.5, 440 * 2 ** (-30.5 / 12)], ])('moves a 440 Hz tone by %d semitones to about %d Hz, at the same length', (semitones, expected) => { const tone = sine(440, 1); const shifted = render(tone, semitones); expect(shifted.length).toBeGreaterThanOrEqual(tone.length); expect(heard(shifted)).toBeLessThanOrEqual(tone.length); expect(heard(shifted)).toBeGreaterThan(tone.length - 50); expect(frequencyOf(shifted)).toBeGreaterThan(expected * 0.98); expect(frequencyOf(shifted)).toBeLessThan(expected * 1.02); expect(loudest(shifted)).toBeLessThan(1.2); expect(loudest(shifted.subarray(RATE / 4, (RATE * 3) / 4))).toBeGreaterThan(0.7); }); it('plays a sound at 0 semitones as it is, from the very first sample', () => { const tone = sine(440, 0.5); const same = render(tone, 0); let off = 0; for (let i = 0; i < tone.length; i++) off = Math.max(off, Math.abs(same[i]! - tone[i]!)); expect(off).toBeLessThan(1e-5); expect(heard(same)).toBe(tone.length); }); it('starts the pitched sound at once rather than after a frame', () => { const shifted = render(sine(440, 0.5), 12); expect(loudest(shifted.subarray(0, 480))).toBeGreaterThan(0.3); }); it('keeps the length while the pitch sweeps down, the pitch half a frame behind the sweep', () => { // A blip of a quarter second taken down two octaves over its own length. const tone = sine(880, 0.25); const sweep = (at: number) => (-24 * at) / tone.length; const swept = render(tone, sweep); expect(heard(swept)).toBeLessThanOrEqual(tone.length); expect(heard(swept)).toBeGreaterThan(tone.length - 50); for (const at of [tone.length / 4, (tone.length * 3) / 4]) { const expected = 880 * 2 ** (sweep(at - HALF_FRAME) / 12); const heardThere = frequencyOf(swept, at - 1200, at + 1200); expect(heardThere).toBeGreaterThan(expected * 0.95); expect(heardThere).toBeLessThan(expected * 1.05); } }); it('reads a sound at its own rate: an 8 kHz tone comes out at its pitch and length', () => { const tone = sine(440, 0.5, 8000); const played = render(tone, 12, { step: 8000 / RATE }); expect(heard(played)).toBeGreaterThan(RATE / 2 - 50); expect(heard(played)).toBeLessThanOrEqual(RATE / 2); expect(frequencyOf(played)).toBeGreaterThan(880 * 0.98); expect(frequencyOf(played)).toBeLessThan(880 * 1.02); }); it('reads on across the end of a looping sound, so its seam does not fade out, and never ends', () => { const tone = sine(500, 0.25); const looped = render(tone, 12, { loop: true, blocks: Math.ceil((tone.length * 2) / BLOCK) }); for (let from = tone.length - 960; from < tone.length + 960; from += 96) expect(loudest(looped.subarray(from, from + 96))).toBeGreaterThan(0.8); expect(heard(looped)).toBe(looped.length); expect(heard(render(tone, 12))).toBeLessThanOrEqual(tone.length); }); it('goes from 0 semitones to a pitch and back without a dip, and then plays the sound as it is', () => { const tone = sine(440, 2); // Up to 5 over 0.1 s from a third in, back down over 0.1 s from two thirds. const ramp = (at: number) => Math.max(0, Math.min(1, (at - RATE / 3) / (RATE / 10))); const moved = render(tone, (at) => 5 * (ramp(at) - ramp(at - RATE / 3))); for (let from = 0; from + 480 <= tone.length; from += 480) expect(loudest(moved.subarray(from, from + 480))).toBeGreaterThan(0.8); const up = frequencyOf(moved, RATE * 0.5, RATE * 0.6); expect(up).toBeGreaterThan(440 * 2 ** (5 / 12) * 0.98); expect(up).toBeLessThan(440 * 2 ** (5 / 12) * 1.02); let off = 0; for (let i = RATE * 1.5; i < RATE * 2 - 50; i++) off = Math.max(off, Math.abs(moved[i]! - tone[i]!)); expect(off).toBeLessThan(1e-5); }); }); describe('a loop between two points (#331)', () => { it('plays up to the loop end, then on from the loop start, never ending', () => { // A ramp, so every sample says where in the sound it was read. const ramp = Float32Array.from({ length: 48000 }, (_, i) => i / 48000); const out = render(ramp, 0, { loop: { start: 12000, end: 24000 }, blocks: 400 }); expect(out.length).toBe(400 * BLOCK); expect(out[20000]).toBeCloseTo(20000 / 48000, 3); // 30000 is 6000 past the end: 6000 past the start. expect(out[30000]).toBeCloseTo(18000 / 48000, 3); expect(out[40000]).toBeCloseTo(16000 / 48000, 3); }); it('takes a loop that does not fit the sound as the whole sound', () => { const ramp = Float32Array.from({ length: 4800 }, (_, i) => i / 4800); const out = render(ramp, 0, { loop: { start: 3000, end: 9000 }, blocks: 60 }); expect(out[6000]).toBeCloseTo(1200 / 4800, 3); }); });