Files
local-transcription/server/nodejs/server.js

874 lines
28 KiB
JavaScript
Raw Normal View History

2025-12-26 16:15:52 -08:00
#!/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 MAX_TRANSCRIPTIONS = 100;
const CLEANUP_INTERVAL = 2 * 60 * 60 * 1000; // 2 hours
// Middleware
app.use(bodyParser.json());
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();
2025-12-26 16:15:52 -08:00
const connections = roomConnections.get(room) || new Set();
const message = JSON.stringify(data);
let sent = 0;
2025-12-26 16:15:52 -08:00
connections.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
sent++;
2025-12-26 16:15:52 -08:00
}
});
const broadcastTime = Date.now() - broadcastStart;
console.log(`[Broadcast] Sent to ${sent} client(s) in room "${room}" (${broadcastTime}ms)`);
2025-12-26 16:15:52 -08:00
}
// 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>fontfamily=Arial</code> - Font family (Arial, Courier, etc.)</li>
</ul>
<p style="font-size: 0.85em; color: #888; margin-top: 10px;">
Example: <code>?room=myroom&fade=15&timestamps=false&maxlines=30&fontsize=18</code>
</p>
</details>
2025-12-26 16:15:52 -08:00
</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);
}
2025-12-26 16:15:52 -08:00
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&fontfamily=Arial\`;
2025-12-26 16:15:52 -08:00
// 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();
2025-12-26 16:15:52 -08:00
try {
const { room, passphrase, user_name, text, timestamp } = req.body;
if (!room || !passphrase || !user_name || !text) {
return res.status(400).json({ error: 'Missing required fields' });
}
const verifyStart = Date.now();
2025-12-26 16:15:52 -08:00
// Verify passphrase
const valid = await verifyPassphrase(room, passphrase);
if (!valid) {
return res.status(401).json({ error: 'Invalid passphrase' });
}
const verifyTime = Date.now() - verifyStart;
2025-12-26 16:15:52 -08:00
// 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()
};
const addStart = Date.now();
2025-12-26 16:15:52 -08:00
await addTranscription(room, transcription);
const addTime = Date.now() - addStart;
const totalTime = Date.now() - requestStart;
console.log(`[${new Date().toISOString()}] Transcription received: "${text.substring(0, 50)}..." (verify: ${verifyTime}ms, add: ${addTime}ms, total: ${totalTime}ms)`);
2025-12-26 16:15:52 -08:00
res.json({ status: 'ok', message: '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 });
}
});
// Serve display page
app.get('/display', (req, res) => {
const { room = 'default', fade = '10', timestamps = 'true', maxlines = '50', fontsize = '16', fontfamily = 'Arial' } = req.query;
2025-12-26 16:15:52 -08:00
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Multi-User Transcription Display</title>
<meta charset="UTF-8">
<style>
body {
margin: 0;
padding: 20px;
background: transparent;
font-family: ${fontfamily}, sans-serif;
font-size: ${fontsize}px;
2025-12-26 16:15:52 -08:00
color: white;
overflow: hidden;
2025-12-26 16:15:52 -08:00
}
#transcriptions {
overflow: hidden;
2025-12-26 16:15:52 -08:00
}
.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;
}
.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;
2025-12-26 16:15:52 -08:00
}
#status.connected { color: #4CAF50; }
#status.disconnected { color: #f44336; }
#status.hidden { opacity: 0; pointer-events: none; }
2025-12-26 16:15:52 -08:00
@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};
2025-12-26 16:15:52 -08:00
const container = document.getElementById('transcriptions');
const statusEl = document.getElementById('status');
const userColors = new Map();
let colorIndex = 0;
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 div = document.createElement('div');
div.className = 'transcription';
const userColor = getUserColor(data.user_name);
let html = '';
if (showTimestamps && data.timestamp) {
html += \`<span class="timestamp">[\${data.timestamp}]</span>\`;
}
if (data.user_name) {
html += \`<span class="user" style="color: \${userColor}">\${data.user_name}:</span>\`;
}
html += \`<span class="text">\${data.text}</span>\`;
div.innerHTML = html;
container.appendChild(div);
if (fadeAfter > 0) {
setTimeout(() => {
div.classList.add('fading');
setTimeout(() => div.remove(), 1000);
}, fadeAfter * 1000);
}
// Enforce max lines limit
while (container.children.length > maxLines) {
2025-12-26 16:15:52 -08:00
container.removeChild(container.firstChild);
}
}
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;
2025-12-26 16:15:52 -08:00
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);
2025-12-26 16:15:52 -08:00
};
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;
}
2025-12-26 16:15:52 -08:00
statusEl.textContent = '🔴 Disconnected';
statusEl.className = 'disconnected';
setTimeout(connect, 3000);
};
}
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);
});