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
59
server.js
59
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 });
|
||||
|
||||
synthesizeSpeech(ttsText, voice)
|
||||
|
|
@ -256,6 +269,20 @@ async function getAiReply(text, aiName) {
|
|||
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) => {
|
||||
const { role, token } = socket.handshake.query;
|
||||
const connectedUser = socket.request.session.user;
|
||||
|
|
@ -327,13 +354,17 @@ io.on('connection', (socket) => {
|
|||
);
|
||||
};
|
||||
|
||||
if (aiReply && DEEPSEEK_API_KEY) {
|
||||
const aiName = streamer.ai_name || 'Альтушка Ирина';
|
||||
// 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
|
||||
// instead of the reply trailing in later with an awkward gap.
|
||||
getAiReply(message, aiName)
|
||||
.then((replyText) => {
|
||||
// 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) {
|
||||
const aiName = streamer.ai_name || 'Альтушка Ирина';
|
||||
// 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
|
||||
// instead of the reply trailing in later with an awkward gap.
|
||||
try {
|
||||
const replyText = await getAiReply(message, aiName);
|
||||
broadcastOriginal();
|
||||
|
||||
const aiMessageId = crypto.randomUUID();
|
||||
|
|
@ -343,14 +374,14 @@ io.on('connection', (socket) => {
|
|||
`${aiName} отвечает. ${replyText}`,
|
||||
AI_VOICE
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
} catch (err) {
|
||||
console.error('[ai] DeepSeek reply failed:', err);
|
||||
broadcastOriginal();
|
||||
});
|
||||
} else {
|
||||
broadcastOriginal();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
broadcastOriginal();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue