68 lines
2.3 KiB
TypeScript
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();
|
|
}
|
|
}
|