Pitch a sound FX without changing its length #312 #313 #314
game-maker / gate (push) Successful in 3m9s
game-maker / suite (push) Successful in 15m19s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 19:32:00 +02:00
1 parent 45e45fb1df
commit 790e483198
7 files changed
+249 -18

No files matched your search

+5 -5
View File
@@ -123,7 +123,7 @@ export const SoundFxOutput: SoundFxNodeDef = {
emit: (plan) => plan,
};
const GAIN = numberRow('gain', 'Gain (dB)', -60, 24, 0.5);
const GAIN = numberRow('gain', 'Gain (dB)', -120, 24, 0.5);
export const SoundFxGain: SoundFxNodeDef = {
type: 'SoundFxGain',
@@ -140,14 +140,14 @@ export const SoundFxGain: SoundFxNodeDef = {
},
};
const SEMITONES = numberRow('semitones', 'Semitones', -24, 24, 1);
const SEMITONES = numberRow('semitones', 'Semitones', -64, 64, 0.1);
export const SoundFxPitch: SoundFxNodeDef = {
type: 'SoundFxPitch',
label: 'Pitch',
category: 'sound',
description:
'Plays the sound higher or lower, in semitones: 12 is an octave up. It is the speed the sound is played at, so a higher one is also shorter, and it applies to the whole sound wherever the node sits in the line.',
'Plays the sound higher or lower, in semitones: 12 is an octave up, 0.5 a quarter tone. The sound keeps its length, and the pitch applies to the whole sound wherever the node sits in the line.',
inputs: [audioIn, numberIn(SEMITONES)],
outputs: [audioOut],
params: [],
@@ -168,11 +168,11 @@ export const EQ_BANDS: readonly { key: string; label: string; filter: FxFilter;
/** About an octave and a half wide: neighbouring peaks overlap, so no frequency falls between them. */
const PEAK_Q = 1;
const EQ_ROWS = EQ_BANDS.map((band) => numberRow(band.key, band.label, -24, 24, 0.5));
const EQ_ROWS = EQ_BANDS.map((band) => numberRow(band.key, band.label, -60, 24, 0.5));
export const SoundFxEq: SoundFxNodeDef = {
type: 'SoundFxEq',
label: 'EQ',
label: '5-Band-EQ',
category: 'sound',
description:
'A five-band equalizer: raises or lowers the lows (80 Hz), low mids (250 Hz), mids (1 kHz), high mids (4 kHz) and highs (12 kHz), each in decibels. A band at 0 does nothing.',
+95
View File
@@ -0,0 +1,95 @@
/** Half a frame, in seconds: the hop between frames. A frame is longer than the period of a low note. */
const HOP_SECONDS = 0.02;
/** How far a frame may move to fit the one before it: a period of 80 Hz. */
const REACH_SECONDS = 0.0125;
/** The first search looks at every fourth offset and sample, the second at every one around the best. */
const COARSE = 4;
/**
* One channel of a sound played `semitones` higher or lower at its own length (#312). The sound is
* first read faster or slower, which sets the pitch and gets the length wrong, and then stretched
* back by WSOLA: frames half a frame apart are laid over each other under a Hann window, each one
* moved a little to where it continues the frame before it, so a tone carries on without a jump in
* phase. A sound that loops reads on across its end into its start, so the seam stays where it was.
*/
export function pitchShift(
samples: Float32Array,
sampleRate: number,
semitones: number,
loop = false,
): Float32Array {
const length = samples.length;
const out = new Float32Array(length);
if (length === 0) return out;
const ratio = 2 ** (semitones / 12);
const read = (index: number): number => {
if (loop) return samples[((index % length) + length) % length]!;
return index >= 0 && index < length ? samples[index]! : 0;
};
const hop = Math.max(1, Math.round(HOP_SECONDS * sampleRate));
const size = hop * 2;
const reach = Math.round(REACH_SECONDS * sampleRate);
// The pitched sound with room on either side for a frame that reaches past an end, so the loops
// below index it directly.
const pad = size + reach + COARSE;
const span = Math.max(1, Math.round(length / ratio));
const pitched = new Float32Array(span + 2 * pad);
for (let i = -pad; i < span + pad; i++) {
if (!loop && (i < 0 || i >= span)) continue;
const position = (((i % span) + span) % span) * ratio;
const below = Math.floor(position);
const fraction = position - below;
pitched[i + pad] = read(below) * (1 - fraction) + read(below + 1) * fraction;
}
const window = new Float32Array(size);
for (let j = 0; j < size; j++) window[j] = 0.5 - 0.5 * Math.cos((2 * Math.PI * j) / size);
const lowest = 0;
const highest = pitched.length - size;
const within = (from: number) => Math.max(lowest, Math.min(highest, from + pad));
let previous: number | null = null;
for (let start = -hop; start < length; start += hop) {
let from = within(Math.round((start + hop) / ratio) - hop);
if (previous !== null) from = bestFit(pitched, previous + hop, from, reach, hop, lowest, highest);
for (let j = Math.max(0, -start); j < size && start + j < length; j++)
out[start + j]! += window[j]! * pitched[from + j]!;
previous = from;
}
return out;
}
/**
* Where near `around` the sound looks most like it does `hop` samples on from `follow`, searched
* coarsely and then finely, never outside `lowest`..`highest`.
*/
function bestFit(
sound: Float32Array,
follow: number,
around: number,
reach: number,
hop: number,
lowest: number,
highest: number,
): number {
const score = (from: number, stride: number): number => {
let sum = 0;
for (let j = 0; j < hop; j += stride) sum += sound[from + j]! * sound[follow + j]!;
return sum;
};
const search = (centre: number, span: number, stride: number): number => {
let best = centre;
let bestScore = score(centre, stride);
for (let offset = -span; offset <= span; offset += stride) {
if (centre + offset < lowest || centre + offset > highest) continue;
const candidate = score(centre + offset, stride);
if (candidate > bestScore) {
best = centre + offset;
bestScore = candidate;
}
}
return best;
};
return search(search(around, reach, COARSE), COARSE - 1, 1);
}