- 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
229 lines
6.2 KiB
TypeScript
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()
|