Files
feuerwerk/tests/core/pitch-shifter.test.ts
T
adminandClaude Opus 5.5 b519cd192f Send a track, a pattern item or a whole song through a sound FX (#346)
A sound FX could only be put on a single sound. Now a track, a pattern
item, a Play pattern and a Play song send everything they play through
one chain of it, as an insert in a DAW. A Play note gets one of its own.

- Track: an FX box in the head next to M and S opens a menu of the
  project's sound FX, and the track's inspector has the row too.
  Order: items, track FX, volume, pan.
- Pattern item: Sound FX in its inspector. Its notes play on a bus of
  their own into the track's bus.
- Play pattern and Play song take fx and fx params. The chain sits on
  the master of the playing, after the tracks and before the level and
  the fades. Set sound FX parameters reaches it by the playback.
- Play note takes fx and fx params next to the instrument's params.
  The FX parameters go by "fx:" so the two sets stay apart. Set sound
  FX parameters on a note changes a name both have in both.
- A Pitch on a bus is a new PitchShifter: the phase vocoder of the
  PitchPlayer, fed while it plays. It cannot look ahead and is exactly
  one frame late at every pitch, 2048 samples.
- That latency is made up. The other way of a Mix is held back by a
  delay of the same length. Every note and clip starts as much earlier
  as the buses it passes are late. The scheduler plans that much
  further ahead, and the first note waits that much after Play.
- The Song tab waits for the chains before it plays and lets its buses
  go a moment after Stop. A playing in a game lets them go after its
  echoes and what the shifters still hold.
- songBuses moved to its own file. Kept in song.ts it pulled the FX
  compiler into the node registry through an import cycle.

Decisions of the user: one chain for the whole (1a), FX everywhere
asked for (2a), Set sound FX parameters on playbacks (3a), and a
pitch shifter of its own with exact latency compensation for a Pitch
on a bus.

Tests:
- Unit pitch-shifter (new): 11 green. At 0 semitones the output is
  the input 2048 samples later, off by at most 1.2e-7.
- Unit sound-library: 84 green, 8 new. Counter-checks (no early
  start, no Mix delay, chain made every time, tune misses the master)
  each turned their test red.
- Unit bus-fx (new): 6 green, 4 counter-checks red. Scheduler ahead: 1
  new, its counter-check red.
- e2e 65 (melektaus-atx): ALL PASSED, 35 checks, 4 new, first run.
  Chrome at 44.1 kHz: the bus was 0.046440 s late, the note started
  exactly that much early. Offline the shifter moved 1 kHz to 2000 Hz
  and passed a click 2048 samples later.
- Gate (melektaus-atx): CHECK EXIT 0, 259 files, 4021 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:15:52 +02:00

111 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});