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>
255 lines
8.4 KiB
TypeScript
255 lines
8.4 KiB
TypeScript
/**
|
||
* The library between `Play sound` and the audio device. Everything here is about the gap between
|
||
* asking for a sound and being able to play it: the bytes are fetched and decoded on first use, so
|
||
* a stop can land while a play is still in flight.
|
||
*
|
||
* The device is stubbed – nothing makes a noise in a unit test, and Web Audio does not exist here.
|
||
*/
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import type { FxChain } from '../../src/core/soundfx/nodes';
|
||
import { SoundLibrary } from '../../src/viewport/SoundLibrary';
|
||
|
||
const FILE = 'a.wav';
|
||
const OTHER = 'b.wav';
|
||
|
||
interface FakeSource {
|
||
buffer: unknown;
|
||
loop: boolean;
|
||
playbackRate: { value: number };
|
||
onended: (() => void) | null;
|
||
started: boolean;
|
||
stopped: boolean;
|
||
connect: (target: unknown) => unknown;
|
||
start: () => void;
|
||
stop: () => void;
|
||
}
|
||
|
||
const sources: FakeSource[] = [];
|
||
/** Every `connect` made, as "from > to" by the kind of node – the order a voice is wired in. */
|
||
const wires: string[] = [];
|
||
const filters: {
|
||
type: string;
|
||
frequency: { value: number };
|
||
gain: { value: number };
|
||
Q: { value: number };
|
||
}[] = [];
|
||
|
||
/** A node that logs where it is connected to and hands the target back, as Web Audio's does. */
|
||
function wired<T extends object>(
|
||
kind: string,
|
||
node: T,
|
||
): T & { kind: string; connect: (to: unknown) => unknown } {
|
||
return {
|
||
...node,
|
||
kind,
|
||
connect: (to: unknown) => {
|
||
wires.push(`${kind} > ${(to as { kind?: string }).kind ?? '?'}`);
|
||
return to;
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Resolves the pending decodes by hand, so a test decides when the bytes arrive. */
|
||
let decodes: (() => void)[] = [];
|
||
|
||
/** A mono buffer as Web Audio hands it out: a length, a rate and the samples. */
|
||
function fakeBuffer(_channels: number, length: number, sampleRate: number) {
|
||
const data = new Float32Array(length);
|
||
return {
|
||
numberOfChannels: 1,
|
||
length,
|
||
sampleRate,
|
||
duration: length / sampleRate,
|
||
getChannelData: () => data,
|
||
};
|
||
}
|
||
|
||
function fakeContext() {
|
||
return {
|
||
state: 'running',
|
||
destination: { kind: 'destination' },
|
||
resume: () => Promise.resolve(),
|
||
suspend: () => Promise.resolve(),
|
||
decodeAudioData: () => new Promise((resolve) => decodes.push(() => resolve(fakeBuffer(1, 1200, 800)))),
|
||
createBuffer: fakeBuffer,
|
||
createBufferSource: () => {
|
||
const source: FakeSource = wired('source', {
|
||
buffer: null,
|
||
loop: false,
|
||
playbackRate: { value: 1 },
|
||
onended: null,
|
||
started: false,
|
||
stopped: false,
|
||
start: () => {
|
||
source.started = true;
|
||
},
|
||
stop: () => {
|
||
source.stopped = true;
|
||
},
|
||
});
|
||
sources.push(source);
|
||
return source;
|
||
},
|
||
createGain: () => wired('gain', { gain: { value: 1 } }),
|
||
createBiquadFilter: () => {
|
||
const filter = wired('filter', {
|
||
type: '',
|
||
frequency: { value: 0 },
|
||
gain: { value: 0 },
|
||
Q: { value: 0 },
|
||
});
|
||
filters.push(filter);
|
||
return filter;
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Lets the promise chain inside `play` run to the point where it would start a voice. */
|
||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
beforeEach(() => {
|
||
sources.length = 0;
|
||
wires.length = 0;
|
||
filters.length = 0;
|
||
decodes = [];
|
||
vi.stubGlobal(
|
||
'AudioContext',
|
||
class {
|
||
constructor() {
|
||
return fakeContext();
|
||
}
|
||
},
|
||
);
|
||
vi.stubGlobal('fetch', () =>
|
||
Promise.resolve({ ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) }),
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
/** Plays `file` and lets its bytes arrive, unless `hold` keeps the decode pending. */
|
||
async function play(library: SoundLibrary, file = FILE, { hold = false } = {}, fx?: FxChain): Promise<void> {
|
||
library.play(file, 1, false, '', fx);
|
||
await settle();
|
||
if (hold) return;
|
||
for (const resolve of decodes.splice(0)) resolve();
|
||
await settle();
|
||
}
|
||
|
||
describe('the sound library', () => {
|
||
it('fetches and decodes a file once, however often it is played', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library);
|
||
await play(library);
|
||
expect(library.stats()).toMatchObject({ started: 2, decoded: 1, playing: 2 });
|
||
// Two voices of one file, both running: plays overlap rather than replacing each other.
|
||
expect(sources.filter((s) => s.started)).toHaveLength(2);
|
||
});
|
||
|
||
it('remembers what it last started, so a muted check can see it at all', async () => {
|
||
const library = new SoundLibrary();
|
||
library.play(FILE, 0.25, true);
|
||
await settle();
|
||
for (const resolve of decodes.splice(0)) resolve();
|
||
await settle();
|
||
expect(library.stats().last).toEqual({
|
||
file: FILE,
|
||
volume: 0.25,
|
||
loop: true,
|
||
pitch: 0,
|
||
stages: 0,
|
||
duration: 1.5,
|
||
});
|
||
});
|
||
|
||
it('plays a dry voice straight into its volume', async () => {
|
||
await play(new SoundLibrary());
|
||
expect(wires).toEqual(['source > gain', 'gain > destination']);
|
||
expect(sources[0]?.playbackRate.value).toBe(1);
|
||
});
|
||
|
||
it('sends a voice through its sound FX in order (#311)', async () => {
|
||
const library = new SoundLibrary();
|
||
const fx: FxChain = {
|
||
pitch: 12,
|
||
stages: [
|
||
{ kind: 'gain', gain: 0.5 },
|
||
{ kind: 'filter', filter: 'lowshelf', frequency: 80, gain: 6, q: 1 },
|
||
],
|
||
};
|
||
await play(library, FILE, {}, fx);
|
||
expect(wires).toEqual(['source > gain', 'gain > filter', 'filter > gain', 'gain > destination']);
|
||
expect(filters).toMatchObject([
|
||
{ type: 'lowshelf', frequency: { value: 80 }, gain: { value: 6 }, Q: { value: 1 } },
|
||
]);
|
||
expect(library.stats().last).toMatchObject({ pitch: 12, stages: 2 });
|
||
});
|
||
|
||
it('pitches a voice on a copy of its length rather than by speed, and keeps the copy (#312)', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library);
|
||
await play(library, FILE, {}, { pitch: 7, stages: [] });
|
||
await play(library, FILE, {}, { pitch: 7, stages: [] });
|
||
await play(library, FILE, {}, { pitch: -3.5, stages: [] });
|
||
const [dry, up, again, down] = sources.map((s) => s.buffer as { length: number; duration: number });
|
||
expect(sources.map((s) => s.playbackRate.value)).toEqual([1, 1, 1, 1]);
|
||
expect(up).not.toBe(dry);
|
||
expect(again).toBe(up);
|
||
expect(down).not.toBe(up);
|
||
expect([up!.duration, down!.duration]).toEqual([dry!.duration, dry!.duration]);
|
||
});
|
||
|
||
it('a stop while the bytes are in flight keeps the sound from starting afterwards', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library, FILE, { hold: true });
|
||
library.stop(FILE);
|
||
for (const resolve of decodes.splice(0)) resolve();
|
||
await settle();
|
||
expect(library.stats()).toMatchObject({ started: 0, playing: 0 });
|
||
});
|
||
|
||
it('but a stop before a play does not: an earlier silence is not a standing veto', async () => {
|
||
const library = new SoundLibrary();
|
||
// Exactly what ending a run does – and the next run has to be able to play that sound again,
|
||
// even though its bytes are still unread at that point.
|
||
library.stop('');
|
||
library.stop(FILE);
|
||
await play(library);
|
||
expect(library.stats()).toMatchObject({ started: 1, playing: 1 });
|
||
});
|
||
|
||
it('stopping everything silences every voice, and only the named file otherwise', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library, FILE);
|
||
await play(library, OTHER);
|
||
library.stop(FILE);
|
||
expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 });
|
||
|
||
library.stopAll();
|
||
expect(library.stats()).toMatchObject({ playing: 0, stopped: 2 });
|
||
expect(sources.every((s) => s.stopped)).toBe(true);
|
||
});
|
||
|
||
it('a voice that ends by itself leaves the count, without being stopped', async () => {
|
||
const library = new SoundLibrary();
|
||
await play(library);
|
||
sources[0]?.onended?.();
|
||
expect(library.stats()).toMatchObject({ playing: 0, started: 1, stopped: 0 });
|
||
});
|
||
|
||
it('a file that cannot be loaded fails once and is never asked for again', async () => {
|
||
const library = new SoundLibrary();
|
||
vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 404 }));
|
||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
expect(await library.load(FILE)).toBeNull();
|
||
expect(await library.load(FILE)).toBeNull();
|
||
expect(library.stats()).toMatchObject({ failed: 1, started: 0 });
|
||
|
||
library.play(FILE, 1, false);
|
||
await settle();
|
||
expect(library.stats().started).toBe(0);
|
||
});
|
||
});
|