feat: add Web Audio sound system with urgency-mapped tones and notification integration
This commit is contained in:
parent
a3ebb9fd6d
commit
b39652accf
154
web/src/lib/sounds.ts
Normal file
154
web/src/lib/sounds.ts
Normal 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);
|
||||
}
|
||||
@ -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 };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user