/** * Alarms and ticks, synthesised with the Web Audio API. * * No audio files to ship or to 404, and no autoplay surprise: the context is * created on the first user gesture (starting a timer counts) and reused. */ let context: AudioContext | null = null; function ensureContext(): AudioContext | null { if (typeof window === "undefined") return null; if (!context) { const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!Ctor) return null; context = new Ctor(); } if (context.state === "suspended") void context.resume(); return context; } function beep(frequency: number, seconds: number, volume: number, at = 0): void { const ctx = ensureContext(); if (!ctx) return; const oscillator = ctx.createOscillator(); const gain = ctx.createGain(); oscillator.type = "sine"; oscillator.frequency.value = frequency; const start = ctx.currentTime + at; // A short ramp instead of a hard stop; a clipped sine clicks. gain.gain.setValueAtTime(0.0001, start); gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, volume), start + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, start + seconds); oscillator.connect(gain).connect(ctx.destination); oscillator.start(start); oscillator.stop(start + seconds + 0.05); } export const sound = { /** Work interval finished: three rising notes. */ workDone(volume: number): void { const level = volume / 100; beep(660, 0.18, 0.25 * level, 0); beep(880, 0.18, 0.25 * level, 0.2); beep(1046, 0.4, 0.25 * level, 0.4); }, /** Break finished: two falling notes — back to it. */ breakDone(volume: number): void { const level = volume / 100; beep(880, 0.18, 0.22 * level, 0); beep(587, 0.35, 0.22 * level, 0.2); }, tick(volume: number): void { beep(1200, 0.03, 0.04 * (volume / 100)); }, unlock(): void { ensureContext(); }, }; /** A desktop notification, when the browser (or Electron) allows one. */ export function notify(title: string, body: string): void { if (typeof Notification === "undefined") return; if (Notification.permission === "granted") { new Notification(title, { body, icon: undefined }); } else if (Notification.permission !== "denied") { void Notification.requestPermission(); } }