From 5131a72983cf421dffd30ba054ae30bc2551c905 Mon Sep 17 00:00:00 2001 From: Mikhail Kiselev Date: Tue, 10 Feb 2026 17:54:31 +0300 Subject: [PATCH] fix: warm up AudioContext on first click, clean up code --- frontend/src/App.svelte | 61 +++++++++++++------------ frontend/src/components/Settings.svelte | 47 +++++++++++-------- 2 files changed, 62 insertions(+), 46 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 28a4720..e9fc779 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -35,6 +35,27 @@ EventsOn('timer:meeting_warning', handleMeetingWarning) EventsOn('timer:meeting_ended', handleMeetingEnded) 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() { @@ -119,14 +140,9 @@ return audioContext } - async function playBeep(frequency, duration, type = 'sine') { + function playBeep(frequency, duration, type = 'sine') { try { 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 gainNode = ctx.createGain() @@ -136,47 +152,36 @@ oscillator.frequency.value = frequency oscillator.type = type - // Use small offset to ensure sound is scheduled in the future - const startTime = ctx.currentTime + 0.01 - gainNode.gain.setValueAtTime(0.3, startTime) - gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration) + gainNode.gain.setValueAtTime(0.3, ctx.currentTime) + gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration) - oscillator.start(startTime) - oscillator.stop(startTime + duration) + oscillator.start(ctx.currentTime) + oscillator.stop(ctx.currentTime + duration) } catch (e) { console.error('Failed to play sound:', e) } } - async function playSound(name) { + function playSound(name) { switch (name) { case 'warning': - // Two short warning beeps - await playBeep(880, 0.15) + playBeep(880, 0.15) setTimeout(() => playBeep(880, 0.15), 200) break case 'timeup': - // Descending tone sequence - await playBeep(1200, 0.2) + playBeep(1200, 0.2) setTimeout(() => playBeep(900, 0.2), 250) setTimeout(() => playBeep(600, 0.3), 500) break case 'meeting_end': - // Final chime - three notes - await playBeep(523, 0.2) // C5 - setTimeout(() => playBeep(659, 0.2), 200) // E5 - setTimeout(() => playBeep(784, 0.4), 400) // G5 + playBeep(523, 0.2) + setTimeout(() => playBeep(659, 0.2), 200) + setTimeout(() => playBeep(784, 0.4), 400) break } } - async 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() - } + function handleMeetingStarted() { meetingActive = true currentView = 'main' } diff --git a/frontend/src/components/Settings.svelte b/frontend/src/components/Settings.svelte index c6374f8..dd338f8 100644 --- a/frontend/src/components/Settings.svelte +++ b/frontend/src/components/Settings.svelte @@ -40,15 +40,9 @@ return audioContext } - async function playBeep(frequency, duration, type = 'sine') { + function playBeep(frequency, duration, type = 'sine') { try { 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 gainNode = ctx.createGain() @@ -58,20 +52,17 @@ oscillator.frequency.value = frequency oscillator.type = type - // Use small offset to ensure sound is scheduled in the future - const startTime = ctx.currentTime + 0.01 - gainNode.gain.setValueAtTime(0.3, startTime) - gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration) + gainNode.gain.setValueAtTime(0.3, ctx.currentTime) + gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration) - oscillator.start(startTime) - oscillator.stop(startTime + duration) + oscillator.start(ctx.currentTime) + oscillator.stop(ctx.currentTime + duration) } catch (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 (customSounds[name]) { playCustomSound(name) @@ -81,16 +72,16 @@ // Otherwise play default beep sounds switch (name) { case 'warning': - await playBeep(880, 0.15) + playBeep(880, 0.15) setTimeout(() => playBeep(880, 0.15), 200) break case 'timeup': - await playBeep(1200, 0.2) + playBeep(1200, 0.2) setTimeout(() => playBeep(900, 0.2), 250) setTimeout(() => playBeep(600, 0.3), 500) break case 'meeting_end': - await playBeep(523, 0.2) + playBeep(523, 0.2) setTimeout(() => playBeep(659, 0.2), 200) setTimeout(() => playBeep(784, 0.4), 400) break @@ -172,9 +163,29 @@ 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 () => { EventsOff('update:progress') EventsOff('update:complete') + document.removeEventListener('click', warmUpAudio) } })