Files
zeroclock/src/utils/audio.ts
Your Name c4703dfe98 tooltips, two-column timer, font selector, tray behavior, icons, readme
- Custom tooltip directive (WCAG AAA) on every button in the app
- Two-column timer layout with sticky hero and recent entries sidebar
- Timer font selector with 16 monospace Google Fonts and live preview
- UI font selector with 15+ Google Fonts
- Close-to-tray and minimize-to-tray settings
- New app icons (no-glow variants), platform icon set
- Mini timer pop-out window
- Favorites strip with drag-reorder and inline actions
- README with feature documentation
- Remove tracked files that belong in gitignore
2026-02-21 01:15:57 +02:00

229 lines
6.2 KiB
TypeScript

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<SoundEvent, boolean>
}
export const DEFAULT_EVENTS: Record<SoundEvent, boolean> = {
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<AudioSettings>) {
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()