Files
local-transcription/server/nodejs/server.js
jknapp ff067b3368 Add unified per-speaker font support and remote transcription service
Font changes:
- Consolidate font settings into single Display Settings section
- Support Web-Safe, Google Fonts, and Custom File uploads for both displays
- Fix Google Fonts URL encoding (use + instead of %2B for spaces)
- Fix per-speaker font inline style quote escaping in Node.js display
- Add font debug logging to help diagnose font issues
- Update web server to sync all font settings on settings change
- Remove deprecated PHP server documentation files

New features:
- Add remote transcription service for GPU offloading
- Add instance lock to prevent multiple app instances
- Add version tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:09:57 -08:00

1134 lines
39 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎤 Local Transcription</h1>
<p>Multi-User Server (Node.js)</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> Server Information</h2>
<div class="stats">
<div class="stat">
<div class="stat-value">Node.js</div>
<div class="stat-label">Runtime</div>
</div>
<div class="stat">
<div class="stat-value">v1.0.0</div>
<div class="stat-label">Version</div>
</div>
<div class="stat">
<div class="stat-value">&lt;100ms</div>
<div class="stat-label">Latency</div>
</div>
<div class="stat">
<div class="stat-value">WebSocket</div>
<div class="stat-label">Protocol</div>
</div>
</div>
</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&timestamps=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 } = 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"
};
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);
}
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"
// Debug: Log received font info
if (fontFamily) {
console.log('Received transcription with font:', fontFamily, '(' + fontType + ')');
}
// 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;\` : '';
// 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);
}
// 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
const userColor = getUserColor(userName);
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="\${fontStyle}">\${data.text}</span>\`;
previewEl.innerHTML = html;
return;
}
}
const div = document.createElement('div');
div.className = isPreview ? 'transcription preview' : 'transcription';
const userColor = getUserColor(userName);
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="\${fontStyle}">\${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);
});