Fix TTS overlap deterministically, preserve message order, fix flaky Google login button
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 29s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 29s
Overlay display duration is now computed server-side from estimated reading time (text length / speaking rate), not from the audio element's 'ended' event — edge-tts streams MP3 without a duration header, and OBS's embedded browser fires 'ended' unreliably for that, which is why the earlier event-based fix didn't hold up. Per-streamer broadcasts are now serialized through a queue, so a message waiting on an AI reply can no longer be overtaken by a later message from a different viewer that doesn't need one. Also fix the Google Sign-In button intermittently not rendering: the GSI script tag is async/defer, and both index.html and send.html/js called google.accounts.id.initialize() without waiting for it to actually finish loading — a race that failed silently until the script was cached from a previous load.
This commit is contained in:
parent
b669c2d0b7
commit
8aeca230bc
6 changed files with 81 additions and 66 deletions
|
|
@ -5,5 +5,5 @@
|
||||||
При этом старайся шутить.
|
При этом старайся шутить.
|
||||||
Не допускать матных слов.
|
Не допускать матных слов.
|
||||||
Можешь дерзить в ответе.
|
Можешь дерзить в ответе.
|
||||||
Очень редко упоминай милых котиков если в тему.
|
Очень редко упоминай милых котиков или песиков или тапибар если в тему.
|
||||||
Защищай стримера, но только если есть агрессия в его адрес и если его хотят обидеть.
|
Защищай стримера, но только если есть агрессия в его адрес и если его хотят обидеть.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Send2Streamer — сообщения от зрителей прямо на стрим</title>
|
<title>Send2Streamer — сообщения от зрителей прямо на стрим</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
<script>
|
||||||
|
window.googleGsiLoaded = new Promise((resolve) => { window.resolveGoogleGsiLoaded = resolve; });
|
||||||
|
</script>
|
||||||
|
<script src="https://accounts.google.com/gsi/client" async defer onload="resolveGoogleGsiLoaded()"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
|
|
@ -87,6 +90,12 @@
|
||||||
const configRes = await fetch('/config');
|
const configRes = await fetch('/config');
|
||||||
const { googleClientId } = await configRes.json();
|
const { googleClientId } = await configRes.json();
|
||||||
|
|
||||||
|
// The GSI script tag is async/defer — without waiting for it to actually
|
||||||
|
// finish loading, this can run before `google` exists (a race that fails
|
||||||
|
// silently), which is why the button sometimes only appeared after a
|
||||||
|
// reload (once the script was already cached).
|
||||||
|
await window.googleGsiLoaded;
|
||||||
|
|
||||||
google.accounts.id.initialize({
|
google.accounts.id.initialize({
|
||||||
client_id: googleClientId,
|
client_id: googleClientId,
|
||||||
callback: handleCredentialResponse,
|
callback: handleCredentialResponse,
|
||||||
|
|
|
||||||
|
|
@ -45,15 +45,13 @@ function playNotificationSound() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function playSpeech(base64Audio, mimeType, onEnded) {
|
function playSpeech(base64Audio, mimeType) {
|
||||||
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
|
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
|
||||||
const done = () => { if (onEnded) onEnded(); };
|
// Fire-and-forget: we don't gate queue timing on this element's 'ended'
|
||||||
audio.addEventListener('ended', done);
|
// event. edge-tts streams MP3 without a duration header (no Xing/VBRI
|
||||||
audio.addEventListener('error', done);
|
// frame), and OBS's embedded CEF browser has been observed firing 'ended'
|
||||||
audio.play().catch((err) => {
|
// unreliably (sometimes early) for such streams — see durationMs below.
|
||||||
console.error('Could not play TTS audio:', err);
|
audio.play().catch((err) => console.error('Could not play TTS audio:', err));
|
||||||
done();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function advanceQueue() {
|
function advanceQueue() {
|
||||||
|
|
@ -68,51 +66,19 @@ function advanceQueue() {
|
||||||
box.classList.add('visible');
|
box.classList.add('visible');
|
||||||
|
|
||||||
playNotificationSound();
|
playNotificationSound();
|
||||||
|
if (current.audio) {
|
||||||
|
playSpeech(current.audio.data, current.audio.mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
// The message stays up for at least durationMs (streamer's configured
|
// durationMs is computed server-side as max(streamer's configured display
|
||||||
// display time), but if its TTS audio is still playing (or hasn't even
|
// time, estimated reading time for the TTS text) — see estimateSpeechMs in
|
||||||
// arrived yet — synthesis is async and can take a few seconds, longer
|
// server.js — so it's long enough to cover the actual audio playback
|
||||||
// than a short configured duration) when that timer fires, we must not
|
// without relying on the browser telling us when the audio finished.
|
||||||
// start the next message's audio on top of it. So we only advance once
|
setTimeout(() => {
|
||||||
// BOTH the minimum time has passed AND we know the audio is actually
|
|
||||||
// done — "not arrived yet" is treated as "still waiting", not "done",
|
|
||||||
// unlike an earlier version of this code that got that backwards.
|
|
||||||
let minTimeElapsed = false;
|
|
||||||
// 'unknown' (no audio yet, may still arrive), 'playing', or 'done'.
|
|
||||||
let audioState = current.audio ? 'playing' : 'unknown';
|
|
||||||
|
|
||||||
const maybeAdvance = () => {
|
|
||||||
if (!minTimeElapsed || audioState !== 'done') return;
|
|
||||||
box.classList.remove('visible');
|
box.classList.remove('visible');
|
||||||
current = null;
|
current = null;
|
||||||
setTimeout(advanceQueue, FADE_MS);
|
setTimeout(advanceQueue, FADE_MS);
|
||||||
};
|
|
||||||
|
|
||||||
current.startAudio = (audio) => {
|
|
||||||
audioState = 'playing';
|
|
||||||
playSpeech(audio.data, audio.mimeType, () => {
|
|
||||||
audioState = 'done';
|
|
||||||
maybeAdvance();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (current.audio) {
|
|
||||||
current.startAudio(current.audio);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
minTimeElapsed = true;
|
|
||||||
maybeAdvance();
|
|
||||||
}, current.durationMs || 10000);
|
}, current.durationMs || 10000);
|
||||||
|
|
||||||
// Safety net: if TTS synthesis fails server-side, no display_audio ever
|
|
||||||
// arrives for this id — don't wait forever for audio that's never coming.
|
|
||||||
setTimeout(() => {
|
|
||||||
if (audioState === 'unknown') {
|
|
||||||
audioState = 'done';
|
|
||||||
maybeAdvance();
|
|
||||||
}
|
|
||||||
}, (current.durationMs || 10000) + 8000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('display_message', (payload) => {
|
socket.on('display_message', (payload) => {
|
||||||
|
|
@ -129,7 +95,7 @@ socket.on('display_message', (payload) => {
|
||||||
socket.on('display_audio', (payload) => {
|
socket.on('display_audio', (payload) => {
|
||||||
const audio = { data: payload.audio, mimeType: payload.mimeType };
|
const audio = { data: payload.audio, mimeType: payload.mimeType };
|
||||||
if (current && current.id === payload.id) {
|
if (current && current.id === payload.id) {
|
||||||
current.startAudio(audio);
|
playSpeech(audio.data, audio.mimeType);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const queued = queue.find((item) => item.id === payload.id);
|
const queued = queue.find((item) => item.id === payload.id);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Отправить сообщение на стрим</title>
|
<title>Отправить сообщение на стрим</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
<script>
|
||||||
|
window.googleGsiLoaded = new Promise((resolve) => { window.resolveGoogleGsiLoaded = resolve; });
|
||||||
|
</script>
|
||||||
|
<script src="https://accounts.google.com/gsi/client" async defer onload="resolveGoogleGsiLoaded()"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,12 @@ async function init() {
|
||||||
const configRes = await fetch('/config');
|
const configRes = await fetch('/config');
|
||||||
const { googleClientId } = await configRes.json();
|
const { googleClientId } = await configRes.json();
|
||||||
|
|
||||||
|
// The GSI script tag is async/defer — without waiting for it to actually
|
||||||
|
// finish loading, this can run before `google` exists (a race that fails
|
||||||
|
// silently), which is why the button sometimes only appeared after a
|
||||||
|
// reload (once the script was already cached).
|
||||||
|
await window.googleGsiLoaded;
|
||||||
|
|
||||||
google.accounts.id.initialize({
|
google.accounts.id.initialize({
|
||||||
client_id: googleClientId,
|
client_id: googleClientId,
|
||||||
callback: handleCredentialResponse,
|
callback: handleCredentialResponse,
|
||||||
|
|
|
||||||
43
server.js
43
server.js
|
|
@ -205,7 +205,20 @@ function synthesizeSpeech(text, voice) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastMessage(room, id, text, from, durationMs, ttsText, voice) {
|
// edge-tts streams MP3 frames without a proper duration header (no Xing/VBRI
|
||||||
|
// frame), which makes some players — including OBS's embedded CEF browser —
|
||||||
|
// fire the audio element's 'ended' event unreliably (sometimes early). So we
|
||||||
|
// don't trust that event for queue timing at all; instead we estimate how
|
||||||
|
// long the text will take to read aloud and use that as the display floor,
|
||||||
|
// which is deterministic regardless of the browser's audio-decoding quirks.
|
||||||
|
function estimateSpeechMs(text) {
|
||||||
|
const CHARS_PER_SECOND = 15; // conservative reading speed for ru/en TTS voices
|
||||||
|
const STARTUP_BUFFER_MS = 800; // covers synthesis/playback startup latency
|
||||||
|
return STARTUP_BUFFER_MS + Math.ceil(text.length / CHARS_PER_SECOND) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastMessage(room, id, text, from, minDurationMs, ttsText, voice) {
|
||||||
|
const durationMs = Math.max(minDurationMs, estimateSpeechMs(ttsText));
|
||||||
io.to(room).emit('display_message', { id, text, from, at: Date.now(), durationMs });
|
io.to(room).emit('display_message', { id, text, from, at: Date.now(), durationMs });
|
||||||
|
|
||||||
synthesizeSpeech(ttsText, voice)
|
synthesizeSpeech(ttsText, voice)
|
||||||
|
|
@ -256,6 +269,20 @@ async function getAiReply(text, aiName) {
|
||||||
return data.choices[0].message.content.trim();
|
return data.choices[0].message.content.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-streamer chain of pending broadcasts. Without this, a message waiting
|
||||||
|
// on an AI reply (a network round-trip, ~1-3s) could be overtaken by a later
|
||||||
|
// message from a different viewer that doesn't need one — this makes
|
||||||
|
// broadcast order match send order instead of "whichever finished first".
|
||||||
|
const broadcastQueues = new Map();
|
||||||
|
|
||||||
|
function enqueueBroadcast(streamerId, task) {
|
||||||
|
const previous = broadcastQueues.get(streamerId) || Promise.resolve();
|
||||||
|
const next = previous.then(task, task).catch((err) => {
|
||||||
|
console.error('[broadcast-queue] task failed:', err);
|
||||||
|
});
|
||||||
|
broadcastQueues.set(streamerId, next);
|
||||||
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
const { role, token } = socket.handshake.query;
|
const { role, token } = socket.handshake.query;
|
||||||
const connectedUser = socket.request.session.user;
|
const connectedUser = socket.request.session.user;
|
||||||
|
|
@ -327,13 +354,17 @@ io.on('connection', (socket) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Broadcasting is enqueued per-streamer so it happens in send order —
|
||||||
|
// otherwise a message waiting on an AI reply could be overtaken by a
|
||||||
|
// later message from another viewer that doesn't need one.
|
||||||
|
enqueueBroadcast(streamer.id, async () => {
|
||||||
if (aiReply && DEEPSEEK_API_KEY) {
|
if (aiReply && DEEPSEEK_API_KEY) {
|
||||||
const aiName = streamer.ai_name || 'Альтушка Ирина';
|
const aiName = streamer.ai_name || 'Альтушка Ирина';
|
||||||
// Wait for the AI reply text before showing anything, so the viewer's
|
// Wait for the AI reply text before showing anything, so the viewer's
|
||||||
// message and the AI reply land in the overlay queue back-to-back
|
// message and the AI reply land in the overlay queue back-to-back
|
||||||
// instead of the reply trailing in later with an awkward gap.
|
// instead of the reply trailing in later with an awkward gap.
|
||||||
getAiReply(message, aiName)
|
try {
|
||||||
.then((replyText) => {
|
const replyText = await getAiReply(message, aiName);
|
||||||
broadcastOriginal();
|
broadcastOriginal();
|
||||||
|
|
||||||
const aiMessageId = crypto.randomUUID();
|
const aiMessageId = crypto.randomUUID();
|
||||||
|
|
@ -343,16 +374,16 @@ io.on('connection', (socket) => {
|
||||||
`${aiName} отвечает. ${replyText}`,
|
`${aiName} отвечает. ${replyText}`,
|
||||||
AI_VOICE
|
AI_VOICE
|
||||||
);
|
);
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => {
|
|
||||||
console.error('[ai] DeepSeek reply failed:', err);
|
console.error('[ai] DeepSeek reply failed:', err);
|
||||||
broadcastOriginal();
|
broadcastOriginal();
|
||||||
});
|
}
|
||||||
} else {
|
} else {
|
||||||
broadcastOriginal();
|
broadcastOriginal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
httpServer.listen(PORT, () => {
|
httpServer.listen(PORT, () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue