The Pitch node changed the playback speed, so a sound pitched up was also shorter and one pitched down longer. A pitch now keeps the length, takes fractions and reaches further. Gain and the EQ bands reach further down, and the EQ is renamed so a parametric one can come later without a mix-up. - Pitch shifting is the pure function pitchShift in core/soundfx/pitchShift.ts. It reads the sound faster or slower and stretches it back by WSOLA: 40 ms frames under a Hann window, each moved within 12.5 ms to where it continues the frame before it. A looping sound reads on across its end. - SoundLibrary plays a pitched copy of the decoded buffer instead of setting playbackRate. Copies are kept per file, pitch and loop, the last 32 of them. - stats().last tells the duration of what was played, so a check can see that the length is kept. - Pitch: -64 to +64 semitones, scrubbed in steps of 0.1 (#312). - Gain: -120 to +24 dB (#313). - EQ is now labelled "5-Band-EQ", its bands go -60 to +24 dB (#314). The node type stays SoundFxEq, so saved graphs load unchanged. Findings: - A plain granular shifter was tried first and missed the pitch: at +0.5 semitones it measured 438 Hz instead of 452.9 Hz, because every grain restarts at the phase of the original. WSOLA measures 452 Hz. - Far ends: +64 semitones from 440 Hz lands above half the sample rate and folds back, -64 is below what a 40 ms frame can follow. Nobody has listened to either. - Speed: 3 s of a 48 kHz channel take 47 ms at +3 and +64, 171 ms at -64 (measured in Node). Tests: - pitch-shift (new): 7 tests. A 440 Hz sine at +12, -12, +7, +0.5 and -30.5 semitones is within 2 % of the target and keeps its length. Level stays at most 1, a loop does not fade at its seam. - sound-library: 10 tests (+1), soundfx 22, node-catalog 368. - Counter-check: with the pitched copy switched off the new sound-library test fails. - e2e 34 (melektaus-atx): ALL PASSED - 28 checks. Its octave-down voice is now also checked for the length of the sound. - Typecheck, ESLint and Prettier clean. The gate did not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
57 lines
2.3 KiB
TypeScript
57 lines
2.3 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|