openvibers/openvibe.live Commits

Own-channel TTS: instant speaker failover + autoplay recovery

OP OpenVibers committed 27 Aug 2026 · f064d8f3f8 · 3 files, +40 −14

Closing the tab that was speaking now hands TTS to another open tab the
INSTANT it closes: leadership uses a queued Web Lock request (every tab
waits in line and the browser promotes the next holder on release)
instead of the old 4-second ifAvailable poll. If the lock machinery
itself fails, the tab plays rather than going silent. And a tab promoted
before any user gesture no longer drops audio to the autoplay policy —
blocked messages requeue (capped at the last 5) and drain on the first
click or keypress.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/public/index.html b/public/index.html
iindex 9b35a034..3b3411fc 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -5001,7 +5001,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
--<script src="/js/chat.js?v=74" defer></script>
++<script src="/js/chat.js?v=75" defer></script>
<script src="/js/chat-autocomplete.js?v=1" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/controls.js?v=8" defer></script>
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 49d0e081..4c1a0f99 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -781,17 +781,15 @@ function isBroadcastingLiveForTTS() {
let _ownTtsLeader = !(typeof navigator !== 'undefined' && 'locks' in navigator);
(function _startOwnTtsLeadership() {
if (_ownTtsLeader) return;
-- let retry = null;
-- const attempt = () => {
-- navigator.locks.request('openvibe-own-channel-tts', { ifAvailable: true }, (lock) => {
-- if (!lock) { _ownTtsLeader = false; return null; }
++ try {
++ // Queued (not ifAvailable) request = proper leader election: every tab waits
++ // in line, and the INSTANT the speaking tab closes, the browser grants the
++ // lock to the next one — TTS recovers by itself with no polling gap.
++ navigator.locks.request('openvibe-own-channel-tts', () => {
_ownTtsLeader = true;
-- if (retry) { clearInterval(retry); retry = null; }
-- return new Promise(() => { }); // hold the lock until this tab closes
-- }).catch(() => { });
-- };
-- attempt();
-- retry = setInterval(() => { if (_ownTtsLeader) { clearInterval(retry); retry = null; } else attempt(); }, 4000);
++ return new Promise(() => { }); // hold until this tab closes
++ }).catch(() => { _ownTtsLeader = true; }); // lock machinery failed → don't go silent
++ } catch { _ownTtsLeader = true; }
})();
function isOwnChannelTtsSpeaker() { return _ownTtsLeader; }
@@@ -5832,6 +5830,28 @@ function playTTSAudio(msg) {
_processTTSAudioQueue();
}
++// Autoplay-policy recovery: a tab promoted to TTS speaker that never saw a user
++// gesture gets its audio.play() rejected (NotAllowedError) — without this the queue
++// would silently drop messages. Requeue and drain on the first interaction.
++let _ttsUnlockArmed = false;
++function _handleTtsPlayBlocked(msg, cleanupUrl) {
++ try { if (cleanupUrl) URL.revokeObjectURL(cleanupUrl); } catch { /* */ }
++ _ttsAudioQueue.unshift(msg);
++ _ttsAudioPlaying = false;
++ if (_ttsUnlockArmed) return;
++ _ttsUnlockArmed = true;
++ const resume = () => {
++ _ttsUnlockArmed = false;
++ document.removeEventListener('pointerdown', resume, true);
++ document.removeEventListener('keydown', resume, true);
++ _processTTSAudioQueue();
++ };
++ document.addEventListener('pointerdown', resume, true);
++ document.addEventListener('keydown', resume, true);
++ // Cap the backlog so a long-unattended tab doesn't burst-read 50 messages later.
++ if (_ttsAudioQueue.length > 5) _ttsAudioQueue = _ttsAudioQueue.slice(-5);
++}
++
function _processTTSAudioQueue() {
if (_ttsAudioPlaying || _ttsAudioQueue.length === 0) return;
_ttsAudioPlaying = true;
@@@ -5870,13 +5890,19 @@ function _processTTSAudioQueue() {
const cleanup = () => { URL.revokeObjectURL(url); try { ctx.close(); } catch { } _ttsAudioPlaying = false; _processTTSAudioQueue(); };
audio.onended = cleanup;
audio.onerror = cleanup;
-- audio.play().catch(cleanup);
++ audio.play().catch((err) => {
++ if (err && err.name === 'NotAllowedError') { try { ctx.close(); } catch { } _handleTtsPlayBlocked(msg, url); return; }
++ cleanup();
++ });
} catch {
// Fallback to Audio.volume if Web Audio API unavailable
audio.volume = volume;
audio.onended = () => { URL.revokeObjectURL(url); _ttsAudioPlaying = false; _processTTSAudioQueue(); };
audio.onerror = () => { URL.revokeObjectURL(url); _ttsAudioPlaying = false; _processTTSAudioQueue(); };
-- audio.play().catch(() => { URL.revokeObjectURL(url); _ttsAudioPlaying = false; _processTTSAudioQueue(); });
++ audio.play().catch((err) => {
++ if (err && err.name === 'NotAllowedError') { _handleTtsPlayBlocked(msg, url); return; }
++ URL.revokeObjectURL(url); _ttsAudioPlaying = false; _processTTSAudioQueue();
++ });
}
} catch {
_ttsAudioPlaying = false;
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex a9fc7aac..80fb8588 100644
---- a/public/popout-chat.html
++++ b/public/popout-chat.html
@@@ -233,7 +233,7 @@
<script src="/js/vibes.js?v=8"></script>
<script src="/js/opencoins.js?v=6"></script>
<script src="/js/cosmetics-ui.js?v=2"></script>
-- <script src="/js/chat.js?v=74"></script>
++ <script src="/js/chat.js?v=75"></script>
<script src="/js/chat-autocomplete.js?v=1"></script>
<script>
'use strict';