Files
feuerwerk/tests/core/pitch-player.test.ts
T
adminandClaude Opus 5.5 144dee0a97 Let a looped sample play out past its loop when the note ends #331
With "Mode" and "Loop" as two fields, One-shot plus Loop ran until
Stop was pressed: letting go of the key did nothing. The two fields
are now one, "Play", with four entries that each say what happens
while the note is held and when it is let go.

- Gate and One-shot play the sound once, as before. Loop repeats it
  and fades out with the note.
- "Loop, then release" is new: the sampler's sustain loop. Let go,
  it finishes the round it is in and plays what comes after "Loop
  end" to the end of the sound.
- A buffer source stops looping and plays on by itself. The pitch
  worklet gets a "leave" message. The phase vocoder then lets the
  round finish that its newest frame has read into, so nothing read
  already changes under the frames still sounding.
- Samples saved with "Mode" and "Loop" are turned into "Play" on
  load. Gate and One-shot stay, either with Loop becomes Loop, since
  playing until stopped is gone.
- The keys of the Instrument tab stay half lit while a note let go
  is still heard. Esc on the keys stops everything, like Stop.
- Striking a ringing key again plays a new note beside it rather
  than stopping the old one, so a drum can be hit quickly. This
  differs from what was proposed in the issue discussion.

Tests:
- instrument + pitch-player + sound-library + node-catalog:
  477 tests, green (12 new).
- e2e 34 (melektaus-atx): ALL PASSED, 45 checks (+1). Let go after
  a loop of 0.05 s, the note played out for 0.31 s on the audio
  clock, the key half lit meanwhile.
- Counter-check: a release that does not leave the loop fails the
  2 new player tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:34:30 +02:00

196 lines
8.5 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,
}: { step?: number; loop?: boolean | { start: number; end: number }; blocks?: number } = {},
): 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);
});
});
describe('a loop between two points (#331)', () => {
it('plays up to the loop end, then on from the loop start, never ending', () => {
// A ramp, so every sample says where in the sound it was read.
const ramp = Float32Array.from({ length: 48000 }, (_, i) => i / 48000);
const out = render(ramp, 0, { loop: { start: 12000, end: 24000 }, blocks: 400 });
expect(out.length).toBe(400 * BLOCK);
expect(out[20000]).toBeCloseTo(20000 / 48000, 3);
// 30000 is 6000 past the end: 6000 past the start.
expect(out[30000]).toBeCloseTo(18000 / 48000, 3);
expect(out[40000]).toBeCloseTo(16000 / 48000, 3);
});
/** Plays `before` blocks of a loop, leaves it and plays on to the end; says what came out. */
function leaving(sound: Float32Array, loop: { start: number; end: number }, before: number): number[] {
const player = new PitchPlayer([sound], 1, RATE, loop);
const out: number[] = [];
const block = new Float32Array(BLOCK);
for (let b = 0; b < 2000; b++) {
if (b === before) player.leaveLoop();
const going = player.render([block], [0]);
out.push(...block);
if (!going) break;
}
return out;
}
it('leaves the loop at the end of the round it is in and plays what comes after it', () => {
const ramp = Float32Array.from({ length: 48000 }, (_, i) => i / 48000);
// Let go at 38400, in the second round of the loop: it runs to 48000, then 24000 on comes.
const out = leaving(ramp, { start: 12000, end: 24000 }, 300);
expect(out[44000]).toBeCloseTo(20000 / 48000, 3);
expect(out[47999]).toBeCloseTo(23999 / 48000, 3);
expect(out[50000]).toBeCloseTo(26000 / 48000, 3);
expect(out[71999]).toBeCloseTo(47999 / 48000, 3);
expect(heard(Float32Array.from(out))).toBe(72000);
});
it('plays straight through when the loop is left before it was ever reached', () => {
const ramp = Float32Array.from({ length: 48000 }, (_, i) => (i + 1) / 48000);
const out = leaving(ramp, { start: 12000, end: 24000 }, 50);
expect(out[30000]).toBeCloseTo(30001 / 48000, 3);
expect(heard(Float32Array.from(out))).toBe(48000);
});
it('takes a loop that does not fit the sound as the whole sound', () => {
const ramp = Float32Array.from({ length: 4800 }, (_, i) => i / 4800);
const out = render(ramp, 0, { loop: { start: 3000, end: 9000 }, blocks: 60 });
expect(out[6000]).toBeCloseTo(1200 / 4800, 3);
});
});