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:
1 parent
93c0a80b7d
commit
de14f0f9e2
34 files changed
+899
-43
No files matched your search
@@ -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();
|
||||
Reference in new issue
Block a user