A lot of people work with a background sound on. You can stream one, or you can generate it in the browser with under 100 lines of JavaScript: no audio files, no library, nothing to download.
In this post we build a small focus timer that:
- generates brown noise live with the Web Audio API
- fades in and out without the click you hear when audio stops abruptly
- shows an accurate countdown even when the tab is in the background
- schedules the end fade on the audio clock, so it stops on time even when the tab is in the background
Total cost: $0. Time: about 15 minutes.
1. The page
<button id="start">Start 25 min</button>
<button id="stop" disabled>Stop</button>
<p id="left">25:00</p>
<label>Volume <input id="vol" type="range" min="0" max="1" step="0.01" value="0.25"></label>
<script src="timer.js"></script>
Browsers only let audio start after a user gesture, so everything begins inside the click handler.
2. Generate brown noise
White noise is random samples. Brown noise is white noise run through a simple leaky integrator, which removes most of the harsh high end. It is a common choice for a soft background.
function brownNoiseBuffer(ctx, seconds = 4) {
const len = ctx.sampleRate * seconds;
const buf = ctx.createBuffer(2, len, ctx.sampleRate);
for (let ch = 0; ch < 2; ch++) {
const d = buf.getChannelData(ch);
let last = 0;
for (let i = 0; i < len; i++) {
const white = Math.random() * 2 - 1;
last = (last + 0.02 * white) / 1.02; // leaky integrator
d[i] = last * 3.5; // make up the lost level
}
}
return buf;
}
Two channels with independent random data give a wider stereo image than one mono channel.
We loop a 4-second buffer. Because it is random noise, the loop point is very hard to hear.
3. Fade in and out without clicks
If you call source.stop() while the signal is loud, the waveform jumps to zero and you hear a click. The fix is to ramp a GainNode down first and stop the source only after the ramp ends.
The other trick: schedule the end of the session once, on the audio clock, the moment it starts. The audio thread runs those events on time even when the page's timers are throttled in a background tab.
const FADE = 3; // seconds
let ctx, src, gain, endAt, tick;
function scheduleEnd(endTime) {
gain.gain.setValueAtTime(volume(), endTime - FADE);
gain.gain.exponentialRampToValueAtTime(0.0001, endTime);
src.stop(endTime + 0.05); // stop after the ramp, not before
}
function startSession(minutes) {
ctx = ctx || new AudioContext();
const t = ctx.currentTime;
gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(volume(), t + FADE);
gain.connect(ctx.destination);
src = ctx.createBufferSource();
src.buffer = brownNoiseBuffer(ctx);
src.loop = true;
src.connect(gain);
src.onended = finishSession;
src.start();
scheduleEnd(t + minutes * 60); // the end fade lives on the audio clock
endAt = Date.now() + minutes * 60_000;
tick = setInterval(update, 250);
update();
}
// Manual Stop: replace the scheduled end with a fade that starts now.
function stopSession() {
if (!src) return;
const t = ctx.currentTime;
gain.gain.cancelScheduledValues(t);
gain.gain.setValueAtTime(gain.gain.value, t);
gain.gain.exponentialRampToValueAtTime(0.0001, t + FADE);
src.stop(t + FADE + 0.05); // a new stop() time replaces the scheduled one
document.getElementById('stop').disabled = true;
}
// Runs when the sound has really finished (scheduled end or manual Stop).
function finishSession() {
const oldGain = gain;
setTimeout(() => oldGain.disconnect(), 100); // free the old node
src = null;
clearInterval(tick);
document.getElementById('left').textContent = '0:00';
document.getElementById('start').disabled = false;
document.getElementById('stop').disabled = true;
}
A few details matter here:
-
exponentialRampToValueAtTimecan't ramp to exactly0, so we use0.0001(inaudible). -
cancelScheduledValuesplussetValueAtTime(gain.gain.value, …)"pins" the current level, so pressing Stop during the fade-in doesn't jump. It also removes the scheduled end fade, which the new fade replaces. -
src.onendedresets the buttons only when the audio has actually stopped, and the oldGainNodeis disconnected so repeated sessions don't pile up nodes.
4. Keep the countdown honest in a background tab
Browsers throttle setInterval in hidden tabs, so counting ticks drifts. Don't count. Store the end time and compute what's left from the clock:
function update() {
const ms = Math.max(0, endAt - Date.now());
const m = Math.floor(ms / 60000), s = Math.floor(ms / 1000) % 60;
document.getElementById('left').textContent = `${m}:${String(s).padStart(2, '0')}`;
}
This function only draws the countdown; it never stops the sound. In a hidden tab the text may update late, but the fade and stop were already scheduled on the audio clock, so the sound still ends on time.
5. Volume and buttons
const volume = () => Math.max(0.0001, +document.getElementById('vol').value);
document.getElementById('vol').addEventListener('input', () => {
if (!src) return;
const t = ctx.currentTime;
const end = t + Math.max(0, (endAt - Date.now()) / 1000);
if (end - t < FADE + 1) return; // already fading out, leave it
gain.gain.cancelScheduledValues(t); // drop the fade-in and the old end fade
gain.gain.setValueAtTime(gain.gain.value, t);
gain.gain.setTargetAtTime(volume(), t, 0.1); // smooth, no zipper noise
scheduleEnd(end); // put the end fade back at the new level
});
document.getElementById('start').onclick = async () => {
if (ctx && ctx.state === 'suspended') await ctx.resume();
startSession(25);
document.getElementById('start').disabled = true;
document.getElementById('stop').disabled = false;
};
document.getElementById('stop').onclick = stopSession;
setTargetAtTime moves the gain smoothly toward the slider value. Setting gain.value directly on every input event can cause small crackles (called "zipper noise"). Because cancelScheduledValues also removes the end fade, the handler schedules it again right away.
6. Things worth adding next
- Pink noise instead of brown: a different filter, a bit brighter.
-
A session log in
localStorage: date, length, and whether you finished. -
A short chime at the end. Use an
OscillatorNodewith its own short gain envelope, the same fade trick in miniature. - Media Session API, so the session shows on the lock screen with a Stop button.
Keep it comfortable
Keep the volume low, so the sound sits under your thoughts rather than over them. Take a break when the session ends. And don't use headphones while driving or anywhere you need to hear your surroundings.
If you'd rather not build it yourself, I made a free browser player with timers and fade-outs at chelli.co.il. It's a relaxation tool, not medical advice.
Questions about the Web Audio parts? Ask in the comments.
Top comments (0)