I wanted my focus timer to feel alive — a tiny start beep, a pause beep, an alarm when the session flips, and an optional metronome tick. I also wanted it to be very small and work offline. So I used the Web Audio API with plain JavaScript.
The idea in short
- Create one
AudioContext
on first user interaction. - For quick beeps: use an
OscillatorNode
(square wave), shape volume with aGainNode
. - For the alarm: play a short sequence of beeps.
- For a soft tick: generate a tiny noise buffer that decays fast.
That’s it. Here’s the code I actually use.
My minimal audio setup
let audioContext = null;
function initAudio() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
I call initAudio()
inside any click handler (e.g. Start button) so the browser sees a user gesture. That avoids autoplay restrictions.
The sounds
I grouped everything in one function. I pass a type
string and switch on it.
function playSound(type) {
const ctx = initAudio();
switch (type) {
case "start": {
// quick, positive beep
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "square";
osc.frequency.setValueAtTime(1000, ctx.currentTime);
g.gain.setValueAtTime(0.5, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.03);
osc.connect(g);
g.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.03);
break;
}
case "pause": {
// slightly lower pitch for pause
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "square";
osc.frequency.setValueAtTime(800, ctx.currentTime);
g.gain.setValueAtTime(0.5, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.03);
osc.connect(g);
g.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.03);
break;
}
case "alarm": {
// three short beeps in a row
for (let i = 0; i < 3; i++) {
const osc = ctx.createOscillator();
const g = ctx.createGain();
const t = ctx.currentTime + i * 0.4; // every 400ms
osc.type = "sine";
osc.frequency.setValueAtTime(1000, t);
g.gain.setValueAtTime(0.4, t);
g.gain.exponentialRampToValueAtTime(0.01, t + 0.3);
osc.connect(g);
g.connect(ctx.destination);
osc.start(t);
osc.stop(t + 0.3);
}
break;
}
case "tick": {
// soft per-second tick using decaying noise
const bufferSize = ctx.sampleRate * 0.05; // 50ms
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] =
(Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.01));
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const g = ctx.createGain();
g.gain.setValueAtTime(0.4, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.05);
noise.connect(g);
g.connect(ctx.destination);
noise.start();
noise.stop(ctx.currentTime + 0.05);
break;
}
}
}
Why these settings?
- Square for start/pause: cuts through nicely at very short lengths.
- 800–1000 Hz: easy to notice without being harsh.
- Exponential volume ramp: avoids clicks.
- Alarm: 3 beeps, 300ms each, 400ms apart → clear but not annoying (hope so, lol).
- Tick: tiny bit of filtered noise feels less robotic than a pure sine.
Wiring it to the UI
In my Astro page I keep a few booleans in module scope and call playSound()
on button clicks or when a session completes.
let soundEnabled = true;
let isRunning = false;
function startTimer() {
if (isRunning) return;
isRunning = true;
if (soundEnabled) playSound("start");
// ...start interval, update UI
}
function stopTimer() {
if (!isRunning) return;
isRunning = false;
if (soundEnabled) playSound("pause");
// ...clear interval, update UI
}
function sessionComplete() {
if (soundEnabled) playSound("alarm");
// flip focus/break, reset remaining seconds, render
}
For the metronome, I simply call playSound("tick")
inside my tick()
function every second — but only if the user enabled it.
What I might try next
- A softer, more organic alarm (two‑tone).
- Volume slider (just map 0–1 to the
GainNode
).
If you want to copy this into your own timer, feel free. The full page that uses this lives in my Focus Timer tool here on the site.