Add unified per-speaker font support and remote transcription service

Font changes:
- Consolidate font settings into single Display Settings section
- Support Web-Safe, Google Fonts, and Custom File uploads for both displays
- Fix Google Fonts URL encoding (use + instead of %2B for spaces)
- Fix per-speaker font inline style quote escaping in Node.js display
- Add font debug logging to help diagnose font issues
- Update web server to sync all font settings on settings change
- Remove deprecated PHP server documentation files

New features:
- Add remote transcription service for GPU offloading
- Add instance lock to prevent multiple app instances
- Add version tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 18:56:12 -08:00
parent f035bdb927
commit ff067b3368
23 changed files with 2486 additions and 1160 deletions

View File

@@ -1,15 +1,15 @@
# Node.js Multi-User Transcription Server
**Much better than PHP for real-time applications!**
A real-time multi-user transcription sync server for streamers and teams.
## Why Node.js is Better Than PHP for This
## Features
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
- **Real-time WebSocket** - Instant message delivery (< 100ms latency)
- **Per-speaker fonts** - Each user can have their own font style
- **Google Fonts support** - 1000+ free fonts loaded from CDN
- **Web-safe fonts** - Universal fonts that work everywhere
- **Custom font uploads** - Upload your own .ttf/.woff2 files
- **Easy deployment** - Works on any VPS, cloud platform, or locally
## Quick Start
@@ -54,13 +54,35 @@ PORT=8080 npm start
Add a Browser source with this URL:
```
http://your-server.com:3000/display?room=YOUR_ROOM&fade=10&timestamps=true
http://your-server.com:3000/display?room=YOUR_ROOM&fade=10&timestamps=true&fontsource=websafe&websafefont=Arial
```
**Parameters:**
- `room` - Your room name (required)
- `fade` - Seconds before text fades (0 = never fade)
- `timestamps` - Show timestamps (true/false)
| Parameter | Default | Description |
|-----------|---------|-------------|
| `room` | default | Your room name (required) |
| `fade` | 10 | Seconds before text fades (0 = never fade) |
| `timestamps` | true | Show timestamps (true/false) |
| `maxlines` | 50 | Max lines visible (prevents scroll bars) |
| `fontsize` | 16 | Font size in pixels |
| `fontsource` | websafe | Font source: `websafe`, `google`, or `custom` |
| `websafefont` | Arial | Web-safe font name |
| `googlefont` | Roboto | Google Font name |
**Font Examples:**
```
# Web-safe font (works everywhere)
?room=myroom&fontsource=websafe&websafefont=Courier+New
# Google Font (loaded from CDN)
?room=myroom&fontsource=google&googlefont=Open+Sans
# Custom font (uploaded by users)
?room=myroom&fontsource=custom
```
**Per-Speaker Fonts:**
Each user can set their own font in the desktop app (Settings → Multi-User Server Sync → Font Source). Per-speaker fonts override the URL defaults, so different speakers can have different fonts on the same display.
## API Endpoints
@@ -74,7 +96,9 @@ Content-Type: application/json
"passphrase": "my-secret",
"user_name": "Alice",
"text": "Hello everyone!",
"timestamp": "12:34:56"
"timestamp": "12:34:56",
"font_family": "Open Sans", // Optional: per-speaker font
"font_type": "google" // Optional: websafe, google, or custom
}
```
@@ -282,17 +306,6 @@ Ports below 1024 require root. Either:
- 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.

View File

@@ -27,11 +27,15 @@ 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());
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');
@@ -146,7 +150,8 @@ function broadcastToRoom(room, data) {
});
const broadcastTime = Date.now() - broadcastStart;
console.log(`[Broadcast] Sent to ${sent} client(s) in room "${room}" (${broadcastTime}ms)`);
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
@@ -418,10 +423,15 @@ app.get('/', (req, res) => {
<li><code>timestamps=true</code> - Show/hide timestamps (true/false)</li>
<li><code>maxlines=50</code> - Max lines visible at once (prevents scroll bars)</li>
<li><code>fontsize=16</code> - Font size in pixels</li>
<li><code>fontfamily=Arial</code> - Font family (Arial, Courier, etc.)</li>
<li><code>fontsource=websafe</code> - Font source: <code>websafe</code>, <code>google</code>, or <code>custom</code></li>
<li><code>websafefont=Arial</code> - Web-safe font (Arial, Times New Roman, Courier New, etc.)</li>
<li><code>googlefont=Roboto</code> - Google Font name (Roboto, Open Sans, Lato, etc.)</li>
</ul>
<p style="font-size: 0.85em; color: #888; margin-top: 10px;">
Example: <code>?room=myroom&fade=15&timestamps=false&maxlines=30&fontsize=18</code>
Example: <code>?room=myroom&fade=15&fontsource=google&googlefont=Open+Sans&fontsize=18</code>
</p>
<p style="font-size: 0.85em; color: #888;">
Note: Per-speaker fonts override the default. Each user can set their own font in the app settings.
</p>
</details>
</div>
@@ -541,7 +551,7 @@ app.get('/', (req, res) => {
// Build URLs
const serverUrl = \`http://\${window.location.host}/api/send\`;
const displayUrl = \`http://\${window.location.host}/display?room=\${encodeURIComponent(room)}&fade=10&timestamps=true&maxlines=50&fontsize=16&fontfamily=Arial\`;
const displayUrl = \`http://\${window.location.host}/display?room=\${encodeURIComponent(room)}&fade=10&timestamps=true&maxlines=50&fontsize=16&fontsource=websafe&websafefont=Arial\`;
// Update UI
document.getElementById('serverUrl').textContent = serverUrl;
@@ -592,7 +602,7 @@ app.get('/', (req, res) => {
app.post('/api/send', async (req, res) => {
const requestStart = Date.now();
try {
const { room, passphrase, user_name, text, timestamp } = req.body;
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' });
@@ -611,17 +621,27 @@ app.post('/api/send', async (req, res) => {
user_name: user_name.trim(),
text: text.trim(),
timestamp: timestamp || new Date().toLocaleTimeString('en-US', { hour12: false }),
created_at: Date.now()
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();
await addTranscription(room, transcription);
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;
console.log(`[${new Date().toISOString()}] Transcription received: "${text.substring(0, 50)}..." (verify: ${verifyTime}ms, add: ${addTime}ms, total: ${totalTime}ms)`);
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: 'Transcription added' });
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 });
@@ -647,9 +667,115 @@ app.get('/api/list', async (req, res) => {
}
});
// 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' } = req.query;
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
? `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${googlefont.replace(/ /g, '+')}&display=swap">`
: '';
res.send(`
<!DOCTYPE html>
@@ -657,12 +783,16 @@ app.get('/display', (req, res) => {
<head>
<title>Multi-User Transcription Display</title>
<meta charset="UTF-8">
${googleFontLink}
<style id="custom-fonts">
/* Custom fonts will be injected here */
</style>
<style>
body {
margin: 0;
padding: 20px;
background: transparent;
font-family: ${fontfamily}, sans-serif;
font-family: "${effectiveFont}", sans-serif;
font-size: ${fontsize}px;
color: white;
overflow: hidden;
@@ -681,6 +811,14 @@ app.get('/display', (req, res) => {
.transcription.fading {
opacity: 0;
}
.transcription.preview {
font-style: italic;
}
.preview-indicator {
color: #888;
font-size: 0.85em;
margin-right: 5px;
}
.timestamp {
color: #888;
font-size: 0.9em;
@@ -721,11 +859,68 @@ app.get('/display', (req, res) => {
const fadeAfter = ${fade};
const showTimestamps = ${timestamps === 'true' || timestamps === '1'};
const maxLines = ${maxlines};
const requestedFont = "${fontfamily}";
const container = document.getElementById('transcriptions');
const statusEl = document.getElementById('status');
const userColors = new Map();
let colorIndex = 0;
// Track preview elements by user for replacement
const userPreviews = new Map();
// Track loaded Google Fonts to avoid duplicate loading
const loadedGoogleFonts = new Set();
// Load a Google Font dynamically
function loadGoogleFont(fontName) {
if (loadedGoogleFonts.has(fontName)) return;
loadedGoogleFonts.add(fontName);
const link = document.createElement('link');
link.rel = 'stylesheet';
// Google Fonts expects spaces as '+' in the URL, not %2B
link.href = \`https://fonts.googleapis.com/css2?family=\${fontName.replace(/ /g, '+')}&display=swap\`;
document.head.appendChild(link);
console.log('Loading Google Font:', fontName);
}
// Load custom fonts for this room
async function loadCustomFonts() {
try {
const response = await fetch(\`/api/fonts?room=\${encodeURIComponent(room)}\`);
const data = await response.json();
if (data.fonts && data.fonts.length > 0) {
let fontFaceCSS = '';
for (const fontName of data.fonts) {
// Determine format based on extension
let format = 'truetype';
if (fontName.endsWith('.woff2')) format = 'woff2';
else if (fontName.endsWith('.woff')) format = 'woff';
else if (fontName.endsWith('.otf')) format = 'opentype';
// Font family name is filename without extension
const familyName = fontName.replace(/\\.(ttf|otf|woff2?)\$/i, '');
fontFaceCSS += \`
@font-face {
font-family: "\${familyName}";
src: url("/fonts/\${encodeURIComponent(room)}/\${encodeURIComponent(fontName)}") format("\${format}");
font-weight: normal;
font-style: normal;
}
\`;
}
// Inject the font-face rules
document.getElementById('custom-fonts').textContent = fontFaceCSS;
console.log('Loaded custom fonts:', data.fonts);
}
} catch (err) {
console.error('Error loading custom fonts:', err);
}
}
function getUserColor(userName) {
if (!userColors.has(userName)) {
const hue = (colorIndex * 137.5) % 360;
@@ -737,32 +932,96 @@ app.get('/display', (req, res) => {
}
function addTranscription(data) {
const div = document.createElement('div');
div.className = 'transcription';
const isPreview = data.is_preview || false;
const userName = data.user_name || '';
const fontFamily = data.font_family || null; // Per-speaker font name
const fontType = data.font_type || null; // "websafe", "google", or "custom"
const userColor = getUserColor(data.user_name);
// Debug: Log received font info
if (fontFamily) {
console.log('Received transcription with font:', fontFamily, '(' + fontType + ')');
}
// Load Google Font if needed
if (fontType === 'google' && fontFamily) {
loadGoogleFont(fontFamily);
}
// Build font style string if font is set
// Use single quotes for font name to avoid conflict with style="" double quotes
const fontStyle = fontFamily ? \`font-family: '\${fontFamily}', sans-serif;\` : '';
// If this is a final transcription, remove any existing preview from this user
if (!isPreview && userPreviews.has(userName)) {
const previewEl = userPreviews.get(userName);
if (previewEl && previewEl.parentNode) {
previewEl.remove();
}
userPreviews.delete(userName);
}
// If this is a preview, update existing preview or create new one
if (isPreview && userPreviews.has(userName)) {
const previewEl = userPreviews.get(userName);
if (previewEl && previewEl.parentNode) {
// Update existing preview
const userColor = getUserColor(userName);
let html = '';
if (showTimestamps && data.timestamp) {
html += \`<span class="timestamp">[\${data.timestamp}]</span>\`;
}
if (userName) {
html += \`<span class="user" style="color: \${userColor}">\${userName}:</span>\`;
}
html += \`<span class="preview-indicator">[...]</span>\`;
html += \`<span class="text" style="\${fontStyle}">\${data.text}</span>\`;
previewEl.innerHTML = html;
return;
}
}
const div = document.createElement('div');
div.className = isPreview ? 'transcription preview' : 'transcription';
const userColor = getUserColor(userName);
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>\`;
if (userName) {
html += \`<span class="user" style="color: \${userColor}">\${userName}:</span>\`;
}
html += \`<span class="text">\${data.text}</span>\`;
if (isPreview) {
html += \`<span class="preview-indicator">[...]</span>\`;
}
html += \`<span class="text" style="\${fontStyle}">\${data.text}</span>\`;
div.innerHTML = html;
container.appendChild(div);
if (fadeAfter > 0) {
setTimeout(() => {
div.classList.add('fading');
setTimeout(() => div.remove(), 1000);
}, fadeAfter * 1000);
// Track preview element for this user
if (isPreview) {
userPreviews.set(userName, div);
} else {
// Only set fade timer for final transcriptions
if (fadeAfter > 0) {
setTimeout(() => {
div.classList.add('fading');
setTimeout(() => div.remove(), 1000);
}, fadeAfter * 1000);
}
}
// Enforce max lines limit
// Enforce max lines limit (don't remove current previews)
while (container.children.length > maxLines) {
container.removeChild(container.firstChild);
const first = container.firstChild;
// Don't remove if it's an active preview
let isActivePreview = false;
userPreviews.forEach((el) => {
if (el === first) isActivePreview = true;
});
if (isActivePreview) break;
container.removeChild(first);
}
}
@@ -821,7 +1080,8 @@ app.get('/display', (req, res) => {
};
}
loadRecent().then(connect);
// Load custom fonts, then recent transcriptions, then connect WebSocket
loadCustomFonts().then(() => loadRecent()).then(connect);
</script>
</body>
</html>