Skip to content

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:

EntryEngineUsed for
@flags/audio/liteRaw Web Audio on one shared AudioContextNav clicks, CTAs, clock ticks, shuffle, notifications, settings wiring, gesture unlock
@flags/audioTone.js synths, reverbs, and PlayerAnswer 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.

packages/audio/src/audio-context.ts
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.

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:

packages/audio/src/lite/lite-audio.ts
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.

configureLiteAudio stores four getter functions. SettingsProvider multiplies the master switch, master volume, and per-channel volume:

apps/web/src/lib/components/runtime/SettingsProvider.svelte
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.

playClick is the template for every lite effect: guard, get context, resume, build nodes, schedule on ctx.currentTime.

packages/audio/src/lite/lite-audio.ts
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().

ExportSourceEnvelopePitches
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 ms392 Hz (G4), then 523.25 Hz (C5) at +60 ms
playStartGame()4 triangle oscillators2 ms attack to 0.16 × vol, 80 ms each523.25, 659.25, 783.99, 1046.5 Hz (C5 E5 G5 C6), 60 ms apart
playClockTick()2 sine oscillators1 ms attack to 0.065 × vol, decays to 0.0001 by 50 ms783.99 Hz (G5), 1174.66 Hz (D6) at +30 ms
playTone(f, d = 0.3, type = "sine")1 oscillator, any typeStarts at 0.1 × vol, exponential decay to 10% over dCaller’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 halfRandom center 900–1600 Hz per click
playNotification()2 sine oscillators, dry 0.5 plus convolver reverb 0.5Starts at 0.3 × vol, decays to 0.001 over 250 ms880 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.

All volumes below are Tone decibels before the global offset described in §4.

audioManager methodVoiceNotes and timing
playSuccessSoundTone.Synth, sine, ADSR 0.01 / 0.1 / 0.3 / 0.3, −12 dBC5, E5, G5 as 8n, 100 ms apart
playErrorSoundFM bell (harmonicity 1.5, modulation index 4), −14 dB, reverb decay 1.4 s wet 0.25B4, G4 (32n), E4 (8n) at 0, 90, 180 ms
playChallengeWinSoundFM bell (harmonicity 2, index 6), −12 dB, reverb decay 2.6 s wet 0.38G4 8n, then C5 E5 G5 2n at +200 ms
playChallengeLoseSoundSame bell and reverb as winA5 8n, then A4 C5 E5 2n at +200 ms
playDailySessionVictorySoundSine pad (−24 dB) and triangle lead (−13 dB) into 5600 Hz lowpass and reverb 2.9 s / 0.34Pad C3 G3 C4, lead C5 E5 G5, pad E4 G4 C5 at +360 ms, lead C5 E5 G5 C6 2n at +480 ms
scheduleDailyResultSlotRevealsSine synth, attack 1 ms, decay 50 ms, −16 dBRound kk: 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 dBOn C5 E5 G5, off G5 E5 C5, neutral E5 G5, 12 ms stagger
playLightSwitchSound(toDark)Sine PolySynth into reverb 1.5 s / 0.3Dark: E5, B4, G4, E3. Light: C5, E5, G5, then C6 plus E6
playStartGameSoundSquare PolySynth, −16 dBC5 E5 G5 C6 as 32n, 60 ms apart
playAnswerSubmittedSoundTwo sine synths into 8200 Hz lowpass and reverb 0.5 s / 0.13880 Hz at −24 dB, 1320 Hz at −26.5 dB, 74 ms later
playDefeatSoundSquare PolySynth, −16 dBE5, C5, G4, then C4 plus E4
playVictorySoundvictory.mp3 from the CDN: Tone.Player if already preloaded under key VICTORY, otherwise HTMLAudioElementVolume 0.5
playSpotlightRevealSoundTriangle synth, decay 120 ms, −16 dBC3 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.

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 nn:

f(n)=440⋅2(n−69)/12f(n) = 440 \cdot 2^{(n-69)/12}

Every hard-coded lite frequency is an equal-tempered note rounded to 2 decimals:

Hz in codeNotennf(n)f(n)
392G467391.995
523.25C572523.251
659.25E576659.255
783.99G579783.991
1046.5C6841046.502
1174.66D6861174.659
880A581880

