openvibers/openvibe.live Commits
/tts messages show in chat and on the OBS overlay, tagged TTS (they were only spoken, so viewers heard text nobody had written) (#286)
Changes
@@@ -490,3 +490,13 @@cursor: auto;user-select: text;}++++/* A /tts message in chat: what was read aloud, tagged. */++.chat-tts-tag {++ display: inline-flex; align-items: center; gap: 0.3em;++ padding: 0 0.45em; border-radius: 1em;++ background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent);++ color: var(--accent-light, #93c5fd);++ font-size: 0.8em; font-weight: 700; letter-spacing: 0.03em;++ vertical-align: 0.05em;++}@@@ -784,14 +784,9 @@ function isBroadcastingLiveForTTS() {}// ── Own-channel TTS: one speaking tab per device ─────────────────────────────--// "TTS On My Channel When Live" must play in exactly ONE tab per browser no matter--// how many chats/popouts the streamer has open — otherwise every tab speaks every--// message on top of each other. A tab showing the streamer's OWN channel chat (or--// their live broadcast page) takes a Web Lock and becomes the speaker; the rest stand--// by and take over automatically when it closes or moves to another channel. Only--// those tabs compete: every tab used to ask at load, so a home-page or other-channel--// tab opened first held the lock for good and the streamer's own popout never spoke--// (owner, 2026-10-18). Browsers without the Web Locks API keep every-tab behavior.++// One tab per browser speaks the streamer's own-channel TTS: a tab showing their own chat (or broadcast page)++// takes a Web Lock; the rest stand by and take over when it closes or leaves (owner, 2026-10-18). No Web Locks:++// every tab speaks.const _ownTtsLocks = typeof navigator !== 'undefined' && 'locks' in navigator;let _ownTtsLeader = !_ownTtsLocks;let _ownTtsRelease = null; // ends this tab's held (or queued) lock request@@@ -3372,6 +3367,8 @@ function handleChatMessage(msg) {break;}case 'tts':++ // Shown as a chat line too (people heard /tts and saw nothing); speaking below is unchanged.++ addChatMessage({ ...msg, type: 'chat', message_type: 'tts' });// Legacy browser-side TTS (Self TTS mode)// Only use broadcast TTS path when viewing own channel chatif (_isViewingOwnBroadcastChat() && broadcastState?.settings?.ttsMode === 'self') {@@@ -3734,6 +3731,7 @@ function buildChatMessageEl(msg, opts = {}) {</div>`;}++ if (msg.message_type === 'tts') text = `<span class="chat-tts-tag" title="Read aloud (/tts)"><i class="fa-solid fa-volume-high"></i> TTS</span> ${text}`;if (msg.message_type === 'channel-sound') {const cmd = esc((msg.sound && msg.sound.command) || '');const sSpeed = Number((msg.sound && msg.sound.speed) || 1);@@@ -591,6 +591,8 @@ body.no-shadow .msg { text-shadow: none; }return;}++ // A /tts message shows as a chat line too, tagged, so viewers see what is being read (owner, 2026-10-20).++ if (msg.type === 'tts') msg = { ...msg, type: 'chat', message_type: 'tts' };if (msg.type !== 'chat') return;const rawName = msg.username || msg.displayName || (msg.anonId ? `anon${msg.anonId}` : 'anon');@@@ -642,6 +644,7 @@ body.no-shadow .msg { text-shadow: none; }text = `<span class="channel-sound-pill"><i class="fa-solid fa-volume-high"></i> played !${cmd}</span>`;} else {text = parseEmotes(rawText);++ if (msg.message_type === 'tts') text = `<span class="channel-sound-pill tts-pill"><i class="fa-solid fa-volume-high"></i> TTS</span> ${text}`;}const sep = msg.message_type === 'channel-sound' ? ' ' : ': ';nnew file mode 100644@@@ -0,0 +1,31 @@++/**++ * /tts messages show in chat (owner, 2026-10-20: "the /tts command doesnt show in chat so people are like wtf").++ * OpenVibe.Chat sends a /tts as a `tts` event only (speech); nothing put it in the chat. Live's chat and the OBS chat++ * overlay now show it as a chat line tagged "TTS", and speaking it is unchanged (the line is added directly, not++ * through the `chat` case, so a broadcaster's self-TTS mode does not read it a second time).++ *++ * node test/tts-in-chat.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const root = path.join(__dirname, '..');++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');++++const chat = read('public/js/chat.js');++// The main socket's tts case adds the line before it decides whether this tab speaks.++const ttsCase = /case 'tts':\n\s*\/\/ Shown as a chat line too[^\n]*\n\s*addChatMessage\(\{ \.\.\.msg, type: 'chat', message_type: 'tts' \}\);\n\s*\/\/ Legacy browser-side TTS/.exec(chat);++assert.ok(ttsCase, "the main socket's tts case shows the message in chat first");++assert.strictEqual(chat.split("addChatMessage({ ...msg, type: 'chat', message_type: 'tts' })").length - 1, 1, 'once (the broadcast page\'s background socket does not add a second line)');++assert.ok(chat.includes(`if (msg.message_type === 'tts') text = \`<span class="chat-tts-tag" title="Read aloud (/tts)"><i class="fa-solid fa-volume-high"></i> TTS</span> \${text}\`;`), 'tagged TTS');++++// The OBS overlay shows it the same way.++const obs = read('public/obs/chat.html');++assert.ok(obs.includes("if (msg.type === 'tts') msg = { ...msg, type: 'chat', message_type: 'tts' };"));++assert.ok(obs.indexOf("if (msg.type === 'tts') msg =") < obs.indexOf("if (msg.type !== 'chat') return;"), 'converted before non-chat events are dropped');++assert.match(obs, /if \(msg\.message_type === 'tts'\) text = `<span class="channel-sound-pill tts-pill">/);++++assert.match(read('public/css/chat-shared.css'), /\.chat-tts-tag \{/);++console.log('tts in chat: /tts messages show in chat and on the overlay, tagged — all checks passed');
@@ -490,3 +490,13 @@
@@ -490,3 +490,13 @@
cursor: auto;
cursor: auto;
user-select: text;
user-select: text;
}
}
+
+/* A /tts message in chat: what was read aloud, tagged. */
+.chat-tts-tag {
+ display: inline-flex; align-items: center; gap: 0.3em;
+ padding: 0 0.45em; border-radius: 1em;
+ background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent);
+ color: var(--accent-light, #93c5fd);
+ font-size: 0.8em; font-weight: 700; letter-spacing: 0.03em;
+ vertical-align: 0.05em;
+}
@@ -784,14 +784,9 @@ function isBroadcastingLiveForTTS() {
@@ -784,14 +784,9 @@ function isBroadcastingLiveForTTS() {
}
}
// ── Own-channel TTS: one speaking tab per device ─────────────────────────────
// ── Own-channel TTS: one speaking tab per device ─────────────────────────────
-// "TTS On My Channel When Live" must play in exactly ONE tab per browser no matter
-// how many chats/popouts the streamer has open — otherwise every tab speaks every
-// message on top of each other. A tab showing the streamer's OWN channel chat (or
-// their live broadcast page) takes a Web Lock and becomes the speaker; the rest stand
-// by and take over automatically when it closes or moves to another channel. Only
-// those tabs compete: every tab used to ask at load, so a home-page or other-channel
-// tab opened first held the lock for good and the streamer's own popout never spoke
-// (owner, 2026-10-18). Browsers without the Web Locks API keep every-tab behavior.
+// One tab per browser speaks the streamer's own-channel TTS: a tab showing their own chat (or broadcast page)
+// takes a Web Lock; the rest stand by and take over when it closes or leaves (owner, 2026-10-18). No Web Locks:
+// every tab speaks.
const _ownTtsLocks = typeof navigator !== 'undefined' && 'locks' in navigator;
const _ownTtsLocks = typeof navigator !== 'undefined' && 'locks' in navigator;
let _ownTtsLeader = !_ownTtsLocks;
let _ownTtsLeader = !_ownTtsLocks;
let _ownTtsRelease = null; // ends this tab's held (or queued) lock request
let _ownTtsRelease = null; // ends this tab's held (or queued) lock request
@@ -3372,6 +3367,8 @@ function handleChatMessage(msg) {
@@ -3372,6 +3367,8 @@ function handleChatMessage(msg) {
break;
break;
}
}
case 'tts':
case 'tts':
+ // Shown as a chat line too (people heard /tts and saw nothing); speaking below is unchanged.
+ addChatMessage({ ...msg, type: 'chat', message_type: 'tts' });
// Legacy browser-side TTS (Self TTS mode)
// Legacy browser-side TTS (Self TTS mode)
// Only use broadcast TTS path when viewing own channel chat
// Only use broadcast TTS path when viewing own channel chat
if (_isViewingOwnBroadcastChat() && broadcastState?.settings?.ttsMode === 'self') {
if (_isViewingOwnBroadcastChat() && broadcastState?.settings?.ttsMode === 'self') {
@@ -3734,6 +3731,7 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -3734,6 +3731,7 @@ function buildChatMessageEl(msg, opts = {}) {
</div>
</div>
`;
`;
}
}
+ if (msg.message_type === 'tts') text = `<span class="chat-tts-tag" title="Read aloud (/tts)"><i class="fa-solid fa-volume-high"></i> TTS</span> ${text}`;
if (msg.message_type === 'channel-sound') {
if (msg.message_type === 'channel-sound') {
const cmd = esc((msg.sound && msg.sound.command) || '');
const cmd = esc((msg.sound && msg.sound.command) || '');
const sSpeed = Number((msg.sound && msg.sound.speed) || 1);
const sSpeed = Number((msg.sound && msg.sound.speed) || 1);
@@ -591,6 +591,8 @@ body.no-shadow .msg { text-shadow: none; }
@@ -591,6 +591,8 @@ body.no-shadow .msg { text-shadow: none; }
return;
return;
}
}
+ // A /tts message shows as a chat line too, tagged, so viewers see what is being read (owner, 2026-10-20).
+ if (msg.type === 'tts') msg = { ...msg, type: 'chat', message_type: 'tts' };
if (msg.type !== 'chat') return;
if (msg.type !== 'chat') return;
const rawName = msg.username || msg.displayName || (msg.anonId ? `anon${msg.anonId}` : 'anon');
const rawName = msg.username || msg.displayName || (msg.anonId ? `anon${msg.anonId}` : 'anon');
@@ -642,6 +644,7 @@ body.no-shadow .msg { text-shadow: none; }
@@ -642,6 +644,7 @@ body.no-shadow .msg { text-shadow: none; }
text = `<span class="channel-sound-pill"><i class="fa-solid fa-volume-high"></i> played !${cmd}</span>`;
text = `<span class="channel-sound-pill"><i class="fa-solid fa-volume-high"></i> played !${cmd}</span>`;
} else {
} else {
text = parseEmotes(rawText);
text = parseEmotes(rawText);
+ if (msg.message_type === 'tts') text = `<span class="channel-sound-pill tts-pill"><i class="fa-solid fa-volume-high"></i> TTS</span> ${text}`;
}
}
const sep = msg.message_type === 'channel-sound' ? ' ' : ': ';
const sep = msg.message_type === 'channel-sound' ? ' ' : ': ';
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+/**
+ * /tts messages show in chat (owner, 2026-10-20: "the /tts command doesnt show in chat so people are like wtf").
+ * OpenVibe.Chat sends a /tts as a `tts` event only (speech); nothing put it in the chat. Live's chat and the OBS chat
+ * overlay now show it as a chat line tagged "TTS", and speaking it is unchanged (the line is added directly, not
+ * through the `chat` case, so a broadcaster's self-TTS mode does not read it a second time).
+ *
+ * node test/tts-in-chat.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.join(__dirname, '..');
+const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
+
+const chat = read('public/js/chat.js');
+// The main socket's tts case adds the line before it decides whether this tab speaks.
+const ttsCase = /case 'tts':\n\s*\/\/ Shown as a chat line too[^\n]*\n\s*addChatMessage\(\{ \.\.\.msg, type: 'chat', message_type: 'tts' \}\);\n\s*\/\/ Legacy browser-side TTS/.exec(chat);
+assert.ok(ttsCase, "the main socket's tts case shows the message in chat first");
+assert.strictEqual(chat.split("addChatMessage({ ...msg, type: 'chat', message_type: 'tts' })").length - 1, 1, 'once (the broadcast page\'s background socket does not add a second line)');
+assert.ok(chat.includes(`if (msg.message_type === 'tts') text = \`<span class="chat-tts-tag" title="Read aloud (/tts)"><i class="fa-solid fa-volume-high"></i> TTS</span> \${text}\`;`), 'tagged TTS');
+
+// The OBS overlay shows it the same way.
+const obs = read('public/obs/chat.html');
+assert.ok(obs.includes("if (msg.type === 'tts') msg = { ...msg, type: 'chat', message_type: 'tts' };"));
+assert.ok(obs.indexOf("if (msg.type === 'tts') msg =") < obs.indexOf("if (msg.type !== 'chat') return;"), 'converted before non-chat events are dropped');
+assert.match(obs, /if \(msg\.message_type === 'tts'\) text = `<span class="channel-sound-pill tts-pill">/);
+
+assert.match(read('public/css/chat-shared.css'), /\.chat-tts-tag \{/);
+console.log('tts in chat: /tts messages show in chat and on the overlay, tagged — all checks passed');