@@ -801,6 +801,113 @@ async function playMessageAudio(message) {
801801
802802// -------------------- Voice playback (TTS) --------------------
803803let currentTtsJob = null ;
804+ const SILENT_WAV_DATA_URL = 'data:audio/wav;base64,UklGRhYAAABXQVZFZm10IBIAAAABAAEAIlYAAESsAAACABAAZGF0YQAAAAA=' ;
805+ let audioUnlocked = false ;
806+ let audioUnlockPromise = null ;
807+ let lastAudioUnlockWarning = 0 ;
808+
809+ function isAutoplayError ( error ) {
810+ if ( ! error ) return false ;
811+ const name = typeof error . name === 'string' ? error . name . toLowerCase ( ) : '' ;
812+ const message = typeof error . message === 'string' ? error . message . toLowerCase ( ) : String ( error || '' ) . toLowerCase ( ) ;
813+ if ( name . includes ( 'notallowed' ) ) return true ;
814+ return / a u t o p l a y | u s e r g e s t u r e | u s e r - g e s t u r e | g e s t u r e | i n t e r a c t i o n r e q u i r e d | n o t a l l o w e d / . test ( message ) ;
815+ }
816+
817+ async function tryUnlockWithAudioContext ( ) {
818+ try {
819+ const AudioContextCtor = globalThis . AudioContext || globalThis . webkitAudioContext ;
820+ if ( ! AudioContextCtor ) return false ;
821+ const ctx = new AudioContextCtor ( ) ;
822+ try {
823+ if ( ctx . state === 'suspended' ) {
824+ await ctx . resume ( ) . catch ( ( ) => { } ) ;
825+ }
826+ const buffer = ctx . createBuffer ( 1 , 1 , ctx . sampleRate || 44100 ) ;
827+ const source = ctx . createBufferSource ( ) ;
828+ source . buffer = buffer ;
829+ source . connect ( ctx . destination ) ;
830+ source . start ( 0 ) ;
831+ if ( typeof source . stop === 'function' ) {
832+ try { source . stop ( 0 ) ; } catch { }
833+ }
834+ await sleep ( 0 ) ;
835+ return true ;
836+ } finally {
837+ try { await ctx . close ( ) ; } catch { }
838+ }
839+ } catch ( error ) {
840+ return false ;
841+ }
842+ }
843+
844+ async function tryUnlockWithSilentAudio ( ) {
845+ try {
846+ if ( typeof globalThis . Audio !== 'function' ) return false ;
847+ const audio = new Audio ( SILENT_WAV_DATA_URL ) ;
848+ audio . muted = true ;
849+ audio . volume = 0 ;
850+ await audio . play ( ) ;
851+ audio . pause ( ) ;
852+ return true ;
853+ } catch ( error ) {
854+ return false ;
855+ }
856+ }
857+
858+ async function unlockAudioPlayback ( ) {
859+ if ( audioUnlocked ) return true ;
860+ if ( audioUnlockPromise ) return audioUnlockPromise ;
861+ if ( typeof globalThis === 'undefined' ) return false ;
862+ audioUnlockPromise = ( async ( ) => {
863+ let unlocked = await tryUnlockWithAudioContext ( ) ;
864+ if ( ! unlocked ) {
865+ unlocked = await tryUnlockWithSilentAudio ( ) ;
866+ }
867+ if ( unlocked ) {
868+ audioUnlocked = true ;
869+ }
870+ return unlocked ;
871+ } ) ( ) ;
872+ try {
873+ return await audioUnlockPromise ;
874+ } finally {
875+ audioUnlockPromise = null ;
876+ }
877+ }
878+
879+ function notifyAudioPlaybackBlocked ( ) {
880+ const now = Date . now ( ) ;
881+ if ( now - lastAudioUnlockWarning < 3000 ) return ;
882+ lastAudioUnlockWarning = now ;
883+ setStatus ( 'Audio playback was blocked by the browser. Tap anywhere on the page to enable sound and try again.' , {
884+ error : true ,
885+ } ) ;
886+ }
887+
888+ async function playAudioWithUnlock ( audio ) {
889+ if ( ! audio ) return ;
890+ try {
891+ await audio . play ( ) ;
892+ } catch ( error ) {
893+ if ( ! isAutoplayError ( error ) ) {
894+ console . warn ( 'Audio playback failed' , error ) ;
895+ return ;
896+ }
897+ const unlocked = await unlockAudioPlayback ( ) ;
898+ if ( ! unlocked ) {
899+ console . warn ( 'Audio playback blocked by browser policies.' , error ) ;
900+ notifyAudioPlaybackBlocked ( ) ;
901+ return ;
902+ }
903+ try {
904+ await audio . play ( ) ;
905+ } catch ( retryError ) {
906+ console . warn ( 'Audio playback failed even after unlock' , retryError ) ;
907+ notifyAudioPlaybackBlocked ( ) ;
908+ }
909+ }
910+ }
804911
805912function getReferrer ( ) {
806913 try {
@@ -1034,17 +1141,21 @@ function tryStartPlayback(job) {
10341141 let watchdog = null ;
10351142 const clearWatchdog = ( ) => { if ( watchdog ) { clearTimeout ( watchdog ) ; watchdog = null ; } } ;
10361143
1037- const startPlay = ( ) => {
1144+ let playbackPromise = null ;
1145+ const attemptPlayback = ( ) => {
10381146 if ( job . cancelled ) return ;
1039- audio . play ( ) . catch ( ( ) => { } ) ;
1147+ if ( playbackPromise ) return ;
1148+ playbackPromise = playAudioWithUnlock ( audio ) . finally ( ( ) => {
1149+ playbackPromise = null ;
1150+ } ) ;
10401151 } ;
1041- audio . addEventListener ( 'loadedmetadata' , startPlay , { once : true } ) ;
1042- audio . addEventListener ( 'loadeddata' , startPlay , { once : true } ) ;
1043- audio . addEventListener ( 'canplay' , startPlay , { once : true } ) ;
1044- audio . addEventListener ( 'canplaythrough' , startPlay , { once : true } ) ;
1045- watchdog = setTimeout ( startPlay , 1500 ) ;
1152+ audio . addEventListener ( 'loadedmetadata' , attemptPlayback , { once : true } ) ;
1153+ audio . addEventListener ( 'loadeddata' , attemptPlayback , { once : true } ) ;
1154+ audio . addEventListener ( 'canplay' , attemptPlayback , { once : true } ) ;
1155+ audio . addEventListener ( 'canplaythrough' , attemptPlayback , { once : true } ) ;
1156+ watchdog = setTimeout ( attemptPlayback , 1500 ) ;
10461157 // Also try an immediate kick-off in case events are delayed
1047- setTimeout ( startPlay , 0 ) ;
1158+ setTimeout ( attemptPlayback , 0 ) ;
10481159
10491160 audio . addEventListener ( 'playing' , ( ) => {
10501161 if ( job . cancelled ) return ;
@@ -1068,7 +1179,7 @@ function tryStartPlayback(job) {
10681179
10691180 audio . addEventListener ( 'stalled' , ( ) => {
10701181 if ( job . cancelled ) return ;
1071- audio . play ( ) . catch ( ( ) => { } ) ;
1182+ void playAudioWithUnlock ( audio ) ;
10721183 } ) ;
10731184
10741185 const stallTimer = setTimeout ( ( ) => {
@@ -2008,6 +2119,7 @@ els.voicePlayback.addEventListener('change', () => {
20082119 }
20092120
20102121 state . voicePlayback = true ;
2122+ void unlockAudioPlayback ( ) ;
20112123 setStatus ( `Voice playback enabled (${ els . voiceSelect . value } ).` ) ;
20122124 playbackStatusTimer = window . setTimeout ( ( ) => {
20132125 resetStatusIfIdle ( ) ;
0 commit comments