Loading...
Loading...
Game audio engineer using Strudel.cc for background music and Web Audio API for sound effects in browser games. Use when adding music or SFX to a game.
npx skill4agent add opusgamelabs/game-creator game-audiostrudel-reference.mdbgm-patterns.mdmixing-guide.md.play()once()@strudel/web.play()| Purpose | Engine | Package |
|---|---|---|
| Background music | Strudel | |
| Sound effects | Web Audio API | Built into browsers |
| Synths | Built-in oscillators (square, triangle, sawtooth, sine), FM synthesis | — |
| Effects | Reverb, delay, filters (LPF/HPF/BPF), distortion, bit-crush, panning | Both |
squaretrianglesawtoothsinebdsdhhcpohsamples()// Kick drum — low sine with fast decay
note('c1').s('sine').gain(0.3).decay(0.15).sustain(0)
// Snare — noise burst (use .n() for noise channel if available, or skip)
note('c3').s('square').gain(0.15).decay(0.08).sustain(0).lpf(1500)
// Hi-hat — high-frequency square with very short decay
note('c6').s('square').gain(0.08).decay(0.03).sustain(0).lpf(8000)npm install @strudel/websrc/
├── audio/
│ ├── AudioManager.js # Strudel init/play/stop for BGM
│ ├── AudioBridge.js # Wires EventBus → audio playback
│ ├── music.js # BGM patterns (Strudel — gameplay, game over)
│ └── sfx.js # SFX (Web Audio API — one-shot sounds)import { initStrudel, hush } from '@strudel/web';
class AudioManager {
constructor() {
this.initialized = false;
this.currentMusic = null;
}
init() {
if (this.initialized) return;
try {
initStrudel();
this.initialized = true;
} catch (e) {
console.warn('[Audio] Strudel init failed:', e);
}
}
playMusic(patternFn) {
if (!this.initialized) return;
this.stopMusic();
// hush() needs a scheduler tick to process before new pattern starts
setTimeout(() => {
try {
this.currentMusic = patternFn();
} catch (e) {
console.warn('[Audio] BGM error:', e);
}
}, 100);
}
stopMusic() {
if (!this.initialized) return;
try { hush(); } catch (e) { /* noop */ }
this.currentMusic = null;
}
}
export const audioManager = new AudioManager();// sfx.js — Web Audio API one-shot sounds
let audioCtx = null;
function getCtx() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
return audioCtx;
}
// Play a single tone that stops after duration
function playTone(freq, type, duration, gain = 0.3, filterFreq = 4000) {
const ctx = getCtx();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, now);
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(gain, now);
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(filterFreq, now);
osc.connect(filter).connect(gainNode).connect(ctx.destination);
osc.start(now);
osc.stop(now + duration);
}
// Play a sequence of tones (each fires once and stops)
function playNotes(notes, type, noteDuration, gap, gain = 0.3, filterFreq = 4000) {
const ctx = getCtx();
const now = ctx.currentTime;
notes.forEach((freq, i) => {
const start = now + i * gap;
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, start);
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(gain, start);
gainNode.gain.exponentialRampToValueAtTime(0.001, start + noteDuration);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(filterFreq, start);
osc.connect(filter).connect(gainNode).connect(ctx.destination);
osc.start(start);
osc.stop(start + noteDuration);
});
}
// Play noise burst (for clicks, whooshes)
function playNoise(duration, gain = 0.2, lpfFreq = 4000, hpfFreq = 0) {
const ctx = getCtx();
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * duration;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(gain, now);
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
const lpf = ctx.createBiquadFilter();
lpf.type = 'lowpass';
lpf.frequency.setValueAtTime(lpfFreq, now);
let chain = source.connect(lpf).connect(gainNode);
if (hpfFreq > 0) {
const hpf = ctx.createBiquadFilter();
hpf.type = 'highpass';
hpf.frequency.setValueAtTime(hpfFreq, now);
source.disconnect();
chain = source.connect(hpf).connect(lpf).connect(gainNode);
}
chain.connect(ctx.destination);
source.start(now);
source.stop(now + duration);
}// Note frequencies: C4=261.63, D4=293.66, E4=329.63, F4=349.23,
// G4=392.00, A4=440.00, B4=493.88, C5=523.25, E5=659.25, B5=987.77
// Score / Coin — bright ascending two-tone chime
export function scoreSfx() {
playNotes([659.25, 987.77], 'square', 0.12, 0.07, 0.3, 5000);
}
// Jump / Flap — quick upward pitch sweep
export function jumpSfx() {
const ctx = getCtx();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.setValueAtTime(261.63, now);
osc.frequency.exponentialRampToValueAtTime(1046.5, now + 0.1);
const g = ctx.createGain();
g.gain.setValueAtTime(0.2, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(3000, now);
osc.connect(f).connect(g).connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.12);
}
// Death / Crash — descending crushed tones
export function deathSfx() {
playNotes([392, 329.63, 261.63, 220, 174.61], 'square', 0.2, 0.1, 0.25, 2000);
}
// Button Click — short pop
export function clickSfx() {
playTone(523.25, 'sine', 0.08, 0.2, 5000);
}
// Power Up — ascending arpeggio
export function powerUpSfx() {
playNotes([261.63, 329.63, 392, 523.25, 659.25], 'square', 0.1, 0.06, 0.3, 5000);
}
// Hit / Damage — low thump
export function hitSfx() {
playTone(65.41, 'square', 0.15, 0.3, 800);
}
// Whoosh — noise sweep
export function whooshSfx() {
playNoise(0.25, 0.15, 6000, 800);
}
// Menu Select — soft confirmation
export function selectSfx() {
playTone(523.25, 'sine', 0.2, 0.25, 6000);
}import { eventBus, Events } from '../core/EventBus.js';
import { audioManager } from './AudioManager.js';
import { gameplayBGM, gameOverTheme } from './music.js';
import { scoreSfx, deathSfx, clickSfx } from './sfx.js';
export function initAudioBridge() {
// Init Strudel on first user interaction (browser autoplay policy)
eventBus.on(Events.AUDIO_INIT, () => audioManager.init());
// BGM transitions (Strudel)
// No menu music by default — games boot directly into gameplay
eventBus.on(Events.MUSIC_GAMEPLAY, () => audioManager.playMusic(gameplayBGM));
eventBus.on(Events.MUSIC_GAMEOVER, () => audioManager.playMusic(gameOverTheme));
eventBus.on(Events.MUSIC_STOP, () => audioManager.stopMusic());
// SFX (Web Audio API — direct one-shot calls)
eventBus.on(Events.SCORE_CHANGED, () => scoreSfx());
eventBus.on(Events.PLAYER_DIED, () => deathSfx());
}isMuted// AudioManager — check mute before playing BGM
playMusic(patternFn) {
if (gameState.game.isMuted || !this.initialized) return;
this.stopMusic();
setTimeout(() => {
try { this.currentMusic = patternFn(); } catch (e) { /* noop */ }
}, 100);
}
// SFX — check mute before playing
export function scoreSfx() {
if (gameState.game.isMuted) return;
playNotes([659.25, 987.77], 'square', 0.12, 0.07, 0.3, 5000);
}
// AudioBridge — handle mute toggle event
eventBus.on(Events.AUDIO_TOGGLE_MUTE, () => {
gameState.game.isMuted = !gameState.game.isMuted;
if (gameState.game.isMuted) audioManager.stopMusic();
});function drawMuteIcon(gfx, muted, size) {
gfx.clear();
const s = size;
// Speaker body — rectangle + triangle cone
gfx.fillStyle(0xffffff);
gfx.fillRect(-s * 0.15, -s * 0.15, s * 0.15, s * 0.3);
gfx.fillTriangle(-s * 0.15, -s * 0.3, -s * 0.15, s * 0.3, -s * 0.45, 0);
if (!muted) {
// Sound waves — two arcs
gfx.lineStyle(2, 0xffffff);
gfx.beginPath();
gfx.arc(0, 0, s * 0.2, -Math.PI / 4, Math.PI / 4);
gfx.strokePath();
gfx.beginPath();
gfx.arc(0, 0, s * 0.35, -Math.PI / 4, Math.PI / 4);
gfx.strokePath();
} else {
// X mark
gfx.lineStyle(3, 0xff4444);
gfx.lineBetween(s * 0.05, -s * 0.25, s * 0.35, s * 0.25);
gfx.lineBetween(s * 0.05, s * 0.25, s * 0.35, -s * 0.25);
}
}// In UIScene.create():
_createMuteButton() {
const ICON_SIZE = 16;
const MARGIN = 12;
const x = this.cameras.main.width - MARGIN - ICON_SIZE;
const y = this.cameras.main.height - MARGIN - ICON_SIZE;
// Hit zone — semi-transparent circle
this.muteBg = this.add.circle(x, y, ICON_SIZE + 4, 0x000000, 0.3)
.setInteractive({ useHandCursor: true })
.setDepth(100);
// Speaker icon drawn with Graphics API
this.muteIcon = this.add.graphics().setDepth(100);
this.muteIcon.setPosition(x, y);
drawMuteIcon(this.muteIcon, gameState.isMuted, ICON_SIZE);
// Click toggles mute
this.muteBg.on('pointerdown', () => {
eventBus.emit(Events.AUDIO_TOGGLE_MUTE);
drawMuteIcon(this.muteIcon, gameState.isMuted, ICON_SIZE);
});
// M key shortcut
this.input.keyboard.on('keydown-M', () => {
eventBus.emit(Events.AUDIO_TOGGLE_MUTE);
drawMuteIcon(this.muteIcon, gameState.isMuted, ICON_SIZE);
});
}localStorage// GameState — read on construct
constructor() {
this.isMuted = localStorage.getItem('muted') === 'true';
// ...
}
// AudioBridge — write on toggle
eventBus.on(Events.AUDIO_TOGGLE_MUTE, () => {
gameState.isMuted = !gameState.isMuted;
try { localStorage.setItem('muted', gameState.isMuted); } catch (_) {}
if (gameState.isMuted) audioManager.stopMusic();
});<...>// BAD — same 16 notes every cycle, gets old in 5 seconds
note('e3 ~ g3 a3 ~ ~ g3 ~ e3 ~ d3 e3 ~ ~ ~ ~')
// GOOD — 4 different phrases that alternate, takes 4x longer to repeat
note('<[e3 ~ g3 a3 ~ ~ g3 ~ e3 ~ d3 e3 ~ ~ ~ ~] [g3 ~ a3 b3 ~ ~ a3 ~ g3 ~ e3 g3 ~ ~ ~ ~] [a3 ~ g3 e3 ~ ~ d3 ~ e3 ~ g3 a3 ~ ~ ~ ~] [b3 ~ a3 g3 ~ ~ e3 ~ d3 ~ e3 ~ g3 ~ a3 ~]>').slow()// Melody repeats every 1 cycle, bass every 1.5, pad every 4
// Creates ~12 cycles before exact alignment
note('...melody...'), // .slow(1) — default
note('...bass...').slow(1.5), // 1.5x slower
note('...pad chords...').slow(4), // 4x slower
note('...texture...').slow(3), // 3x slower?note('b4 ~ ~ ~ e5? ~ ~ ~ g4? ~ ~ ~ a4? ~ ~ ~').lpf('<1200 800 1600 1000>') // different brightness each cycle.slow()note('<[~ ~ ~ ~ ~ b3 ~ ~] [~ ~ d4 ~ ~ ~ ~ ~]>').slow(1.5).slow()npm install @strudel/websrc/audio/AudioManager.jssrc/audio/music.jsstack().play()src/audio/sfx.js.start().stop()src/audio/AudioBridge.jsinitAudioBridge()main.jsAUDIO_INITMUSIC_GAMEPLAYMUSIC_GAMEOVERMUSIC_STOPMUSIC_MENUAUDIO_TOGGLE_MUTEinitStrudel()hush()hush()hush()hush()localStorage@strudel/web