A Pitch used to keep the length only for the pitch a voice started at. Set sound FX parameters changed it by speed, so a sweep made the sound longer or shorter, and length into time no longer spanned the sound. Now a way with a Pitch is played by an AudioWorklet, a phase vocoder whose pitch is an AudioParam in semitones. The length never changes, and a pitch glides, ramps and eases like a Gain. - PitchPlayer (core/soundfx) is the vocoder as a plain class, so it runs in a unit test. Frames of about 43 ms, four overlapping, a real FFT done as a complex one of half the size. - Peaks are moved with the bins around them (Laroche and Dolson's phase locking), and frames are turned by half their size so a phase is the one at the middle. Without both, a tone beat against itself and dipped to 0.54 of its loudness while the pitch moved. - It reads the sound itself rather than being fed it. It looks ahead as far as a frame reaches, so a voice starts with its first sample and every way of a Mix stays in step. A change of pitch is heard about half a frame (20 ms) late. - At exactly 0 a frame is the sound itself and costs next to nothing. Coming back to 0, the phases are pulled round to the sound's for about half a second before it switches, so the switch does not dip. - pitchWorklet (viewport) is the processor, bundled by Vite through ?worker&url. It is loaded with the first play that has a Pitch, which waits for it like for its bytes. A file's samples are sent with its first voice only and kept in the worklet's scope. - A way has the worklet when its plan has a Pitch, a typed-in 0 excepted (FxChain.shifted). A way without one stays a plain buffer source. Without AudioWorklet a Pitch is not heard, and the console says so. - The pitched copies (WSOLA, pitchShift.ts) and their cache are gone. - Decisions of the user: the player at the source rather than an effect in the chain, a phase vocoder rather than WSOLA, no limit on voices, no range tighter than the node's -64 to +64. - stats() says whether the worklet is there, levels() gives a pitch in semitones, and __gm.sound.pitchWorkletUrl() lets a check run it. Tests: - pitch-player.test.ts (new, replaces pitch-shift.test.ts): 11 tests. Pitches from -30.5 to +12 within 2 %, the length to the sample, 0 exact to 1e-5, a sweep half a frame behind, 8 kHz sounds, loops, ramps there and back without a dip. - sound-library.test.ts: 23 tests (+4), soundfx.test.ts: 31 (+1). - Counter-checks: no worklet in the player, shifted taken from the value, no pull back to 0. 15 tests fail, files restored by cmp. - e2e 34 on melektaus-atx: ALL PASSED, 34 checks (+1). In real Chrome the worklet is there and the pitch param glides to 3.0007. Offline: 880.0 Hz for 440 an octave up, 48000 of 48000 samples, a sweep 0 to -12 at 392 then 248 Hz, still 48000 samples. - Gate on melektaus-atx: 242 test files, 3690 tests, CHECK EXIT 0. - Cost: 53 ms per second of a stereo voice at +5 in Node on stein2, 5 ms at 0. In headless Chrome 16 voices for 2 s took 1.3 s, about 24 fill the audio thread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
141 lines
6.0 KiB
TypeScript
141 lines
6.0 KiB
TypeScript
/**
|
|
* 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 } = {},
|
|
): 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);
|
|
});
|
|
});
|