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

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:
vrubelroman 2026-07-08 18:21:57 +00:00
parent b669c2d0b7
commit 8aeca230bc
6 changed files with 81 additions and 66 deletions

View file

@ -4,7 +4,10 @@
<meta charset="UTF-8" />
<title>Send2Streamer — сообщения от зрителей прямо на стрим</title>
<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>
body {
font-family: system-ui, sans-serif;
@ -87,6 +90,12 @@
const configRes = await fetch('/config');
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({
client_id: googleClientId,
callback: handleCredentialResponse,

View file

@ -45,15 +45,13 @@ function playNotificationSound() {
}
}
function playSpeech(base64Audio, mimeType, onEnded) {
function playSpeech(base64Audio, mimeType) {
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
const done = () => { if (onEnded) onEnded(); };
audio.addEventListener('ended', done);
audio.addEventListener('error', done);
audio.play().catch((err) => {
console.error('Could not play TTS audio:', err);
done();
});
// Fire-and-forget: we don't gate queue timing on this element's 'ended'
// event. edge-tts streams MP3 without a duration header (no Xing/VBRI
// frame), and OBS's embedded CEF browser has been observed firing 'ended'
// unreliably (sometimes early) for such streams — see durationMs below.
audio.play().catch((err) => console.error('Could not play TTS audio:', err));
}
function advanceQueue() {
@ -68,51 +66,19 @@ function advanceQueue() {
box.classList.add('visible');
playNotificationSound();
if (current.audio) {
playSpeech(current.audio.data, current.audio.mimeType);
}
// The message stays up for at least durationMs (streamer's configured
// display time), but if its TTS audio is still playing (or hasn't even
// arrived yet — synthesis is async and can take a few seconds, longer
// than a short configured duration) when that timer fires, we must not
// start the next message's audio on top of it. So we only advance once
// 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;
// durationMs is computed server-side as max(streamer's configured display
// time, estimated reading time for the TTS text) — see estimateSpeechMs in
// server.js — so it's long enough to cover the actual audio playback
// without relying on the browser telling us when the audio finished.
setTimeout(() => {
box.classList.remove('visible');
current = null;
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);
// 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) => {
@ -129,7 +95,7 @@ socket.on('display_message', (payload) => {
socket.on('display_audio', (payload) => {
const audio = { data: payload.audio, mimeType: payload.mimeType };
if (current && current.id === payload.id) {
current.startAudio(audio);
playSpeech(audio.data, audio.mimeType);
return;
}
const queued = queue.find((item) => item.id === payload.id);

View file

@ -4,7 +4,10 @@
<meta charset="UTF-8" />
<title>Отправить сообщение на стрим</title>
<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>
body {
font-family: system-ui, sans-serif;

View file

@ -65,6 +65,12 @@ async function init() {
const configRes = await fetch('/config');
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({
client_id: googleClientId,
callback: handleCredentialResponse,