fix: warm up AudioContext on first click, clean up code

This commit is contained in:
Mikhail Kiselev
2026-02-10 17:54:31 +03:00
parent 6dac14e0c1
commit 5131a72983
2 changed files with 62 additions and 46 deletions

View File

@@ -35,6 +35,27 @@
EventsOn('timer:meeting_warning', handleMeetingWarning) EventsOn('timer:meeting_warning', handleMeetingWarning)
EventsOn('timer:meeting_ended', handleMeetingEnded) EventsOn('timer:meeting_ended', handleMeetingEnded)
EventsOn('timer:speaker_changed', handleSpeakerChanged) EventsOn('timer:speaker_changed', handleSpeakerChanged)
// Warm up AudioContext on first user interaction
const warmUpAudio = async () => {
const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
}
// Play silent sound to fully unlock audio
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
gainNode.gain.value = 0 // Silent
oscillator.start()
oscillator.stop(ctx.currentTime + 0.001)
// Remove listener after first interaction
document.removeEventListener('click', warmUpAudio)
document.removeEventListener('keydown', warmUpAudio)
}
document.addEventListener('click', warmUpAudio)
document.addEventListener('keydown', warmUpAudio)
}) })
async function loadSettings() { async function loadSettings() {
@@ -119,14 +140,9 @@
return audioContext return audioContext
} }
async function playBeep(frequency, duration, type = 'sine') { function playBeep(frequency, duration, type = 'sine') {
try { try {
const ctx = getAudioContext() const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
// Wait a frame for currentTime to update after resume
await new Promise(resolve => setTimeout(resolve, 50))
}
const oscillator = ctx.createOscillator() const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain() const gainNode = ctx.createGain()
@@ -136,47 +152,36 @@
oscillator.frequency.value = frequency oscillator.frequency.value = frequency
oscillator.type = type oscillator.type = type
// Use small offset to ensure sound is scheduled in the future gainNode.gain.setValueAtTime(0.3, ctx.currentTime)
const startTime = ctx.currentTime + 0.01 gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration)
gainNode.gain.setValueAtTime(0.3, startTime)
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration)
oscillator.start(startTime) oscillator.start(ctx.currentTime)
oscillator.stop(startTime + duration) oscillator.stop(ctx.currentTime + duration)
} catch (e) { } catch (e) {
console.error('Failed to play sound:', e) console.error('Failed to play sound:', e)
} }
} }
async function playSound(name) { function playSound(name) {
switch (name) { switch (name) {
case 'warning': case 'warning':
// Two short warning beeps playBeep(880, 0.15)
await playBeep(880, 0.15)
setTimeout(() => playBeep(880, 0.15), 200) setTimeout(() => playBeep(880, 0.15), 200)
break break
case 'timeup': case 'timeup':
// Descending tone sequence playBeep(1200, 0.2)
await playBeep(1200, 0.2)
setTimeout(() => playBeep(900, 0.2), 250) setTimeout(() => playBeep(900, 0.2), 250)
setTimeout(() => playBeep(600, 0.3), 500) setTimeout(() => playBeep(600, 0.3), 500)
break break
case 'meeting_end': case 'meeting_end':
// Final chime - three notes playBeep(523, 0.2)
await playBeep(523, 0.2) // C5 setTimeout(() => playBeep(659, 0.2), 200)
setTimeout(() => playBeep(659, 0.2), 200) // E5 setTimeout(() => playBeep(784, 0.4), 400)
setTimeout(() => playBeep(784, 0.4), 400) // G5
break break
} }
} }
async function handleMeetingStarted() { function handleMeetingStarted() {
// Pre-initialize AudioContext on user gesture (meeting start click)
// This is required because AudioContext can only be resumed during user interaction
const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
}
meetingActive = true meetingActive = true
currentView = 'main' currentView = 'main'
} }

View File

@@ -40,15 +40,9 @@
return audioContext return audioContext
} }
async function playBeep(frequency, duration, type = 'sine') { function playBeep(frequency, duration, type = 'sine') {
try { try {
const ctx = getAudioContext() const ctx = getAudioContext()
// Resume context if suspended (required by browsers on first interaction)
if (ctx.state === 'suspended') {
await ctx.resume()
// Wait a frame for currentTime to update after resume
await new Promise(resolve => setTimeout(resolve, 50))
}
const oscillator = ctx.createOscillator() const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain() const gainNode = ctx.createGain()
@@ -58,20 +52,17 @@
oscillator.frequency.value = frequency oscillator.frequency.value = frequency
oscillator.type = type oscillator.type = type
// Use small offset to ensure sound is scheduled in the future gainNode.gain.setValueAtTime(0.3, ctx.currentTime)
const startTime = ctx.currentTime + 0.01 gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration)
gainNode.gain.setValueAtTime(0.3, startTime)
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration)
oscillator.start(startTime) oscillator.start(ctx.currentTime)
oscillator.stop(startTime + duration) oscillator.stop(ctx.currentTime + duration)
} catch (e) { } catch (e) {
console.error('Failed to play sound:', e) console.error('Failed to play sound:', e)
alert('Sound error: ' + e.message)
} }
} }
async function testSound(name) { function testSound(name) {
// If custom sound exists, play it // If custom sound exists, play it
if (customSounds[name]) { if (customSounds[name]) {
playCustomSound(name) playCustomSound(name)
@@ -81,16 +72,16 @@
// Otherwise play default beep sounds // Otherwise play default beep sounds
switch (name) { switch (name) {
case 'warning': case 'warning':
await playBeep(880, 0.15) playBeep(880, 0.15)
setTimeout(() => playBeep(880, 0.15), 200) setTimeout(() => playBeep(880, 0.15), 200)
break break
case 'timeup': case 'timeup':
await playBeep(1200, 0.2) playBeep(1200, 0.2)
setTimeout(() => playBeep(900, 0.2), 250) setTimeout(() => playBeep(900, 0.2), 250)
setTimeout(() => playBeep(600, 0.3), 500) setTimeout(() => playBeep(600, 0.3), 500)
break break
case 'meeting_end': case 'meeting_end':
await playBeep(523, 0.2) playBeep(523, 0.2)
setTimeout(() => playBeep(659, 0.2), 200) setTimeout(() => playBeep(659, 0.2), 200)
setTimeout(() => playBeep(784, 0.4), 400) setTimeout(() => playBeep(784, 0.4), 400)
break break
@@ -172,9 +163,29 @@
updateComplete = true updateComplete = true
}) })
// Warm up AudioContext on first user interaction (for sound tests)
const warmUpAudio = async () => {
const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
}
// Play silent sound to fully unlock audio
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
gainNode.gain.value = 0 // Silent
oscillator.start()
oscillator.stop(ctx.currentTime + 0.001)
// Remove listener after first interaction
document.removeEventListener('click', warmUpAudio)
}
document.addEventListener('click', warmUpAudio)
return () => { return () => {
EventsOff('update:progress') EventsOff('update:progress')
EventsOff('update:complete') EventsOff('update:complete')
document.removeEventListener('click', warmUpAudio)
} }
}) })