openvibers/openvibe.live Commits

Own-channel TTS works on every surface + offline-channel TTS option

OP OpenVibers committed 27 Aug 2026 · 14868c2cfb · 4 files, +81 −12

After the popout's live-stream swap the streamer stopped hearing TTS: the
own-channel detection leaned on isStreaming(), which only exists on the
broadcast page, so popouts fell into the viewer path where the new
self-filter muted their own messages. Own-channel is now detected from
the joined channel itself (chatChannelUserId === currentUser.id), so the
streamer's TTS — self-messages included, since they ARE the audience —
plays in popouts and on the channel page, still behind the same
one-speaking-tab Web Lock and cross-tab settings sync.

New "TTS On My Channel When Offline" option: the server now synthesizes
TTS for offline channel-room messages too (only while the channel owner
is actually connected to their own room — no audio nobody hears), and
delivers it to the channel room. The offline option rides the exact same
dedupe lock, source filters, and storage-event sync as the live one.

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 5d908f14..9b35a034 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=73" defer></script>
++<script src="/js/chat.js?v=74" 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 de342c63..49d0e081 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -182,6 +182,7 @@ const CHAT_SETTINGS_DEFAULTS = {
// OFF prepends "." to your messages so TTS skips them (and mutes local playback).
// Defaults ON so messages aren't dot-prefixed/muted-styled by default.
streamingTtsEnabled: true, // Separate TTS toggle while broadcasting live
++ channelTtsAlways: false, // Hear your own channel's chat TTS even while NOT live
ttsVolume: 80, // TTS message volume (0–100)
soundVolume: 80, // Chat !sound / soundboard clip volume (0–100), independent of TTS
// Chat sounds (soundboard + channel !sound commands) — independent of TTS
@@@ -794,6 +795,20 @@ let _ownTtsLeader = !(typeof navigator !== 'undefined' && 'locks' in navigator);
})();
function isOwnChannelTtsSpeaker() { return _ownTtsLeader; }
++// 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
++// exists on the broadcast page (that gap is why own-channel TTS died in popouts
++// after a live-stream swap).
++function _isOwnChannelChat() {
++ return !!(typeof currentUser !== 'undefined' && currentUser && chatChannelUserId
++ && Number(chatChannelUserId) === Number(currentUser.id));
++}
++// Own-channel TTS gating: live context (a stream id is joined) follows "TTS On My
++// Channel When Live"; offline channel chat follows "TTS On My Channel When Offline".
++function _ownChannelTtsWanted() {
++ return chatStreamId ? !!chatSettings.streamingTtsEnabled : !!chatSettings.channelTtsAlways;
++}
++
// Who this tab is chatting as — set by the server's 'auth' confirmation.
let _myChatIdentity = null;
// Set before a same-socket surface rejoin (live↔offline swap) so the re-issued auth
@@@ -829,12 +844,14 @@ window.addEventListener('storage', (e) => {
if (!next || typeof next !== 'object') return;
const ttsWasOn = !!chatSettings.ttsEnabled;
const streamTtsWasOn = !!chatSettings.streamingTtsEnabled;
++ const alwaysWasOn = !!chatSettings.channelTtsAlways;
chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...next };
chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
applyChatSettings();
syncTTSToggleButtons();
// TTS switched off elsewhere → stop anything already queued/speaking here too.
-- if ((ttsWasOn && !chatSettings.ttsEnabled) || (streamTtsWasOn && !chatSettings.streamingTtsEnabled)) cancelAllTTS();
++ if ((ttsWasOn && !chatSettings.ttsEnabled) || (streamTtsWasOn && !chatSettings.streamingTtsEnabled)
++ || (alwaysWasOn && !chatSettings.channelTtsAlways)) cancelAllTTS();
} catch { /* ignore malformed writes */ }
});
@@@ -2831,6 +2848,13 @@ function handleChatMessage(msg) {
if (isOwnChannelTtsSpeaker() && isChatTTSEnabled({ streaming: true }) && typeof speakBroadcastTTS === 'function' && _isTTSEnabledForSource(msg.source_platform)) {
speakBroadcastTTS(msg.message || msg.text, msg.username);
}
++ } else if (_isOwnChannelChat()) {
++ // Own channel on any surface (popout / channel page): the streamer IS
++ // the audience — no self-filter, one speaking tab per device, gated by
++ // the live/offline own-channel TTS options.
++ if (isOwnChannelTtsSpeaker() && _ownChannelTtsWanted() && _isTTSEnabledForSource(msg.source_platform)) {
++ speakTTS(msg.message || msg.text, msg.voiceFX, msg.username);
++ }
} else if (isChatTTSEnabled() && !_isOwnTtsMessage(msg) && _isTTSEnabledForSource(msg.source_platform)) {
speakTTS(msg.message || msg.text, msg.voiceFX, msg.username);
}
@@@ -2840,6 +2864,8 @@ function handleChatMessage(msg) {
// Only route through broadcast audio when on own channel
if (_isViewingOwnBroadcastChat() && typeof playBroadcastTTSAudio === 'function') {
if (isOwnChannelTtsSpeaker() && isChatTTSEnabled({ streaming: true }) && _isTTSEnabledForSource(msg.source_platform)) playBroadcastTTSAudio(msg);
++ } else if (_isOwnChannelChat()) {
++ if (isOwnChannelTtsSpeaker() && _ownChannelTtsWanted() && _isTTSEnabledForSource(msg.source_platform)) playTTSAudio(msg);
} else if (isChatTTSEnabled() && !_isOwnTtsMessage(msg) && _isTTSEnabledForSource(msg.source_platform)) {
playTTSAudio(msg);
}
@@@ -2847,9 +2873,11 @@ function handleChatMessage(msg) {
case 'soundboard-audio':
// Chat sound clips (101soundboards + channel !sound commands) — play through the
// audio queue with pitch/speed modifiers. Gated on the sounds toggle, NOT TTS.
-- // Own-channel-while-live clips follow the one-speaking-tab rule like TTS.
++ // Own-channel clips follow the one-speaking-tab rule like TTS, on every surface.
if (_isViewingOwnBroadcastChat() && typeof playBroadcastTTSAudio === 'function') {
if (isOwnChannelTtsSpeaker() && isChatSoundsEnabled()) playBroadcastTTSAudio(msg);
++ } else if (_isOwnChannelChat()) {
++ if (isOwnChannelTtsSpeaker() && isChatSoundsEnabled()) playTTSAudio(msg);
} else if (isChatSoundsEnabled()) {
playTTSAudio(msg);
}
@@@ -6092,6 +6120,10 @@ function buildSettingsPanelHTML() {
<span>TTS On My Channel When Live</span>
<input type="checkbox" data-setting="streamingTtsEnabled" onchange="onChatSettingChange(this)">
</label>
++ <label class="csp-row" title="Also read your channel's chat aloud while you are OFFLINE (same one-tab-per-device + cross-tab sync as the live option).">
++ <span>TTS On My Channel When Offline</span>
++ <input type="checkbox" data-setting="channelTtsAlways" onchange="onChatSettingChange(this)">
++ </label>
<label class="csp-row">
<span>TTS Volume</span>
<input type="range" min="0" max="100" step="5" data-setting="ttsVolume" onchange="onChatSettingChange(this)" oninput="onChatSettingChange(this)">
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex dcf4594c..a9fc7aac 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=73"></script>
++ <script src="/js/chat.js?v=74"></script>
<script src="/js/chat-autocomplete.js?v=1"></script>
<script>
'use strict';
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex d1f32a66..3f31853a 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -895,6 +895,20 @@ class ChatServer {
// Pure global chat (homepage) — offline channel messages were already
// delivered to the channel room above.
this.broadcastGlobal(chatMsg);
++ } else if (this._channelOwnerInRoom(client.channelUserId)) {
++ // OFFLINE channel chat with the channel OWNER present in their own room:
++ // synthesize TTS so their "TTS on my channel even when offline" option has
++ // audio to play (client-side settings + the one-speaking-tab lock decide
++ // whether it's actually audible). Owner absent = skip the synth cost.
++ this.synthesizeAndBroadcastTTS(
++ null,
++ username,
++ text,
++ chatMsg.voiceFX,
++ null,
++ client.user ? `user:${client.user.username}` : `anon:${client.anonId}`,
++ client.channelUserId
++ );
}
// Merge real chat into the streamer's PowerChat unified overlay (chat:write).
@@@ -1600,7 +1614,30 @@ class ChatServer {
* Synthesize TTS audio for a chat message and broadcast to stream.
* Runs asynchronously — does not block message delivery.
*/
-- async synthesizeAndBroadcastTTS(streamId, username, text, voiceFX, sourcePlatform = null, identityKey = null) {
++ // Is the channel owner connected to their OWN channel room right now? Gates
++ // offline-channel TTS synthesis so we never pay for audio nobody will hear.
++ _channelOwnerInRoom(channelUserId) {
++ if (!channelUserId) return false;
++ for (const [ws, client] of this.clients) {
++ if (ws.readyState !== WebSocket.OPEN) continue;
++ if (client.user && Number(client.user.id) === Number(channelUserId)
++ && Number(client.channelUserId) === Number(channelUserId)) return true;
++ }
++ return false;
++ }
++
++ // TTS audio delivery: to the stream room while live, to the CHANNEL room for
++ // offline channel chat (streamId null).
++ _broadcastTtsPayload(streamId, channelUserId, payload) {
++ if (streamId) this.broadcastToStream(streamId, payload);
++ else if (channelUserId) this.broadcastToChannelRoom(channelUserId, null, payload);
++ }
++
++ // streamId may be null for OFFLINE channel chat — pass channelUserId instead and
++ // the audio is delivered to the channel room rather than a stream room.
++ async synthesizeAndBroadcastTTS(streamId, username, text, voiceFX, sourcePlatform = null, identityKey = null, channelUserId = null) {
++ // Queue accounting key: per-stream when live, per-channel when offline.
++ const queueKey = streamId || (channelUserId ? `ch:${channelUserId}` : null);
try {
// "." prefix = user opted this message out of TTS — never synthesize or broadcast it.
if (String(text || '').trimStart().startsWith('.')) return;
@@@ -1610,7 +1647,7 @@ class ChatServer {
// Queue limit checks
const limits = ttsEngine.getQueueLimits();
-- const globalCount = this.ttsQueueSize.get(streamId) || 0;
++ const globalCount = this.ttsQueueSize.get(queueKey) || 0;
if (globalCount >= limits.maxGlobal) return;
// Determine voice ID from equipped cosmetic
@@@ -1620,7 +1657,7 @@ class ChatServer {
}
// Increment queue counter
-- this.ttsQueueSize.set(streamId, globalCount + 1);
++ this.ttsQueueSize.set(queueKey, globalCount + 1);
// Honor the channel's configured TTS length (streamers can raise it up to 1200);
// falls back to the site default when unset. Without this the server synth always
@@@ -1638,13 +1675,13 @@ class ChatServer {
}
// Decrement queue counter
-- const current = this.ttsQueueSize.get(streamId) || 1;
-- this.ttsQueueSize.set(streamId, Math.max(0, current - 1));
++ const current = this.ttsQueueSize.get(queueKey) || 1;
++ this.ttsQueueSize.set(queueKey, Math.max(0, current - 1));
if (!result) return;
// Broadcast TTS audio to all clients in the stream
-- this.broadcastToStream(streamId, {
++ this._broadcastTtsPayload(streamId, channelUserId, {
type: 'tts-audio',
username,
// Stable sender identity ("user:<login>" / "anon:<id>") so clients can
@@@ -1663,8 +1700,8 @@ class ChatServer {
});
} catch (err) {
// Ensure queue counter is decremented on error
-- const current = this.ttsQueueSize.get(streamId) || 1;
-- this.ttsQueueSize.set(streamId, Math.max(0, current - 1));
++ const current = this.ttsQueueSize.get(queueKey) || 1;
++ this.ttsQueueSize.set(queueKey, Math.max(0, current - 1));
console.error('[TTS] Synthesis broadcast error:', err.message);
}
}