The exception is 1320 Hz in playNotification and playAnswerSubmittedSound. It is exactly 32×880\tfrac{3}{2} \times 880, a just perfect fifth above A5. The equal-tempered E6 is f(88)=1318.51f(88) = 1318.51 Hz. The gap is

1200log⁡213201318.51≈1.96 cents1200 \log_2 \frac{1320}{1318.51} \approx 1.96 \text{ cents}

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: nn and n+7n + 7, frequency ratio 27/12≈1.4982^{7/12} \approx 1.498. Slot kk fires at

tk=t0+initialDelayMs+k⋅stepMs1000,k=1…roundCountt_k = t_0 + \frac{\text{initialDelayMs} + k \cdot \text{stepMs}}{1000}, \qquad k = 1 \ldots \text{roundCount}

on Tone’s audio clock, so the pings stay in time with the reveal animation even when the main thread is busy.

exponentialRampToValueAtTime from v0v_0 at t0t_0 to v1v_1 at t1t_1 follows

v(t)=v0(v1v0)t−t0t1−t0v(t) = v_0 \left(\frac{v_1}{v_0}\right)^{\frac{t - t_0}{t_1 - t_0}}

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 20log⁡1010−4=−8020 \log_{10} 10^{-4} = -80 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.

Lite multiplies linear gain: peak =gsound⋅V= g_\text{sound} \cdot V, where VV is the settings product from SettingsProvider,

V=[master on]⋅masterSoundVolume100⋅sfxVolume100V = [\text{master on}] \cdot \frac{\text{masterSoundVolume}}{100} \cdot \frac{\text{sfxVolume}}{100}

Tone works in decibels. toneManager.getVolumeOffset() converts the same VV and adds it to each voice’s base level:

ΔdB=20log⁡10V,V=0.5⇒−6.02 dB,V=0⇒−100 dB\Delta_\text{dB} = 20 \log_{10} V, \qquad V = 0.5 \Rightarrow -6.02 \text{ dB}, \qquad V = 0 \Rightarrow -100 \text{ dB}

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.

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:

offseti=(i7)2⋅0.38 s,peaki=(1−i14)⋅0.15 V\text{offset}_i = \left(\frac{i}{7}\right)^2 \cdot 0.38 \text{ s}, \qquad \text{peak}_i = \left(1 - \frac{i}{14}\right) \cdot 0.15\,V

playNotification builds a 2-second stereo impulse response once per sample rate and caches it: sample jj of N=2fsN = 2 f_s is uniform noise shaped by a quadratic decay, xj=uj (1−j/N)2x_j = u_j \,(1 - j/N)^2 with uj∈[−1,1)u_j \in [-1, 1).

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 ff, 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.

tsup.config.ts builds two ESM entries with code splitting and marks tone and @flags/shared as external:

OutputBytes (current dist/)
lite-audio.js15,008
index.js (rich)69,802
Shared chunk2,332
tone-exports chunk330

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 by Tone.start() inside toneManager.initializeTone(), which usually runs after an await and 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.
  • isStarted latches once. initializeTone sets isStarted = true after Tone.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 globalThis and returns early, so Svelte SSR can import both modules. getSharedAudioContext returns null on the server.
  • SFX off. shouldPlaySoundEffect() is false, so the first-gesture handler removes its listeners and does nothing else: primeAudioForGesture returns 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 calls warmupRichAudio or a rich sound.
  • Fast repeats. Lite creates new nodes per call, so rapid clicks layer rather than cut off. audioManager.playAudio keeps a pendingPlays set 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, richAudioModulePromise holds 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). getSharedAudioContext catches it and returns null, 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.value before triggering, so a volume change mid-tail applies to notes that are still ringing. One-shot synths (toggle, theme, daily fanfare) are disposed by setTimeout after 1000 to 4000 ms.
ChoiceAlternativeWhy this one
Two entries in one packageSeparate lite and rich packagesOne version, one shared-context module, and lite can dynamic-import rich by relative path.
Raw Web Audio for UITone for everythingKeeps Tone off the nav and button chunks; a click needs 4 nodes, not a synth library.
Tone for stingsHand-written Web Audio everywhereFM synthesis, polyphony, note names, shared reverb buses, and Transport-relative durations cost little code with Tone.
Procedural synthesisSample filesNo 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 kindExplicit “enable sound” buttonNobody has to opt in, and the first sound after any tap plays on time.
Idle-time Tone warmupWarm up at page loadDownloading 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.