Play a sound through a graph of gain, pitch and EQ effects #311

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 16:45:42 +02:00
1 parent 93c0a80b7d
commit de14f0f9e2
34 files changed
+899 -43

No files matched your search

+44 -8
View File
@@ -1,3 +1,4 @@
import { DRY, type FxChain, type FxStage } from '../core/soundfx/nodes';
import type { AudioSink } from '../runtime/systems/Audio';
const DEFAULT_BASE = '/game-maker/data/sounds';
@@ -35,7 +36,7 @@ export class SoundLibrary implements AudioSink {
private started = 0;
private stopped = 0;
/** The last voice actually started, with the values it was started at – what a check reads back. */
private last: { file: string; volume: number; loop: boolean } | null = null;
private last: LastVoice | null = null;
/**
* How often a stop has reached each file, and how often one has silenced everything. Counters
* rather than a "cancelled" flag: what a play has to know is whether a stop happened *after it
@@ -53,18 +54,19 @@ export class SoundLibrary implements AudioSink {
this.base = base.replace(/\/+$/, '');
}
play(file: string, volume: number, loop: boolean, owner = ''): void {
play(file: string, volume: number, loop: boolean, owner = '', fx: FxChain = DRY): void {
if (file === '' || this.failed.has(file)) return;
const buffer = this.buffers.get(file);
if (buffer) {
this.start(file, buffer, volume, loop, owner);
this.start(file, buffer, volume, loop, owner, fx);
return;
}
const askedAt = this.cancelsFor(file, owner);
void this.load(file).then((loaded) => {
// A `Stop sound` – or the destruction of the behavior that asked – that happened while the
// bytes were in flight has to win, or a sound the run already ended would start afterwards.
if (loaded && this.cancelsFor(file, owner) === askedAt) this.start(file, loaded, volume, loop, owner);
if (loaded && this.cancelsFor(file, owner) === askedAt)
this.start(file, loaded, volume, loop, owner, fx);
});
}
@@ -104,7 +106,7 @@ export class SoundLibrary implements AudioSink {
stopped: number;
decoded: number;
failed: number;
last: { file: string; volume: number; loop: boolean } | null;
last: LastVoice | null;
} {
return {
playing: this.voices.length,
@@ -155,7 +157,14 @@ export class SoundLibrary implements AudioSink {
this.stopped += going.length;
}
private start(file: string, buffer: AudioBuffer, volume: number, loop: boolean, owner: string): void {
private start(
file: string,
buffer: AudioBuffer,
volume: number,
loop: boolean,
owner: string,
fx: FxChain,
): void {
const context = this.audio();
// A context starts suspended where the page has not been interacted with; without this the
// voice would run silently and end as if it had played.
@@ -163,10 +172,13 @@ export class SoundLibrary implements AudioSink {
const source = context.createBufferSource();
source.buffer = buffer;
source.loop = loop;
source.playbackRate.value = 2 ** (fx.pitch / 12);
const gain = context.createGain();
gain.gain.value = Math.max(0, Math.min(1, volume));
this.last = { file, volume: gain.gain.value, loop };
source.connect(gain).connect(context.destination);
this.last = { file, volume: gain.gain.value, loop, pitch: fx.pitch, stages: fx.stages.length };
let tail: AudioNode = source;
for (const stage of fx.stages) tail = tail.connect(fxNode(context, stage));
tail.connect(gain).connect(context.destination);
const voice: Voice = { file, owner, source, gain };
source.onended = () => {
this.voices = this.voices.filter((other) => other !== voice);
@@ -186,4 +198,28 @@ export class SoundLibrary implements AudioSink {
}
}
/** What `stats` says about the last voice: enough for a check to tell which effects it went through. */
interface LastVoice {
file: string;
volume: number;
loop: boolean;
/** Semitones, and how many gain and filter nodes it passed. */
pitch: number;
stages: number;
}
function fxNode(context: AudioContext, stage: FxStage): AudioNode {
if (stage.kind === 'gain') {
const gain = context.createGain();
gain.gain.value = stage.gain;
return gain;
}
const filter = context.createBiquadFilter();
filter.type = stage.filter;
filter.frequency.value = stage.frequency;
filter.gain.value = stage.gain;
filter.Q.value = stage.q;
return filter;
}
export const soundLibrary = new SoundLibrary();