All files / src/lib audio.ts

95.65% Statements 44/46
87.5% Branches 14/16
66.66% Functions 4/6
95.65% Lines 44/46

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135            6x     6x             9x 2x     7x   7x     9x 5x 5x           2x                 7x 1x     6x 6x 1x       5x             5x 5x 5x 5x 5x 5x 5x                     3x 3x 1x       2x       2x 2x       2x     2x   2x                               6x 6x 6x     6x 6x   6x   6x   6x     6x 6x     6x 6x    
/**
 * Audio utilities for timer completion alerts using Web Audio API
 * Uses programmatic tone generation (no external audio files needed)
 */
 
// Singleton AudioContext - created lazily on first use
let audioContext: AudioContext | null = null
 
// Track if audio has been unlocked (iOS requires user interaction)
let audioUnlocked = false
 
/**
 * Get or create the AudioContext singleton
 * Uses webkitAudioContext fallback for older Safari
 */
function getAudioContext(): AudioContext | null {
  if (audioContext) {
    return audioContext
  }
 
  try {
    const AudioContextClass =
      window.AudioContext ||
      (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
 
    if (AudioContextClass) {
      audioContext = new AudioContextClass()
      return audioContext
    }
  } catch {
    // Web Audio API not supported
  }
 
  return null
}
 
/**
 * Unlock audio playback on iOS/Safari
 * Must be called from a user interaction event (click, touch)
 * Plays a silent buffer to enable future audio playback
 */
export function unlockAudio(): void {
  if (audioUnlocked) {
    return
  }
 
  const ctx = getAudioContext()
  if (!ctx) {
    return
  }
 
  // Resume suspended context (Chrome autoplay policy)
  Iif (ctx.state === 'suspended') {
    ctx.resume().catch(() => {
      // Ignore resume errors
    })
  }
 
  // Play a silent buffer to unlock iOS audio
  try {
    const buffer = ctx.createBuffer(1, 1, 22050)
    const source = ctx.createBufferSource()
    source.buffer = buffer
    source.connect(ctx.destination)
    source.start(0)
    audioUnlocked = true
  } catch {
    // Silent fail - audio may still work
  }
}
 
/**
 * Play a pleasant beep tone for timer completion
 * Generates a two-tone alert sound using Web Audio API
 */
export function playTimerAlert(): void {
  const ctx = getAudioContext()
  if (!ctx) {
    return
  }
 
  // Resume context if suspended
  Iif (ctx.state === 'suspended') {
    ctx.resume().catch(() => {})
  }
 
  try {
    const now = ctx.currentTime
 
    // Create a pleasant two-tone beep pattern
    // First beep: higher pitch
    playTone(ctx, 880, now, 0.15) // A5
    // Short pause
    // Second beep: same pitch
    playTone(ctx, 880, now + 0.2, 0.15) // A5
    // Third beep: lower pitch (confirmation)
    playTone(ctx, 660, now + 0.45, 0.25) // E5
  } catch {
    // Audio playback failed - silent fail
  }
}
 
/**
 * Play a single tone at the specified frequency
 */
function playTone(
  ctx: AudioContext,
  frequency: number,
  startTime: number,
  duration: number
): void {
  // Create oscillator for the tone
  const oscillator = ctx.createOscillator()
  oscillator.type = 'sine'
  oscillator.frequency.setValueAtTime(frequency, startTime)
 
  // Create gain node for volume envelope (prevents clicks)
  const gainNode = ctx.createGain()
  gainNode.gain.setValueAtTime(0, startTime)
  // Quick attack
  gainNode.gain.linearRampToValueAtTime(0.3, startTime + 0.01)
  // Sustain
  gainNode.gain.setValueAtTime(0.3, startTime + duration - 0.05)
  // Quick release (prevents click)
  gainNode.gain.linearRampToValueAtTime(0, startTime + duration)
 
  // Connect oscillator -> gain -> output
  oscillator.connect(gainNode)
  gainNode.connect(ctx.destination)
 
  // Schedule playback
  oscillator.start(startTime)
  oscillator.stop(startTime + duration)
}
 
← Back to Dashboard