2025-12-26 10:09:12 -08:00
|
|
|
"""Server sync client for multi-user transcription."""
|
|
|
|
|
|
|
|
|
|
import requests
|
|
|
|
|
import json
|
2026-01-11 18:56:12 -08:00
|
|
|
import base64
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Optional, List
|
2025-12-26 10:09:12 -08:00
|
|
|
from datetime import datetime
|
|
|
|
|
import threading
|
|
|
|
|
import queue
|
2025-12-26 16:44:55 -08:00
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
2025-12-26 10:09:12 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ServerSyncClient:
|
2026-01-11 18:56:12 -08:00
|
|
|
"""Client for syncing transcriptions to a multi-user server."""
|
2025-12-26 10:09:12 -08:00
|
|
|
|
2026-01-11 18:56:12 -08:00
|
|
|
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):
|
2025-12-26 10:09:12 -08:00
|
|
|
"""
|
|
|
|
|
Initialize server sync client.
|
|
|
|
|
|
|
|
|
|
Args:
|
2026-01-11 18:56:12 -08:00
|
|
|
url: Server URL (e.g., http://example.com/api/send)
|
2025-12-26 10:09:12 -08:00
|
|
|
room: Room name
|
|
|
|
|
passphrase: Room passphrase
|
|
|
|
|
user_name: User's display name
|
2026-01-11 18:56:12 -08:00
|
|
|
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
|
2025-12-26 10:09:12 -08:00
|
|
|
"""
|
|
|
|
|
self.url = url
|
|
|
|
|
self.room = room
|
|
|
|
|
self.passphrase = passphrase
|
|
|
|
|
self.user_name = user_name
|
2026-01-11 18:56:12 -08:00
|
|
|
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"
|
2025-12-26 10:09:12 -08:00
|
|
|
|
|
|
|
|
# Queue for sending transcriptions asynchronously
|
|
|
|
|
self.send_queue = queue.Queue()
|
|
|
|
|
self.is_running = False
|
|
|
|
|
self.send_thread: Optional[threading.Thread] = None
|
|
|
|
|
|
2025-12-26 16:44:55 -08:00
|
|
|
# Thread pool for parallel HTTP requests (max 3 concurrent)
|
|
|
|
|
self.executor = ThreadPoolExecutor(max_workers=3)
|
|
|
|
|
|
2025-12-26 10:09:12 -08:00
|
|
|
# Statistics
|
|
|
|
|
self.sent_count = 0
|
|
|
|
|
self.error_count = 0
|
|
|
|
|
self.last_error: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
def start(self):
|
|
|
|
|
"""Start the sync client."""
|
|
|
|
|
if self.is_running:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self.is_running = True
|
|
|
|
|
self.send_thread = threading.Thread(target=self._send_loop, daemon=True)
|
|
|
|
|
self.send_thread.start()
|
|
|
|
|
print(f"Server sync started: room={self.room}")
|
|
|
|
|
|
2026-01-11 18:56:12 -08:00
|
|
|
# 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}")
|
|
|
|
|
|
2025-12-26 10:09:12 -08:00
|
|
|
def stop(self):
|
|
|
|
|
"""Stop the sync client."""
|
|
|
|
|
self.is_running = False
|
|
|
|
|
if self.send_thread:
|
|
|
|
|
self.send_thread.join(timeout=2.0)
|
2025-12-26 16:44:55 -08:00
|
|
|
# Shutdown executor and wait for pending requests
|
|
|
|
|
self.executor.shutdown(wait=False) # Don't wait - let pending requests finish in background
|
2025-12-26 10:09:12 -08:00
|
|
|
print("Server sync stopped")
|
|
|
|
|
|
2026-01-11 18:56:12 -08:00
|
|
|
def send_transcription(self, text: str, timestamp: Optional[datetime] = None, is_preview: bool = False):
|
2025-12-26 10:09:12 -08:00
|
|
|
"""
|
|
|
|
|
Send a transcription to the server (non-blocking).
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
text: Transcription text
|
|
|
|
|
timestamp: Timestamp (defaults to now)
|
2026-01-11 18:56:12 -08:00
|
|
|
is_preview: Whether this is a preview transcription
|
2025-12-26 10:09:12 -08:00
|
|
|
"""
|
|
|
|
|
if timestamp is None:
|
|
|
|
|
timestamp = datetime.now()
|
|
|
|
|
|
2025-12-26 16:44:55 -08:00
|
|
|
# Debug: Log when transcription is queued
|
|
|
|
|
import time
|
|
|
|
|
queue_time = time.time()
|
|
|
|
|
|
2025-12-26 10:09:12 -08:00
|
|
|
# Add to queue
|
|
|
|
|
self.send_queue.put({
|
|
|
|
|
'text': text,
|
2025-12-26 16:44:55 -08:00
|
|
|
'timestamp': timestamp.strftime("%H:%M:%S"),
|
2026-01-11 18:56:12 -08:00
|
|
|
'is_preview': is_preview,
|
2025-12-26 16:44:55 -08:00
|
|
|
'queue_time': queue_time # For debugging
|
2025-12-26 10:09:12 -08:00
|
|
|
})
|
|
|
|
|
|
2026-01-11 18:56:12 -08:00
|
|
|
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)
|
|
|
|
|
|
2025-12-26 10:09:12 -08:00
|
|
|
def _send_loop(self):
|
|
|
|
|
"""Background thread for sending transcriptions."""
|
|
|
|
|
while self.is_running:
|
|
|
|
|
try:
|
2025-12-26 16:44:55 -08:00
|
|
|
# Get transcription from queue (with shorter timeout for responsiveness)
|
2025-12-26 10:09:12 -08:00
|
|
|
try:
|
2025-12-26 16:44:55 -08:00
|
|
|
trans_data = self.send_queue.get(timeout=0.1)
|
2025-12-26 10:09:12 -08:00
|
|
|
except queue.Empty:
|
|
|
|
|
continue
|
|
|
|
|
|
2025-12-26 16:44:55 -08:00
|
|
|
# Send to server in parallel using thread pool
|
|
|
|
|
# This allows multiple requests to be in-flight simultaneously
|
|
|
|
|
self.executor.submit(self._send_to_server, trans_data)
|
2025-12-26 10:09:12 -08:00
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error in server sync send loop: {e}")
|
|
|
|
|
self.error_count += 1
|
|
|
|
|
self.last_error = str(e)
|
|
|
|
|
|
|
|
|
|
def _send_to_server(self, trans_data: dict):
|
|
|
|
|
"""
|
2025-12-26 16:44:55 -08:00
|
|
|
Send a transcription to the server (PHP or Node.js).
|
2025-12-26 10:09:12 -08:00
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
trans_data: Dictionary with 'text' and 'timestamp'
|
|
|
|
|
"""
|
2025-12-26 16:44:55 -08:00
|
|
|
import time
|
|
|
|
|
send_start = time.time()
|
|
|
|
|
|
2025-12-26 10:09:12 -08:00
|
|
|
try:
|
2025-12-26 16:44:55 -08:00
|
|
|
# Debug: Calculate queue delay
|
|
|
|
|
if 'queue_time' in trans_data:
|
|
|
|
|
queue_delay = (send_start - trans_data['queue_time']) * 1000
|
|
|
|
|
print(f"[Server Sync] Queue delay: {queue_delay:.0f}ms")
|
|
|
|
|
|
2025-12-26 10:09:12 -08:00
|
|
|
# Prepare payload
|
|
|
|
|
payload = {
|
|
|
|
|
'room': self.room,
|
|
|
|
|
'passphrase': self.passphrase,
|
|
|
|
|
'user_name': self.user_name,
|
|
|
|
|
'text': trans_data['text'],
|
2026-01-11 18:56:12 -08:00
|
|
|
'timestamp': trans_data['timestamp'],
|
|
|
|
|
'is_preview': trans_data.get('is_preview', False)
|
2025-12-26 10:09:12 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-11 18:56:12 -08:00
|
|
|
# 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})")
|
2025-12-26 16:44:55 -08:00
|
|
|
else:
|
2026-01-11 18:56:12 -08:00
|
|
|
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
|
|
|
|
|
)
|
2025-12-26 16:44:55 -08:00
|
|
|
|
|
|
|
|
request_time = (time.time() - request_start) * 1000
|
|
|
|
|
print(f"[Server Sync] HTTP request: {request_time:.0f}ms, Status: {response.status_code}")
|
2025-12-26 10:09:12 -08:00
|
|
|
|
|
|
|
|
# Check response
|
|
|
|
|
if response.status_code == 200:
|
|
|
|
|
self.sent_count += 1
|
|
|
|
|
self.last_error = None
|
|
|
|
|
else:
|
|
|
|
|
error_msg = f"Server returned {response.status_code}"
|
|
|
|
|
try:
|
|
|
|
|
error_data = response.json()
|
|
|
|
|
if 'error' in error_data:
|
|
|
|
|
error_msg = error_data['error']
|
|
|
|
|
except:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
print(f"Server sync error: {error_msg}")
|
|
|
|
|
self.error_count += 1
|
|
|
|
|
self.last_error = error_msg
|
|
|
|
|
|
|
|
|
|
except requests.exceptions.Timeout:
|
|
|
|
|
print("Server sync timeout")
|
|
|
|
|
self.error_count += 1
|
|
|
|
|
self.last_error = "Request timeout"
|
|
|
|
|
|
|
|
|
|
except requests.exceptions.ConnectionError:
|
|
|
|
|
print("Server sync connection error")
|
|
|
|
|
self.error_count += 1
|
|
|
|
|
self.last_error = "Connection error"
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Server sync error: {e}")
|
|
|
|
|
self.error_count += 1
|
|
|
|
|
self.last_error = str(e)
|
|
|
|
|
|
|
|
|
|
def get_stats(self) -> dict:
|
|
|
|
|
"""Get sync statistics."""
|
|
|
|
|
return {
|
|
|
|
|
'sent': self.sent_count,
|
|
|
|
|
'errors': self.error_count,
|
|
|
|
|
'last_error': self.last_error,
|
|
|
|
|
'queue_size': self.send_queue.qsize()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def is_healthy(self) -> bool:
|
|
|
|
|
"""Check if sync is working (no recent errors)."""
|
|
|
|
|
# Consider healthy if less than 10% error rate
|
|
|
|
|
total = self.sent_count + self.error_count
|
|
|
|
|
if total == 0:
|
|
|
|
|
return True
|
|
|
|
|
return (self.error_count / total) < 0.1
|