openvibers/openvibe.live Commits

TTS: dedupe identical utterances at synth AND playback

OP OpenVibers committed 27 Aug 2026 · 18e50df541 · 4 files, +35 −3

Double TTS (reported for relayed RobotStreamer chatters) can come from
several directions — a duplicated relay bridge synthesizing one message
twice, or one tab holding two sockets (main + background broadcast) that
both deliver the same audio. Two chokepoints now collapse them: the
server refuses to synthesize the same speaker+text for the same room
twice within 8s (logs "[TTS] deduped duplicate synth" so the true source
shows itself in the journal), and the client refuses to PLAY the same
speaker+text twice within 8s across all playback paths (viewer queue,
own-channel, broadcast page, background socket). Keyed per room
server-side so multi-slot mirrors still serve their own viewers.

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 b6229e97..b0a3948a 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=75" defer></script>
++<script src="/js/chat.js?v=76" 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 4c1a0f99..ec9fba10 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -2640,6 +2640,7 @@ function _handleBgBroadcastMessage(msg) {
break;
case 'tts-audio':
if (!isOwnChannelTtsSpeaker() || !isChatTTSEnabled({ streaming: true }) || !_isTTSEnabledForSource(msg.source_platform)) return;
++ if (_ttsRecentlyPlayed(msg)) return; // bg socket + main socket can both deliver
if (typeof playBroadcastTTSAudio === 'function' && typeof broadcastState !== 'undefined') {
playBroadcastTTSAudio(msg);
}
@@@ -2861,7 +2862,8 @@ function handleChatMessage(msg) {
// Server-synthesized TTS audio (Site-Wide TTS mode)
// 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);
++ if (isOwnChannelTtsSpeaker() && isChatTTSEnabled({ streaming: true }) && _isTTSEnabledForSource(msg.source_platform)
++ && !_ttsRecentlyPlayed(msg)) playBroadcastTTSAudio(msg);
} else if (_isOwnChannelChat()) {
if (isOwnChannelTtsSpeaker() && _ownChannelTtsWanted() && _isTTSEnabledForSource(msg.source_platform)) playTTSAudio(msg);
} else if (isChatTTSEnabled() && !_isOwnTtsMessage(msg) && _isTTSEnabledForSource(msg.source_platform)) {
@@@ -5822,10 +5824,27 @@ function _createJunglePitchShifter(context) {
};
}
++// Last line of defense against double TTS: whatever the cause (two sockets in one
++// tab, a duplicated relay bridge, a re-broadcast), the same speaker saying the same
++// thing twice within a few seconds plays ONCE.
++const _recentTtsPlays = new Map();
++function _ttsRecentlyPlayed(msg) {
++ const key = `${msg.username || ''}|${String(msg.message || msg.text || '').slice(0, 200)}`;
++ const now = Date.now();
++ const last = _recentTtsPlays.get(key);
++ if (last && now - last < 8000) return true;
++ _recentTtsPlays.set(key, now);
++ if (_recentTtsPlays.size > 200) {
++ for (const [k, t] of _recentTtsPlays) if (now - t > 30000) _recentTtsPlays.delete(k);
++ }
++ return false;
++}
++
function playTTSAudio(msg) {
if (!msg.audio || !msg.mimeType) return;
// "." prefix = TTS explicitly skipped.
if (String(msg.message || msg.text || '').trimStart().startsWith('.')) return;
++ if (msg.type === 'tts-audio' && _ttsRecentlyPlayed(msg)) return;
_ttsAudioQueue.push(msg);
_processTTSAudioQueue();
}
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex 912bb87c..3b983f25 100644
---- a/public/popout-chat.html
++++ b/public/popout-chat.html
@@@ -233,7 +233,7 @@
<script src="/js/vibes.js?v=10"></script>
<script src="/js/opencoins.js?v=6"></script>
<script src="/js/cosmetics-ui.js?v=2"></script>
-- <script src="/js/chat.js?v=75"></script>
++ <script src="/js/chat.js?v=76"></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 3f31853a..3e8f5cf1 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -1638,6 +1638,19 @@ class ChatServer {
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);
++ // Same utterance, same room, twice within a few seconds = a duplicated bridge
++ // or double-fired relay (RS restart races have produced exactly this) — one
++ // synthesis, one broadcast. Keyed per room so multi-slot mirrors still get
++ // their own copy for their own viewers.
++ if (!this._recentTtsDedupe) this._recentTtsDedupe = new Map();
++ const dedupeKey = `${queueKey}|${identityKey || username}|${String(text).slice(0, 200)}`;
++ const nowMs = Date.now();
++ const lastMs = this._recentTtsDedupe.get(dedupeKey);
++ if (lastMs && nowMs - lastMs < 8000) { console.log(`[TTS] deduped duplicate synth for ${username} in ${queueKey}`); return; }
++ this._recentTtsDedupe.set(dedupeKey, nowMs);
++ if (this._recentTtsDedupe.size > 500) {
++ for (const [k, t] of this._recentTtsDedupe) if (nowMs - t > 30000) this._recentTtsDedupe.delete(k);
++ }
try {
// "." prefix = user opted this message out of TTS — never synthesize or broadcast it.
if (String(text || '').trimStart().startsWith('.')) return;