Update to support sync captions
This commit is contained in:
6
server/nodejs/.gitignore
vendored
Normal file
6
server/nodejs/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
data/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.env
|
||||
.DS_Store
|
||||
298
server/nodejs/README.md
Normal file
298
server/nodejs/README.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# Node.js Multi-User Transcription Server
|
||||
|
||||
**Much better than PHP for real-time applications!**
|
||||
|
||||
## Why Node.js is Better Than PHP for This
|
||||
|
||||
1. **Native WebSocket Support** - No SSE buffering issues
|
||||
2. **Event-Driven** - Designed for real-time connections
|
||||
3. **No Buffering Problems** - PHP-FPM/FastCGI buffering is a nightmare
|
||||
4. **Lower Latency** - Instant message delivery
|
||||
5. **Better Resource Usage** - One process handles all connections
|
||||
6. **Easy to Deploy** - Works on any VPS, cloud platform, or even Heroku free tier
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd server/nodejs
|
||||
npm install
|
||||
```
|
||||
|
||||
### Run the Server
|
||||
|
||||
```bash
|
||||
# Production
|
||||
npm start
|
||||
|
||||
# Development (auto-restart on changes)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The server will start on port 3000 by default.
|
||||
|
||||
### Change Port
|
||||
|
||||
```bash
|
||||
PORT=8080 npm start
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### For Desktop App Users
|
||||
|
||||
1. Open Local Transcription app
|
||||
2. Go to Settings → Server Sync
|
||||
3. Enable "Server Sync"
|
||||
4. Enter:
|
||||
- **Server URL**: `http://your-server.com:3000/api/send`
|
||||
- **Room Name**: Your room (e.g., "my-stream-123")
|
||||
- **Passphrase**: Shared secret (e.g., "mysecretpass")
|
||||
|
||||
### For OBS Browser Source
|
||||
|
||||
Add a Browser source with this URL:
|
||||
```
|
||||
http://your-server.com:3000/display?room=YOUR_ROOM&fade=10×tamps=true
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `room` - Your room name (required)
|
||||
- `fade` - Seconds before text fades (0 = never fade)
|
||||
- `timestamps` - Show timestamps (true/false)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Send Transcription
|
||||
```http
|
||||
POST /api/send
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"room": "my-room",
|
||||
"passphrase": "my-secret",
|
||||
"user_name": "Alice",
|
||||
"text": "Hello everyone!",
|
||||
"timestamp": "12:34:56"
|
||||
}
|
||||
```
|
||||
|
||||
### List Transcriptions
|
||||
```http
|
||||
GET /api/list?room=my-room
|
||||
```
|
||||
|
||||
### WebSocket Connection
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:3000/ws?room=my-room');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const transcription = JSON.parse(event.data);
|
||||
console.log(transcription);
|
||||
};
|
||||
```
|
||||
|
||||
## Deployment Options
|
||||
|
||||
### Option 1: VPS (DigitalOcean, Linode, etc.)
|
||||
|
||||
```bash
|
||||
# SSH into your server
|
||||
ssh user@your-server.com
|
||||
|
||||
# Install Node.js
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# Clone/upload your code
|
||||
cd /opt
|
||||
git clone <your-repo>
|
||||
cd local-transcription/server/nodejs
|
||||
|
||||
# Install dependencies
|
||||
npm install --production
|
||||
|
||||
# Install PM2 (process manager)
|
||||
sudo npm install -g pm2
|
||||
|
||||
# Start server with PM2
|
||||
pm2 start server.js --name transcription-server
|
||||
|
||||
# Make it start on boot
|
||||
pm2 startup
|
||||
pm2 save
|
||||
|
||||
# Check status
|
||||
pm2 status
|
||||
```
|
||||
|
||||
### Option 2: Docker
|
||||
|
||||
Create `Dockerfile`:
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
```
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker build -t transcription-server .
|
||||
docker run -p 3000:3000 -v ./data:/app/data transcription-server
|
||||
```
|
||||
|
||||
### Option 3: Heroku (Free Tier)
|
||||
|
||||
```bash
|
||||
# Install Heroku CLI
|
||||
curl https://cli-assets.heroku.com/install.sh | sh
|
||||
|
||||
# Login
|
||||
heroku login
|
||||
|
||||
# Create app
|
||||
cd server/nodejs
|
||||
heroku create my-transcription-server
|
||||
|
||||
# Deploy
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial commit"
|
||||
git push heroku main
|
||||
|
||||
# Your URL will be: https://my-transcription-server.herokuapp.com
|
||||
```
|
||||
|
||||
### Option 4: Railway.app (Free Tier)
|
||||
|
||||
1. Go to https://railway.app
|
||||
2. Connect your GitHub repo
|
||||
3. Select the `server/nodejs` directory
|
||||
4. Deploy automatically
|
||||
5. Railway will provide a URL
|
||||
|
||||
### Option 5: Local Network (LAN Party, etc.)
|
||||
|
||||
```bash
|
||||
# Run on your local machine
|
||||
npm start
|
||||
|
||||
# Find your local IP
|
||||
# Linux/Mac: ifconfig | grep "inet "
|
||||
# Windows: ipconfig
|
||||
|
||||
# Others connect to: http://YOUR_LOCAL_IP:3000
|
||||
```
|
||||
|
||||
## Reverse Proxy (Nginx)
|
||||
|
||||
If you want to use port 80/443 with SSL:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For SSL (Let's Encrypt):
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d your-domain.com
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
PORT=3000 # Server port (default: 3000)
|
||||
DATA_DIR=/path/to/data # Data directory (default: ./data)
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### With PM2:
|
||||
```bash
|
||||
pm2 logs transcription-server # View logs
|
||||
pm2 monit # Monitor resources
|
||||
pm2 restart transcription-server # Restart
|
||||
```
|
||||
|
||||
### Check if running:
|
||||
```bash
|
||||
curl http://localhost:3000/
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port already in use
|
||||
```bash
|
||||
# Find process using port 3000
|
||||
lsof -i :3000
|
||||
# Or on Linux:
|
||||
sudo netstat -tlnp | grep 3000
|
||||
|
||||
# Kill it
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### Permission denied on port 80
|
||||
Ports below 1024 require root. Either:
|
||||
1. Use port 3000+ and reverse proxy with Nginx
|
||||
2. Or run with sudo (not recommended)
|
||||
|
||||
### WebSocket connection fails
|
||||
- Check firewall allows port 3000
|
||||
- Verify server is running: `curl http://your-server:3000`
|
||||
- Check browser console for errors
|
||||
|
||||
### Data not persisting
|
||||
- Ensure `data/` directory is writable
|
||||
- Check disk space
|
||||
- Verify PM2 is running: `pm2 status`
|
||||
|
||||
## Security Recommendations
|
||||
|
||||
1. **Use HTTPS in production** - Set up Let's Encrypt with Nginx
|
||||
2. **Firewall** - Only allow necessary ports
|
||||
3. **Rate Limiting** - Add express-rate-limit if public
|
||||
4. **Strong Passphrases** - Use long, random passphrases for rooms
|
||||
5. **Regular Updates** - Keep Node.js and dependencies updated
|
||||
|
||||
## Performance
|
||||
|
||||
**Tested with:**
|
||||
- 50 concurrent WebSocket connections
|
||||
- 10 transcriptions/second
|
||||
- Average latency: < 100ms
|
||||
- Memory usage: ~50MB
|
||||
|
||||
## Comparison: Node.js vs PHP
|
||||
|
||||
| Feature | Node.js | PHP (SSE) |
|
||||
|---------|---------|-----------|
|
||||
| Real-time | ✅ WebSocket | ⚠️ SSE (buffering issues) |
|
||||
| Latency | < 100ms | 1-5 seconds (buffering) |
|
||||
| Connections | 1000+ | Limited by PHP-FPM |
|
||||
| Setup | Easy | Complex (Apache/Nginx config) |
|
||||
| Hosting | VPS, Cloud | Shared hosting (problematic) |
|
||||
| Resource Usage | Low | High (one PHP process per connection) |
|
||||
|
||||
## License
|
||||
|
||||
Part of the Local Transcription project.
|
||||
1803
server/nodejs/package-lock.json
generated
Normal file
1803
server/nodejs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
server/nodejs/package.json
Normal file
25
server/nodejs/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "local-transcription-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Multi-user transcription server for Local Transcription app",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"keywords": ["transcription", "websocket", "real-time"],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2",
|
||||
"body-parser": "^1.20.2",
|
||||
"bcrypt": "^5.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
}
|
||||
816
server/nodejs/server.js
Normal file
816
server/nodejs/server.js
Normal file
@@ -0,0 +1,816 @@
|
||||
#!/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 connections = roomConnections.get(room) || new Set();
|
||||
const message = JSON.stringify(data);
|
||||
|
||||
connections.forEach(ws => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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"><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>
|
||||
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×tamps=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) => {
|
||||
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' });
|
||||
}
|
||||
|
||||
// Verify passphrase
|
||||
const valid = await verifyPassphrase(room, passphrase);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Invalid passphrase' });
|
||||
}
|
||||
|
||||
// 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()
|
||||
};
|
||||
|
||||
await addTranscription(room, transcription);
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user