Files
kadence/frontend/src/lib/sound.ts
T
2026-10-18 18:02:05 +03:00

68 lines
2.3 KiB
TypeScript

/**
* 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();
}
}