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:
@@ -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×tamps=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×tamps=true&maxlines=50&fontsize=16&fontfamily=Arial\`;
|
||||
const displayUrl = \`http://\${window.location.host}/display?room=\${encodeURIComponent(room)}&fade=10×tamps=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>
|
||||
|
||||
Reference in New Issue
Block a user