openvibers/openvibe.live Commits
Own-channel TTS works on every surface + offline-channel TTS option
Changes
@@@ -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>@@@ -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 livettsVolume: 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 channelif (_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)">@@@ -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';@@@ -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 checksconst 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);}}
@@ -5001,7 +5001,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" 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/chat-autocomplete.js?v=1" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/controls.js?v=8" defer></script>
<script src="/js/controls.js?v=8" defer></script>
@@ -182,6 +182,7 @@ const CHAT_SETTINGS_DEFAULTS = {
@@ -182,6 +182,7 @@ const CHAT_SETTINGS_DEFAULTS = {
// OFF prepends "." to your messages so TTS skips them (and mutes local playback).
// 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.
// Defaults ON so messages aren't dot-prefixed/muted-styled by default.
streamingTtsEnabled: true, // Separate TTS toggle while broadcasting live
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)
ttsVolume: 80, // TTS message volume (0–100)
soundVolume: 80, // Chat !sound / soundboard clip volume (0–100), independent of TTS
soundVolume: 80, // Chat !sound / soundboard clip volume (0–100), independent of TTS
// Chat sounds (soundboard + channel !sound commands) — independent of TTS
// Chat sounds (soundboard + channel !sound commands) — independent of TTS
@@ -794,6 +795,20 @@ let _ownTtsLeader = !(typeof navigator !== 'undefined' && 'locks' in navigator);
@@ -794,6 +795,20 @@ let _ownTtsLeader = !(typeof navigator !== 'undefined' && 'locks' in navigator);
})();
})();
function isOwnChannelTtsSpeaker() { return _ownTtsLeader; }
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.
// Who this tab is chatting as — set by the server's 'auth' confirmation.
let _myChatIdentity = null;
let _myChatIdentity = null;
// Set before a same-socket surface rejoin (live↔offline swap) so the re-issued auth
// Set before a same-socket surface rejoin (live↔offline swap) so the re-issued auth
@@ -829,12 +844,14 @@ window.addEventListener('storage', (e) => {
@@ -829,12 +844,14 @@ window.addEventListener('storage', (e) => {
if (!next || typeof next !== 'object') return;
if (!next || typeof next !== 'object') return;
const ttsWasOn = !!chatSettings.ttsEnabled;
const ttsWasOn = !!chatSettings.ttsEnabled;
const streamTtsWasOn = !!chatSettings.streamingTtsEnabled;
const streamTtsWasOn = !!chatSettings.streamingTtsEnabled;
+ const alwaysWasOn = !!chatSettings.channelTtsAlways;
chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...next };
chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...next };
chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
applyChatSettings();
applyChatSettings();
syncTTSToggleButtons();
syncTTSToggleButtons();
// TTS switched off elsewhere → stop anything already queued/speaking here too.
// 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 */ }
} catch { /* ignore malformed writes */ }
});
});
@@ -2831,6 +2848,13 @@ function handleChatMessage(msg) {
@@ -2831,6 +2848,13 @@ function handleChatMessage(msg) {
if (isOwnChannelTtsSpeaker() && isChatTTSEnabled({ streaming: true }) && typeof speakBroadcastTTS === 'function' && _isTTSEnabledForSource(msg.source_platform)) {
if (isOwnChannelTtsSpeaker() && isChatTTSEnabled({ streaming: true }) && typeof speakBroadcastTTS === 'function' && _isTTSEnabledForSource(msg.source_platform)) {
speakBroadcastTTS(msg.message || msg.text, msg.username);
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)) {
} else if (isChatTTSEnabled() && !_isOwnTtsMessage(msg) && _isTTSEnabledForSource(msg.source_platform)) {
speakTTS(msg.message || msg.text, msg.voiceFX, msg.username);
speakTTS(msg.message || msg.text, msg.voiceFX, msg.username);
}
}
@@ -2840,6 +2864,8 @@ function handleChatMessage(msg) {
@@ -2840,6 +2864,8 @@ function handleChatMessage(msg) {
// Only route through broadcast audio when on own channel
// Only route through broadcast audio when on own channel
if (_isViewingOwnBroadcastChat() && typeof playBroadcastTTSAudio === 'function') {
if (_isViewingOwnBroadcastChat() && typeof playBroadcastTTSAudio === 'function') {
if (isOwnChannelTtsSpeaker() && isChatTTSEnabled({ streaming: true }) && _isTTSEnabledForSource(msg.source_platform)) playBroadcastTTSAudio(msg);
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)) {
} else if (isChatTTSEnabled() && !_isOwnTtsMessage(msg) && _isTTSEnabledForSource(msg.source_platform)) {
playTTSAudio(msg);
playTTSAudio(msg);
}
}
@@ -2847,9 +2873,11 @@ function handleChatMessage(msg) {
@@ -2847,9 +2873,11 @@ function handleChatMessage(msg) {
case 'soundboard-audio':
case 'soundboard-audio':
// Chat sound clips (101soundboards + channel !sound commands) — play through the
// Chat sound clips (101soundboards + channel !sound commands) — play through the
// audio queue with pitch/speed modifiers. Gated on the sounds toggle, NOT TTS.
// 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 (_isViewingOwnBroadcastChat() && typeof playBroadcastTTSAudio === 'function') {
if (isOwnChannelTtsSpeaker() && isChatSoundsEnabled()) playBroadcastTTSAudio(msg);
if (isOwnChannelTtsSpeaker() && isChatSoundsEnabled()) playBroadcastTTSAudio(msg);
+ } else if (_isOwnChannelChat()) {
+ if (isOwnChannelTtsSpeaker() && isChatSoundsEnabled()) playTTSAudio(msg);
} else if (isChatSoundsEnabled()) {
} else if (isChatSoundsEnabled()) {
playTTSAudio(msg);
playTTSAudio(msg);
}
}
@@ -6092,6 +6120,10 @@ function buildSettingsPanelHTML() {
@@ -6092,6 +6120,10 @@ function buildSettingsPanelHTML() {
<span>TTS On My Channel When Live</span>
<span>TTS On My Channel When Live</span>
<input type="checkbox" data-setting="streamingTtsEnabled" onchange="onChatSettingChange(this)">
<input type="checkbox" data-setting="streamingTtsEnabled" onchange="onChatSettingChange(this)">
</label>
</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">
<label class="csp-row">
<span>TTS Volume</span>
<span>TTS Volume</span>
<input type="range" min="0" max="100" step="5" data-setting="ttsVolume" onchange="onChatSettingChange(this)" oninput="onChatSettingChange(this)">
<input type="range" min="0" max="100" step="5" data-setting="ttsVolume" onchange="onChatSettingChange(this)" oninput="onChatSettingChange(this)">
@@ -233,7 +233,7 @@
@@ -233,7 +233,7 @@
<script src="/js/vibes.js?v=8"></script>
<script src="/js/vibes.js?v=8"></script>
<script src="/js/opencoins.js?v=6"></script>
<script src="/js/opencoins.js?v=6"></script>
<script src="/js/cosmetics-ui.js?v=2"></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 src="/js/chat-autocomplete.js?v=1"></script>
<script>
<script>
'use strict';
'use strict';
@@ -895,6 +895,20 @@ class ChatServer {
@@ -895,6 +895,20 @@ class ChatServer {
// Pure global chat (homepage) — offline channel messages were already
// Pure global chat (homepage) — offline channel messages were already
// delivered to the channel room above.
// delivered to the channel room above.
this.broadcastGlobal(chatMsg);
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).
// Merge real chat into the streamer's PowerChat unified overlay (chat:write).
@@ -1600,7 +1614,30 @@ class ChatServer {
@@ -1600,7 +1614,30 @@ class ChatServer {
* Synthesize TTS audio for a chat message and broadcast to stream.
* Synthesize TTS audio for a chat message and broadcast to stream.
* Runs asynchronously — does not block message delivery.
* 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 {
try {
// "." prefix = user opted this message out of TTS — never synthesize or broadcast it.
// "." prefix = user opted this message out of TTS — never synthesize or broadcast it.
if (String(text || '').trimStart().startsWith('.')) return;
if (String(text || '').trimStart().startsWith('.')) return;
@@ -1610,7 +1647,7 @@ class ChatServer {
@@ -1610,7 +1647,7 @@ class ChatServer {
// Queue limit checks
// Queue limit checks
const limits = ttsEngine.getQueueLimits();
const limits = ttsEngine.getQueueLimits();
- const globalCount = this.ttsQueueSize.get(streamId) || 0;
+ const globalCount = this.ttsQueueSize.get(queueKey) || 0;
if (globalCount >= limits.maxGlobal) return;
if (globalCount >= limits.maxGlobal) return;
// Determine voice ID from equipped cosmetic
// Determine voice ID from equipped cosmetic
@@ -1620,7 +1657,7 @@ class ChatServer {
@@ -1620,7 +1657,7 @@ class ChatServer {
}
}
// Increment queue counter
// 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);
// 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
// falls back to the site default when unset. Without this the server synth always
@@ -1638,13 +1675,13 @@ class ChatServer {
@@ -1638,13 +1675,13 @@ class ChatServer {
}
}
// Decrement queue counter
// 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;
if (!result) return;
// Broadcast TTS audio to all clients in the stream
// Broadcast TTS audio to all clients in the stream
- this.broadcastToStream(streamId, {
+ this._broadcastTtsPayload(streamId, channelUserId, {
type: 'tts-audio',
type: 'tts-audio',
username,
username,
// Stable sender identity ("user:<login>" / "anon:<id>") so clients can
// Stable sender identity ("user:<login>" / "anon:<id>") so clients can
@@ -1663,8 +1700,8 @@ class ChatServer {
@@ -1663,8 +1700,8 @@ class ChatServer {
});
});
} catch (err) {
} catch (err) {
// Ensure queue counter is decremented on error
// 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);
console.error('[TTS] Synthesis broadcast error:', err.message);
}
}
}
}