#!/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(` Local Transcription Multi-User Server

🎤 Local Transcription

Multi-User Server (Node.js)

đŸŸĸ Server Running

🚀 Quick Start

Generate a unique room with random credentials:

📡 API Endpoints

POST /api/send
Send a transcription to a room
GET /api/list?room=ROOM
List recent transcriptions from a room
WS /ws?room=ROOM
WebSocket connection for real-time updates
GET /display?room=ROOM
Web display page for OBS

🔗 Quick Links

💡 Example: Send a Transcription

Try this curl command to send a test message:

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"
  }'

Then view it at: /display?room=demo

â„šī¸ Server Information

Node.js
Runtime
v1.0.0
Version
<100ms
Latency
WebSocket
Protocol
`); }); // 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 ? `` : ''; res.send(` Multi-User Transcription Display ${googleFontLink}
âšĢ Connecting...
`); }); // 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); });