A browser chime with a real start, stop, and voice limit
A synthesized chime needs only a few nodes. A reliable page also needs to decide when sound is allowed, how many chimes may overlap, and what happens when they finish. Those lifecycle decisions often matter more than adding another oscillator.
The Lab creates sound lazily and starts with sound off. Its chime uses a fundamental and a quieter near-third partial, an envelope, a low-pass filter, and optional stereo panning. This note turns that structure into a small reusable example.
Keep a persistent output and temporary voices
A useful graph has an application-owned master gain node and temporary subgraphs for one-shot sounds. The voice envelope shapes the chime; the master gain controls the page’s overall volume. Connect the temporary nodes to that output and release them when the voice ends.
Web Audio supports routing graphs, including feedback arrangements subject to its rules; it is not accurately described as a universally acyclic graph. The simple one-way graph above is enough for this sound.
Schedule the envelope on the audio clock
Use AudioContext.currentTime for oscillator timing and gain automation. A short attack avoids abruptly switching from silence to a nonzero amplitude. An exponential decay must end at a positive value; ramping exponentially to zero is invalid.
function createChimePlayer(ctx, output) {
let voices = 0;
let lastStart = -Infinity;
return function playChime(frequency = 384) {
const now = ctx.currentTime;
if (ctx.state !== "running" || voices >= 6 || now - lastStart < 0.11) {
return false;
}
if (!Number.isFinite(frequency) || frequency < 40 || frequency > 4000) {
return false;
}
const envelope = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.value = 2600;
envelope.connect(filter).connect(output);
envelope.gain.setValueAtTime(0, now);
envelope.gain.linearRampToValueAtTime(0.055, now + 0.012);
envelope.gain.exponentialRampToValueAtTime(0.0004, now + 0.95);
const nodes = [envelope, filter];
let remaining = 2;
for (const [ratio, level] of [[1, 1], [2.997, 0.22]]) {
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.frequency.value = frequency * ratio;
gain.gain.value = level;
oscillator.connect(gain).connect(envelope);
nodes.push(oscillator, gain);
oscillator.onended = () => {
if (--remaining === 0) {
nodes.forEach(node => node.disconnect());
voices--;
}
};
oscillator.start(now);
oscillator.stop(now + 1);
}
voices++;
lastStart = now;
return true;
};
} This example assumes a valid running context and a connected application-owned output. It bounds logical chimes, not raw oscillators: six two-oscillator chimes can mean twelve oscillator nodes. The output and context belong to the caller, so a finished voice must not disconnect the shared master or close the entire context.
Choose an overlap policy
The Lab’s actual chime gate permits at most six overlapping chimes and separates accepted starts by at least 110 ms. Those are authored limits. There is no Web Audio rule that every device supports “16 to 64 voices”; cost depends on the graph, the browser, other work, and the device.
The example above drops a new chime when the budget is full. Other interactions may need to replace the oldest voice or prioritize a critical alert. If you steal a voice, give it a short release instead of cutting a nonzero waveform abruptly. Test rapid repeated input, not only one carefully spaced click.
Use audio completion to release the budget. A JavaScript timeout can be delayed when a tab is hidden, even after the scheduled sound has finished. The source Lab uses a timeout for its count; the example instead decrements after both oscillator ended events. That is an explicit adaptation, not an exact copy.
What the Lab actually synthesizes
The saved Lab source builds a drone from 48 Hz and 96.5 Hz sine oscillators plus a quiet 144.8 Hz triangle oscillator. They feed a low-pass filter initially set to 220 Hz. A 0.05 Hz oscillator modulates that filter, and a separate filtered-noise path adds air. The chime’s near-third partial is a different part of the graph.
A pair at f and 2.997f should not be explained as a slow beat between two nearby fundamentals. At a 48 Hz fundamental, its deviation from an exact third harmonic is 0.144 Hz, but that arithmetic alone does not establish a separately audible 0.144 Hz beat. The previous article’s claimed one-beat-per-3.3-seconds result had no supporting recording or measurement.
Signal Strike also synthesizes effects with tone and noise helpers. Its weapon sounds have their own parameters. The saved source does not support the earlier description of a shared six-slot gunshot ring buffer, so that claim has been removed.
A remembered preference is not a fresh gesture
Create or resume the context in a sound button’s interaction handler. Keep the page usable if the browser rejects or delays the attempt. A stored “sound on” setting can arm an intention, but should not be treated as proof that the browser permits playback on the next visit.
soundButton.addEventListener("click", async () => {
try {
context ??= new AudioContext();
await context.resume();
// Create the master and chime player once, then play or enable sound.
} catch {
soundStatus.textContent = "Sound is unavailable. You can keep exploring.";
}
}); On page exit, stop owned sources and disconnect owned nodes, or close a context that belongs exclusively to the page. When hiding a tab, suspend or mute according to the interaction’s policy. On return, distinguish a paused context from one that has been closed.
Verify the behavior you intend to ship
Test the normal browser policy with a real button interaction; a launch flag that bypasses autoplay cannot validate the visitor’s first-use experience. Check repeated clicks, the six-voice gate, mute, backgrounding, and teardown. Listen at a modest volume on more than one output device. Automated graph checks can catch leaked state, but they cannot establish that a sound is pleasant or appropriately mixed.
Sources & revision
Read the dated source excerpt. It includes original file paths, line numbers, and SHA-256 hashes for the version used in this guide. The public repository may contain a different revision.
28 Oct 2026: Corrected the drone graph, partial-detuning explanation, and invented game voice pool. Replaced incomplete cleanup code with a bounded example.
These notes use AI assistance. Read the editorial method and corrections policy. Found an error? Send a correction with this article’s title.