A sound could only be played as it was uploaded. A Sound FX asset is
a node graph like a shader: Input, effects, Output. Play sound has a
new input fx that sends the voice through it.
- New asset type soundfx ("Sound FX"), not placeable, with a graph
and its own tab. The heading in the asset panel is "Sound effects",
so it does not take the click meant for the + menu entry.
- Nodes in core/soundfx/nodes.ts, category sound, port type audio
that only fits audio: Input, Output, Gain (dB), Pitch (semitones),
EQ (80 Hz and 12 kHz shelves, peaks at 250 Hz, 1 kHz and 4 kHz).
- compileSoundFx walks back from the Output with the shared graph
emitter. An input takes one edge, so the result is one line: a
pitch and a list of gain and filter stages. A node without input
or a graph without Output reports a problem on the node.
- Pitch is the playback rate of the source. It applies to the whole
sound and also changes its length, the node description says so.
- The BehaviorSystem resolves fx and hands the line to the AudioSink.
An fx that is missing or does not compile plays the sound dry.
- SoundLibrary wires source, stages, volume, destination in that
order. stats().last now also names pitch and stage count.
- The tab header picks one of the project's sounds and plays it
through the graph as it stands. Stop there ends only its own voice.
Tests:
- soundfx.test.ts (new, 11), sound-library (9, +2 for the wiring),
node-catalog (+1 for fx), graph and asset-groups adjusted
- e2e 34: ALL PASSED - 26 checks (+5: + menu opens the tab, palette,
2 effects, tab Play and game Play through the FX at -12 st)
- Gate on melektaus-atx: typecheck, lint, prettier clean, 239 of 240
test files green. The red one was sound-library (its fake
AudioContext had no playbackRate), fixed afterwards, gate not
re-run in full.
- Counter-check: without the audio rule in portsCompatible the port
test fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
7.3 KiB
TypeScript
222 lines
7.3 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)[] = [];
|
||
|
||
function fakeContext() {
|
||
return {
|
||
state: 'running',
|
||
destination: { kind: 'destination' },
|
||
resume: () => Promise.resolve(),
|
||
suspend: () => Promise.resolve(),
|
||
decodeAudioData: () => new Promise((resolve) => decodes.push(() => resolve({ duration: 1.5 }))),
|
||
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 });
|
||
});
|
||
|
||
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, pitched at the source (#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(sources[0]?.playbackRate.value).toBe(2);
|
||
expect(filters).toMatchObject([
|
||
{ type: 'lowshelf', frequency: { value: 80 }, gain: { value: 6 }, Q: { value: 1 } },
|
||
]);
|
||
expect(library.stats().last).toMatchObject({ pitch: 12, stages: 2 });
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|