openvibers/openvibe.live Commits

TTS dedupe: key on message identity, not content

OP OpenVibers committed 27 Aug 2026 · eb36a45f46 · 6 files, +33 −20

The content-based 8s dedupe ate legitimate repeats — a chatter typing
"hurry up" twice was only read once. Every synthesis call now carries the
persisted chat message id as a ttsKey (threaded through the main path,
offline channel path, platform relays, and the RS mirror) and both
chokepoints dedupe on THAT: the same message twice is a dupe, the same
TEXT in two messages reads every time. The content fallback for keyless
payloads shrinks to a 2s window — wide enough for a racing double
delivery, far too narrow to eat a real repeat.

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 b0a3948a..c7456110 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=76" defer></script>
++<script src="/js/chat.js?v=77" 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 ec9fba10..58d1587f 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -5824,18 +5824,22 @@ 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.
++// Last line of defense against double TTS: the SAME utterance delivered twice (two
++// sockets in one tab, a re-broadcast) plays once. Keyed by the server's per-message
++// ttsKey — so a user legitimately typing the same text again IS read again. The
++// content fallback for keyless payloads uses a 2s window: wide enough for a racing
++// double delivery, far too narrow to eat a real repeat.
const _recentTtsPlays = new Map();
function _ttsRecentlyPlayed(msg) {
-- const key = `${msg.username || ''}|${String(msg.message || msg.text || '').slice(0, 200)}`;
++ const keyed = !!msg.ttsKey;
++ const key = keyed ? `k:${msg.ttsKey}` : `${msg.username || ''}|${String(msg.message || msg.text || '').slice(0, 200)}`;
++ const windowMs = keyed ? 30000 : 2000;
const now = Date.now();
const last = _recentTtsPlays.get(key);
-- if (last && now - last < 8000) return true;
++ if (last && now - last < windowMs) return true;
_recentTtsPlays.set(key, now);
if (_recentTtsPlays.size > 200) {
-- for (const [k, t] of _recentTtsPlays) if (now - t > 30000) _recentTtsPlays.delete(k);
++ for (const [k, t] of _recentTtsPlays) if (now - t > 60000) _recentTtsPlays.delete(k);
}
return false;
}
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex 3b983f25..a1b8893c 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=76"></script>
++ <script src="/js/chat.js?v=77"></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 3e8f5cf1..e095a9cd 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -874,7 +874,9 @@ class ChatServer {
text,
chatMsg.voiceFX,
null,
-- client.user ? `user:${client.user.username}` : `anon:${client.anonId}`
++ client.user ? `user:${client.user.username}` : `anon:${client.anonId}`,
++ null,
++ chatMsg.id ? `m${chatMsg.id}` : null
);
// Check for 101soundboards links in the message (async, non-blocking)
@@@ -907,7 +909,8 @@ class ChatServer {
chatMsg.voiceFX,
null,
client.user ? `user:${client.user.username}` : `anon:${client.anonId}`,
-- client.channelUserId
++ client.channelUserId,
++ chatMsg.id ? `m${chatMsg.id}` : null
);
}
@@@ -1635,21 +1638,24 @@ class ChatServer {
// 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) {
++ async synthesizeAndBroadcastTTS(streamId, username, text, voiceFX, sourcePlatform = null, identityKey = null, channelUserId = null, ttsKey = 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.
++ // Duplicate suppression by MESSAGE IDENTITY, not content: the same chat
++ // message synthesized twice (duplicated bridge, double-fired relay) is a dupe;
++ // a user legitimately typing the same text again is NOT and must be read
++ // again. Callers pass ttsKey (the persisted message id). The content fallback
++ // for id-less paths uses a 2s window — wide enough for a racing double
++ // delivery, far too narrow to eat a real repeat.
if (!this._recentTtsDedupe) this._recentTtsDedupe = new Map();
-- const dedupeKey = `${queueKey}|${identityKey || username}|${String(text).slice(0, 200)}`;
++ const dedupeKey = ttsKey ? `k:${queueKey}|${ttsKey}` : `${queueKey}|${identityKey || username}|${String(text).slice(0, 200)}`;
++ const windowMs = ttsKey ? 30000 : 2000;
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; }
++ if (lastMs && nowMs - lastMs < windowMs) { console.log(`[TTS] deduped duplicate synth for ${username} in ${queueKey} (${ttsKey ? 'same message id' : 'same content <2s'})`); 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);
++ for (const [k, t] of this._recentTtsDedupe) if (nowMs - t > 60000) this._recentTtsDedupe.delete(k);
}
try {
// "." prefix = user opted this message out of TTS — never synthesize or broadcast it.
@@@ -1701,6 +1707,9 @@ class ChatServer {
// skip their OWN message's TTS locally — senders were hearing their
// message twice (their tab + the stream audio).
sender_key: identityKey || undefined,
++ // Unique per utterance — clients dedupe playback on this, so identical
++ // TEXT from separate messages still reads every time.
++ ttsKey: ttsKey || undefined,
message: text,
audio: result.audio,
mimeType: result.mimeType,
ddiff --git a/server/integrations/chat-relay-service.js b/server/integrations/chat-relay-service.js
iindex f1f1b81f..b82fa5d3 100644
---- a/server/integrations/chat-relay-service.js
++++ b/server/integrations/chat-relay-service.js
@@@ -430,7 +430,7 @@ class ChatRelayService {
// Feed relayed platform chat (Kick/Twitch/YouTube) into server-side TTS
try {
-- chatServer.synthesizeAndBroadcastTTS(bridge.streamId, prefixedUsername, chatMsg.message, null, bridge.platform, `${bridge.platform}:${prefixedUsername}`);
++ chatServer.synthesizeAndBroadcastTTS(bridge.streamId, prefixedUsername, chatMsg.message, null, bridge.platform, `${bridge.platform}:${prefixedUsername}`, null, chatMsg.id ? `m${chatMsg.id}` : null);
} catch { /* non-critical */ }
// Merge relayed chat into the streamer's PowerChat unified overlay too. Only
ddiff --git a/server/integrations/robotstreamer-service.js b/server/integrations/robotstreamer-service.js
iindex 85ded2b5..9a49497e 100644
---- a/server/integrations/robotstreamer-service.js
++++ b/server/integrations/robotstreamer-service.js
@@@ -740,7 +740,7 @@ class RobotStreamerService {
// Feed relayed RS chat into server-side TTS (same path as native chat)
try {
-- chatServer.synthesizeAndBroadcastTTS(stream.id, username, mirrored.message, null, 'rs', `rs:${username}`);
++ chatServer.synthesizeAndBroadcastTTS(stream.id, username, mirrored.message, null, 'rs', `rs:${username}`, null, mirrored.id ? `m${mirrored.id}` : null);
} catch { /* non-critical */ }
// ...and into the streamer's PowerChat overlay (relayed chat was never forwarded).