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 ) {
2025-12-26 16:44:55 -08:00
const broadcastStart = Date . now ( ) ;
2025-12-26 16:15:52 -08:00
const connections = roomConnections . get ( room ) || new Set ( ) ;
const message = JSON . stringify ( data ) ;
2025-12-26 16:44:55 -08:00
let sent = 0 ;
2025-12-26 16:15:52 -08:00
connections . forEach ( ws => {
if ( ws . readyState === WebSocket . OPEN ) {
ws . send ( message ) ;
2025-12-26 16:44:55 -08:00
sent ++ ;
2025-12-26 16:15:52 -08:00
}
} ) ;
2025-12-26 16:44:55 -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 < / t i t l e >
< style >
* {
margin : 0 ;
padding : 0 ;
box - sizing : border - box ;
}
body {
font - family : - apple - system , BlinkMacSystemFont , 'Segoe UI' , Roboto , sans - serif ;
background : linear - gradient ( 135 deg , # 667 eea 0 % , # 764 ba2 100 % ) ;
min - height : 100 vh ;
padding : 20 px ;
color : # 333 ;
}
. container {
max - width : 900 px ;
margin : 0 auto ;
}
. header {
background : white ;
padding : 40 px ;
border - radius : 15 px ;
box - shadow : 0 10 px 30 px rgba ( 0 , 0 , 0 , 0.2 ) ;
margin - bottom : 30 px ;
text - align : center ;
}
. header h1 {
color : # 667 eea ;
font - size : 2.5 em ;
margin - bottom : 10 px ;
}
. header p {
color : # 666 ;
font - size : 1.2 em ;
}
. status {
background : # 4 CAF50 ;
color : white ;
padding : 15 px 30 px ;
border - radius : 50 px ;
display : inline - block ;
font - weight : bold ;
margin - top : 20 px ;
}
. card {
background : white ;
padding : 30 px ;
border - radius : 15 px ;
box - shadow : 0 5 px 15 px rgba ( 0 , 0 , 0 , 0.1 ) ;
margin - bottom : 20 px ;
}
. card h2 {
color : # 667 eea ;
margin - bottom : 15 px ;
font - size : 1.5 em ;
}
. card h3 {
color : # 555 ;
margin - top : 20 px ;
margin - bottom : 10 px ;
}
. endpoint {
background : # f5f5f5 ;
padding : 15 px ;
border - radius : 8 px ;
margin - bottom : 15 px ;
font - family : 'Courier New' , monospace ;
}
. endpoint - method {
display : inline - block ;
background : # 667 eea ;
color : white ;
padding : 5 px 10 px ;
border - radius : 5 px ;
font - weight : bold ;
margin - right : 10 px ;
}
. endpoint - path {
color : # 333 ;
font - weight : bold ;
}
. endpoint - desc {
color : # 666 ;
margin - top : 5 px ;
font - family : sans - serif ;
}
. url - box {
background : # f5f5f5 ;
padding : 15 px ;
border - radius : 8 px ;
font - family : 'Courier New' , monospace ;
border - left : 4 px solid # 667 eea ;
margin : 10 px 0 ;
word - break : break - all ;
}
. quick - links {
display : grid ;
grid - template - columns : repeat ( auto - fit , minmax ( 200 px , 1 fr ) ) ;
gap : 15 px ;
margin - top : 20 px ;
}
. quick - link {
background : linear - gradient ( 135 deg , # 667 eea 0 % , # 764 ba2 100 % ) ;
color : white ;
padding : 20 px ;
border - radius : 10 px ;
text - decoration : none ;
text - align : center ;
transition : transform 0.2 s ;
}
. quick - link : hover {
transform : translateY ( - 5 px ) ;
}
. quick - link h4 {
margin - bottom : 5 px ;
}
. quick - link p {
font - size : 0.9 em ;
opacity : 0.9 ;
}
code {
background : # f5f5f5 ;
padding : 2 px 6 px ;
border - radius : 3 px ;
font - family : 'Courier New' , monospace ;
}
. stats {
display : grid ;
grid - template - columns : repeat ( auto - fit , minmax ( 150 px , 1 fr ) ) ;
gap : 15 px ;
margin - top : 20 px ;
}
. stat {
background : # f5f5f5 ;
padding : 20 px ;
border - radius : 10 px ;
text - align : center ;
}
. stat - value {
font - size : 2 em ;
font - weight : bold ;
color : # 667 eea ;
}
. stat - label {
color : # 666 ;
margin - top : 5 px ;
}
ol , ul {
margin - left : 20 px ;
line - height : 1.8 ;
}
pre {
background : # 2 d2d2d ;
color : # f8f8f2 ;
padding : 15 px ;
border - radius : 8 px ;
overflow - x : auto ;
margin - top : 10 px ;
}
< / s t y l e >
< / h e a d >
< body >
< div class = "container" >
< div class = "header" >
< h1 > 🎤 Local Transcription < / h 1 >
< p > Multi - User Server ( Node . js ) < / p >
< div class = "status" > 🟢 Server Running < / d i v >
< / d i v >
< div class = "card" >
< h2 > 🚀 Quick Start < / h 2 >
< 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
< / b u t t o n >
< / d i v >
< 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 < / h 3 >
< p > < strong > Server URL : < / s t r o n g > < / p >
< div class = "url-box" id = "serverUrl" onclick = "copyText('serverUrl')" > < / d i v >
< p style = "margin-top: 15px;" > < strong > Room Name : < / s t r o n g > < / p >
< div class = "url-box" id = "roomName" onclick = "copyText('roomName')" > < / d i v >
< p style = "margin-top: 15px;" > < strong > Passphrase : < / s t r o n g > < / p >
< div class = "url-box" id = "passphrase" onclick = "copyText('passphrase')" > < / d i v >
< p style = "margin-top: 15px; font-size: 0.9em; color: #666;" >
< strong > Setup : < / s t r o n g > O p e n L o c a l T r a n s c r i p t i o n a p p → S e t t i n g s → S e r v e r S y n c →
Enable it and paste the values above
< / p >
< / d i v >
< 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 < / h 3 >
< p > < strong > Display URL : < / s t r o n g > < / p >
< div class = "url-box" id = "displayUrl" onclick = "copyText('displayUrl')" > < / d i v >
< 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 - 400 px .
< / p >
2025-12-27 06:15:55 -08:00
< details style = "margin-top: 15px;" >
< summary style = "cursor: pointer; font-weight: bold; color: #667eea;" > ⚙ ️ URL Parameters ( Optional ) < / s u m m a r y >
< ul style = "margin-top: 10px; font-size: 0.9em; color: #666;" >
< li > < code > fade = 10 < / c o d e > - S e c o n d s b e f o r e t e x t f a d e s ( 0 = n e v e r f a d e ) < / l i >
< li > < code > timestamps = true < / c o d e > - S h o w / h i d e t i m e s t a m p s ( t r u e / f a l s e ) < / l i >
< li > < code > maxlines = 50 < / c o d e > - M a x l i n e s v i s i b l e a t o n c e ( p r e v e n t s s c r o l l b a r s ) < / l i >
< li > < code > fontsize = 16 < / c o d e > - F o n t s i z e i n p i x e l s < / l i >
< li > < code > fontfamily = Arial < / c o d e > - F o n t f a m i l y ( A r i a l , C o u r i e r , e t c . ) < / l i >
< / u l >
< p style = "font-size: 0.85em; color: #888; margin-top: 10px;" >
Example : < code > ? room = myroom & fade = 15 & timestamps = false & maxlines = 30 & fontsize = 18 < / c o d e >
< / p >
< / d e t a i l s >
2025-12-26 16:15:52 -08:00
< / d i v >
< / d i v >
< / d i v >
< div class = "card" >
< h2 > 📡 API Endpoints < / h 2 >
< div class = "endpoint" >
< div >
< span class = "endpoint-method" > POST < / s p a n >
< span class = "endpoint-path" > / a p i / s e n d < / s p a n >
< / d i v >
< div class = "endpoint-desc" > Send a transcription to a room < / d i v >
< / d i v >
< div class = "endpoint" >
< div >
< span class = "endpoint-method" > GET < / s p a n >
< span class = "endpoint-path" > / a p i / l i s t ? r o o m = R O O M < / s p a n >
< / d i v >
< div class = "endpoint-desc" > List recent transcriptions from a room < / d i v >
< / d i v >
< div class = "endpoint" >
< div >
< span class = "endpoint-method" > WS < / s p a n >
< span class = "endpoint-path" > / w s ? r o o m = R O O M < / s p a n >
< / d i v >
< div class = "endpoint-desc" > WebSocket connection for real - time updates < / d i v >
< / d i v >
< div class = "endpoint" >
< div >
< span class = "endpoint-method" > GET < / s p a n >
< span class = "endpoint-path" > / d i s p l a y ? r o o m = R O O M < / s p a n >
< / d i v >
< div class = "endpoint-desc" > Web display page for OBS < / d i v >
< / d i v >
< / d i v >
< div class = "card" >
< h2 > 🔗 Quick Links < / h 2 >
< div class = "quick-links" >
< a href = "/display?room=demo&fade=10" class = "quick-link" >
< h4 > 📺 Demo Display < / h 4 >
< p > Test the display page < / p >
< / a >
< a href = "/api/list?room=demo" class = "quick-link" >
< h4 > 📋 API Test < / h 4 >
< p > View API response < / p >
< / a >
< / d i v >
< / d i v >
< div class = "card" >
< h2 > 💡 Example : Send a Transcription < / h 2 >
< 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"
} ' < / p r e >
< p style = "margin-top: 15px;" > Then view it at : < a href = "/display?room=demo" style = "color: #667eea;" > / d i s p l a y ? r o o m = d e m o < / a > < / p >
< / d i v >
< div class = "card" >
< h2 > ℹ ️ Server Information < / h 2 >
< div class = "stats" >
< div class = "stat" >
< div class = "stat-value" > Node . js < / d i v >
< div class = "stat-label" > Runtime < / d i v >
< / d i v >
< div class = "stat" >
< div class = "stat-value" > v1 . 0.0 < / d i v >
< div class = "stat-label" > Version < / d i v >
< / d i v >
< div class = "stat" >
< div class = "stat-value" > & lt ; 100 ms < / d i v >
< div class = "stat-label" > Latency < / d i v >
< / d i v >
< div class = "stat" >
< div class = "stat-value" > WebSocket < / d i v >
< div class = "stat-label" > Protocol < / d i v >
< / d i v >
< / d i v >
< / d i v >
< / d i v >
< script >
2025-12-26 16:44:55 -08:00
// 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 \` ;
2025-12-27 06:15:55 -08:00
const displayUrl = \ ` http:// \$ {window.location.host}/display?room= \$ {encodeURIComponent(room)}&fade=10×tamps=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 ) ;
} ) ;
}
< / s c r i p t >
< / b o d y >
< / h t m l >
` );
} ) ;
// Send transcription
app . post ( '/api/send' , async ( req , res ) => {
2025-12-26 16:44:55 -08:00
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' } ) ;
}
2025-12-26 16:44:55 -08:00
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' } ) ;
}
2025-12-26 16:44:55 -08:00
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 ( )
} ;
2025-12-26 16:44:55 -08:00
const addStart = Date . now ( ) ;
2025-12-26 16:15:52 -08:00
await addTranscription ( room , transcription ) ;
2025-12-26 16:44:55 -08:00
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 ) => {
2025-12-27 06:15:55 -08:00
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 < / t i t l e >
< meta charset = "UTF-8" >
< style >
body {
margin : 0 ;
padding : 20 px ;
background : transparent ;
2025-12-27 06:15:55 -08:00
font - family : $ { fontfamily } , sans - serif ;
font - size : $ { fontsize } px ;
2025-12-26 16:15:52 -08:00
color : white ;
2025-12-27 06:15:55 -08:00
overflow : hidden ;
2025-12-26 16:15:52 -08:00
}
# transcriptions {
2025-12-27 06:15:55 -08:00
overflow : hidden ;
2025-12-26 16:15:52 -08:00
}
. transcription {
margin : 10 px 0 ;
padding : 10 px ;
background : rgba ( 0 , 0 , 0 , 0.7 ) ;
border - radius : 5 px ;
animation : slideIn 0.3 s ease - out ;
transition : opacity 1 s ease - out ;
}
. transcription . fading {
opacity : 0 ;
}
. timestamp {
color : # 888 ;
font - size : 0.9 em ;
margin - right : 10 px ;
}
. user {
font - weight : bold ;
margin - right : 10 px ;
}
. text {
color : white ;
}
# status {
position : fixed ;
top : 10 px ;
right : 10 px ;
padding : 10 px ;
background : rgba ( 0 , 0 , 0 , 0.8 ) ;
border - radius : 5 px ;
font - size : 0.9 em ;
2025-12-26 17:04:28 -08:00
transition : opacity 2 s ease - out ;
2025-12-26 16:15:52 -08:00
}
# status . connected { color : # 4 CAF50 ; }
# status . disconnected { color : # f44336 ; }
2025-12-26 17:04:28 -08:00
# status . hidden { opacity : 0 ; pointer - events : none ; }
2025-12-26 16:15:52 -08:00
@ keyframes slideIn {
from { opacity : 0 ; transform : translateY ( - 10 px ) ; }
to { opacity : 1 ; transform : translateY ( 0 ) ; }
}
< / s t y l e >
< / h e a d >
< body >
< div id = "status" class = "disconnected" > ⚫ Connecting ... < / d i v >
< div id = "transcriptions" > < / d i v >
< script >
const room = "${room}" ;
const fadeAfter = $ { fade } ;
2025-12-27 06:15:55 -08:00
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 ) ;
}
2025-12-27 06:15:55 -08:00
// 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 ) ;
}
}
2025-12-26 17:04:28 -08:00
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' ;
2025-12-26 17:04:28 -08:00
// 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 = ( ) => {
2025-12-26 17:04:28 -08:00
// 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 ) ;
< / s c r i p t >
< / b o d y >
< / h t m l >
` );
} ) ;
// 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 ) ;
} ) ;