| name | procedural-audio |
| description | Create and modify procedural audio (music, SFX, footsteps) using Web Audio API in 2D&D |
| license | MIT |
Procedural Audio System
All audio in 2D&D is synthesized at runtime via the Web Audio API — no external audio files.
The main engine lives in src/systems/audio.ts; focused trap synthesis lives in
src/systems/trapAudio.ts.
Architecture
Audio Engine Singleton
import { audioEngine } from "../systems/audio";
audioEngine.init();
Gain Node Graph
AudioContext.destination
└── masterGain (volume * muted)
├── musicGain (music tracks)
├── sfxGain (attack, chest, dungeon, potion, trap SFX)
├── dialogGain (NPC dialogue blips)
└── footstepGain (terrain footsteps, very low volume)
Volume Persistence
All volume settings are persisted with accessibility settings in the versioned
2dnd_preferences document. audioEngine subscribes to that shared store so
title and in-game controls update the live gain graph immediately. The legacy
2dnd_audio_prefs key migrates automatically and preferences remain separate
from 2dnd_save.
audioEngine.setMasterVolume(0.8);
audioEngine.setMusicVolume(0.6);
audioEngine.setSFXVolume(0.4);
audioEngine.setDialogVolume(0.5);
audioEngine.toggleMute();
Musical Scales
Six scales define the musical character of each location:
| Scale | Mood | Used For |
|---|
MAJOR_PENTA | Happy, bright | Grasslands, villages, highlands, campaign ending |
MINOR_PENTA | Melancholic | Frozen, ancient, mystical areas |
HARMONIC_MINOR | Exotic, desert | Arid, canyon, title screen |
DIMINISHED | Eerie, unsettling | Swamp, murky areas |
NATURAL_MINOR | Dark, moody | Scorched, volcanic, industrial |
PHRYGIAN_DOM | Tense, intense | Boss fights, battle theme |
BiomeProfile Interface
Every music track is driven by a BiomeProfile:
interface BiomeProfile {
baseNote: number;
scale: Scale;
bpm: number;
wave: OscillatorType;
padWave: OscillatorType;
}
Adding a New Biome Profile
Add to BIOME_PROFILES record. The key must match the first word of chunk names:
export const BIOME_PROFILES: Record<string, BiomeProfile> = {
Mystic: { baseNote: 3, scale: HARMONIC_MINOR, bpm: 74, wave: "triangle", padWave: "sine" },
};
Adding a New Boss Music Override
Add to BOSS_OVERRIDES with the boss monster's ID as key:
const BOSS_OVERRIDES: Record<string, Partial<BiomeProfile>> = {
ancientLich: { baseNote: -12, bpm: 130, scale: DIMINISHED, wave: "square", padWave: "sawtooth" },
};
Adding a New City Music Override
Add to CITY_OVERRIDES with the city name as key:
const CITY_OVERRIDES: Record<string, Partial<BiomeProfile>> = {
Starhaven: { baseNote: 7, bpm: 110, scale: MAJOR_PENTA, wave: "sine", padWave: "triangle" },
};
Orchestral Layers
Every track automatically layers these instruments via playNote():
- Lead — profile's
wave type at the melody frequency
- Pad/Bass — profile's
padWave at half frequency, every other beat
- Strings — sine with 5Hz vibrato, sustained every 4 beats
- Brass — sawtooth stab a fifth above, offset every 4 beats
- Kick drum — pitched-down sine (150→40Hz) on even beats
- Hihat — filtered noise burst on odd beats
Night Mode
Major scales automatically shift to their relative minor at night.
Already-minor scales drop the root by 2–3 semitones for a darker feel.
Campaign ending music
audioEngine.playEndingMusic() selects the warm, resolved ENDING_PROFILE.
Keep ending as its own TrackKind, include it in playAllSounds(), and call
playTitleMusic() before Ending hands off to Boot so the ending loop does not
continue on the title screen.
Defeat result music
audioEngine.playDefeatMusic() selects the slow natural-minor
DEFEAT_PROFILE. Keep defeat as its own TrackKind, include it in
playAllSounds(), and start it from DefeatScene rather than Battle so the
result sequence owns its music lifecycle. Stop the weather overlay first with
playWeatherSFX(WeatherType.Clear) without mutating the persisted weather state.
Campaign cutscene cues
audioEngine.playCutsceneCue() accepts the typed cues exported by the cutscene
data hub. Keep cue selection in immutable cutscene definitions and synthesis in
audio.ts. Route cues through the existing SFX channel, keep them short, and
disconnect each oscillator and gain node after ended so long cutscene chains
do not retain Web Audio graph nodes.
Adding New SFX
SFX methods follow this pattern using the sfxGain node:
playNewSFX(): void {
if (!this.ctx || !this.sfxGain) return;
const ctx = this.ctx;
const dest = this.sfxGain;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.value = 440;
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.2);
osc.connect(gain);
gain.connect(dest);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.25);
}
Existing SFX Catalog
| Method | Sound Design | Trigger |
|---|
playAttackSFX() | Swoosh + impact thump + metallic clang | Normal melee/spell hit |
playMissSFX() | Airy highpass whoosh + descending pitch | Missed attack |
playCriticalHitSFX() | Deep slam + noise crunch + rising sting + bell | Critical hit (nat 20) |
playChestOpenSFX() | 4-note ascending twinkle + shimmer | Opening a chest |
playDungeonEnterSFX() | Deep boom + eerie tone + stone scrape | Entering a dungeon |
playTrapSFX(type) | Per-type oscillator/noise profile | Triggering a dungeon trap |
playPotionSFX() | 3 glug bubbles + healing shimmer | Using a consumable |
playSpellSFX() | Rising magical charge + bright release | Spell presentation |
playAbilitySFX() | Compact class-action pulse | Ability presentation |
playDefendSFX() | Two-tone shield brace | Defend presentation |
playFleeSFX() | Rising filtered retreat sweep | Flee attempt |
playFaintSFX() | Descending collapse tone | Once-only faint state |
playFootstepSFX(terrain) | Filtered noise burst, varies by terrain | Every player step |
playDialogueBlip(pitch) | Quick square wave blip | NPC dialogue (future) |
playCutsceneCue(cue) | Typed procedural sting | Campaign cutscene step |
playGatheringStartSFX(discipline) | Discipline-specific start cue | Gathering begins |
playGatheringActionSFX(discipline, action) | Short input feedback | Gathering input |
|
Footstep Terrain Mapping
The playFootstepSFX(terrainType) method uses the Terrain enum value to pick filter parameters:
- Grass (0): soft rustle, high filter, low volume
- Sand (4): shifting sound, highpass filter
- Mountain (2): rocky crunch, low filter
- Swamp (14): squelch, lowpass filter
- DungeonFloor (9): echoing stone tap
Weather Ambient SFX
Weather SFX use looping noise buffers routed through sfxGain:
- Rain: Lowpass-filtered white noise (800Hz cutoff)
- Snow: Very soft highpass noise (3000Hz)
- Sandstorm: Bandpass noise (1200Hz, Q=0.8)
- Storm: Heavy lowpass rain + periodic sine thunder rumble
- Fog: Sustained low sine drone (80Hz)
Testing
Audio tests verify the API surface and state (no actual audio output in test env):
expect(typeof audioEngine.playAttackSFX).toBe("function");
expect(() => audioEngine.playAttackSFX()).not.toThrow();
Common Pitfalls
- ❌ Never add external audio files — synthesize everything
- ❌ Never call
audioEngine.init() outside a user gesture — browsers will block it
- ❌ Don't forget to add new SFX methods to the
playAllSounds() demo and test file
- Trap profiles belong in
trapAudio.ts; keep audio.ts as the public routing
surface.
- ❌ Don't use volumes above 0.3 for individual oscillators — they stack up quickly
- ❌ Don't leave ended cutscene oscillators or gain nodes connected
- Battle presentation owns action-cue selection so scenes do not play the same
synthesized cue twice.