openvibers/openvibe.live Commits

Own-channel TTS: only tabs on the streamer's own channel chat (or their live broadcast page) compete to be the speaking tab, and a tab gives it up when it leaves; every tab asked at load, so a home-page or other-channel tab opened first held it and the own popout never spoke (owner, 2026-10-18) (#242)

OP OpenVibers committed 26 days ago · ebfa82b848 · 2 files, +151 −17

Changes

ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex a0df07dc..d7a11e32 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -788,23 +788,40 @@ function isBroadcastingLiveForTTS() {
// ── Own-channel TTS: one speaking tab per device ─────────────────────────────
// "TTS On My Channel When Live" must play in exactly ONE tab per browser no matter
// how many chats/popouts the streamer has open — otherwise every tab speaks every
--// message on top of each other. The first tab takes a Web Lock and becomes the
--// speaker; the rest stand by and take over automatically when it closes. Browsers
--// without the Web Locks API keep the old every-tab behavior.
--let _ownTtsLeader = !(typeof navigator !== 'undefined' && 'locks' in navigator);
--(function _startOwnTtsLeadership() {
-- if (_ownTtsLeader) return;
-- 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;
-- 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; }
++// message on top of each other. A tab showing the streamer's OWN channel chat (or
++// their live broadcast page) takes a Web Lock and becomes the speaker; the rest stand
++// by and take over automatically when it closes or moves to another channel. Only
++// those tabs compete: every tab used to ask at load, so a home-page or other-channel
++// tab opened first held the lock for good and the streamer's own popout never spoke
++// (owner, 2026-10-18). Browsers without the Web Locks API keep every-tab behavior.
++const _ownTtsLocks = typeof navigator !== 'undefined' && 'locks' in navigator;
++let _ownTtsLeader = !_ownTtsLocks;
++let _ownTtsRelease = null; // ends this tab's held (or queued) lock request
++function _syncOwnTtsLeadership() {
++ if (!_ownTtsLocks) return;
++ const wanted = _isOwnChannelChat() || isBroadcastingLiveForTTS();
++ if (wanted && !_ownTtsRelease) {
++ let release;
++ const held = new Promise((resolve) => { release = resolve; });
++ _ownTtsRelease = release;
++ try {
++ // Queued (not ifAvailable) request = proper leader election: the INSTANT the
++ // speaking tab closes or leaves, the browser grants the lock to the next one.
++ navigator.locks.request('openvibe-own-channel-tts', () => {
++ if (_ownTtsRelease !== release) return undefined; // left before it was granted
++ _ownTtsLeader = true;
++ return held;
++ }).catch(() => { _ownTtsLeader = true; }); // lock machinery failed → don't go silent
++ } catch { _ownTtsLeader = true; }
++ } else if (!wanted && _ownTtsRelease) {
++ const release = _ownTtsRelease;
++ _ownTtsRelease = null;
++ _ownTtsLeader = false;
++ release();
++ }
++}
++function isOwnChannelTtsSpeaker() { _syncOwnTtsLeadership(); return _ownTtsLeader; }
++window.addEventListener('openvibe-auth-changed', () => { try { _syncOwnTtsLeadership(); } catch { /* */ } });
// Is the ACTIVE chat surface the logged-in user's OWN channel? Works on every
// surface — channel page, offline chat, popout — unlike isStreaming(), which only
@@@ -2579,6 +2596,7 @@ function _chatInputIdForTarget(targetId) {
function initChat(streamId, channelUserId = null) {
const prevChannelUserId = chatChannelUserId;
chatChannelUserId = channelUserId || null;
++ _syncOwnTtsLeadership();
const nextTargetId = getChatRenderTargetId();
// Same CHANNEL, different surface — a live stream ended (stream id → null) or the
@@@ -2934,6 +2952,7 @@ function destroyChat(forceClose = false) {
}
// The channel scope belongs to the page that set it; a later global fetch must not inherit it.
chatChannelUserId = null;
++ _syncOwnTtsLeadership();
chatRenderTargetId = null;
clearFullscreenChatMessages();
_fullscreenChatRecent = [];
@@@ -3217,6 +3236,7 @@ function handleChatMessage(msg) {
name: String(msg.username || ''),
core: msg.core_username || null,
};
++ _syncOwnTtsLeadership(); // signed in by now: an own-channel surface joins the speaker election
_updateTranslateHint(msg.channel_language);
// A surface rejoin (live↔offline swap on the same socket) re-issues this
// confirmation — skip the duplicate "Chatting as" notice, but still run
ddiff --git a/test/own-channel-tts-speaker.test.js b/test/own-channel-tts-speaker.test.js
nnew file mode 100644
iindex 00000000..aa70ce44
---- /dev/null
++++ b/test/own-channel-tts-speaker.test.js
@@@ -0,0 +1,114 @@
++/**
++ * Own-channel TTS: one speaking tab per browser, chosen among the tabs that show the streamer's OWN channel chat (or
++ * their live broadcast page). Owner, 2026-10-18: "i still dont hear my own tts in my own popout chat". Every tab used
++ * to ask for the Web Lock when chat.js loaded, so a home-page or other-channel tab opened first held it for good and
++ * the own-channel popout never spoke (Chat queued and sent the audio; the popout dropped it as "not the speaker").
++ *
++ * node test/own-channel-tts-speaker.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
++
++/** The source of a top-level function declaration, braces matched (after its parameter list). */
++function extract(name) {
++ const m = new RegExp(`function ${name}\\(`).exec(src);
++ assert.ok(m, `${name}() must exist`);
++ let i = m.index + m[0].length;
++ for (let parens = 1; parens; i++) { if (src[i] === '(') parens++; else if (src[i] === ')') parens--; }
++ i = src.indexOf('{', i);
++ for (let depth = 0; i < src.length; i++) {
++ if (src[i] === '{') depth++;
++ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);
++ }
++ throw new Error(`unbalanced ${name}`);
++}
++const from = src.indexOf('const _ownTtsLocks');
++const to = src.indexOf("window.addEventListener('openvibe-auth-changed', () => { try { _syncOwnTtsLeadership(); }");
++assert.ok(from > 0 && to > from, 'the speaker election block exists');
++const election = src.slice(from, src.indexOf('\n', to) + 1);
++
++/** One browser's Web Locks: exclusive, queued, granted asynchronously like the real thing. */
++function lockManager() {
++ const queues = new Map();
++ const pump = (name) => {
++ const q = queues.get(name);
++ if (!q || q.busy || !q.waiting.length) return;
++ q.busy = true;
++ const { cb, done } = q.waiting.shift();
++ queueMicrotask(async () => {
++ try { done(await cb({ name })); } finally { q.busy = false; pump(name); }
++ });
++ };
++ return {
++ request(name, cb) {
++ if (!queues.has(name)) queues.set(name, { busy: false, waiting: [] });
++ return new Promise((done) => { queues.get(name).waiting.push({ cb, done }); pump(name); });
++ },
++ };
++}
++
++function tab(locks, { userId = 1, channelUserId = null, streaming = false } = {}) {
++ const ctx = vm.createContext({ navigator: { locks }, window: { addEventListener() {} }, queueMicrotask, Promise, currentUser: { id: userId }, chatChannelUserId: channelUserId, streamingNow: streaming });
++ vm.runInContext(`${extract('isBroadcastingLiveForTTS')}\n${extract('_isOwnChannelChat')}\nfunction isStreaming() { return streamingNow; }\n${election}`, ctx);
++ return {
++ speaker: () => vm.runInContext('isOwnChannelTtsSpeaker()', ctx),
++ show: (channelUserId) => { ctx.chatChannelUserId = channelUserId; vm.runInContext('_syncOwnTtsLeadership()', ctx); },
++ live: (on) => { ctx.streamingNow = on; vm.runInContext('_syncOwnTtsLeadership()', ctx); },
++ };
++}
++const settle = () => new Promise((r) => setImmediate(r));
++
++(async () => {
++ const locks = lockManager();
++ // The streamer (user 1) has the home page open, then someone else's channel, then their own popout.
++ const home = tab(locks);
++ home.show(null);
++ const other = tab(locks);
++ other.show(42);
++ await settle();
++ const popout = tab(locks);
++ popout.show(1);
++ await settle();
++ assert.strictEqual(popout.speaker(), true, 'the own-channel popout speaks: tabs that are not on the own channel never took the lock');
++ assert.strictEqual(other.speaker(), false);
++
++ // A second own-channel surface waits its turn, and takes over the moment the speaker moves to another channel.
++ const channelPage = tab(locks);
++ channelPage.show(1);
++ await settle();
++ assert.strictEqual(channelPage.speaker(), false, 'one speaker per browser');
++ popout.show(42);
++ await settle();
++ assert.strictEqual(popout.speaker(), false, 'leaving the own channel gives the lock up');
++ assert.strictEqual(channelPage.speaker(), true, 'and the next own-channel tab speaks at once');
++
++ // The broadcast page joins the chat without a channel id: live there counts as the own channel.
++ channelPage.show(null);
++ const studio = tab(locks, { streaming: true });
++ studio.show(null);
++ await settle();
++ assert.strictEqual(studio.speaker(), true, 'the live broadcast page speaks');
++ studio.live(false);
++ await settle();
++ assert.strictEqual(studio.speaker(), false, 'and stops competing once the stream ends');
++
++ // A request still queued when its tab leaves is dropped when it is finally granted.
++ const a = tab(locks), b = tab(locks);
++ a.show(1); b.show(1);
++ b.show(null);
++ await settle();
++ assert.strictEqual(a.speaker(), true);
++ a.show(null);
++ await settle();
++ const c = tab(locks);
++ c.show(1);
++ await settle();
++ assert.strictEqual(c.speaker(), true, "a left tab's late grant never blocks the next speaker");
++
++ console.log('own-channel TTS speaker: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });