From b39652accfb6f1b680071377b542f202f5ba6040 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 21:06:25 -0800 Subject: [PATCH] feat: add Web Audio sound system with urgency-mapped tones and notification integration --- web/src/lib/sounds.ts | 154 ++++++++++++++++++++++++++++++++++++++++++ web/src/lib/store.ts | 10 +++ 2 files changed, 164 insertions(+) create mode 100644 web/src/lib/sounds.ts diff --git a/web/src/lib/sounds.ts b/web/src/lib/sounds.ts new file mode 100644 index 0000000..5fc9681 --- /dev/null +++ b/web/src/lib/sounds.ts @@ -0,0 +1,154 @@ +// ── Sound System (Web Audio API) ─────────────────────────────── +// Generates alarm tones programmatically — no audio files needed + +import type { UrgencyLevel } from './urgency'; + +let audioContext: AudioContext | null = null; + +function getAudioContext(): AudioContext | null { + if (typeof window === 'undefined') return null; + if (!audioContext) { + try { + audioContext = new AudioContext(); + } catch { + return null; + } + } + if (audioContext.state === 'suspended') { + audioContext.resume(); + } + return audioContext; +} + +interface ToneConfig { + frequency: number; + type: OscillatorType; + duration: number; // seconds + volume: number; // 0-1 + rampDown: number; // fade-out seconds + repeat: number; + gap: number; // seconds between repeats +} + +const URGENCY_TONES: Record = { + critical: { + frequency: 880, + type: 'square', + duration: 0.3, + volume: 0.8, + rampDown: 0.05, + repeat: 5, + gap: 0.15, + }, + important: { + frequency: 660, + type: 'sawtooth', + duration: 0.25, + volume: 0.6, + rampDown: 0.08, + repeat: 3, + gap: 0.2, + }, + standard: { + frequency: 523, + type: 'sine', + duration: 0.3, + volume: 0.5, + rampDown: 0.1, + repeat: 2, + gap: 0.3, + }, + gentle: { + frequency: 440, + type: 'sine', + duration: 0.4, + volume: 0.3, + rampDown: 0.2, + repeat: 1, + gap: 0, + }, + passive: { + frequency: 330, + type: 'sine', + duration: 0.2, + volume: 0.15, + rampDown: 0.1, + repeat: 1, + gap: 0, + }, +}; + +function playTone(config: ToneConfig): void { + const ctx = getAudioContext(); + if (!ctx) return; + + for (let i = 0; i < config.repeat; i++) { + const startTime = ctx.currentTime + i * (config.duration + config.gap); + + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + oscillator.type = config.type; + oscillator.frequency.value = config.frequency; + + gainNode.gain.setValueAtTime(config.volume, startTime); + gainNode.gain.linearRampToValueAtTime(0, startTime + config.duration); + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.start(startTime); + oscillator.stop(startTime + config.duration + 0.01); + } +} + +/** + * Play the alarm sound for a given urgency level. + */ +export function playAlarmSound(urgency: UrgencyLevel): void { + const config = URGENCY_TONES[urgency]; + playTone(config); +} + +/** + * Play a soft warning chime (used for cascade pre-warnings). + */ +export function playWarningChime(urgency: UrgencyLevel): void { + const base = URGENCY_TONES[urgency]; + playTone({ + ...base, + volume: base.volume * 0.5, + repeat: 1, + duration: 0.2, + }); +} + +/** + * Play a short click/tap sound for UI feedback. + */ +export function playClickSound(): void { + const ctx = getAudioContext(); + if (!ctx) return; + + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + oscillator.type = 'sine'; + oscillator.frequency.value = 1000; + + gainNode.gain.setValueAtTime(0.1, ctx.currentTime); + gainNode.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.05); + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.start(ctx.currentTime); + oscillator.stop(ctx.currentTime + 0.06); +} + +/** + * Preview a sound for a given urgency level (for settings). + */ +export function previewSound(urgency: UrgencyLevel): void { + playAlarmSound(urgency); +} diff --git a/web/src/lib/store.ts b/web/src/lib/store.ts index a6dd63d..02b4e7f 100644 --- a/web/src/lib/store.ts +++ b/web/src/lib/store.ts @@ -16,6 +16,8 @@ import { shouldTimerFire, } from './timer-engine'; import { checkWarnings } from './cascade'; +import { playAlarmSound, playWarningChime } from './sounds'; +import { sendFireNotification, sendWarningNotification } from './notifications'; export interface TimerStore { timers: Timer[]; @@ -120,6 +122,8 @@ export const useTimerStore = create()( // Check if timer should fire if (shouldTimerFire(timer, now)) { changed = true; + playAlarmSound(timer.urgency); + sendFireNotification(timer.label, timer.urgency, timer.id); return fireTimer(timer); } @@ -128,6 +132,12 @@ export const useTimerStore = create()( if (newlyFired.length > 0) { firedWarningIds.push(...newlyFired.map((wId) => `${timer.id}:${wId}`)); changed = true; + // Play warning chime and send notification + const firedWarning = timer.warnings.find((w) => newlyFired.includes(w.id)); + if (firedWarning) { + playWarningChime(timer.urgency); + sendWarningNotification(timer.label, firedWarning.minutesBefore, timer.urgency, timer.id); + } // If any warning fired, update state to 'warning' if still active if (timer.state === 'active') { return { ...timer, state: 'warning' as const };