When creating a display URL for OBS, you can customize it with these URL parameters:
Parameter
Description
Default
room
Room name (required)
-
fade
Seconds before text fades (0 = never)
10
timestamps
Show timestamps (true/false)
true
maxlines
Maximum visible lines
50
fontsize
Font size in pixels
16
fontsource
Font source: websafe, google, or custom
websafe
websafefont
Web-safe font name (Arial, Courier New, etc.)
Arial
googlefont
Google Font name (Roboto, Open Sans, etc.)
Roboto
Note: Per-user colors and fonts set in the desktop app will override these defaults.
Each user can customize their name color, text color, and background color in Settings.
`);
});
// 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,
user_color, text_color, background_color } = 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"
// Per-user color settings
user_color: user_color || null, // User name color (e.g., "#4CAF50")
text_color: text_color || null, // Text color (e.g., "#FFFFFF")
background_color: background_color || null // Background color (e.g., "#000000B3")
};
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);
});