Initial commit: overlay + sender pages for StreamLabs browser source
Express + Socket.IO backend with Google login on the sender page, a transparent overlay page for OBS/StreamLabs Browser Source, and TTS voice-over via the edge-tts Python CLI (same engine as t2sTelegramBot). Dockerized for deployment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
7882db1f37
12 changed files with 1930 additions and 0 deletions
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.git
|
||||||
15
.env.example
Normal file
15
.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# OAuth 2.0 Client ID (Web application) from https://console.cloud.google.com/apis/credentials
|
||||||
|
# Authorized JavaScript origins must include the origin you open sender.html from,
|
||||||
|
# e.g. http://localhost:3000 for local dev.
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
|
||||||
|
# Random long string, used to sign the session cookie. Generate with:
|
||||||
|
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# Microsoft Edge Read Aloud voice used for TTS (free, no API key needed).
|
||||||
|
# Same engine as t2sTelegramBot uses. List of voices: `npx edge-tts-node --list-voices` (if available)
|
||||||
|
# or see https://github.com/Migushthe2nd/MsEdgeTTS
|
||||||
|
TTS_VOICE=ru-RU-DmitryNeural
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache python3 py3-pip && \
|
||||||
|
pip install --no-cache-dir --break-system-packages edge-tts
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY server.js ./
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
restart: unless-stopped
|
||||||
1417
package-lock.json
generated
Normal file
1417
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
package.json
Normal file
18
package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "send-message-to-streamer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Overlay page for OBS/StreamLabs browser source + sender page with Google login",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-session": "^1.18.0",
|
||||||
|
"google-auth-library": "^9.14.1",
|
||||||
|
"socket.io": "^4.7.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
public/overlay.html
Normal file
55
public/overlay.html
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Overlay</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#box {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%) scale(0.95);
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 20px 32px;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
color: #fff;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
#box.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
#from {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="box">
|
||||||
|
<div id="from"></div>
|
||||||
|
<div id="text"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script src="/overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
public/overlay.js
Normal file
72
public/overlay.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
const box = document.getElementById('box');
|
||||||
|
const fromEl = document.getElementById('from');
|
||||||
|
const textEl = document.getElementById('text');
|
||||||
|
const socket = io();
|
||||||
|
|
||||||
|
let hideTimer = null;
|
||||||
|
let audioCtx = null;
|
||||||
|
|
||||||
|
function getAudioCtx() {
|
||||||
|
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
return audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browsers suspend AudioContext until a user gesture happens on the page.
|
||||||
|
// OBS's Browser Source doesn't enforce this, but for testing in a normal
|
||||||
|
// tab, unlock it on the first click/keypress anywhere on the page.
|
||||||
|
['click', 'keydown'].forEach((eventName) => {
|
||||||
|
document.addEventListener(eventName, () => {
|
||||||
|
const ctx = getAudioCtx();
|
||||||
|
if (ctx.state === 'suspended') ctx.resume();
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function playNotificationSound() {
|
||||||
|
try {
|
||||||
|
const ctx = getAudioCtx();
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(880, ctx.currentTime);
|
||||||
|
osc.frequency.setValueAtTime(1320, ctx.currentTime + 0.1);
|
||||||
|
gain.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + 0.35);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Could not play notification sound:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(text, from) {
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
|
||||||
|
fromEl.textContent = from || '';
|
||||||
|
fromEl.style.display = from ? 'block' : 'none';
|
||||||
|
textEl.textContent = text;
|
||||||
|
// force reflow so the transition re-triggers even if a message is already visible
|
||||||
|
void box.offsetWidth;
|
||||||
|
box.classList.add('visible');
|
||||||
|
|
||||||
|
playNotificationSound();
|
||||||
|
|
||||||
|
const durationMs = 10000;
|
||||||
|
hideTimer = setTimeout(() => {
|
||||||
|
box.classList.remove('visible');
|
||||||
|
}, durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playSpeech(base64Audio, mimeType) {
|
||||||
|
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
|
||||||
|
audio.play().catch((err) => console.error('Could not play TTS audio:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('display_message', (payload) => {
|
||||||
|
showMessage(payload.text, payload.from);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('display_audio', (payload) => {
|
||||||
|
playSpeech(payload.audio, payload.mimeType);
|
||||||
|
});
|
||||||
77
public/sender.html
Normal file
77
public/sender.html
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<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>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 60px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
#user-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
#user-bar img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
#logout {
|
||||||
|
margin-left: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
#hidden-until-login {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="login-container"></div>
|
||||||
|
|
||||||
|
<div id="hidden-until-login">
|
||||||
|
<div id="user-bar">
|
||||||
|
<img id="user-pic" src="" alt="" />
|
||||||
|
<span id="user-name"></span>
|
||||||
|
<span id="logout">выйти</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea id="message" placeholder="Текст для оверлея..." maxlength="500"></textarea>
|
||||||
|
<br />
|
||||||
|
<button id="send">Отправить</button>
|
||||||
|
<div id="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script src="/sender.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
86
public/sender.js
Normal file
86
public/sender.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
const loginContainer = document.getElementById('login-container');
|
||||||
|
const mainSection = document.getElementById('hidden-until-login');
|
||||||
|
const userPic = document.getElementById('user-pic');
|
||||||
|
const userName = document.getElementById('user-name');
|
||||||
|
const logoutBtn = document.getElementById('logout');
|
||||||
|
const messageInput = document.getElementById('message');
|
||||||
|
const sendBtn = document.getElementById('send');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
const socket = io();
|
||||||
|
|
||||||
|
function showLoggedIn(user) {
|
||||||
|
loginContainer.style.display = 'none';
|
||||||
|
mainSection.style.display = 'block';
|
||||||
|
userPic.src = user.picture || '';
|
||||||
|
userName.textContent = user.name || user.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoggedOut() {
|
||||||
|
loginContainer.style.display = 'block';
|
||||||
|
mainSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCredentialResponse(response) {
|
||||||
|
const res = await fetch('/auth/google', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ credential: response.credential }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const { user } = await res.json();
|
||||||
|
showLoggedIn(user);
|
||||||
|
// The socket connected before login with no session cookie; reconnect so
|
||||||
|
// its handshake picks up the freshly-issued, now-authenticated cookie.
|
||||||
|
socket.disconnect();
|
||||||
|
socket.connect();
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = 'Не удалось войти, попробуйте ещё раз';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const configRes = await fetch('/config');
|
||||||
|
const { googleClientId } = await configRes.json();
|
||||||
|
|
||||||
|
google.accounts.id.initialize({
|
||||||
|
client_id: googleClientId,
|
||||||
|
callback: handleCredentialResponse,
|
||||||
|
});
|
||||||
|
google.accounts.id.renderButton(loginContainer, { theme: 'outline', size: 'large' });
|
||||||
|
|
||||||
|
const meRes = await fetch('/me');
|
||||||
|
const { user } = await meRes.json();
|
||||||
|
if (user) {
|
||||||
|
showLoggedIn(user);
|
||||||
|
} else {
|
||||||
|
showLoggedOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', () => {
|
||||||
|
const text = messageInput.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
socket.emit('send_message', text);
|
||||||
|
messageInput.value = '';
|
||||||
|
statusEl.textContent = 'Отправлено';
|
||||||
|
setTimeout(() => { statusEl.textContent = ''; }, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
messageInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
sendBtn.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('send_error', (msg) => {
|
||||||
|
statusEl.textContent = 'Ошибка: ' + msg;
|
||||||
|
});
|
||||||
|
|
||||||
|
logoutBtn.addEventListener('click', async () => {
|
||||||
|
await fetch('/auth/logout', { method: 'POST' });
|
||||||
|
showLoggedOut();
|
||||||
|
});
|
||||||
|
|
||||||
|
init();
|
||||||
159
server.js
Normal file
159
server.js
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const session = require('express-session');
|
||||||
|
const { createServer } = require('http');
|
||||||
|
const { Server } = require('socket.io');
|
||||||
|
const { OAuth2Client } = require('google-auth-library');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
|
||||||
|
const SESSION_SECRET = process.env.SESSION_SECRET;
|
||||||
|
const TTS_VOICE = process.env.TTS_VOICE || 'ru-RU-DmitryNeural';
|
||||||
|
|
||||||
|
if (!GOOGLE_CLIENT_ID) {
|
||||||
|
console.error('Missing GOOGLE_CLIENT_ID in .env (see .env.example)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!SESSION_SECRET) {
|
||||||
|
console.error('Missing SESSION_SECRET in .env (see .env.example)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthClient = new OAuth2Client(GOOGLE_CLIENT_ID);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
const io = new Server(httpServer);
|
||||||
|
|
||||||
|
const sessionMiddleware = session({
|
||||||
|
secret: SESSION_SECRET,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
|
||||||
|
// set to true once served over HTTPS in production
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'lax',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(sessionMiddleware);
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// Share the express session with socket.io connections.
|
||||||
|
const wrap = (middleware) => (socket, next) => middleware(socket.request, {}, next);
|
||||||
|
io.use(wrap(sessionMiddleware));
|
||||||
|
|
||||||
|
app.get('/config', (req, res) => {
|
||||||
|
res.json({ googleClientId: GOOGLE_CLIENT_ID });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/me', (req, res) => {
|
||||||
|
res.json({ user: req.session.user || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/google', async (req, res) => {
|
||||||
|
const { credential } = req.body;
|
||||||
|
if (!credential) {
|
||||||
|
return res.status(400).json({ error: 'missing credential' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ticket = await oauthClient.verifyIdToken({
|
||||||
|
idToken: credential,
|
||||||
|
audience: GOOGLE_CLIENT_ID,
|
||||||
|
});
|
||||||
|
const payload = ticket.getPayload();
|
||||||
|
|
||||||
|
req.session.user = {
|
||||||
|
email: payload.email,
|
||||||
|
name: payload.name,
|
||||||
|
picture: payload.picture,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[auth] logged in:', payload.email);
|
||||||
|
res.json({ user: req.session.user });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Google token verification failed:', err.message);
|
||||||
|
res.status(401).json({ error: 'invalid token' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shells out to the `edge-tts` Python CLI (same engine as t2sTelegramBot uses)
|
||||||
|
// instead of the edge-tts-node npm package, whose request-signing algorithm
|
||||||
|
// currently gets rejected by Microsoft's servers with a 403.
|
||||||
|
function synthesizeSpeech(text) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
'edge-tts',
|
||||||
|
['-t', text, '-v', TTS_VOICE],
|
||||||
|
{ encoding: 'buffer', maxBuffer: 10 * 1024 * 1024 },
|
||||||
|
(err, stdout) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve(stdout);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/auth/logout', (req, res) => {
|
||||||
|
req.session.destroy(() => res.json({ ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
const connectedUser = socket.request.session.user;
|
||||||
|
console.log(`[socket] connected id=${socket.id} user=${connectedUser ? connectedUser.email : '(none)'}`);
|
||||||
|
|
||||||
|
socket.on('disconnect', (reason) => {
|
||||||
|
console.log(`[socket] disconnected id=${socket.id} reason=${reason}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('send_message', (text) => {
|
||||||
|
// The session snapshot on socket.request is captured once, at connect time.
|
||||||
|
// If the socket connected before login (page load opens it immediately),
|
||||||
|
// it never sees a session updated later by the separate /auth/google request
|
||||||
|
// unless we explicitly reload it from the store here.
|
||||||
|
socket.request.session.reload((err) => {
|
||||||
|
const user = !err && socket.request.session.user;
|
||||||
|
console.log(`[send_message] id=${socket.id} user=${user ? user.email : '(none)'} text=${JSON.stringify(text)}`);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
socket.emit('send_error', 'Not authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof text !== 'string' || !text.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = text.trim().slice(0, 500);
|
||||||
|
const clientCount = io.engine.clientsCount;
|
||||||
|
console.log(`[broadcast] "${message}" -> ${clientCount} connected client(s)`);
|
||||||
|
io.emit('display_message', {
|
||||||
|
text: message,
|
||||||
|
from: user.name,
|
||||||
|
at: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Voice is generated after the fact so the text shows up immediately
|
||||||
|
// instead of waiting on the round trip to the TTS service.
|
||||||
|
synthesizeSpeech(message)
|
||||||
|
.then((audioBuffer) => {
|
||||||
|
io.emit('display_audio', {
|
||||||
|
audio: audioBuffer.toString('base64'),
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[tts] synthesis failed:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`Listening on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue