Hold viewer message until AI reply is ready, when requested
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 30s

Previously the viewer's message displayed immediately and the AI
reply showed up whenever DeepSeek happened to respond, landing with
an awkward gap. Now, when the AI checkbox is checked, both texts are
fetched/prepared before anything is broadcast, so the original
message and the AI reply queue back-to-back on the overlay. Falls
back to just showing the original message if the AI call fails.
Behavior without the checkbox is unchanged (still immediate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-01 17:39:33 +00:00
parent ad78436ae2
commit 729f6eb47e
2 changed files with 39 additions and 42 deletions

View file

@ -3,3 +3,4 @@
твой ответ будет прочитан вслух на стриме и показан на экране, поэтому не
пиши длинных текстов, списков или markdown-разметки.
При этом старайся шутить.
Не допускать матных слов.

View file

@ -182,6 +182,22 @@ function synthesizeSpeech(text, voice) {
});
}
function broadcastMessage(room, id, text, from, durationMs, ttsText, voice) {
io.to(room).emit('display_message', { id, text, from, at: Date.now(), durationMs });
synthesizeSpeech(ttsText, voice)
.then((audioBuffer) => {
io.to(room).emit('display_audio', {
id,
audio: audioBuffer.toString('base64'),
mimeType: 'audio/mpeg',
});
})
.catch((err) => {
console.error('[tts] synthesis failed:', err);
});
}
function getAiSystemPrompt() {
try {
const content = fs.readFileSync(AI_PROMPT_PATH, 'utf8').trim();
@ -264,59 +280,39 @@ io.on('connection', (socket) => {
const messageId = crypto.randomUUID();
const room = `streamer-${streamer.id}`;
const durationMs = (streamer.display_duration_seconds || 10) * 1000;
console.log(`[broadcast] streamer=${streamer.id} id=${messageId} from="${displayName}" "${message}"`);
io.to(room).emit('display_message', {
id: messageId,
text: message,
from: displayName,
at: Date.now(),
durationMs: (streamer.display_duration_seconds || 10) * 1000,
});
// Voice is generated after the fact so the text shows up immediately
// instead of waiting on the round trip to the TTS service. The overlay
// queues messages and matches this to the right one by id, since it may
// arrive before that message's turn to display.
synthesizeSpeech(`Сообщение от ${displayName}. ${message}`, streamer.tts_voice || DEFAULT_TTS_VOICE)
.then((audioBuffer) => {
io.to(room).emit('display_audio', {
id: messageId,
audio: audioBuffer.toString('base64'),
mimeType: 'audio/mpeg',
});
})
.catch((err) => {
console.error('[tts] synthesis failed:', err);
});
const broadcastOriginal = () => {
broadcastMessage(
room, messageId, message, displayName, durationMs,
`Сообщение от ${displayName}. ${message}`,
streamer.tts_voice || DEFAULT_TTS_VOICE
);
};
if (aiReply && DEEPSEEK_API_KEY) {
// 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)
.then((replyText) => {
broadcastOriginal();
const aiMessageId = crypto.randomUUID();
console.log(`[ai] streamer=${streamer.id} id=${aiMessageId} reply="${replyText}"`);
io.to(room).emit('display_message', {
id: aiMessageId,
text: replyText,
from: '🤖 ИИ',
at: Date.now(),
durationMs: (streamer.display_duration_seconds || 10) * 1000,
});
synthesizeSpeech(`Ответ от ИИ. ${replyText}`, AI_VOICE)
.then((audioBuffer) => {
io.to(room).emit('display_audio', {
id: aiMessageId,
audio: audioBuffer.toString('base64'),
mimeType: 'audio/mpeg',
});
})
.catch((err) => {
console.error('[tts] AI reply synthesis failed:', err);
});
broadcastMessage(
room, aiMessageId, replyText, '🤖 ИИ', durationMs,
`Ответ от ИИ. ${replyText}`,
AI_VOICE
);
})
.catch((err) => {
console.error('[ai] DeepSeek reply failed:', err);
broadcastOriginal();
});
} else {
broadcastOriginal();
}
});
});