Files
local-transcription/server/nodejs/server.js
jknapp 64c864b0f0 Fix multi-user server sync performance and integration
Major fixes:
- Integrated ServerSyncClient into GUI for actual multi-user sync
- Fixed CUDA device display to show actual hardware used
- Optimized server sync with parallel HTTP requests (5x faster)
- Fixed 2-second DNS delay by using 127.0.0.1 instead of localhost
- Added comprehensive debugging and performance logging

Performance improvements:
- HTTP requests: 2045ms → 52ms (97% faster)
- Multi-user sync lag: ~4s → ~100ms (97% faster)
- Parallel request processing with ThreadPoolExecutor (3 workers)

New features:
- Room generator with one-click copy on Node.js landing page
- Auto-detection of PHP vs Node.js server types
- Localhost warning banner for WSL2 users
- Comprehensive debug logging throughout sync pipeline

Files modified:
- gui/main_window_qt.py - Server sync integration, device display fix
- client/server_sync.py - Parallel HTTP, server type detection
- server/nodejs/server.js - Room generator, warnings, debug logs

Documentation added:
- PERFORMANCE_FIX.md - Server sync optimization details
- FIX_2_SECOND_HTTP_DELAY.md - DNS/localhost issue solution
- LATENCY_GUIDE.md - Audio chunk duration tuning guide
- DEBUG_4_SECOND_LAG.md - Comprehensive debugging guide
- SESSION_SUMMARY.md - Complete session summary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-26 16:44:55 -08:00

839 lines
26 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 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();
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;
console.log(`[Broadcast] Sent to ${sent} client(s) in room "${room}" (${broadcastTime}ms)`);
}
// 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>
</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\`;
// 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 } = 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()
};
const addStart = Date.now();
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)`);
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' } = req.query;
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: Arial, sans-serif;
color: white;
}
#transcriptions {
max-height: 100vh;
overflow-y: auto;
}
.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;
}
#status.connected { color: #4CAF50; }
#status.disconnected { color: #f44336; }
@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};
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);
container.scrollTop = container.scrollHeight;
if (fadeAfter > 0) {
setTimeout(() => {
div.classList.add('fading');
setTimeout(() => div.remove(), 1000);
}, fadeAfter * 1000);
}
while (container.children.length > 100) {
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);
}
}
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';
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
addTranscription(data);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
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);
});