Files
feuerwerk/tests/core/pitch-shift.test.ts
T
adminandClaude Opus 5.5 790e483198
game-maker / gate (push) Successful in 3m9s
game-maker / suite (push) Successful in 15m19s
Pitch a sound FX without changing its length #312 #313 #314
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>
2026-10-05 19:32:00 +02:00

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);
});
});