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,
|
shouldTimerFire,
|
||||||
} from './timer-engine';
|
} from './timer-engine';
|
||||||
import { checkWarnings } from './cascade';
|
import { checkWarnings } from './cascade';
|
||||||
|
import { playAlarmSound, playWarningChime } from './sounds';
|
||||||
|
import { sendFireNotification, sendWarningNotification } from './notifications';
|
||||||
|
|
||||||
export interface TimerStore {
|
export interface TimerStore {
|
||||||
timers: Timer[];
|
timers: Timer[];
|
||||||
@ -120,6 +122,8 @@ export const useTimerStore = create<TimerStore>()(
|
|||||||
// Check if timer should fire
|
// Check if timer should fire
|
||||||
if (shouldTimerFire(timer, now)) {
|
if (shouldTimerFire(timer, now)) {
|
||||||
changed = true;
|
changed = true;
|
||||||
|
playAlarmSound(timer.urgency);
|
||||||
|
sendFireNotification(timer.label, timer.urgency, timer.id);
|
||||||
return fireTimer(timer);
|
return fireTimer(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,6 +132,12 @@ export const useTimerStore = create<TimerStore>()(
|
|||||||
if (newlyFired.length > 0) {
|
if (newlyFired.length > 0) {
|
||||||
firedWarningIds.push(...newlyFired.map((wId) => `${timer.id}:${wId}`));
|
firedWarningIds.push(...newlyFired.map((wId) => `${timer.id}:${wId}`));
|
||||||
changed = true;
|
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 any warning fired, update state to 'warning' if still active
|
||||||
if (timer.state === 'active') {
|
if (timer.state === 'active') {
|
||||||
return { ...timer, state: 'warning' as const };
|
return { ...timer, state: 'warning' as const };
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user