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)

OP OpenVibers committed 25 days ago · f0fed5be6d · 3 files, +87 −3

Changes

ddiff --git a/server/rundown/desk.js b/server/rundown/desk.js
iindex 2480102d..f070334c 100644
---- a/server/rundown/desk.js
++++ b/server/rundown/desk.js
@@@ -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_events
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 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(); } } };
ddiff --git a/server/rundown/filters.js b/server/rundown/filters.js
iindex c114ac1e..3348a435 100644
---- a/server/rundown/filters.js
++++ b/server/rundown/filters.js
@@@ -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 };
ddiff --git a/test/rundown.test.js b/test/rundown.test.js
iindex e9ee6814..f9558624 100644
---- a/test/rundown.test.js
++++ b/test/rundown.test.js
@@@ -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', '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');