openvibers/openvibe.live Commits
TTS dedupe: key on message identity, not content
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=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>@@@ -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;}@@@ -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';@@@ -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,@@@ -430,7 +430,7 @@ class ChatRelayService {// Feed relayed platform chat (Kick/Twitch/YouTube) into server-side TTStry {-- 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@@@ -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).
@@ -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=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/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>
@@ -5824,18 +5824,22 @@ function _createJunglePitchShifter(context) {
@@ -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();
const _recentTtsPlays = new Map();
function _ttsRecentlyPlayed(msg) {
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 now = Date.now();
const last = _recentTtsPlays.get(key);
const last = _recentTtsPlays.get(key);
- if (last && now - last < 8000) return true;
+ if (last && now - last < windowMs) return true;
_recentTtsPlays.set(key, now);
_recentTtsPlays.set(key, now);
if (_recentTtsPlays.size > 200) {
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;
return false;
}
}
@@ -233,7 +233,7 @@
@@ -233,7 +233,7 @@
<script src="/js/vibes.js?v=10"></script>
<script src="/js/vibes.js?v=10"></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=76"></script>
+ <script src="/js/chat.js?v=77"></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';
@@ -874,7 +874,9 @@ class ChatServer {
@@ -874,7 +874,9 @@ class ChatServer {
text,
text,
chatMsg.voiceFX,
chatMsg.voiceFX,
null,
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)
// Check for 101soundboards links in the message (async, non-blocking)
@@ -907,7 +909,8 @@ class ChatServer {
@@ -907,7 +909,8 @@ class ChatServer {
chatMsg.voiceFX,
chatMsg.voiceFX,
null,
null,
client.user ? `user:${client.user.username}` : `anon:${client.anonId}`,
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 {
@@ -1635,21 +1638,24 @@ class ChatServer {
// streamId may be null for OFFLINE channel chat — pass channelUserId instead and
// 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.
// 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.
// Queue accounting key: per-stream when live, per-channel when offline.
const queueKey = streamId || (channelUserId ? `ch:${channelUserId}` : null);
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();
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 nowMs = Date.now();
const lastMs = this._recentTtsDedupe.get(dedupeKey);
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);
this._recentTtsDedupe.set(dedupeKey, nowMs);
if (this._recentTtsDedupe.size > 500) {
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 {
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.
@@ -1701,6 +1707,9 @@ class ChatServer {
@@ -1701,6 +1707,9 @@ class ChatServer {
// skip their OWN message's TTS locally — senders were hearing their
// skip their OWN message's TTS locally — senders were hearing their
// message twice (their tab + the stream audio).
// message twice (their tab + the stream audio).
sender_key: identityKey || undefined,
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,
message: text,
audio: result.audio,
audio: result.audio,
mimeType: result.mimeType,
mimeType: result.mimeType,
@@ -430,7 +430,7 @@ class ChatRelayService {
@@ -430,7 +430,7 @@ class ChatRelayService {
// Feed relayed platform chat (Kick/Twitch/YouTube) into server-side TTS
// Feed relayed platform chat (Kick/Twitch/YouTube) into server-side TTS
try {
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 */ }
} catch { /* non-critical */ }
// Merge relayed chat into the streamer's PowerChat unified overlay too. Only
// Merge relayed chat into the streamer's PowerChat unified overlay too. Only
@@ -740,7 +740,7 @@ class RobotStreamerService {
@@ -740,7 +740,7 @@ class RobotStreamerService {
// Feed relayed RS chat into server-side TTS (same path as native chat)
// Feed relayed RS chat into server-side TTS (same path as native chat)
try {
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 */ }
} catch { /* non-critical */ }
// ...and into the streamer's PowerChat overlay (relayed chat was never forwarded).
// ...and into the streamer's PowerChat overlay (relayed chat was never forwarded).