feat: add Web Audio sound system with urgency-mapped tones and notification integration

This commit is contained in:
saravanakumardb1 2026-02-27 21:06:25 -08:00
parent a3ebb9fd6d
commit b39652accf
2 changed files with 164 additions and 0 deletions

154
web/src/lib/sounds.ts Normal file
View File

@ -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<UrgencyLevel, ToneConfig> = {
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);
}

View File

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