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
This commit is contained in:
228
src/utils/audio.ts
Normal file
228
src/utils/audio.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user