Skip to content

Commit e7e2783

Browse files
authored
Merge pull request #17 from Unity-Lab-AI/codex/fix-voice-playback-on-website
Fix voice playback blocked by autoplay
2 parents c020a27 + d04c990 commit e7e2783

1 file changed

Lines changed: 121 additions & 9 deletions

File tree

src/main.js

Lines changed: 121 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,113 @@ async function playMessageAudio(message) {
801801

802802
// -------------------- Voice playback (TTS) --------------------
803803
let 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 /autoplay|user gesture|user-gesture|gesture|interaction required|notallowed/.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

805912
function 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

Comments
 (0)