Synth
@yagejs-addons/synth gives a game sound effects without any audio files. A
sound is a plain parameter object — an oscillator, a pitch glide, an envelope,
maybe a filter. The addon renders it to samples, wraps them in an
AudioBuffer, and registers it with @yagejs/audio under an alias, so it
plays through the engine’s channels, volumes, mute, and blur auto-pause
exactly like a preloaded file.
It is worth reaching for when you want a jam-ready shoot/hit/pickup set without sourcing audio, when a sound should be tweakable by editing a number, or when tests need to assert on what a sound actually does.
Install
Section titled “Install”npm install @yagejs-addons/synthnpm install @yagejs/core @yagejs/audioBoth peers are required — the addon registers its buffers through
@yagejs/audio. There is no ./presenters subpath: nothing here draws.
Five-minute setup
Section titled “Five-minute setup”import { AudioPlugin, AudioManagerKey } from "@yagejs/audio";import { SynthPlugin, synthPresets, synthVariantAliases } from "@yagejs-addons/synth";
engine.use(new AudioPlugin());engine.use( new SynthPlugin({ sounds: { explosion: synthPresets.explosion(), coin: synthPresets.coin(), // Four takes, spread in pitch, so repeated shots don't sound identical. shoot: { sound: synthPresets.shoot(), variants: 4 }, }, }),);Playing them is the normal audio API — from a Component, Entity, or Scene:
const audio = this.use(AudioManagerKey);
audio.play("explosion", { volume: 0.8 });audio.playRandom(synthVariantAliases("shoot", 4));Presets
Section titled “Presets”Each preset is a function returning patch data, and takes overrides — so a different gun, a bigger explosion, or a wooden floor is one number away.
synthPresets.shoot({ frequency: 900 }); // higher-pitchedsynthPresets.explosion({ frequency: 200, duration: 0.6 }); // bigger boomsynthPresets.footstep({ surface: "wood" }); // stone | wood | grasssynthPresets.victory({ noteDuration: 0.24 }); // slower fanfaresynthPresets.shoot({ gain: 0.5 }); // half as loudWhat you can override follows the shape of the sound, so a field the preset cannot honour is a compile error rather than a line that quietly does nothing:
- One voice or layered (shoot, hit, explosion, footstep, wind, …) takes
patch fields —
frequency,duration,filter, and the rest. On a layered preset they land on the lead voice, so the layers keep their relationship. - A note sequence (pickup, coin, victory, defeat) takes its shared voice’s
fields plus
noteDurationandnoteSpacing. Pitch comes from the notes, sofrequency,glideTo,delay, andseamlessare rejected.dialogueBeepsis the exception: it generates its notes, so it adds a basefrequencyalong withcount,spread, andphraseSeed. gainworks on all of them: it multiplies every voice’s volume, which is what you want for making a whole layered sound quieter.volumesets a single voice’s peak instead.
The full set: shoot, hit, explosion, hurt, pickup, coin, jump,
land, dash, powerup, footstep, uiClick, uiBlip, alarm,
victory, defeat, roomTone, wind, dialogueBeeps. Their levels are
tuned to sit next to each other; scale them with gain to taste.
Two of them are loops rather than one-shots. wind is an ambient bed with
gust swells baked inside the loop. dialogueBeeps is speech chatter for
text reveals — short blips around a base pitch with syllable-like rests:
new SynthPlugin({ sounds: { "voice/guard": synthPresets.dialogueBeeps({ frequency: 220, phraseSeed: 4 }) },});
const talking = audio.play("voice/guard", { loop: true, channel: "voice" });audio.stop(talking); // when the line finishes revealingThe same phraseSeed always speaks the same phrase, so a character’s voice is
stable across plays; a different frequency and phraseSeed gives each
character their own.
Writing your own sound
Section titled “Writing your own sound”A patch is one voice:
const zap = { wave: "square", // sine | square | sawtooth | triangle | noise frequency: 1200, // starting pitch glideTo: 200, // pitch at the end — the exponential fall duration: 0.09, attack: 0.004, // fade-in; the rest of the duration is the release curve: 4, // release steepness volume: 0.2,};Add noise: 0.4 to mix white noise into the tone, or a filter with an
optional sweep:
const whoosh = { wave: "noise", duration: 0.22, filter: { type: "bandpass", frequency: 500, sweepTo: 5200, q: 2 },};An array of patches plays as one sound, so layers stack — each voice can start late:
const shotgun = [ { wave: "sawtooth", frequency: 300, glideTo: 40, duration: 0.25, volume: 0.3 }, { wave: "noise", duration: 0.3, volume: 0.3, filter: { type: "lowpass", frequency: 2000, sweepTo: 300 } }, { wave: "square", frequency: 90, duration: 0.1, delay: 0.28, volume: 0.1 }, // the pump];And a jingle bakes a note sequence into a single buffer:
const levelUp = { notes: [523, 659, 784, 1046], // Hz; 0 is a rest noteDuration: 0.18, noteSpacing: 0.12, // shorter than the notes, so they ring into each other voice: { wave: "triangle", volume: 0.26 },};Any of the three shapes — a patch, an array of patches, a jingle — goes
straight into SynthPlugin’s sounds config.
Sounds you can test
Section titled “Sounds you can test”renderSynthPatch turns a patch into a Float32Array. It is plain math: no
WebAudio, no randomness that isn’t seeded, no engine involved. The same patch
always renders the same samples, so a sound can be checked in a unit test the
way any other pure function is.
import { renderSynthPatch } from "@yagejs-addons/synth";
const samples = renderSynthPatch({ frequency: 800, glideTo: 100, duration: 0.4 });expect(samples).toHaveLength(0.4 * 44100);Registering a sound outside the plugin config
Section titled “Registering a sound outside the plugin config”synthBuffer renders straight to an AudioBuffer for sounds a game builds at
runtime — a boss whose hit sound tracks its remaining health, say:
import { registerSound, unregisterSound } from "@yagejs/audio";import { synthBuffer, synthPresets } from "@yagejs-addons/synth";
registerSound("boss-hit", synthBuffer(synthPresets.hit({ frequency: 180 })));// laterunregisterSound("boss-hit");Variation between plays
Section titled “Variation between plays”A rendered buffer sounds identical every time it plays. Two ways to break that up:
// Several takes, picked at random per play.new SynthPlugin({ sounds: { shoot: { sound: synthPresets.shoot(), variants: 4, detune: 0.08 } },});audio.playRandom(synthVariantAliases("shoot", 4));
// Or jitter the playback rate at the call site (variants register only the// suffixed aliases, so play one of those).audio.play("shoot.1", { speed: 0.95 + Math.random() * 0.1 });A variants: n entry registers alias.1 … alias.n — nothing under the bare
alias — spread evenly across ±detune (default ±6%), each with its own noise.
Ambient loops
Section titled “Ambient loops”seamless: true drops the envelope and crossfades the end of the buffer into
its start, so it loops without a click. The crossfade consumes up to 50 ms, so
the buffer comes out slightly shorter than duration — don’t schedule
anything against the exact requested length:
new SynthPlugin({ sounds: { ambience: synthPresets.roomTone({ duration: 6 }) } });
audio.play("ambience", { loop: true, channel: "music" });Save and load
Section titled “Save and load”Nothing to persist. The plugin re-registers every sound from its config on
each boot, so a snapshot only ever holds the alias string — the same contract
as runtime textures. plugin.aliases lists what it registered, and
onDestroy gives them back.