export type SoundEvent = | 'timer_start' | 'timer_stop' | 'timer_pause' | 'timer_resume' | 'idle_alert' | 'goal_reached' | 'break_reminder' export const SOUND_EVENTS: { key: SoundEvent; label: string }[] = [ { key: 'timer_start', label: 'Timer start' }, { key: 'timer_stop', label: 'Timer stop' }, { key: 'timer_pause', label: 'Pause' }, { key: 'timer_resume', label: 'Resume' }, { key: 'idle_alert', label: 'Idle alert' }, { key: 'goal_reached', label: 'Goal reached' }, { key: 'break_reminder', label: 'Break reminder' }, ] export interface AudioSettings { enabled: boolean mode: 'synthesized' | 'system' | 'custom' volume: number events: Record } export const DEFAULT_EVENTS: Record = { timer_start: true, timer_stop: true, timer_pause: true, timer_resume: true, idle_alert: true, goal_reached: true, break_reminder: true, } const DEFAULT_SETTINGS: AudioSettings = { enabled: false, mode: 'synthesized', volume: 70, events: { ...DEFAULT_EVENTS }, } class AudioEngine { private ctx: AudioContext | null = null private settings: AudioSettings = { ...DEFAULT_SETTINGS, events: { ...DEFAULT_SETTINGS.events } } private ensureContext(): AudioContext { if (!this.ctx) { this.ctx = new AudioContext() } if (this.ctx.state === 'suspended') { this.ctx.resume() } return this.ctx } private get gain(): number { return this.settings.volume / 100 } updateSettings(partial: Partial) { if (partial.enabled !== undefined) this.settings.enabled = partial.enabled if (partial.mode !== undefined) this.settings.mode = partial.mode if (partial.volume !== undefined) this.settings.volume = partial.volume if (partial.events !== undefined) this.settings.events = { ...partial.events } } getSettings(): AudioSettings { return { ...this.settings, events: { ...this.settings.events } } } play(event: SoundEvent) { if (!this.settings.enabled) return if (!this.settings.events[event]) return if (this.settings.mode !== 'synthesized') return this.synthesize(event) } playTest(event: SoundEvent) { this.synthesize(event) } private synthesize(event: SoundEvent) { switch (event) { case 'timer_start': this.playTimerStart() break case 'timer_stop': this.playTimerStop() break case 'timer_pause': this.playTimerPause() break case 'timer_resume': this.playTimerResume() break case 'idle_alert': this.playIdleAlert() break case 'goal_reached': this.playGoalReached() break case 'break_reminder': this.playBreakReminder() break } } // Quick ascending two-note chime: C5 then E5 private playTimerStart() { const ctx = this.ensureContext() const t = ctx.currentTime const vol = this.gain // Note 1: C5 (523Hz) for 100ms this.playTone(ctx, t, 523, 0.100, vol, 0.010, 0.050, 3) // Note 2: E5 (659Hz) for 150ms this.playTone(ctx, t + 0.110, 659, 0.150, vol, 0.010, 0.050, 3) } // Descending resolve: G5 sliding to C5 over 250ms private playTimerStop() { const ctx = this.ensureContext() const t = ctx.currentTime const vol = this.gain const duration = 0.250 const osc = ctx.createOscillator() const gain = ctx.createGain() osc.type = 'sine' osc.frequency.setValueAtTime(784, t) osc.frequency.linearRampToValueAtTime(523, t + duration) gain.gain.setValueAtTime(0, t) gain.gain.linearRampToValueAtTime(vol, t + 0.010) gain.gain.setValueAtTime(vol, t + duration - 0.100) gain.gain.linearRampToValueAtTime(0, t + duration) osc.connect(gain) gain.connect(ctx.destination) osc.start(t) osc.stop(t + duration) } // Single soft tone: A4 (440Hz) for 120ms private playTimerPause() { const ctx = this.ensureContext() const t = ctx.currentTime this.playTone(ctx, t, 440, 0.120, this.gain, 0.005, 0.060) } // Single bright tone: C5 (523Hz) for 120ms private playTimerResume() { const ctx = this.ensureContext() const t = ctx.currentTime this.playTone(ctx, t, 523, 0.120, this.gain, 0.005, 0.060) } // Two quick pulses at A5 (880Hz), each 80ms with 60ms gap private playIdleAlert() { const ctx = this.ensureContext() const t = ctx.currentTime const vol = this.gain this.playTone(ctx, t, 880, 0.080, vol, 0.005, 0.030) this.playTone(ctx, t + 0.140, 880, 0.080, vol, 0.005, 0.030) } // Ascending three-note fanfare: C5, E5, G5 private playGoalReached() { const ctx = this.ensureContext() const t = ctx.currentTime const vol = this.gain this.playTone(ctx, t, 523, 0.120, vol, 0.010, 0.040, 3) this.playTone(ctx, t + 0.130, 659, 0.120, vol, 0.010, 0.040, 3) this.playTone(ctx, t + 0.260, 784, 0.120, vol, 0.010, 0.040, 3) } // Gentle single chime at E5 (659Hz), 200ms, long release private playBreakReminder() { const ctx = this.ensureContext() const t = ctx.currentTime this.playTone(ctx, t, 659, 0.200, this.gain, 0.020, 0.100) } // Helper: play a single tone with ADSR-style envelope // Optional detuneCents adds a second oscillator slightly detuned for warmth private playTone( ctx: AudioContext, startAt: number, freq: number, duration: number, vol: number, attack: number, release: number, detuneCents?: number ) { const endAt = startAt + duration const gainNode = ctx.createGain() gainNode.gain.setValueAtTime(0, startAt) gainNode.gain.linearRampToValueAtTime(vol, startAt + attack) gainNode.gain.setValueAtTime(vol, endAt - release) gainNode.gain.linearRampToValueAtTime(0, endAt) gainNode.connect(ctx.destination) const osc1 = ctx.createOscillator() osc1.type = 'sine' osc1.frequency.setValueAtTime(freq, startAt) osc1.connect(gainNode) osc1.start(startAt) osc1.stop(endAt) if (detuneCents) { const osc2 = ctx.createOscillator() osc2.type = 'sine' osc2.frequency.setValueAtTime(freq, startAt) osc2.detune.setValueAtTime(detuneCents, startAt) osc2.connect(gainNode) osc2.start(startAt) osc2.stop(endAt) } } } export const audioEngine = new AudioEngine()