Let streamers name their AI assistant; fix overlay TTS audio overlap
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 30s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 30s
Streamer dashboard gains an editable AI assistant name (default "Альтушка Ирина"), stored per-streamer and used for the overlay label, TTS intro, and DeepSeek system prompt. Also fixes a bug where a long message's TTS audio could still be playing when the next queued message started its own audio — the overlay queue now waits for the current audio to actually finish (not just the configured display duration) before advancing.
This commit is contained in:
parent
fec77e2737
commit
ec6d8c4cfe
6 changed files with 106 additions and 22 deletions
|
|
@ -61,6 +61,9 @@
|
|||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.settings-row input#ai-name {
|
||||
width: 220px;
|
||||
}
|
||||
.settings-row button {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
|
|
@ -101,11 +104,22 @@
|
|||
<div class="settings-row">
|
||||
<input id="display-duration" type="number" min="1" max="120" />
|
||||
<span>сек.</span>
|
||||
<button id="save-duration">Сохранить</button>
|
||||
</div>
|
||||
<div id="settings-status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Имя ИИ-помощника</h2>
|
||||
<p>Как зовут вашего ИИ-помощника, который отвечает зрителям (по умолчанию «Альтушка Ирина»).</p>
|
||||
<div class="settings-row">
|
||||
<input id="ai-name" type="text" maxlength="60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<button id="save-settings">Сохранить</button>
|
||||
</div>
|
||||
<div id="settings-status"></div>
|
||||
|
||||
<p id="logout">выйти</p>
|
||||
|
||||
<script>
|
||||
|
|
@ -126,15 +140,17 @@
|
|||
document.getElementById('overlay-url').value = data.overlayUrl;
|
||||
document.getElementById('sender-url').value = data.senderUrl;
|
||||
document.getElementById('display-duration').value = data.displayDurationSeconds;
|
||||
document.getElementById('ai-name').value = data.aiName;
|
||||
}
|
||||
|
||||
document.getElementById('save-duration').addEventListener('click', async () => {
|
||||
document.getElementById('save-settings').addEventListener('click', async () => {
|
||||
const statusEl = document.getElementById('settings-status');
|
||||
const seconds = Number(document.getElementById('display-duration').value);
|
||||
const aiName = document.getElementById('ai-name').value;
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayDurationSeconds: seconds }),
|
||||
body: JSON.stringify({ displayDurationSeconds: seconds, aiName }),
|
||||
});
|
||||
if (res.ok) {
|
||||
statusEl.textContent = 'Сохранено';
|
||||
|
|
|
|||
|
|
@ -45,9 +45,15 @@ function playNotificationSound() {
|
|||
}
|
||||
}
|
||||
|
||||
function playSpeech(base64Audio, mimeType) {
|
||||
function playSpeech(base64Audio, mimeType, onEnded) {
|
||||
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
|
||||
audio.play().catch((err) => console.error('Could not play TTS audio:', err));
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
function advanceQueue() {
|
||||
|
|
@ -62,14 +68,37 @@ function advanceQueue() {
|
|||
box.classList.add('visible');
|
||||
|
||||
playNotificationSound();
|
||||
if (current.audio) {
|
||||
playSpeech(current.audio.data, current.audio.mimeType);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
// The message stays up for at least durationMs (streamer's configured
|
||||
// display time), but if its TTS audio is still playing when that timer
|
||||
// fires (e.g. a long question read aloud), we must not start the next
|
||||
// message's audio on top of it — so we also wait for the audio to
|
||||
// actually finish before advancing.
|
||||
let minTimeElapsed = false;
|
||||
let audioFinished = !current.audio;
|
||||
|
||||
const maybeAdvance = () => {
|
||||
if (!minTimeElapsed || !audioFinished) return;
|
||||
box.classList.remove('visible');
|
||||
current = null;
|
||||
setTimeout(advanceQueue, FADE_MS);
|
||||
};
|
||||
|
||||
current.startAudio = (audio) => {
|
||||
audioFinished = false;
|
||||
playSpeech(audio.data, audio.mimeType, () => {
|
||||
audioFinished = true;
|
||||
maybeAdvance();
|
||||
});
|
||||
};
|
||||
|
||||
if (current.audio) {
|
||||
current.startAudio(current.audio);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
minTimeElapsed = true;
|
||||
maybeAdvance();
|
||||
}, current.durationMs || 10000);
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +116,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) {
|
||||
playSpeech(audio.data, audio.mimeType);
|
||||
current.startAudio(audio);
|
||||
return;
|
||||
}
|
||||
const queued = queue.find((item) => item.id === payload.id);
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
|
||||
<div class="checkbox-row">
|
||||
<input id="ai-reply" type="checkbox" />
|
||||
<label for="ai-reply">Задать вопрос Альтушке Ирине 🤖</label>
|
||||
<label for="ai-reply" id="ai-reply-label">Задать вопрос Альтушке Ирине 🤖</label>
|
||||
</div>
|
||||
|
||||
<button id="send">Отправить</button>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const logoutBtn = document.getElementById('logout');
|
|||
const displayNameInput = document.getElementById('display-name');
|
||||
const messageInput = document.getElementById('message');
|
||||
const aiReplyCheckbox = document.getElementById('ai-reply');
|
||||
const aiReplyLabel = document.getElementById('ai-reply-label');
|
||||
const sendBtn = document.getElementById('send');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
|
|
@ -51,7 +52,16 @@ async function handleCredentialResponse(response) {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadAiName() {
|
||||
const res = await fetch(`/api/streamer-info?token=${encodeURIComponent(token)}`);
|
||||
if (!res.ok) return;
|
||||
const { aiName } = await res.json();
|
||||
if (aiName) aiReplyLabel.textContent = `Задать вопрос: ${aiName} 🤖`;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
loadAiName();
|
||||
|
||||
const configRes = await fetch('/config');
|
||||
const { googleClientId } = await configRes.json();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue