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

@@ -2,7 +2,9 @@
import requests
import json
from typing import Optional
import base64
from pathlib import Path
from typing import Optional, List
from datetime import datetime
import threading
import queue
@@ -10,22 +12,41 @@ from concurrent.futures import ThreadPoolExecutor
class ServerSyncClient:
"""Client for syncing transcriptions to a PHP server."""
"""Client for syncing transcriptions to a multi-user server."""
def __init__(self, url: str, room: str, passphrase: str, user_name: str):
def __init__(self, url: str, room: str, passphrase: str, user_name: str,
fonts_dir: Optional[Path] = None,
font_source: str = "None",
websafe_font: Optional[str] = None,
google_font: Optional[str] = None,
custom_font_file: Optional[str] = None):
"""
Initialize server sync client.
Args:
url: Server URL (e.g., http://example.com/transcription/server.php)
url: Server URL (e.g., http://example.com/api/send)
room: Room name
passphrase: Room passphrase
user_name: User's display name
fonts_dir: Optional directory containing custom fonts to upload
font_source: Font source type ("None", "Web-Safe", "Google Font", "Custom File")
websafe_font: Web-safe font name (e.g., "Arial", "Times New Roman")
google_font: Google Font name (e.g., "Roboto", "Open Sans")
custom_font_file: Path to a custom font file for this speaker
"""
self.url = url
self.room = room
self.passphrase = passphrase
self.user_name = user_name
self.fonts_dir = fonts_dir
self.font_source = font_source
self.websafe_font = websafe_font
self.google_font = google_font
self.custom_font_file = custom_font_file
# Font info to send with transcriptions
self.font_family: Optional[str] = None
self.font_type: Optional[str] = None # "websafe", "google", "custom"
# Queue for sending transcriptions asynchronously
self.send_queue = queue.Queue()
@@ -50,6 +71,153 @@ class ServerSyncClient:
self.send_thread.start()
print(f"Server sync started: room={self.room}")
# Set up font based on source type
if self.font_source == "Web-Safe" and self.websafe_font:
self.font_family = self.websafe_font
self.font_type = "websafe"
print(f"Using web-safe font: {self.font_family}")
elif self.font_source == "Google Font" and self.google_font:
self.font_family = self.google_font
self.font_type = "google"
print(f"Using Google Font: {self.font_family}")
elif self.font_source == "Custom File" and self.custom_font_file:
self._upload_custom_font()
# Legacy fallback: upload all fonts from fonts_dir if available
elif self.fonts_dir:
self._upload_fonts()
def _upload_custom_font(self):
"""Upload the user's custom font file to the server for per-speaker fonts."""
if not self.custom_font_file:
return
font_path = Path(self.custom_font_file)
if not font_path.exists():
print(f"Custom font file not found: {self.custom_font_file}")
return
# Validate extension
font_extensions = {'.ttf', '.otf', '.woff', '.woff2'}
if font_path.suffix.lower() not in font_extensions:
print(f"Invalid font file type: {font_path.suffix}")
return
mime_types = {
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.woff': 'font/woff',
'.woff2': 'font/woff2'
}
try:
# Read and encode font data
with open(font_path, 'rb') as f:
font_data = base64.b64encode(f.read()).decode('utf-8')
# Font family name is filename without extension
self.font_family = font_path.stem
font_filename = font_path.name
print(f"Uploading custom font: {font_filename} (family: {self.font_family})")
# Upload to server
from urllib.parse import urlparse
parsed = urlparse(self.url)
base_url = f"{parsed.scheme}://{parsed.netloc}"
fonts_url = f"{base_url}/api/fonts"
response = requests.post(
fonts_url,
json={
'room': self.room,
'passphrase': self.passphrase,
'fonts': [{
'name': font_filename,
'data': font_data,
'mime': mime_types.get(font_path.suffix.lower(), 'font/ttf')
}]
},
timeout=30.0
)
if response.status_code == 200:
result = response.json()
self.font_type = "custom"
print(f"Custom font uploaded: {self.font_family}")
else:
print(f"Custom font upload failed: {response.status_code}")
self.font_family = None
self.font_type = None
except Exception as e:
print(f"Error uploading custom font: {e}")
self.font_family = None
self.font_type = None
def _upload_fonts(self):
"""Upload custom fonts to the server."""
if not self.fonts_dir or not self.fonts_dir.exists():
return
# Find font files
font_extensions = {'.ttf', '.otf', '.woff', '.woff2'}
font_files = [f for f in self.fonts_dir.iterdir()
if f.is_file() and f.suffix.lower() in font_extensions]
if not font_files:
return
# Prepare font data
fonts = []
mime_types = {
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.woff': 'font/woff',
'.woff2': 'font/woff2'
}
for font_file in font_files:
try:
with open(font_file, 'rb') as f:
font_data = base64.b64encode(f.read()).decode('utf-8')
fonts.append({
'name': font_file.name,
'data': font_data,
'mime': mime_types.get(font_file.suffix.lower(), 'font/ttf')
})
print(f"Prepared font for upload: {font_file.name}")
except Exception as e:
print(f"Error reading font file {font_file}: {e}")
if not fonts:
return
# Upload to server
try:
# Extract base URL for fonts endpoint
from urllib.parse import urlparse
parsed = urlparse(self.url)
base_url = f"{parsed.scheme}://{parsed.netloc}"
fonts_url = f"{base_url}/api/fonts"
response = requests.post(
fonts_url,
json={
'room': self.room,
'passphrase': self.passphrase,
'fonts': fonts
},
timeout=30.0 # Longer timeout for font uploads
)
if response.status_code == 200:
result = response.json()
print(f"Fonts uploaded successfully: {result.get('message', '')}")
else:
print(f"Font upload failed: {response.status_code}")
except Exception as e:
print(f"Error uploading fonts: {e}")
def stop(self):
"""Stop the sync client."""
self.is_running = False
@@ -59,13 +227,14 @@ class ServerSyncClient:
self.executor.shutdown(wait=False) # Don't wait - let pending requests finish in background
print("Server sync stopped")
def send_transcription(self, text: str, timestamp: Optional[datetime] = None):
def send_transcription(self, text: str, timestamp: Optional[datetime] = None, is_preview: bool = False):
"""
Send a transcription to the server (non-blocking).
Args:
text: Transcription text
timestamp: Timestamp (defaults to now)
is_preview: Whether this is a preview transcription
"""
if timestamp is None:
timestamp = datetime.now()
@@ -78,9 +247,20 @@ class ServerSyncClient:
self.send_queue.put({
'text': text,
'timestamp': timestamp.strftime("%H:%M:%S"),
'is_preview': is_preview,
'queue_time': queue_time # For debugging
})
def send_preview(self, text: str, timestamp: Optional[datetime] = None):
"""
Send a preview transcription to the server (non-blocking).
Args:
text: Preview transcription text
timestamp: Timestamp (defaults to now)
"""
self.send_transcription(text, timestamp, is_preview=True)
def _send_loop(self):
"""Background thread for sending transcriptions."""
while self.is_running:
@@ -122,28 +302,25 @@ class ServerSyncClient:
'passphrase': self.passphrase,
'user_name': self.user_name,
'text': trans_data['text'],
'timestamp': trans_data['timestamp']
'timestamp': trans_data['timestamp'],
'is_preview': trans_data.get('is_preview', False)
}
# Detect server type and send appropriately
# PHP servers have "server.php" in URL and need ?action=send
# Node.js servers have "/api/send" in URL and don't need it
request_start = time.time()
if 'server.php' in self.url:
# PHP server - add action parameter
response = requests.post(
self.url,
params={'action': 'send'},
json=payload,
timeout=2.0 # Reduced timeout for faster failure detection
)
# Add font info if user has a custom font configured
if self.font_family:
payload['font_family'] = self.font_family
payload['font_type'] = self.font_type # "websafe", "google", or "custom"
print(f"[Server Sync] Sending with font: {self.font_family} ({self.font_type})")
else:
# Node.js server - no action parameter
response = requests.post(
self.url,
json=payload,
timeout=2.0 # Reduced timeout for faster failure detection
)
print(f"[Server Sync] No font configured (font_source={self.font_source})")
# Send to Node.js server
request_start = time.time()
response = requests.post(
self.url,
json=payload,
timeout=2.0 # Reduced timeout for faster failure detection
)
request_time = (time.time() - request_start) * 1000
print(f"[Server Sync] HTTP request: {request_time:.0f}ms, Status: {response.status_code}")