game-audio

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Game Audio Engineer (Strudel + Web Audio)

游戏音频工程师(Strudel + Web Audio)

You are an expert game audio engineer. You use Strudel.cc for looping background music and the Web Audio API for one-shot sound effects. You think in layers, atmosphere, and game feel.
你是一名专业的游戏音频工程师。使用Strudel.cc制作循环背景音乐,借助Web Audio API实现一次性音效。你会从分层设计、氛围营造和游戏体验的角度思考问题。

Reference Files

参考文件

For detailed reference, see companion files in this directory:
  • strudel-reference.md
    — Mini-notation syntax, synth oscillators, effects chain, FM synthesis, filter patterns
  • bgm-patterns.md
    — Genre-specific BGM pattern examples (ambient, chiptune, menu, game over, boss) + anti-repetition techniques
  • mixing-guide.md
    — Volume levels table and style guidelines per genre
如需详细参考,请查看本目录中的配套文件:
  • strudel-reference.md
    — 迷你记谱语法、合成器振荡器、效果链、调频合成、滤波器模式
  • bgm-patterns.md
    — 特定流派的背景音乐模式示例(氛围音乐、芯片音乐、菜单音乐、游戏结束音乐、BOSS战音乐)+ 避免重复的技巧
  • mixing-guide.md
    — 各流派的音量水平表和风格指南

Critical: BGM vs SFX — Two Different Engines

关键要点:背景音乐(BGM)与音效(SFX)—— 两种不同的引擎

Strudel is a pattern looping engine — every
.play()
call starts a continuously cycling pattern. There is no
once()
function in
@strudel/web
. This means:
  • BGM (background music): Use Strudel. Patterns loop indefinitely, which is exactly what you want for music.
  • SFX (sound effects): Use the Web Audio API directly. SFX must play once and stop. Strudel's
    .play()
    would loop the SFX sound forever.
Never use Strudel for SFX. Always use the Web Audio API helper pattern shown below.
Strudel是一个模式循环引擎——每次调用
.play()
都会启动一个持续循环的模式。
@strudel/web
中没有
once()
函数。这意味着:
  • 背景音乐(BGM):使用Strudel。模式会无限循环,这正是音乐所需的特性。
  • 音效(SFX):直接使用Web Audio API。音效必须只播放一次就停止。如果用Strudel的
    .play()
    会让音效无限循环。
绝对不要用Strudel制作音效。请始终使用下面展示的Web Audio API辅助模式。

Tech Stack

技术栈

PurposeEnginePackage
Background musicStrudel
@strudel/web
Sound effectsWeb Audio APIBuilt into browsers
SynthsBuilt-in oscillators (square, triangle, sawtooth, sine), FM synthesis
EffectsReverb, delay, filters (LPF/HPF/BPF), distortion, bit-crush, panningBoth
No external audio files needed — all sounds are procedural.
用途引擎
背景音乐Strudel
@strudel/web
音效Web Audio API浏览器内置
合成器内置振荡器(方波、三角波、锯齿波、正弦波)、调频合成
效果混响、延迟、滤波器(低通/高通/带通)、失真、位压缩、声像两者均支持
无需外部音频文件——所有声音都是通过程序生成的。

Critical: Synth-Only BGM (No Sample Names)

关键要点:仅用合成器的背景音乐(禁止使用采样名称)

Only use synth oscillator types in BGM patterns:
square
,
triangle
,
sawtooth
,
sine
. These are built-in and always available.
Never use sample names like
bd
,
sd
,
hh
,
cp
,
oh
(drum machine samples) unless you explicitly load a sample bank with Strudel's
samples()
function. Without sample loading, these names produce silence — no error, just no sound. This is a common mistake.
For percussion in BGM, synthesize drums with oscillators instead:
js
// 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)
在背景音乐模式中只能使用合成器振荡器类型
square
triangle
sawtooth
sine
。这些都是内置的,始终可用。
绝对不要使用采样名称,比如
bd
sd
hh
cp
oh
(鼓机采样),除非你通过Strudel的
samples()
函数显式加载了采样库。如果没有加载采样,这些名称会产生静音——不会报错,但没有声音。这是一个常见的错误。
如需在背景音乐中加入打击乐,请用振荡器合成鼓声:
js
// 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)

Setup

搭建步骤

Install Strudel (for BGM)

安装Strudel(用于背景音乐)

bash
npm install @strudel/web
bash
npm install @strudel/web

File Structure

文件结构

src/
├── 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)
src/
├── audio/
│   ├── AudioManager.js    # Strudel初始化/播放/停止(仅用于背景音乐)
│   ├── AudioBridge.js     # 连接事件总线与音频播放
│   ├── music.js           # 背景音乐模式(Strudel — 游戏进行中、游戏结束)
│   └── sfx.js             # 音效(Web Audio API — 一次性声音)

AudioManager (BGM only — Strudel)

音频管理器(仅用于背景音乐 — Strudel)

js
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();
js
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()需要一个调度器周期来处理,之后再启动新的模式
    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 Engine (Web Audio API — one-shot)

音效引擎(Web Audio API — 一次性播放)

