/** * The pitch of a sound FX (#312): a sound played higher or lower keeps its length. Measured on a * sine, whose pitch is the number of times it rises through zero per second. */ import { describe, expect, it } from 'vitest'; import { pitchShift } from '../../src/core/soundfx/pitchShift'; const RATE = 16000; function sine(frequency: number, seconds: number): 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; } /** Rises through zero per second over the middle half, away from the fade at either end. */ function frequencyOf(samples: Float32Array): number { const from = Math.floor(samples.length / 4); const to = Math.floor((samples.length * 3) / 4); let rises = 0; for (let i = 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); describe('pitch shifting', () => { 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 = pitchShift(tone, RATE, semitones); expect(shifted.length).toBe(tone.length); expect(frequencyOf(shifted)).toBeGreaterThan(expected * 0.98); expect(frequencyOf(shifted)).toBeLessThan(expected * 1.02); }); it('leaves a sound alone at 0 and does not make it louder', () => { const tone = sine(440, 0.5); const same = pitchShift(tone, RATE, 0); const middle = tone.length / 2; expect(same[middle]).toBeCloseTo(tone[middle]!, 5); expect(loudest(pitchShift(tone, RATE, 12))).toBeLessThanOrEqual(1.0001); }); it('reads on across the end of a looping sound, so its seam does not fade out', () => { const tone = sine(500, 0.25); const tail = (samples: Float32Array) => loudest(samples.subarray(samples.length - 40)); expect(tail(pitchShift(tone, RATE, 12))).toBeLessThan(0.6); expect(tail(pitchShift(tone, RATE, 12, true))).toBeGreaterThan(0.95); }); });