#!/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
đ Quick Start
Generate a unique room with random credentials:
đą For Desktop App Users
Server URL:
Room Name:
Passphrase:
Setup: Open Local Transcription app â Settings â Server Sync â
Enable it and paste the values above
đē For OBS Browser Source
Display URL:
Add a Browser source in OBS and paste this URL. Set width to 1920 and height to 200-400px.
âī¸ URL Parameters (Optional)
fade=10 - Seconds before text fades (0 = never fade)
timestamps=true - Show/hide timestamps (true/false)
maxlines=50 - Max lines visible at once (prevents scroll bars)
fontsize=16 - Font size in pixels
fontsource=websafe - Font source: websafe, google, or custom
websafefont=Arial - Web-safe font (Arial, Times New Roman, Courier New, etc.)
googlefont=Roboto - Google Font name (Roboto, Open Sans, Lato, etc.)
Example: ?room=myroom&fade=15&fontsource=google&googlefont=Open+Sans&fontsize=18
Note: Per-speaker fonts override the default. Each user can set their own font in the app settings.
đĄ 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
đĄ 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
`);
});
// 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);
});