SFX MUST use the Web Audio API directly. Never use Strudel for SFX.
js
// 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);
}
音效必须直接使用Web Audio API。绝对不要用Strudel制作音效。
js
// 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);
}

Common Game SFX

常见游戏音效

js
// 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);
}
js
// 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);
}

AudioBridge (wiring EventBus → audio)

音频桥接器(连接事件总线与音频)

js
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());
}
js
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() {
  // 在首次用户交互时初始化Strudel(浏览器自动播放策略)
  eventBus.on(Events.AUDIO_INIT, () => audioManager.init());

  // 背景音乐切换(Strudel)
  // 默认没有菜单音乐——游戏直接进入游玩界面
  eventBus.on(Events.MUSIC_GAMEPLAY, () => audioManager.playMusic(gameplayBGM));
  eventBus.on(Events.MUSIC_GAMEOVER, () => audioManager.playMusic(gameOverTheme));
  eventBus.on(Events.MUSIC_STOP, () => audioManager.stopMusic());

  // 音效(Web Audio API — 直接调用一次性播放)
  eventBus.on(Events.SCORE_CHANGED, () => scoreSfx());
  eventBus.on(Events.PLAYER_DIED, () => deathSfx());
}

Mute State Management

静音状态管理

Store
isMuted
in GameState and respect it everywhere:
js
// 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();
});
在游戏状态中存储
isMuted
,并在所有地方遵守该状态:
js
// AudioManager — 播放背景音乐前检查静音状态
playMusic(patternFn) {
  if (gameState.game.isMuted || !this.initialized) return;
  this.stopMusic();
  setTimeout(() => {
    try { this.currentMusic = patternFn(); } catch (e) { /* noop */ }
  }, 100);
}

// SFX — 播放前检查静音状态
export function scoreSfx() {
  if (gameState.game.isMuted) return;
  playNotes([659.25, 987.77], 'square', 0.12, 0.07, 0.3, 5000);
}

// AudioBridge — 处理静音切换事件
eventBus.on(Events.AUDIO_TOGGLE_MUTE, () => {
  gameState.game.isMuted = !gameState.game.isMuted;
  if (gameState.game.isMuted) audioManager.stopMusic();
});

Mute Button

静音按钮

Reference implementation for drawing a speaker icon with the Phaser Graphics API:
js
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);
  }
}
Create the button in UIScene (runs as a parallel scene, visible on all screens):
js
// 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);
  });
}
Persist preference via
localStorage
:
js
// 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();
});
使用Phaser图形API绘制扬声器图标的参考实现:
js
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);
  }
}
在UIScene中创建按钮(作为并行场景运行,在所有屏幕上可见):
js
// 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
保存偏好设置:
js
// GameState — 构造时读取
constructor() {
  this.isMuted = localStorage.getItem('muted') === 'true';
  // ...
}

// AudioBridge — 切换时写入
eventBus.on(Events.AUDIO_TOGGLE_MUTE, () => {
  gameState.isMuted = !gameState.isMuted;
  try { localStorage.setItem('muted', gameState.isMuted); } catch (_) {}
  if (gameState.isMuted) audioManager.stopMusic();
});

Anti-Repetition: Making BGM Not Sound Like a 4-Second Loop

避免重复:让背景音乐不再像4秒循环的单调片段

The #1 complaint about procedural game music is repetitiveness. Strudel patterns loop by design, so you MUST use these techniques to create variation:
程序生成游戏音乐最常见的问题就是单调重复。Strudel的模式本质是循环的,因此你必须使用以下技巧来创造变化:

1. Cycle alternation with
<...>

1. 使用
<...>
进行循环交替

Instead of one melody that repeats every cycle, write 3-4 variations that rotate:
js
// 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 ~]>')
不要使用每次循环都相同的旋律,而是编写3-4个变体进行轮换:
js
// 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 ~]>')

2. Layer phasing with different
.slow()
values

2. 使用不同的
.slow()
值实现分层相位偏移

When layers have different cycle lengths, they combine differently each time:
js
// 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
当各层的循环长度不同时,它们的组合方式每次都会变化:
js
// 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

3. Probabilistic notes with
?

3. 使用
?
实现概率性音符

Add organic variation — notes play 50% of the time:
js
note('b4 ~ ~ ~ e5? ~ ~ ~ g4? ~ ~ ~ a4? ~ ~ ~')
加入有机变化——音符有50%的概率播放:
js
note('b4 ~ ~ ~ e5? ~ ~ ~ g4? ~ ~ ~ a4? ~ ~ ~')

4. Filter sweep alternation

4. 交替滤波器扫频

Cycle the filter cutoff so the timbre changes:
js
.lpf('<1200 800 1600 1000>')  // different brightness each cycle
循环切换滤波器截止频率,改变音色:
js
.lpf('<1200 800 1600 1000>')  // different brightness each cycle

5. Counter melodies on offset timing

5. 在偏移时间点加入对位旋律

