- Add color settings (user_color, text_color, background_color) to config - Add color picker buttons in Settings dialog with alpha support for backgrounds - Update local web display to use configurable colors - Send per-user colors with transcriptions to multi-user server - Update Node.js server to apply per-user colors on display page - Improve server landing page: replace tech details with display options reference - Bump version to 1.3.2 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1225 lines
44 KiB
JavaScript
1225 lines
44 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Multi-User Transcription Server (Node.js)
|
|
*
|
|
* Much better than PHP for real-time applications:
|
|
* - Native WebSocket support
|
|
* - No buffering issues
|
|
* - Better for long-lived connections
|
|
* - Lower resource usage
|
|
*
|
|
* Install: npm install express ws body-parser
|
|
* Run: node server.js
|
|
*/
|
|
|
|
const express = require('express');
|
|
const WebSocket = require('ws');
|
|
const http = require('http');
|
|
const bodyParser = require('body-parser');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
const wss = new WebSocket.Server({ server });
|
|
|
|
// Configuration
|
|
const PORT = process.env.PORT || 3000;
|
|
const DATA_DIR = path.join(__dirname, 'data');
|
|
const FONTS_DIR = path.join(__dirname, 'fonts');
|
|
const MAX_TRANSCRIPTIONS = 100;
|
|
const CLEANUP_INTERVAL = 2 * 60 * 60 * 1000; // 2 hours
|
|
|
|
// In-memory font storage by room (font_name -> {data: Buffer, mime: string})
|
|
const roomFonts = new Map();
|
|
|
|
// Middleware
|
|
app.use(bodyParser.json({ limit: '10mb' })); // Increase limit for font uploads
|
|
app.use((req, res, next) => {
|
|
res.header('Access-Control-Allow-Origin', '*');
|
|
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
|
if (req.method === 'OPTIONS') {
|
|
return res.sendStatus(200);
|
|
}
|
|
next();
|
|
});
|
|
|
|
// In-memory cache of rooms (reduces file I/O)
|
|
const rooms = new Map();
|
|
|
|
// Track WebSocket connections by room
|
|
const roomConnections = new Map();
|
|
|
|
// Ensure data directory exists
|
|
async function ensureDataDir() {
|
|
try {
|
|
await fs.mkdir(DATA_DIR, { recursive: true });
|
|
} catch (err) {
|
|
console.error('Error creating data directory:', err);
|
|
}
|
|
}
|
|
|
|
// Get room file path
|
|
function getRoomFile(room) {
|
|
const hash = crypto.createHash('md5').update(room).digest('hex');
|
|
return path.join(DATA_DIR, `room_${hash}.json`);
|
|
}
|
|
|
|
// Load room data
|
|
async function loadRoom(room) {
|
|
if (rooms.has(room)) {
|
|
return rooms.get(room);
|
|
}
|
|
|
|
const file = getRoomFile(room);
|
|
try {
|
|
const data = await fs.readFile(file, 'utf8');
|
|
const roomData = JSON.parse(data);
|
|
rooms.set(room, roomData);
|
|
return roomData;
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Save room data
|
|
async function saveRoom(room, roomData) {
|
|
rooms.set(room, roomData);
|
|
const file = getRoomFile(room);
|
|
await fs.writeFile(file, JSON.stringify(roomData, null, 2));
|
|
}
|
|
|
|
// Verify passphrase
|
|
async function verifyPassphrase(room, passphrase) {
|
|
let roomData = await loadRoom(room);
|
|
|
|
// If room doesn't exist, create it
|
|
if (!roomData) {
|
|
const bcrypt = require('bcrypt');
|
|
const hash = await bcrypt.hash(passphrase, 10);
|
|
roomData = {
|
|
passphrase_hash: hash,
|
|
created_at: Date.now(),
|
|
last_activity: Date.now(),
|
|
transcriptions: []
|
|
};
|
|
await saveRoom(room, roomData);
|
|
return true;
|
|
}
|
|
|
|
// Verify passphrase
|
|
const bcrypt = require('bcrypt');
|
|
return await bcrypt.compare(passphrase, roomData.passphrase_hash);
|
|
}
|
|
|
|
// Add transcription
|
|
async function addTranscription(room, transcription) {
|
|
let roomData = await loadRoom(room);
|
|
if (!roomData) {
|
|
throw new Error('Room not found');
|
|
}
|
|
|
|
roomData.transcriptions.push(transcription);
|
|
|
|
// Limit transcriptions
|
|
if (roomData.transcriptions.length > MAX_TRANSCRIPTIONS) {
|
|
roomData.transcriptions = roomData.transcriptions.slice(-MAX_TRANSCRIPTIONS);
|
|
}
|
|
|
|
roomData.last_activity = Date.now();
|
|
await saveRoom(room, roomData);
|
|
|
|
// Broadcast to all connected clients in this room
|
|
broadcastToRoom(room, transcription);
|
|
}
|
|
|
|
// Broadcast to all clients in a room
|
|
function broadcastToRoom(room, data) {
|
|
const broadcastStart = Date.now();
|
|
const connections = roomConnections.get(room) || new Set();
|
|
const message = JSON.stringify(data);
|
|
|
|
let sent = 0;
|
|
connections.forEach(ws => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(message);
|
|
sent++;
|
|
}
|
|
});
|
|
|
|
const broadcastTime = Date.now() - broadcastStart;
|
|
const fontInfo = data.font_family ? ` [font: ${data.font_family} (${data.font_type})]` : '';
|
|
console.log(`[Broadcast] Sent to ${sent} client(s) in room "${room}" (${broadcastTime}ms)${fontInfo}`);
|
|
}
|
|
|
|
// Cleanup old rooms
|
|
async function cleanupOldRooms() {
|
|
const now = Date.now();
|
|
const files = await fs.readdir(DATA_DIR);
|
|
|
|
for (const file of files) {
|
|
if (!file.startsWith('room_') || !file.endsWith('.json')) {
|
|
continue;
|
|
}
|
|
|
|
const filepath = path.join(DATA_DIR, file);
|
|
try {
|
|
const data = JSON.parse(await fs.readFile(filepath, 'utf8'));
|
|
const lastActivity = data.last_activity || data.created_at || 0;
|
|
|
|
if (now - lastActivity > CLEANUP_INTERVAL) {
|
|
await fs.unlink(filepath);
|
|
console.log(`Cleaned up old room: ${file}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`Error processing ${file}:`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Routes
|
|
|
|
// Server info / landing page
|
|
app.get('/', (req, res) => {
|
|
res.send(`
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Local Transcription Multi-User Server</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
color: #333;
|
|
}
|
|
|
|
.container {
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.header {
|
|
background: white;
|
|
padding: 40px;
|
|
border-radius: 15px;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
|
margin-bottom: 30px;
|
|
text-align: center;
|
|
}
|
|
|
|
.header h1 {
|
|
color: #667eea;
|
|
font-size: 2.5em;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.header p {
|
|
color: #666;
|
|
font-size: 1.2em;
|
|
}
|
|
|
|
.status {
|
|
background: #4CAF50;
|
|
color: white;
|
|
padding: 15px 30px;
|
|
border-radius: 50px;
|
|
display: inline-block;
|
|
font-weight: bold;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.card {
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 15px;
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.card h2 {
|
|
color: #667eea;
|
|
margin-bottom: 15px;
|
|
font-size: 1.5em;
|
|
}
|
|
|
|
.card h3 {
|
|
color: #555;
|
|
margin-top: 20px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.endpoint {
|
|
background: #f5f5f5;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin-bottom: 15px;
|
|
font-family: 'Courier New', monospace;
|
|
}
|
|
|
|
.endpoint-method {
|
|
display: inline-block;
|
|
background: #667eea;
|
|
color: white;
|
|
padding: 5px 10px;
|
|
border-radius: 5px;
|
|
font-weight: bold;
|
|
margin-right: 10px;
|
|
}
|
|
|
|
.endpoint-path {
|
|
color: #333;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.endpoint-desc {
|
|
color: #666;
|
|
margin-top: 5px;
|
|
font-family: sans-serif;
|
|
}
|
|
|
|
.url-box {
|
|
background: #f5f5f5;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
font-family: 'Courier New', monospace;
|
|
border-left: 4px solid #667eea;
|
|
margin: 10px 0;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.quick-links {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 15px;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.quick-link {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 20px;
|
|
border-radius: 10px;
|
|
text-decoration: none;
|
|
text-align: center;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.quick-link:hover {
|
|
transform: translateY(-5px);
|
|
}
|
|
|
|
.quick-link h4 {
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.quick-link p {
|
|
font-size: 0.9em;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
code {
|
|
background: #f5f5f5;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-family: 'Courier New', monospace;
|
|
}
|
|
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 15px;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.stat {
|
|
background: #f5f5f5;
|
|
padding: 20px;
|
|
border-radius: 10px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 2em;
|
|
font-weight: bold;
|
|
color: #667eea;
|
|
}
|
|
|
|
.stat-label {
|
|
color: #666;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
ol, ul {
|
|
margin-left: 20px;
|
|
line-height: 1.8;
|
|
}
|
|
|
|
pre {
|
|
background: #2d2d2d;
|
|
color: #f8f8f2;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
overflow-x: auto;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.button {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
border: none;
|
|
padding: 15px 30px;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
}
|
|
|
|
.button:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>🎤 Local Transcription</h1>
|
|
<p>Multi-User Transcription Server</p>
|
|
<div class="status">🟢 Server Running</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>🚀 Quick Start</h2>
|
|
<p>Generate a unique room with random credentials:</p>
|
|
|
|
<div style="text-align: center; margin: 20px 0;">
|
|
<button class="button" onclick="generateRoom()" style="font-size: 1.2em; padding: 20px 40px;">
|
|
🎲 Generate New Room
|
|
</button>
|
|
</div>
|
|
|
|
<div id="roomDetails" style="display: none; margin-top: 30px;">
|
|
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; border-left: 4px solid #667eea;">
|
|
<h3 style="margin-top: 0;">📱 For Desktop App Users</h3>
|
|
<p><strong>Server URL:</strong></p>
|
|
<div class="url-box" id="serverUrl" onclick="copyText('serverUrl')"></div>
|
|
<p style="margin-top: 15px;"><strong>Room Name:</strong></p>
|
|
<div class="url-box" id="roomName" onclick="copyText('roomName')"></div>
|
|
<p style="margin-top: 15px;"><strong>Passphrase:</strong></p>
|
|
<div class="url-box" id="passphrase" onclick="copyText('passphrase')"></div>
|
|
<p style="margin-top: 15px; font-size: 0.9em; color: #666;">
|
|
<strong>Setup:</strong> Open Local Transcription app → Settings → Server Sync →
|
|
Enable it and paste the values above
|
|
</p>
|
|
</div>
|
|
|
|
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; border-left: 4px solid #667eea; margin-top: 20px;">
|
|
<h3 style="margin-top: 0;">📺 For OBS Browser Source</h3>
|
|
<p><strong>Display URL:</strong></p>
|
|
<div class="url-box" id="displayUrl" onclick="copyText('displayUrl')"></div>
|
|
<p style="margin-top: 15px; font-size: 0.9em; color: #666;">
|
|
Add a Browser source in OBS and paste this URL. Set width to 1920 and height to 200-400px.
|
|
</p>
|
|
<details style="margin-top: 15px;">
|
|
<summary style="cursor: pointer; font-weight: bold; color: #667eea;">⚙️ URL Parameters (Optional)</summary>
|
|
<ul style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
|
<li><code>fade=10</code> - Seconds before text fades (0 = never fade)</li>
|
|
<li><code>timestamps=true</code> - Show/hide timestamps (true/false)</li>
|
|
<li><code>maxlines=50</code> - Max lines visible at once (prevents scroll bars)</li>
|
|
<li><code>fontsize=16</code> - Font size in pixels</li>
|
|
<li><code>fontsource=websafe</code> - Font source: <code>websafe</code>, <code>google</code>, or <code>custom</code></li>
|
|
<li><code>websafefont=Arial</code> - Web-safe font (Arial, Times New Roman, Courier New, etc.)</li>
|
|
<li><code>googlefont=Roboto</code> - Google Font name (Roboto, Open Sans, Lato, etc.)</li>
|
|
</ul>
|
|
<p style="font-size: 0.85em; color: #888; margin-top: 10px;">
|
|
Example: <code>?room=myroom&fade=15&fontsource=google&googlefont=Open+Sans&fontsize=18</code>
|
|
</p>
|
|
<p style="font-size: 0.85em; color: #888;">
|
|
Note: Per-speaker fonts override the default. Each user can set their own font in the app settings.
|
|
</p>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>📡 API Endpoints</h2>
|
|
|
|
<div class="endpoint">
|
|
<div>
|
|
<span class="endpoint-method">POST</span>
|
|
<span class="endpoint-path">/api/send</span>
|
|
</div>
|
|
<div class="endpoint-desc">Send a transcription to a room</div>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<div>
|
|
<span class="endpoint-method">GET</span>
|
|
<span class="endpoint-path">/api/list?room=ROOM</span>
|
|
</div>
|
|
<div class="endpoint-desc">List recent transcriptions from a room</div>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<div>
|
|
<span class="endpoint-method">WS</span>
|
|
<span class="endpoint-path">/ws?room=ROOM</span>
|
|
</div>
|
|
<div class="endpoint-desc">WebSocket connection for real-time updates</div>
|
|
</div>
|
|
|
|
<div class="endpoint">
|
|
<div>
|
|
<span class="endpoint-method">GET</span>
|
|
<span class="endpoint-path">/display?room=ROOM</span>
|
|
</div>
|
|
<div class="endpoint-desc">Web display page for OBS</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>🔗 Quick Links</h2>
|
|
<div class="quick-links">
|
|
<a href="/display?room=demo&fade=10" class="quick-link">
|
|
<h4>📺 Demo Display</h4>
|
|
<p>Test the display page</p>
|
|
</a>
|
|
<a href="/api/list?room=demo" class="quick-link">
|
|
<h4>📋 API Test</h4>
|
|
<p>View API response</p>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>💡 Example: Send a Transcription</h2>
|
|
<p>Try this curl command to send a test message:</p>
|
|
<pre>curl -X POST "http://${req.headers.host}/api/send" \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '{
|
|
"room": "demo",
|
|
"passphrase": "demopass",
|
|
"user_name": "TestUser",
|
|
"text": "Hello from the API!",
|
|
"timestamp": "12:34:56"
|
|
}'</pre>
|
|
<p style="margin-top: 15px;">Then view it at: <a href="/display?room=demo" style="color: #667eea;">/display?room=demo</a></p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>📺 Display Options Reference</h2>
|
|
<p>When creating a display URL for OBS, you can customize it with these URL parameters:</p>
|
|
<table style="width: 100%; border-collapse: collapse; margin-top: 15px;">
|
|
<tr style="background: #f5f5f5;">
|
|
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;">Parameter</th>
|
|
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;">Description</th>
|
|
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;">Default</th>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>room</code></td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">Room name (required)</td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">-</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>fade</code></td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">Seconds before text fades (0 = never)</td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">10</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>timestamps</code></td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">Show timestamps (true/false)</td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">true</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>maxlines</code></td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">Maximum visible lines</td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">50</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>fontsize</code></td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">Font size in pixels</td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">16</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>fontsource</code></td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">Font source: websafe, google, or custom</td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">websafe</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>websafefont</code></td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">Web-safe font name (Arial, Courier New, etc.)</td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">Arial</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>googlefont</code></td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">Google Font name (Roboto, Open Sans, etc.)</td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">Roboto</td>
|
|
</tr>
|
|
</table>
|
|
<p style="margin-top: 15px; font-size: 0.9em; color: #666;">
|
|
<strong>Note:</strong> Per-user colors and fonts set in the desktop app will override these defaults.
|
|
Each user can customize their name color, text color, and background color in Settings.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Warn if using localhost on WSL2 (slow DNS)
|
|
if (window.location.hostname === 'localhost') {
|
|
const warning = document.createElement('div');
|
|
warning.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; background: #ff9800; color: white; padding: 15px; text-align: center; z-index: 9999; font-weight: bold;';
|
|
warning.innerHTML = '⚠️ Using "localhost" may be slow on WSL2! Try accessing via <a href="http://127.0.0.1:' + window.location.port + '" style="color: white; text-decoration: underline;">http://127.0.0.1:' + window.location.port + '</a> instead for faster performance.';
|
|
document.body.insertBefore(warning, document.body.firstChild);
|
|
}
|
|
|
|
function generateRoom() {
|
|
// Generate random room name
|
|
const adjectives = ['swift', 'bright', 'cosmic', 'electric', 'turbo', 'mega', 'ultra', 'super', 'hyper', 'alpha'];
|
|
const nouns = ['phoenix', 'dragon', 'tiger', 'falcon', 'comet', 'storm', 'blaze', 'thunder', 'frost', 'nebula'];
|
|
const randomNum = Math.floor(Math.random() * 10000);
|
|
const room = \`\${adjectives[Math.floor(Math.random() * adjectives.length)]}-\${nouns[Math.floor(Math.random() * nouns.length)]}-\${randomNum}\`;
|
|
|
|
// Generate random passphrase (16 characters)
|
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
let passphrase = '';
|
|
for (let i = 0; i < 16; i++) {
|
|
passphrase += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
|
|
// Build URLs
|
|
const serverUrl = \`http://\${window.location.host}/api/send\`;
|
|
const displayUrl = \`http://\${window.location.host}/display?room=\${encodeURIComponent(room)}&fade=10×tamps=true&maxlines=50&fontsize=16&fontsource=websafe&websafefont=Arial\`;
|
|
|
|
// Update UI
|
|
document.getElementById('serverUrl').textContent = serverUrl;
|
|
document.getElementById('roomName').textContent = room;
|
|
document.getElementById('passphrase').textContent = passphrase;
|
|
document.getElementById('displayUrl').textContent = displayUrl;
|
|
|
|
// Show room details
|
|
document.getElementById('roomDetails').style.display = 'block';
|
|
|
|
// Scroll to room details
|
|
document.getElementById('roomDetails').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
|
|
function copyText(elementId) {
|
|
const element = document.getElementById(elementId);
|
|
const text = element.textContent;
|
|
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
const originalBg = element.style.background;
|
|
element.style.background = '#d4edda';
|
|
element.style.transition = 'background 0.3s';
|
|
|
|
setTimeout(() => {
|
|
element.style.background = originalBg;
|
|
}, 1500);
|
|
|
|
// Show tooltip
|
|
const tooltip = document.createElement('div');
|
|
tooltip.textContent = '✓ Copied!';
|
|
tooltip.style.cssText = 'position: fixed; top: 20px; right: 20px; background: #28a745; color: white; padding: 10px 20px; border-radius: 5px; z-index: 1000; font-weight: bold;';
|
|
document.body.appendChild(tooltip);
|
|
|
|
setTimeout(() => {
|
|
tooltip.remove();
|
|
}, 2000);
|
|
}).catch(err => {
|
|
console.error('Failed to copy:', err);
|
|
});
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
});
|
|
|
|
// Send transcription
|
|
app.post('/api/send', async (req, res) => {
|
|
const requestStart = Date.now();
|
|
try {
|
|
const { room, passphrase, user_name, text, timestamp, is_preview, font_family, font_type,
|
|
user_color, text_color, background_color } = req.body;
|
|
|
|
if (!room || !passphrase || !user_name || !text) {
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
|
}
|
|
|
|
const verifyStart = Date.now();
|
|
// Verify passphrase
|
|
const valid = await verifyPassphrase(room, passphrase);
|
|
if (!valid) {
|
|
return res.status(401).json({ error: 'Invalid passphrase' });
|
|
}
|
|
const verifyTime = Date.now() - verifyStart;
|
|
|
|
// Create transcription
|
|
const transcription = {
|
|
user_name: user_name.trim(),
|
|
text: text.trim(),
|
|
timestamp: timestamp || new Date().toLocaleTimeString('en-US', { hour12: false }),
|
|
created_at: Date.now(),
|
|
is_preview: is_preview || false,
|
|
font_family: font_family || null, // Per-speaker font name
|
|
font_type: font_type || null, // Font type: "websafe", "google", or "custom"
|
|
// Per-user color settings
|
|
user_color: user_color || null, // User name color (e.g., "#4CAF50")
|
|
text_color: text_color || null, // Text color (e.g., "#FFFFFF")
|
|
background_color: background_color || null // Background color (e.g., "#000000B3")
|
|
};
|
|
|
|
const addStart = Date.now();
|
|
if (is_preview) {
|
|
// Previews are only broadcast, not stored
|
|
broadcastToRoom(room, transcription);
|
|
} else {
|
|
// Final transcriptions are stored and broadcast
|
|
await addTranscription(room, transcription);
|
|
}
|
|
const addTime = Date.now() - addStart;
|
|
|
|
const totalTime = Date.now() - requestStart;
|
|
const previewLabel = is_preview ? ' [PREVIEW]' : '';
|
|
console.log(`[${new Date().toISOString()}]${previewLabel} Transcription received: "${text.substring(0, 50)}..." (verify: ${verifyTime}ms, add: ${addTime}ms, total: ${totalTime}ms)`);
|
|
|
|
res.json({ status: 'ok', message: is_preview ? 'Preview broadcast' : 'Transcription added' });
|
|
} catch (err) {
|
|
console.error('Error in /api/send:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// List transcriptions
|
|
app.get('/api/list', async (req, res) => {
|
|
try {
|
|
const { room } = req.query;
|
|
|
|
if (!room) {
|
|
return res.status(400).json({ error: 'Missing room parameter' });
|
|
}
|
|
|
|
const roomData = await loadRoom(room);
|
|
const transcriptions = roomData ? roomData.transcriptions : [];
|
|
|
|
res.json({ transcriptions });
|
|
} catch (err) {
|
|
console.error('Error in /api/list:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Upload fonts for a room
|
|
app.post('/api/fonts', async (req, res) => {
|
|
try {
|
|
const { room, passphrase, fonts } = req.body;
|
|
|
|
if (!room || !passphrase) {
|
|
return res.status(400).json({ error: 'Missing room or passphrase' });
|
|
}
|
|
|
|
// Verify passphrase
|
|
const valid = await verifyPassphrase(room, passphrase);
|
|
if (!valid) {
|
|
return res.status(401).json({ error: 'Invalid passphrase' });
|
|
}
|
|
|
|
if (!fonts || !Array.isArray(fonts)) {
|
|
return res.status(400).json({ error: 'No fonts provided' });
|
|
}
|
|
|
|
// Initialize room fonts storage if needed
|
|
if (!roomFonts.has(room)) {
|
|
roomFonts.set(room, new Map());
|
|
}
|
|
const fontsMap = roomFonts.get(room);
|
|
|
|
// Process each font
|
|
let addedCount = 0;
|
|
for (const font of fonts) {
|
|
if (!font.name || !font.data || !font.mime) continue;
|
|
|
|
// Decode base64 font data
|
|
const fontData = Buffer.from(font.data, 'base64');
|
|
fontsMap.set(font.name, {
|
|
data: fontData,
|
|
mime: font.mime,
|
|
uploaded_at: Date.now()
|
|
});
|
|
addedCount++;
|
|
console.log(`[Fonts] Uploaded font "${font.name}" for room "${room}" (${fontData.length} bytes)`);
|
|
}
|
|
|
|
res.json({ status: 'ok', message: `${addedCount} font(s) uploaded`, fonts: Array.from(fontsMap.keys()) });
|
|
} catch (err) {
|
|
console.error('Error in /api/fonts:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Serve uploaded fonts
|
|
app.get('/fonts/:room/:fontname', (req, res) => {
|
|
const { room, fontname } = req.params;
|
|
|
|
const fontsMap = roomFonts.get(room);
|
|
if (!fontsMap) {
|
|
return res.status(404).json({ error: 'Room not found' });
|
|
}
|
|
|
|
const font = fontsMap.get(fontname);
|
|
if (!font) {
|
|
return res.status(404).json({ error: 'Font not found' });
|
|
}
|
|
|
|
res.set('Content-Type', font.mime);
|
|
res.set('Cache-Control', 'public, max-age=3600');
|
|
res.send(font.data);
|
|
});
|
|
|
|
// List fonts for a room
|
|
app.get('/api/fonts', (req, res) => {
|
|
const { room } = req.query;
|
|
|
|
if (!room) {
|
|
return res.status(400).json({ error: 'Missing room parameter' });
|
|
}
|
|
|
|
const fontsMap = roomFonts.get(room);
|
|
const fonts = fontsMap ? Array.from(fontsMap.keys()) : [];
|
|
|
|
res.json({ fonts });
|
|
});
|
|
|
|
// Serve display page
|
|
app.get('/display', (req, res) => {
|
|
const {
|
|
room = 'default',
|
|
fade = '10',
|
|
timestamps = 'true',
|
|
maxlines = '50',
|
|
fontsize = '16',
|
|
fontfamily = 'Arial',
|
|
// New font source parameters
|
|
fontsource = 'websafe', // websafe, google, or custom
|
|
websafefont = 'Arial',
|
|
googlefont = 'Roboto'
|
|
} = req.query;
|
|
|
|
// Determine the effective default font based on fontsource
|
|
let effectiveFont = fontfamily; // Legacy fallback
|
|
if (fontsource === 'google' && googlefont) {
|
|
effectiveFont = googlefont;
|
|
} else if (fontsource === 'websafe' && websafefont) {
|
|
effectiveFont = websafefont;
|
|
}
|
|
|
|
// Generate Google Font link if needed
|
|
// Note: Google Fonts expects spaces as '+' in the URL, not %2B
|
|
const googleFontLink = fontsource === 'google' && googlefont
|
|
? `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${googlefont.replace(/ /g, '+')}&display=swap">`
|
|
: '';
|
|
|
|
res.send(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Multi-User Transcription Display</title>
|
|
<meta charset="UTF-8">
|
|
${googleFontLink}
|
|
<style id="custom-fonts">
|
|
/* Custom fonts will be injected here */
|
|
</style>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
padding: 20px;
|
|
background: transparent;
|
|
font-family: "${effectiveFont}", sans-serif;
|
|
font-size: ${fontsize}px;
|
|
color: white;
|
|
overflow: hidden;
|
|
}
|
|
#transcriptions {
|
|
overflow: hidden;
|
|
}
|
|
.transcription {
|
|
margin: 10px 0;
|
|
padding: 10px;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
border-radius: 5px;
|
|
animation: slideIn 0.3s ease-out;
|
|
transition: opacity 1s ease-out;
|
|
}
|
|
.transcription.fading {
|
|
opacity: 0;
|
|
}
|
|
.transcription.preview {
|
|
font-style: italic;
|
|
}
|
|
.preview-indicator {
|
|
color: #888;
|
|
font-size: 0.85em;
|
|
margin-right: 5px;
|
|
}
|
|
.timestamp {
|
|
color: #888;
|
|
font-size: 0.9em;
|
|
margin-right: 10px;
|
|
}
|
|
.user {
|
|
font-weight: bold;
|
|
margin-right: 10px;
|
|
}
|
|
.text {
|
|
color: white;
|
|
}
|
|
#status {
|
|
position: fixed;
|
|
top: 10px;
|
|
right: 10px;
|
|
padding: 10px;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
border-radius: 5px;
|
|
font-size: 0.9em;
|
|
transition: opacity 2s ease-out;
|
|
}
|
|
#status.connected { color: #4CAF50; }
|
|
#status.disconnected { color: #f44336; }
|
|
#status.hidden { opacity: 0; pointer-events: none; }
|
|
@keyframes slideIn {
|
|
from { opacity: 0; transform: translateY(-10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="status" class="disconnected">⚫ Connecting...</div>
|
|
<div id="transcriptions"></div>
|
|
|
|
<script>
|
|
const room = "${room}";
|
|
const fadeAfter = ${fade};
|
|
const showTimestamps = ${timestamps === 'true' || timestamps === '1'};
|
|
const maxLines = ${maxlines};
|
|
const requestedFont = "${fontfamily}";
|
|
const container = document.getElementById('transcriptions');
|
|
const statusEl = document.getElementById('status');
|
|
const userColors = new Map();
|
|
let colorIndex = 0;
|
|
|
|
// Track preview elements by user for replacement
|
|
const userPreviews = new Map();
|
|
|
|
// Track loaded Google Fonts to avoid duplicate loading
|
|
const loadedGoogleFonts = new Set();
|
|
|
|
// Load a Google Font dynamically
|
|
function loadGoogleFont(fontName) {
|
|
if (loadedGoogleFonts.has(fontName)) return;
|
|
loadedGoogleFonts.add(fontName);
|
|
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
// Google Fonts expects spaces as '+' in the URL, not %2B
|
|
link.href = \`https://fonts.googleapis.com/css2?family=\${fontName.replace(/ /g, '+')}&display=swap\`;
|
|
document.head.appendChild(link);
|
|
console.log('Loading Google Font:', fontName);
|
|
}
|
|
|
|
// Load custom fonts for this room
|
|
async function loadCustomFonts() {
|
|
try {
|
|
const response = await fetch(\`/api/fonts?room=\${encodeURIComponent(room)}\`);
|
|
const data = await response.json();
|
|
|
|
if (data.fonts && data.fonts.length > 0) {
|
|
let fontFaceCSS = '';
|
|
for (const fontName of data.fonts) {
|
|
// Determine format based on extension
|
|
let format = 'truetype';
|
|
if (fontName.endsWith('.woff2')) format = 'woff2';
|
|
else if (fontName.endsWith('.woff')) format = 'woff';
|
|
else if (fontName.endsWith('.otf')) format = 'opentype';
|
|
|
|
// Font family name is filename without extension
|
|
const familyName = fontName.replace(/\\.(ttf|otf|woff2?)\$/i, '');
|
|
|
|
fontFaceCSS += \`
|
|
@font-face {
|
|
font-family: "\${familyName}";
|
|
src: url("/fonts/\${encodeURIComponent(room)}/\${encodeURIComponent(fontName)}") format("\${format}");
|
|
font-weight: normal;
|
|
font-style: normal;
|
|
}
|
|
\`;
|
|
}
|
|
|
|
// Inject the font-face rules
|
|
document.getElementById('custom-fonts').textContent = fontFaceCSS;
|
|
console.log('Loaded custom fonts:', data.fonts);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading custom fonts:', err);
|
|
}
|
|
}
|
|
|
|
function getUserColor(userName) {
|
|
if (!userColors.has(userName)) {
|
|
const hue = (colorIndex * 137.5) % 360;
|
|
const color = \`hsl(\${hue}, 85%, 65%)\`;
|
|
userColors.set(userName, color);
|
|
colorIndex++;
|
|
}
|
|
return userColors.get(userName);
|
|
}
|
|
|
|
// Helper to convert hex color with alpha to rgba
|
|
function hexToRgba(hex) {
|
|
if (!hex) return null;
|
|
hex = hex.replace('#', '');
|
|
if (hex.length === 8) { // RRGGBBAA
|
|
const r = parseInt(hex.substring(0, 2), 16);
|
|
const g = parseInt(hex.substring(2, 4), 16);
|
|
const b = parseInt(hex.substring(4, 6), 16);
|
|
const a = parseInt(hex.substring(6, 8), 16) / 255;
|
|
return \`rgba(\${r}, \${g}, \${b}, \${a.toFixed(2)})\`;
|
|
} else if (hex.length === 6) { // RRGGBB
|
|
return '#' + hex;
|
|
}
|
|
return '#' + hex;
|
|
}
|
|
|
|
function addTranscription(data) {
|
|
const isPreview = data.is_preview || false;
|
|
const userName = data.user_name || '';
|
|
const fontFamily = data.font_family || null; // Per-speaker font name
|
|
const fontType = data.font_type || null; // "websafe", "google", or "custom"
|
|
// Per-user color settings
|
|
const userColorSetting = data.user_color || null; // User name color
|
|
const textColorSetting = data.text_color || null; // Text color
|
|
const bgColorSetting = data.background_color || null; // Background color
|
|
|
|
// Debug: Log received font/color info
|
|
if (fontFamily || userColorSetting) {
|
|
console.log('Received transcription with font:', fontFamily, '(' + fontType + ')',
|
|
'colors:', userColorSetting, textColorSetting, bgColorSetting);
|
|
}
|
|
|
|
// Load Google Font if needed
|
|
if (fontType === 'google' && fontFamily) {
|
|
loadGoogleFont(fontFamily);
|
|
}
|
|
|
|
// Build font style string if font is set
|
|
// Use single quotes for font name to avoid conflict with style="" double quotes
|
|
const fontStyle = fontFamily ? \`font-family: '\${fontFamily}', sans-serif;\` : '';
|
|
// Build text color style
|
|
const textStyle = textColorSetting ? \`color: \${textColorSetting};\` : '';
|
|
// Combine font and text color for text span
|
|
const combinedTextStyle = fontStyle + textStyle;
|
|
// Build background style for transcription div
|
|
const bgStyle = bgColorSetting ? \`background: \${hexToRgba(bgColorSetting)};\` : '';
|
|
|
|
// If this is a final transcription, remove any existing preview from this user
|
|
if (!isPreview && userPreviews.has(userName)) {
|
|
const previewEl = userPreviews.get(userName);
|
|
if (previewEl && previewEl.parentNode) {
|
|
previewEl.remove();
|
|
}
|
|
userPreviews.delete(userName);
|
|
}
|
|
|
|
// Determine user name color: use custom color if provided, otherwise auto-generated
|
|
const userColor = userColorSetting || getUserColor(userName);
|
|
|
|
// If this is a preview, update existing preview or create new one
|
|
if (isPreview && userPreviews.has(userName)) {
|
|
const previewEl = userPreviews.get(userName);
|
|
if (previewEl && previewEl.parentNode) {
|
|
// Update existing preview
|
|
let html = '';
|
|
if (showTimestamps && data.timestamp) {
|
|
html += \`<span class="timestamp">[\${data.timestamp}]</span>\`;
|
|
}
|
|
if (userName) {
|
|
html += \`<span class="user" style="color: \${userColor}">\${userName}:</span>\`;
|
|
}
|
|
html += \`<span class="preview-indicator">[...]</span>\`;
|
|
html += \`<span class="text" style="\${combinedTextStyle}">\${data.text}</span>\`;
|
|
previewEl.innerHTML = html;
|
|
// Update background color if provided
|
|
if (bgStyle) {
|
|
previewEl.style.background = hexToRgba(bgColorSetting);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const div = document.createElement('div');
|
|
div.className = isPreview ? 'transcription preview' : 'transcription';
|
|
// Apply per-user background color if provided
|
|
if (bgStyle) {
|
|
div.style.background = hexToRgba(bgColorSetting);
|
|
}
|
|
|
|
let html = '';
|
|
if (showTimestamps && data.timestamp) {
|
|
html += \`<span class="timestamp">[\${data.timestamp}]</span>\`;
|
|
}
|
|
if (userName) {
|
|
html += \`<span class="user" style="color: \${userColor}">\${userName}:</span>\`;
|
|
}
|
|
if (isPreview) {
|
|
html += \`<span class="preview-indicator">[...]</span>\`;
|
|
}
|
|
html += \`<span class="text" style="\${combinedTextStyle}">\${data.text}</span>\`;
|
|
|
|
div.innerHTML = html;
|
|
container.appendChild(div);
|
|
|
|
// Track preview element for this user
|
|
if (isPreview) {
|
|
userPreviews.set(userName, div);
|
|
} else {
|
|
// Only set fade timer for final transcriptions
|
|
if (fadeAfter > 0) {
|
|
setTimeout(() => {
|
|
div.classList.add('fading');
|
|
setTimeout(() => div.remove(), 1000);
|
|
}, fadeAfter * 1000);
|
|
}
|
|
}
|
|
|
|
// Enforce max lines limit (don't remove current previews)
|
|
while (container.children.length > maxLines) {
|
|
const first = container.firstChild;
|
|
// Don't remove if it's an active preview
|
|
let isActivePreview = false;
|
|
userPreviews.forEach((el) => {
|
|
if (el === first) isActivePreview = true;
|
|
});
|
|
if (isActivePreview) break;
|
|
container.removeChild(first);
|
|
}
|
|
}
|
|
|
|
async function loadRecent() {
|
|
try {
|
|
const response = await fetch(\`/api/list?room=\${encodeURIComponent(room)}\`);
|
|
const data = await response.json();
|
|
if (data.transcriptions) {
|
|
data.transcriptions.slice(-20).forEach(addTranscription);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading recent:', err);
|
|
}
|
|
}
|
|
|
|
let statusHideTimeout = null;
|
|
|
|
function connect() {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const ws = new WebSocket(\`\${protocol}//\${window.location.host}/ws?room=\${encodeURIComponent(room)}\`);
|
|
|
|
ws.onopen = () => {
|
|
statusEl.textContent = '🟢 Connected';
|
|
statusEl.className = 'connected';
|
|
|
|
// Clear any existing timeout
|
|
if (statusHideTimeout) {
|
|
clearTimeout(statusHideTimeout);
|
|
}
|
|
|
|
// Fade out after 20 seconds
|
|
statusHideTimeout = setTimeout(() => {
|
|
statusEl.classList.add('hidden');
|
|
}, 20000);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
addTranscription(data);
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
// Clear hide timeout on disconnect
|
|
if (statusHideTimeout) {
|
|
clearTimeout(statusHideTimeout);
|
|
statusHideTimeout = null;
|
|
}
|
|
|
|
statusEl.textContent = '🔴 Disconnected';
|
|
statusEl.className = 'disconnected';
|
|
setTimeout(connect, 3000);
|
|
};
|
|
}
|
|
|
|
// Load custom fonts, then recent transcriptions, then connect WebSocket
|
|
loadCustomFonts().then(() => loadRecent()).then(connect);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
});
|
|
|
|
// WebSocket handler
|
|
wss.on('connection', (ws, req) => {
|
|
const params = new URLSearchParams(req.url.split('?')[1]);
|
|
const room = params.get('room') || 'default';
|
|
|
|
console.log(`WebSocket connected to room: ${room}`);
|
|
|
|
// Add to room connections
|
|
if (!roomConnections.has(room)) {
|
|
roomConnections.set(room, new Set());
|
|
}
|
|
roomConnections.get(room).add(ws);
|
|
|
|
ws.on('close', () => {
|
|
const connections = roomConnections.get(room);
|
|
if (connections) {
|
|
connections.delete(ws);
|
|
if (connections.size === 0) {
|
|
roomConnections.delete(room);
|
|
}
|
|
}
|
|
console.log(`WebSocket disconnected from room: ${room}`);
|
|
});
|
|
});
|
|
|
|
// Start server
|
|
async function start() {
|
|
await ensureDataDir();
|
|
|
|
// Run cleanup periodically
|
|
setInterval(cleanupOldRooms, CLEANUP_INTERVAL);
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`✅ Multi-User Transcription Server running on port ${PORT}`);
|
|
console.log(` Display URL: http://localhost:${PORT}/display?room=YOUR_ROOM`);
|
|
console.log(` API endpoint: http://localhost:${PORT}/api/send`);
|
|
});
|
|
}
|
|
|
|
start().catch(err => {
|
|
console.error('Failed to start server:', err);
|
|
process.exit(1);
|
|
});
|