Synthesized audio engine
1. Foundational mental model
Section titled “1. Foundational mental model”Almost every sound in flags.games is generated at runtime. There are no click samples or ding files for UI: a button click is two triangle-wave oscillators, a shuffle is seven bursts of filtered white noise, and a challenge win is an FM bell chord into a reverb. The only prerecorded effect in the SFX path is the victory sting, https://cdn.flags.games/audio/victory.mp3. Multiplayer session music and voice clips are separate asset-based systems.
@flags/audio exposes two entry points built from one package:
| Entry | Engine | Used for |
|---|---|---|
@flags/audio/lite | Raw Web Audio on one shared AudioContext | Nav clicks, CTAs, clock ticks, shuffle, notifications, settings wiring, gesture unlock |
@flags/audio | Tone.js synths, reverbs, and Player | Answer feedback, challenge win and lose, daily fanfare and result pings, toggle and theme stings, session music |
Tone.js is large. ADR 0002 puts it at about 270 KB minified. The split keeps it off the critical path: shell pages load only the lite module, and the rich module arrives by dynamic import() after the first user gesture or when a game route asks for it.
Challenge routes go one step further: apps/web/src/lib/challenges/challenge-audio.ts wraps every sting in await import("@flags/audio"), so a challenge chunk has no static Tone dependency at all.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”Shared context
Section titled “Shared context”let sharedContext: AudioContext | null = null;
export function getSharedAudioContext(): AudioContext | null { if (!("window" in globalThis)) { return null; } if (!sharedContext) { try { sharedContext = new AudioContext(); } catch { return null; } } return sharedContext;}The context is created eagerly on the first call, not on the first gesture. The ADR explains why: passive sounds such as globe activity pings need a context after in-app navigation, and creating one is cheap next to downloading Tone. resumeSharedAudioContext has a { sync: true } overload that calls resume() without awaiting, for use inside pointer handlers where the browser only honors the call in the same stack as the gesture.
Autoplay unlock
Section titled “Autoplay unlock”Browsers start an AudioContext in the suspended state until the page receives a user gesture. setupAudioOnFirstGesture listens once for the earliest gesture of any kind:
const onFirstGesture = () => { document.removeEventListener("pointerdown", onFirstGesture, true); document.removeEventListener("keydown", onFirstGesture); document.removeEventListener("touchstart", onFirstGesture, true); primeAudioForGesture(); if (!shouldPlaySoundEffect()) { return; } void startRichAudioModuleImport().then((module) => { scheduleWarmupToneOnIdle(module); });};pointerdown and touchstart are registered with { capture: true, passive: true }, so the handler runs before any component handler and never blocks scrolling.
Settings wiring
Section titled “Settings wiring”configureLiteAudio stores four getter functions. SettingsProvider multiplies the master switch, master volume, and per-channel volume:
configureLiteAudio({ getVolume: () => (currentSettings.masterSoundEnabled ? 1 : 0) * (currentSettings.masterSoundVolume / 100) * (currentSettings.sfxVolume / 100), getSoundEffectsEnabled: () => currentSettings.masterSoundEnabled && currentSettings.sfxEnabled, getMusicVolume: () => (currentSettings.masterSoundEnabled ? 1 : 0) * (currentSettings.masterSoundVolume / 100) * (currentSettings.musicVolume / 100), getMusicEnabled: () => currentSettings.masterSoundEnabled && currentSettings.musicEnabled,});Configuring does not start the rich import. It passes the music getters to the session-music bridge and, if the rich module is already loaded, pushes volume and the SFX reader into audioManager. Before hydration, lite defaults to volume 0.75, SFX on, music volume 0.4.
A lite sound, end to end
Section titled “A lite sound, end to end”playClick is the template for every lite effect: guard, get context, resume, build nodes, schedule on ctx.currentTime.
const filter = ctx.createBiquadFilter();filter.type = "lowpass";filter.frequency.value = 2500;filter.Q.value = 0.7;filter.connect(ctx.destination);
const notes = [ { frequency: 392, startOffset: 0, duration: 0.1 }, { frequency: 523.25, startOffset: 0.06, duration: 0.12 },] as const;
for (const note of notes) { const oscillator = ctx.createOscillator(); const gain = ctx.createGain(); oscillator.type = "triangle"; oscillator.frequency.value = note.frequency;
const start = now + note.startOffset; const end = start + note.duration; const peak = 0.14 * globalVolume;
gain.gain.setValueAtTime(0.0001, start); gain.gain.exponentialRampToValueAtTime(peak, start + 0.002); gain.gain.exponentialRampToValueAtTime(0.0001, end);Nodes are not reused. Each call creates fresh oscillators and gains, and Web Audio frees them after stop().
Lite catalogue
Section titled “Lite catalogue”| Export | Source | Envelope | Pitches |
|---|---|---|---|
playClick() | 2 triangle oscillators into a 2500 Hz lowpass (Q 0.7) | 2 ms exponential attack to 0.14 × vol, exponential decay over 100 / 120 ms | 392 Hz (G4), then 523.25 Hz (C5) at +60 ms |
playStartGame() | 4 triangle oscillators | 2 ms attack to 0.16 × vol, 80 ms each | 523.25, 659.25, 783.99, 1046.5 Hz (C5 E5 G5 C6), 60 ms apart |
playClockTick() | 2 sine oscillators | 1 ms attack to 0.065 × vol, decays to 0.0001 by 50 ms | 783.99 Hz (G5), 1174.66 Hz (D6) at +30 ms |
playTone(f, d = 0.3, type = "sine") | 1 oscillator, any type | Starts at 0.1 × vol, exponential decay to 10% over d | Caller’s f |
playShuffle() | 7 white-noise buffers of 45 ms through bandpass filters (Q 1.8) | 6 ms linear attack, exponential decay; peak falls from 0.15 × vol to about half | Random center 900–1600 Hz per click |
playNotification() | 2 sine oscillators, dry 0.5 plus convolver reverb 0.5 | Starts at 0.3 × vol, decays to 0.001 over 250 ms | 880 Hz, 1320 Hz at +80 ms |
playToggle, playThemeSwitch, and playStartGameFanfare look like lite calls but go through playRich, which awaits prepareAudio() and the shared import before calling into Tone.
Rich catalogue
Section titled “Rich catalogue”All volumes below are Tone decibels before the global offset described in §4.
audioManager method | Voice | Notes and timing |
|---|---|---|
playSuccessSound | Tone.Synth, sine, ADSR 0.01 / 0.1 / 0.3 / 0.3, −12 dB | C5, E5, G5 as 8n, 100 ms apart |
playErrorSound | FM bell (harmonicity 1.5, modulation index 4), −14 dB, reverb decay 1.4 s wet 0.25 | B4, G4 (32n), E4 (8n) at 0, 90, 180 ms |
playChallengeWinSound | FM bell (harmonicity 2, index 6), −12 dB, reverb decay 2.6 s wet 0.38 | G4 8n, then C5 E5 G5 2n at +200 ms |
playChallengeLoseSound | Same bell and reverb as win | A5 8n, then A4 C5 E5 2n at +200 ms |
playDailySessionVictorySound | Sine pad (−24 dB) and triangle lead (−13 dB) into 5600 Hz lowpass and reverb 2.9 s / 0.34 | Pad C3 G3 C4, lead C5 E5 G5, pad E4 G4 C5 at +360 ms, lead C5 E5 G5 C6 2n at +480 ms |
scheduleDailyResultSlotReveals | Sine synth, attack 1 ms, decay 50 ms, −16 dB | Round : root then its fifth 36 ms later (G4/D5, A4/E5, C5/G5, D5/A5, E5/B5) |
playToggleSwitchSound(state) | Sine PolySynth into 6 kHz lowpass and reverb 1.2 s / 0.35, −16 dB | On C5 E5 G5, off G5 E5 C5, neutral E5 G5, 12 ms stagger |
playLightSwitchSound(toDark) | Sine PolySynth into reverb 1.5 s / 0.3 | Dark: E5, B4, G4, E3. Light: C5, E5, G5, then C6 plus E6 |
playStartGameSound | Square PolySynth, −16 dB | C5 E5 G5 C6 as 32n, 60 ms apart |
playAnswerSubmittedSound | Two sine synths into 8200 Hz lowpass and reverb 0.5 s / 0.13 | 880 Hz at −24 dB, 1320 Hz at −26.5 dB, 74 ms later |
playDefeatSound | Square PolySynth, −16 dB | E5, C5, G4, then C4 plus E4 |
playVictorySound | victory.mp3 from the CDN: Tone.Player if already preloaded under key VICTORY, otherwise HTMLAudioElement | Volume 0.5 |
playSpotlightRevealSound | Triangle synth, decay 120 ms, −16 dB | C3 as 32n |
Reverbs are shared buses keyed by "${decay}-${wet}", so repeated stings reuse one convolver instead of generating a new impulse response each time.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Lite code writes frequencies in hertz. Tone code writes note names, which Tone converts with twelve-tone equal temperament at A4 = 440 Hz. For MIDI note number :
Every hard-coded lite frequency is an equal-tempered note rounded to 2 decimals:
| Hz in code | Note | ||
|---|---|---|---|
| 392 | G4 | 67 | 391.995 |
| 523.25 | C5 | 72 | 523.251 |
| 659.25 | E5 | 76 | 659.255 |
| 783.99 | G5 | 79 | 783.991 |
| 1046.5 | C6 | 84 | 1046.502 |
| 1174.66 | D6 | 86 | 1174.659 |
| 880 | A5 | 81 | 880 |
The exception is 1320 Hz in playNotification and playAnswerSubmittedSound. It is exactly , a just perfect fifth above A5. The equal-tempered E6 is Hz. The gap is
which is far below what anyone can hear, so the choice between the two makes no audible difference.
The daily result pings climb a C major pentatonic (G4 A4 C5 D5 E5), and each ping is a two-note dyad a perfect fifth apart: and , frequency ratio . Slot fires at
on Tone’s audio clock, so the pings stay in time with the reveal animation even when the main thread is busy.
Envelopes
Section titled “Envelopes”exponentialRampToValueAtTime from at to at follows
An exponential ramp cannot start or end at 0, which is why every lite envelope starts with setValueAtTime(0.0001, start) and decays back to 0.0001. That floor is dB, below audibility on phone speakers. Exponential decay sounds even to the ear because loudness perception is roughly logarithmic; a linear fade to zero seems to stop abruptly.
Volume
Section titled “Volume”Lite multiplies linear gain: peak , where is the settings product from SettingsProvider,
Tone works in decibels. toneManager.getVolumeOffset() converts the same and adds it to each voice’s base level:
Both conventions give the same amplitude scaling. The victory mp3 uses the same formula on volume × globalVolume and clamps non-finite results to −100 dB.
Noise and reverb
Section titled “Noise and reverb”playShuffle spaces its 7 clicks quadratically across 0.38 s, so they bunch at the start and spread out, the way a riffle slows down:
playNotification builds a 2-second stereo impulse response once per sample rate and caches it: sample of is uniform noise shaped by a quadratic decay, with .
FM bells
Section titled “FM bells”The error, win, and lose sounds use Tone.FMSynth. A sine modulator at harmonicity × f modulates the carrier’s frequency with depth set by modulationIndex, and the modulator has its own faster envelope. With harmonicity 2 the sidebands land on integer multiples of , which reads as a bright, bell-like tone. The error bell’s harmonicity 1.5 puts sidebands at half-integer multiples, and its lower index of 4 gives fewer audible sidebands, so it sounds darker.
Bundle split
Section titled “Bundle split”tsup.config.ts builds two ESM entries with code splitting and marks tone and @flags/shared as external:
| Output | Bytes (current dist/) |
|---|---|
lite-audio.js | 15,008 |
index.js (rich) | 69,802 |
| Shared chunk | 2,332 |
tone-exports chunk | 330 |
Tone itself is resolved by the web app’s bundler. Two dynamic imports keep it off first load: lite imports ../index.js only on demand, and toneManager.getTone() imports ./tone-exports only when a Tone sound is first built.
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Two contexts. Lite sounds and rich fallbacks use the shared context; Tone uses its own.
prepareAudio()resumes only the shared one. Tone’s context is resumed byTone.start()insidetoneManager.initializeTone(), which usually runs after anawaitand outside the gesture stack. I have not confirmed how this behaves on iOS Safari; if the first rich sound is silent on mobile, check this path first. isStartedlatches once.initializeTonesetsisStarted = trueafterTone.start()resolves and never re-checks. If the OS suspends Tone’s context later (backgrounded tab, phone call),toneManager.resumeContext()exists but nothing in the package calls it automatically.- SSR. Every entry point checks
"window" in globalThisand returns early, so Svelte SSR can import both modules.getSharedAudioContextreturnsnullon the server. - SFX off.
shouldPlaySoundEffect()is false, so the first-gesture handler removes its listeners and does nothing else:primeAudioForGesturereturns early and the rich import is skipped. The context stays suspended until a later sound resumes it. Tone is not downloaded until SFX is turned on (ensureRichAudioModule) or a game route callswarmupRichAudioor a rich sound. - Fast repeats. Lite creates new nodes per call, so rapid clicks layer rather than cut off.
audioManager.playAudiokeeps apendingPlaysset per key and a 2-second retry cooldown after a failed file, so a broken mp3 does not spin. - Import failure. If the rich chunk fails to load,
richAudioModulePromiseholds the rejected promise and later rich calls reject too. Lite sounds keep working since they never touch that module. - Context creation failure.
new AudioContext()can throw (some embedded webviews, or too many contexts).getSharedAudioContextcatches it and returnsnull, and every lite sound returns silently. - Shared bell voices. The error, win, and lose bells and the daily slot synth are built once and reused. Each call sets
bell.volume.valuebefore triggering, so a volume change mid-tail applies to notes that are still ringing. One-shot synths (toggle, theme, daily fanfare) are disposed bysetTimeoutafter 1000 to 4000 ms.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Choice | Alternative | Why this one |
|---|---|---|
| Two entries in one package | Separate lite and rich packages | One version, one shared-context module, and lite can dynamic-import rich by relative path. |
| Raw Web Audio for UI | Tone for everything | Keeps Tone off the nav and button chunks; a click needs 4 nodes, not a synth library. |
| Tone for stings | Hand-written Web Audio everywhere | FM synthesis, polyphony, note names, shared reverb buses, and Transport-relative durations cost little code with Tone. |
| Procedural synthesis | Sample files | No network fetch per sound, no decode latency, and pitch or timing tweaks are code changes. The victory mp3 is the one exception. |
| Unlock on first gesture of any kind | Explicit “enable sound” button | Nobody has to opt in, and the first sound after any tap plays on time. |
| Idle-time Tone warmup | Warm up at page load | Downloading Tone before the player interacts would cost every visitor, including those with SFX off. |
Non-goals: spatial audio, user-supplied sound packs, recording or analysis of microphone input, and syncing SFX across players in multiplayer. Session music (musicManager) is documented in docs/reference/audio-api.md and is outside this chapter.