openvibers/openvibe.live Commits
The Rundown leaves out chat read aloud: a speech line holding most of a chat message sent up to two minutes before it is the stream's TTS reading chat, not the streamer ('WTF this site is so cool.' on goosely's stream) (#265)
Changes
@@@ -176,10 +176,43 @@ async function ownLanguages(userId) {}/** The next stretch: speech lines after read_to_sec; game lines, repeated lines and lines in a language the streamer does not speak out; at most MAX_WORDS. */++/**++ * The stream's chat messages, as seconds into the stream, for filters.readAloud(): a message the streamer's TTS read out++ * is in the transcript too. Read from OpenVibe.Chat a page at a time (oldest first) and kept per stream; no Chat, no++ * filtering.++ */++let _chat = new Map(); // stream id → { afterId, heard, at }++const CHAT_PAGE = 500;++async function streamChat(stream) {++ const started = toMs(stream.started_at);++ if (!started) return [];++ const hit = _chat.get(stream.id) || { afterId: 0, heard: [], at: 0 };++ if (Date.now() - hit.at > 60_000) {++ try {++ const chat = require('../chat/chat-client');++ for (let page = 0; page < 6; page++) {++ const out = await chat.readMessages({ stream_id: stream.id, after_id: hit.afterId, limit: CHAT_PAGE });++ const rows = (out && out.messages) || [];++ for (const m of rows) {++ hit.afterId = Math.max(hit.afterId, Number(m.id) || 0);++ const at = toMs(m.timestamp);++ if (at && (m.message_type || 'chat') === 'chat') hit.heard.push(...filters.chatHeard([{ at: (at - started) / 1000, text: m.message }]));++ }++ if (rows.length < CHAT_PAGE) break;++ }++ hit.at = Date.now();++ } catch { /* Chat unreachable: read without it */ }++ _chat.set(stream.id, hit);++ if (_chat.size > 64) _chat.delete(_chat.keys().next().value);++ }++ return hit.heard;++}++async function nextStretch(stream) {const rows = await db.all(`SELECT start_sec, text, vod_id FROM stream_timeline_eventsWHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 800`, [stream.id, stream.read_to_sec]);const repeated = await repeatedLines();++ const heard = rows.length ? await streamChat(stream) : [];const langs = await ownLanguages(stream.user_id);const theirs = (text) => { if (!langs) return true; const l = filters.lineLang(text); return !l || langs.has(l); };const lines = [];@@@ -187,7 +220,7 @@ async function nextStretch(stream) {for (const r of rows) {const text = String(r.text || '').replace(/\s+/g, ' ').trim();const w = words(text);-- if (text && !filters.gameLine(text) && !repeated.has(filters.norm(text)) && theirs(text)) {++ if (text && !filters.gameLine(text) && !repeated.has(filters.norm(text)) && theirs(text) && !filters.readAloud(text, Number(r.start_sec), heard)) {if ((n + w > MAX_WORDS || lines.length >= MAX_LINES) && lines.length) break;lines.push({ t: Math.round(Number(r.start_sec) * 10) / 10, text: text.slice(0, 1000), vod_id: r.vod_id });n += w;@@@ -397,4 +430,4 @@ function start() {}function stop() { if (typeof _job === 'function') _job(); _job = null; }--module.exports = { start, stop, tick, readStretch, writeEpisode, refreshTopic, keepSegments, topicFor, nextStretch, ready, optedOut, MAX_LINES, _test: { reset() { _calls = []; _announced = []; _fails = new Map(); _repeated = { at: 0, set: new Set() }; _langs = new Map(); _people = { at: 0, entries: [] }; } } };++module.exports = { start, stop, tick, readStretch, writeEpisode, refreshTopic, keepSegments, topicFor, nextStretch, ready, optedOut, MAX_LINES, _test: { reset() { _calls = []; _announced = []; _fails = new Map(); _repeated = { at: 0, set: new Set() }; _langs = new Map(); _people = { at: 0, entries: [] }; _chat = new Map(); } } };@@@ -132,4 +132,28 @@ function sameTopic(a, b) {return both / (x.size + y.size - both) >= 0.6;}--module.exports = { gameLine, verifiedQuote, addsAccusation, privateDetail, vagueTopic, slugify, sameTopic, norm, lineLang, GAME_LINES };++// A chat message the stream's TTS read aloud is heard by speech-to-text and lands in the transcript as if the streamer++// said it ("WTF this site is so cool." on goosely's stream, 2026-10-19). A line that holds most of the words of a chat++// message sent shortly before it is the message, not the streamer.++const READ_ALOUD_BEFORE = 120; // TTS queues: a message can be read up to two minutes after it was sent++const READ_ALOUD_AFTER = 5; // speech-to-text times are a little early at times++const tokens = (t) => norm(t).split(' ').filter(Boolean);++/** Messages as [{ at: seconds into the stream, words: Set }] for readAloud(); messages under 3 words never count. */++function chatHeard(messages) {++ return (messages || []).map((m) => ({ at: Number(m.at), words: new Set(tokens(m.text)) })).filter((m) => Number.isFinite(m.at) && m.words.size >= 3);++}++/** Is this speech line (at `t` seconds) a chat message read aloud? `heard` from chatHeard(). */++function readAloud(text, t, heard) {++ if (!heard || !heard.length) return false;++ const line = new Set(tokens(text));++ if (line.size < 3) return false;++ for (const m of heard) {++ if (m.at > t + READ_ALOUD_AFTER || m.at < t - READ_ALOUD_BEFORE) continue;++ let both = 0;++ for (const w of m.words) if (line.has(w)) both++;++ if (both / m.words.size >= 0.7) return true;++ }++ return false;++}++++module.exports = { gameLine, verifiedQuote, addsAccusation, privateDetail, vagueTopic, slugify, sameTopic, norm, lineLang, chatHeard, readAloud, GAME_LINES };@@@ -39,6 +39,14 @@ for (const t of ['First blood was mine', 'This is my favourite ride', 'Supermarkassert.ok(filters.sameTopic('League ranked matchmaking', 'Ranked matchmaking in League'));assert.ok(!filters.sameTopic('League ranked matchmaking', 'Moving apartments'));['chat', 'Games', 'life', 'Just chatting', 'the stream', 'x'].forEach((t) => assert.ok(filters.vagueTopic(t), `vague: ${t}`));++{++ const heard = filters.chatHeard([{ at: 150, text: 'WTF this site is so cool' }, { at: 400, text: 'lol' }, { at: 10, text: 'first time here, hello everyone' }]);++ assert.strictEqual(heard.length, 2, 'a message under three words never counts');++ assert.ok(filters.readAloud('WTF this site is so cool.', 158, heard), 'the TTS reading a chat message is not the streamer');++ assert.ok(filters.readAloud('Viewer says WTF, this site is so cool', 200, heard), 'with the name read first');++ assert.ok(!filters.readAloud('WTF this site is so cool.', 400, heard), 'long after the message: the streamer said it');++ assert.ok(!filters.readAloud('this site is great honestly', 158, heard), 'a reply in other words is the streamer');++}assert.ok(!filters.vagueTopic('League ranked matchmaking') && !filters.vagueTopic('Halloween'));['Frustration and chaos in the match', 'Talking with chat and teammates', 'Commands, builds and mid-game criticism', 'Mid-game rage'].forEach((t) => assert.ok(filters.vagueTopic(t), `a mood or the play: ${t}`));['Briar in League', 'Theme park rides', 'Boxing fight night', 'Minecraft base building', 'League autofill'].forEach((t) => assert.ok(!filters.vagueTopic(t), `a subject: ${t}`));@@@ -327,6 +335,25 @@ console.log('✅ API: front page, topic, reactions as a signed-out reader; old /console.log('✅ a re-read never announces twice and leaves no stale episode');}++// ── Chat read aloud by TTS never reaches the AI ──++{++ const tts = await mk('ttsstreamer');++ const st = await stream(tts, { startedAgoMin: 600, endedAgoMin: 560, title: 'Chatting' });++ await speak(st, tts, ['we are going to the store later to get some snacks for the stream', 'WTF this site is so cool.', ...filler(30, 'Snack talk')]);++ const started = Date.parse((await db.get('SELECT started_at FROM streams WHERE id = ?', [st])).started_at.replace(' ', 'T') + 'Z');++ const chat = require('../server/chat/chat-client');++ const prevRead = chat.readMessages;++ chat.readMessages = async (q) => (Number(q.stream_id) === st && !q.after_id ? { messages: [{ id: 7, message: 'WTF this site is so cool', message_type: 'chat', timestamp: new Date(started + 5000).toISOString().replace('T', ' ').slice(0, 19) }] } : { messages: [] });++ desk._test.reset();++ await desk.tick();++ const sent = calls.filter((c) => c.workflow === 'live.rundown.segment' && c.input.streamer === 'ttsstreamer');++ assert.strictEqual(sent.length, 1);++ assert.ok(!sent[0].input.lines.some((l) => /site is so cool/.test(l.text)), 'the message the TTS read is not in what the AI reads');++ assert.ok(sent[0].input.lines.some((l) => /snacks for the stream/.test(l.text)), 'the streamer\'s own words are');++ chat.readMessages = prevRead;++ console.log('✅ chat read aloud by TTS is left out');++}++// ── Play-by-play: a stream keeps its best 2 takes on a topic (and 2 with no topic) ──{const gamer = await mk('leaguenight');
@@ -176,10 +176,43 @@ async function ownLanguages(userId) {
@@ -176,10 +176,43 @@ async function ownLanguages(userId) {
}
}
/** The next stretch: speech lines after read_to_sec; game lines, repeated lines and lines in a language the streamer does not speak out; at most MAX_WORDS. */
/** The next stretch: speech lines after read_to_sec; game lines, repeated lines and lines in a language the streamer does not speak out; at most MAX_WORDS. */
+/**
+ * The stream's chat messages, as seconds into the stream, for filters.readAloud(): a message the streamer's TTS read out
+ * is in the transcript too. Read from OpenVibe.Chat a page at a time (oldest first) and kept per stream; no Chat, no
+ * filtering.
+ */
+let _chat = new Map(); // stream id → { afterId, heard, at }
+const CHAT_PAGE = 500;
+async function streamChat(stream) {
+ const started = toMs(stream.started_at);
+ if (!started) return [];
+ const hit = _chat.get(stream.id) || { afterId: 0, heard: [], at: 0 };
+ if (Date.now() - hit.at > 60_000) {
+ try {
+ const chat = require('../chat/chat-client');
+ for (let page = 0; page < 6; page++) {
+ const out = await chat.readMessages({ stream_id: stream.id, after_id: hit.afterId, limit: CHAT_PAGE });
+ const rows = (out && out.messages) || [];
+ for (const m of rows) {
+ hit.afterId = Math.max(hit.afterId, Number(m.id) || 0);
+ const at = toMs(m.timestamp);
+ if (at && (m.message_type || 'chat') === 'chat') hit.heard.push(...filters.chatHeard([{ at: (at - started) / 1000, text: m.message }]));
+ }
+ if (rows.length < CHAT_PAGE) break;
+ }
+ hit.at = Date.now();
+ } catch { /* Chat unreachable: read without it */ }
+ _chat.set(stream.id, hit);
+ if (_chat.size > 64) _chat.delete(_chat.keys().next().value);
+ }
+ return hit.heard;
+}
+
async function nextStretch(stream) {
async function nextStretch(stream) {
const rows = await db.all(`SELECT start_sec, text, vod_id FROM stream_timeline_events
const rows = await db.all(`SELECT start_sec, text, vod_id FROM stream_timeline_events
WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 800`, [stream.id, stream.read_to_sec]);
WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 800`, [stream.id, stream.read_to_sec]);
const repeated = await repeatedLines();
const repeated = await repeatedLines();
+ const heard = rows.length ? await streamChat(stream) : [];
const langs = await ownLanguages(stream.user_id);
const langs = await ownLanguages(stream.user_id);
const theirs = (text) => { if (!langs) return true; const l = filters.lineLang(text); return !l || langs.has(l); };
const theirs = (text) => { if (!langs) return true; const l = filters.lineLang(text); return !l || langs.has(l); };
const lines = [];
const lines = [];
@@ -187,7 +220,7 @@ async function nextStretch(stream) {
@@ -187,7 +220,7 @@ async function nextStretch(stream) {
for (const r of rows) {
for (const r of rows) {
const text = String(r.text || '').replace(/\s+/g, ' ').trim();
const text = String(r.text || '').replace(/\s+/g, ' ').trim();
const w = words(text);
const w = words(text);
- if (text && !filters.gameLine(text) && !repeated.has(filters.norm(text)) && theirs(text)) {
+ if (text && !filters.gameLine(text) && !repeated.has(filters.norm(text)) && theirs(text) && !filters.readAloud(text, Number(r.start_sec), heard)) {
if ((n + w > MAX_WORDS || lines.length >= MAX_LINES) && lines.length) break;
if ((n + w > MAX_WORDS || lines.length >= MAX_LINES) && lines.length) break;
lines.push({ t: Math.round(Number(r.start_sec) * 10) / 10, text: text.slice(0, 1000), vod_id: r.vod_id });
lines.push({ t: Math.round(Number(r.start_sec) * 10) / 10, text: text.slice(0, 1000), vod_id: r.vod_id });
n += w;
n += w;
@@ -397,4 +430,4 @@ function start() {
@@ -397,4 +430,4 @@ function start() {
}
}
function stop() { if (typeof _job === 'function') _job(); _job = null; }
function stop() { if (typeof _job === 'function') _job(); _job = null; }
-module.exports = { start, stop, tick, readStretch, writeEpisode, refreshTopic, keepSegments, topicFor, nextStretch, ready, optedOut, MAX_LINES, _test: { reset() { _calls = []; _announced = []; _fails = new Map(); _repeated = { at: 0, set: new Set() }; _langs = new Map(); _people = { at: 0, entries: [] }; } } };
+module.exports = { start, stop, tick, readStretch, writeEpisode, refreshTopic, keepSegments, topicFor, nextStretch, ready, optedOut, MAX_LINES, _test: { reset() { _calls = []; _announced = []; _fails = new Map(); _repeated = { at: 0, set: new Set() }; _langs = new Map(); _people = { at: 0, entries: [] }; _chat = new Map(); } } };
@@ -132,4 +132,28 @@ function sameTopic(a, b) {
@@ -132,4 +132,28 @@ function sameTopic(a, b) {
return both / (x.size + y.size - both) >= 0.6;
return both / (x.size + y.size - both) >= 0.6;
}
}
-module.exports = { gameLine, verifiedQuote, addsAccusation, privateDetail, vagueTopic, slugify, sameTopic, norm, lineLang, GAME_LINES };
+// A chat message the stream's TTS read aloud is heard by speech-to-text and lands in the transcript as if the streamer
+// said it ("WTF this site is so cool." on goosely's stream, 2026-10-19). A line that holds most of the words of a chat
+// message sent shortly before it is the message, not the streamer.
+const READ_ALOUD_BEFORE = 120; // TTS queues: a message can be read up to two minutes after it was sent
+const READ_ALOUD_AFTER = 5; // speech-to-text times are a little early at times
+const tokens = (t) => norm(t).split(' ').filter(Boolean);
+/** Messages as [{ at: seconds into the stream, words: Set }] for readAloud(); messages under 3 words never count. */
+function chatHeard(messages) {
+ return (messages || []).map((m) => ({ at: Number(m.at), words: new Set(tokens(m.text)) })).filter((m) => Number.isFinite(m.at) && m.words.size >= 3);
+}
+/** Is this speech line (at `t` seconds) a chat message read aloud? `heard` from chatHeard(). */
+function readAloud(text, t, heard) {
+ if (!heard || !heard.length) return false;
+ const line = new Set(tokens(text));
+ if (line.size < 3) return false;
+ for (const m of heard) {
+ if (m.at > t + READ_ALOUD_AFTER || m.at < t - READ_ALOUD_BEFORE) continue;
+ let both = 0;
+ for (const w of m.words) if (line.has(w)) both++;
+ if (both / m.words.size >= 0.7) return true;
+ }
+ return false;
+}
+
+module.exports = { gameLine, verifiedQuote, addsAccusation, privateDetail, vagueTopic, slugify, sameTopic, norm, lineLang, chatHeard, readAloud, GAME_LINES };
@@ -39,6 +39,14 @@ for (const t of ['First blood was mine', 'This is my favourite ride', 'Supermark
@@ -39,6 +39,14 @@ for (const t of ['First blood was mine', 'This is my favourite ride', 'Supermark
assert.ok(filters.sameTopic('League ranked matchmaking', 'Ranked matchmaking in League'));
assert.ok(filters.sameTopic('League ranked matchmaking', 'Ranked matchmaking in League'));
assert.ok(!filters.sameTopic('League ranked matchmaking', 'Moving apartments'));
assert.ok(!filters.sameTopic('League ranked matchmaking', 'Moving apartments'));
['chat', 'Games', 'life', 'Just chatting', 'the stream', 'x'].forEach((t) => assert.ok(filters.vagueTopic(t), `vague: ${t}`));
['chat', 'Games', 'life', 'Just chatting', 'the stream', 'x'].forEach((t) => assert.ok(filters.vagueTopic(t), `vague: ${t}`));
+{
+ const heard = filters.chatHeard([{ at: 150, text: 'WTF this site is so cool' }, { at: 400, text: 'lol' }, { at: 10, text: 'first time here, hello everyone' }]);
+ assert.strictEqual(heard.length, 2, 'a message under three words never counts');
+ assert.ok(filters.readAloud('WTF this site is so cool.', 158, heard), 'the TTS reading a chat message is not the streamer');
+ assert.ok(filters.readAloud('Viewer says WTF, this site is so cool', 200, heard), 'with the name read first');
+ assert.ok(!filters.readAloud('WTF this site is so cool.', 400, heard), 'long after the message: the streamer said it');
+ assert.ok(!filters.readAloud('this site is great honestly', 158, heard), 'a reply in other words is the streamer');
+}
assert.ok(!filters.vagueTopic('League ranked matchmaking') && !filters.vagueTopic('Halloween'));
assert.ok(!filters.vagueTopic('League ranked matchmaking') && !filters.vagueTopic('Halloween'));
['Frustration and chaos in the match', 'Talking with chat and teammates', 'Commands, builds and mid-game criticism', 'Mid-game rage'].forEach((t) => assert.ok(filters.vagueTopic(t), `a mood or the play: ${t}`));
['Frustration and chaos in the match', 'Talking with chat and teammates', 'Commands, builds and mid-game criticism', 'Mid-game rage'].forEach((t) => assert.ok(filters.vagueTopic(t), `a mood or the play: ${t}`));
['Briar in League', 'Theme park rides', 'Boxing fight night', 'Minecraft base building', 'League autofill'].forEach((t) => assert.ok(!filters.vagueTopic(t), `a subject: ${t}`));
['Briar in League', 'Theme park rides', 'Boxing fight night', 'Minecraft base building', 'League autofill'].forEach((t) => assert.ok(!filters.vagueTopic(t), `a subject: ${t}`));
@@ -327,6 +335,25 @@ console.log('✅ API: front page, topic, reactions as a signed-out reader; old /
@@ -327,6 +335,25 @@ console.log('✅ API: front page, topic, reactions as a signed-out reader; old /
console.log('✅ a re-read never announces twice and leaves no stale episode');
console.log('✅ a re-read never announces twice and leaves no stale episode');
}
}
+// ── Chat read aloud by TTS never reaches the AI ──
+{
+ const tts = await mk('ttsstreamer');
+ const st = await stream(tts, { startedAgoMin: 600, endedAgoMin: 560, title: 'Chatting' });
+ await speak(st, tts, ['we are going to the store later to get some snacks for the stream', 'WTF this site is so cool.', ...filler(30, 'Snack talk')]);
+ const started = Date.parse((await db.get('SELECT started_at FROM streams WHERE id = ?', [st])).started_at.replace(' ', 'T') + 'Z');
+ const chat = require('../server/chat/chat-client');
+ const prevRead = chat.readMessages;
+ chat.readMessages = async (q) => (Number(q.stream_id) === st && !q.after_id ? { messages: [{ id: 7, message: 'WTF this site is so cool', message_type: 'chat', timestamp: new Date(started + 5000).toISOString().replace('T', ' ').slice(0, 19) }] } : { messages: [] });
+ desk._test.reset();
+ await desk.tick();
+ const sent = calls.filter((c) => c.workflow === 'live.rundown.segment' && c.input.streamer === 'ttsstreamer');
+ assert.strictEqual(sent.length, 1);
+ assert.ok(!sent[0].input.lines.some((l) => /site is so cool/.test(l.text)), 'the message the TTS read is not in what the AI reads');
+ assert.ok(sent[0].input.lines.some((l) => /snacks for the stream/.test(l.text)), 'the streamer\'s own words are');
+ chat.readMessages = prevRead;
+ console.log('✅ chat read aloud by TTS is left out');
+}
+
// ── Play-by-play: a stream keeps its best 2 takes on a topic (and 2 with no topic) ──
// ── Play-by-play: a stream keeps its best 2 takes on a topic (and 2 with no topic) ──
{
{
const gamer = await mk('leaguenight');
const gamer = await mk('leaguenight');