/** * The pitch of a bus (#346): a sound fed in while it plays comes out moved by the pitch, exactly one * frame late at every pitch – the latency a song makes up by starting its notes that much earlier. */ import { describe, expect, it } from 'vitest'; import { PitchPlayer, PitchShifter, shifterLatency } from '../../src/core/soundfx/pitchPlayer'; const RATE = 48000; const BLOCK = 128; /** A frame at 48 kHz. */ const FRAME = 2048; function sine(frequency: number, samples: number, from = 0): Float32Array { const out = new Float32Array(samples); for (let i = from; i < samples; i++) out[i] = Math.sin((2 * Math.PI * frequency * (i - from)) / RATE); return out; } /** Feeds the channels in block by block, as a worklet does, the pitch from `semitones(sample)`. */ function shift( input: readonly Float32Array[], semitones: number | ((at: number) => number), outputs = input.length, ): Float32Array[] { const length = input[0]!.length; const shifter = new PitchShifter(outputs, RATE); const out = Array.from({ length: outputs }, () => new Float32Array(length)); const pitch = new Float32Array(BLOCK); for (let at = 0; at < length; at += BLOCK) { const end = Math.min(length, at + BLOCK); for (let i = 0; i < BLOCK; i++) pitch[i] = typeof semitones === 'number' ? semitones : semitones(at + i); shifter.render( input.map((channel) => channel.subarray(at, end)), out.map((channel) => channel.subarray(at, end)), typeof semitones === 'number' ? [semitones] : pitch.subarray(0, end - at), ); } return out; } function frequencyOf(samples: Float32Array, from: number, to: number): number { 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); } /** The first sample at least half as loud as the loudest. */ function onset(samples: Float32Array): number { const peak = samples.reduce((max, v) => Math.max(max, Math.abs(v)), 0); return samples.findIndex((v) => Math.abs(v) >= peak / 2); } describe('the pitch shifter of a bus', () => { it('is one frame late, in seconds as the sound library counts it', () => { expect(new PitchShifter(2, RATE).latency).toBe(FRAME); expect(shifterLatency(RATE)).toBe(FRAME / RATE); expect(shifterLatency(44100)).toBe(FRAME / 44100); }); it('hands out the sound itself at 0 semitones, exactly a frame later', () => { const noise = Float32Array.from({ length: RATE / 2 }, (_, i) => Math.sin(i * 0.37) * Math.cos(i * 0.011)); const [out] = shift([noise], 0); let off = 0; for (let i = 0; i < FRAME; i++) off = Math.max(off, Math.abs(out![i]!)); for (let i = FRAME; i < noise.length; i++) off = Math.max(off, Math.abs(out![i]! - noise[i - FRAME]!)); expect(off).toBeLessThan(1e-5); }); it.each([ [12, 880], [-12, 220], [7, 440 * 2 ** (7 / 12)], [-5.5, 440 * 2 ** (-5.5 / 12)], ])('moves a 440 Hz tone fed in by %d semitones to about %d Hz', (semitones, expected) => { const [out] = shift([sine(440, RATE)], semitones); const heard = frequencyOf(out!, RATE / 4, (RATE * 3) / 4); expect(heard).toBeGreaterThan(expected * 0.98); expect(heard).toBeLessThan(expected * 1.02); }); it.each([12, -12, 7])( 'is late by the same frame at %d semitones as the file player is not', (semitones) => { const start = RATE / 2; const tone = sine(440, RATE * 1.5, start); const [late] = shift([tone], semitones); const own = new Float32Array(tone.length); new PitchPlayer([tone], 1, RATE, false).render([own], [semitones]); expect(Math.abs(onset(late!) - onset(own) - FRAME)).toBeLessThanOrEqual(64); }, ); it('follows a pitch that changes while the sound goes on', () => { const [out] = shift([sine(440, RATE * 2)], (at) => (at < RATE ? 0 : 12)); expect(frequencyOf(out!, RATE / 4, (RATE * 3) / 4)).toBeCloseTo(440, -1); const up = frequencyOf(out!, RATE + RATE / 4, RATE + (RATE * 3) / 4); expect(up).toBeGreaterThan(880 * 0.98); expect(up).toBeLessThan(880 * 1.02); }); it('plays a mono sound on both sides, and nothing coming in as silence', () => { const [left, right] = shift([sine(440, RATE / 2)], 5, 2); expect(Array.from(right!)).toEqual(Array.from(left!)); expect(left!.some((v) => Math.abs(v) > 0.5)).toBe(true); const shifter = new PitchShifter(2, RATE); const out = [new Float32Array(BLOCK), new Float32Array(BLOCK)]; for (let b = 0; b < 40; b++) shifter.render([], out, [3]); expect(out.every((channel) => channel.every((v) => v === 0))).toBe(true); }); });