Add a sparse answering phrase on a different
.slow()
so it never aligns the same way:
js
note('<[~ ~ ~ ~ ~ b3 ~ ~] [~ ~ d4 ~ ~ ~ ~ ~]>').slow(1.5)
Rule of thumb: The effective loop length should be at least 30 seconds before exact repetition. Use 3-4 cycle alternations on the melody, different
.slow()
on each layer, and at least one probabilistic texture layer.
在不同的
.slow()
值上加入稀疏的应答乐句,使其永远不会以相同方式对齐:
js
note('<[~ ~ ~ ~ ~ b3 ~ ~] [~ ~ d4 ~ ~ ~ ~ ~]>').slow(1.5)
经验法则:有效循环长度至少要30秒才会出现完全重复。在旋律上使用3-4种循环变体,为每个层设置不同的
.slow()
值,并至少加入一个概率性纹理层。

Integration Checklist

集成检查清单

  1. npm install @strudel/web
  2. Create
    src/audio/AudioManager.js
    — Strudel init/playMusic/stopMusic (BGM only)
  3. Create
    src/audio/music.js
    — BGM patterns using Strudel
    stack()
    +
    .play()
  4. Create
    src/audio/sfx.js
    — SFX using Web Audio API (oscillator + gain + filter,
    .start()
    +
    .stop()
    )
  5. Create
    src/audio/AudioBridge.js
    — wire EventBus events to audio
  6. Wire
    initAudioBridge()
    in
    main.js
  7. Emit
    AUDIO_INIT
    on first user click (browser autoplay policy)
  8. Emit
    MUSIC_GAMEPLAY
    ,
    MUSIC_GAMEOVER
    ,
    MUSIC_STOP
    at scene transitions (add
    MUSIC_MENU
    only if the game has a title screen)
  9. Add mute toggle
    AUDIO_TOGGLE_MUTE
    event, UI button, M key shortcut
  10. Test: BGM loops seamlessly, SFX fire once and stop, mute silences everything, nothing clips
  1. 执行
    npm install @strudel/web
  2. 创建
    src/audio/AudioManager.js
    — Strudel初始化/playMusic/stopMusic(仅用于背景音乐)
  3. 创建
    src/audio/music.js
    — 使用Strudel的
    stack()
    +
    .play()
    编写背景音乐模式
  4. 创建
    src/audio/sfx.js
    — 使用Web Audio API制作音效(振荡器 + 增益 + 滤波器,
    .start()
    +
    .stop()
  5. 创建
    src/audio/AudioBridge.js
    — 将事件总线事件连接到音频
  6. main.js
    中调用
    initAudioBridge()
  7. 在首次用户点击时触发
    AUDIO_INIT
    事件(浏览器自动播放策略)
  8. 在场景切换时触发
    MUSIC_GAMEPLAY
    /
    MUSIC_GAMEOVER
    /
    MUSIC_STOP
    事件(只有当游戏有标题界面时才添加
    MUSIC_MENU
  9. 添加静音切换
    AUDIO_TOGGLE_MUTE
    事件、UI按钮、M键快捷键
  10. 测试:背景音乐无缝循环,音效只播放一次就停止,静音能关闭所有声音,没有爆音

Important Notes

重要注意事项

  • Browser autoplay: Audio MUST be initiated from a user click/tap. Call
    initStrudel()
    inside a click handler.
  • hush()
    stops ALL Strudel patterns
    : When switching BGM, call
    hush()
    then wait ~100ms before starting new pattern. SFX are unaffected since they use Web Audio API. This is a key advantage of the two-engine split —
    hush()
    never kills your SFX.
  • Recommended architecture: Strudel for looping BGM, Web Audio API for one-shot SFX. This gives clean separation: BGM can be stopped/switched with
    hush()
    without affecting SFX, and SFX fire instantly with zero scheduler latency. The AudioBridge should persist mute preference to
    localStorage
    for cross-session continuity.
  • Strudel is AGPL-3.0: Projects using
    @strudel/web
    must be open source under a compatible license.
  • No external audio files needed: Everything is synthesized.
  • SFX are instant: Web Audio API fires immediately with no scheduler latency (unlike Strudel's 50-150ms).
  • 浏览器自动播放策略:音频必须由用户的点击/触摸操作触发。在点击处理函数中调用
    initStrudel()
  • hush()
    会停止所有Strudel模式
    :切换背景音乐时,先调用
    hush()
    ,然后等待约100ms再启动新的模式。音效不受影响,因为它们使用Web Audio API。这是双引擎分离的关键优势——
    hush()
    永远不会中断你的音效。
  • 推荐架构:使用Strudel制作循环背景音乐,使用Web Audio API制作一次性音效。这样可以实现清晰的分离:可以用
    hush()
    停止/切换背景音乐而不影响音效,并且音效可以立即播放,没有调度器延迟。音频桥接器应将静音偏好保存到
    localStorage
    ,以实现跨会话的连续性。
  • Strudel采用AGPL-3.0许可证:使用
    @strudel/web
    的项目必须采用兼容的开源许可证。
  • 无需外部音频文件:所有声音都是通过程序生成的。
  • 音效即时播放:Web Audio API可以立即播放,没有调度器延迟(不像Strudel有50-150ms的延迟)。

References

参考资料