2025-12-25 18:48:23 -08:00
|
|
|
"""Web server for displaying transcriptions in a browser (for OBS browser source)."""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
from fastapi import FastAPI, WebSocket
|
|
|
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
import json
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TranscriptionWebServer:
|
|
|
|
|
"""Web server for displaying transcriptions."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, host: str = "127.0.0.1", port: int = 8080, show_timestamps: bool = True, fade_after_seconds: int = 10):
|
|
|
|
|
"""
|
|
|
|
|
Initialize web server.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
host: Server host address
|
|
|
|
|
port: Server port
|
|
|
|
|
show_timestamps: Whether to show timestamps in transcriptions
|
|
|
|
|
fade_after_seconds: Time in seconds before transcriptions fade out (0 = never fade)
|
|
|
|
|
"""
|
|
|
|
|
self.host = host
|
|
|
|
|
self.port = port
|
|
|
|
|
self.show_timestamps = show_timestamps
|
|
|
|
|
self.fade_after_seconds = fade_after_seconds
|
|
|
|
|
self.app = FastAPI()
|
|
|
|
|
self.active_connections: List[WebSocket] = []
|
|
|
|
|
self.transcriptions = [] # Store recent transcriptions
|
|
|
|
|
|
|
|
|
|
# Setup routes
|
|
|
|
|
self._setup_routes()
|
|
|
|
|
|
|
|
|
|
def _setup_routes(self):
|
|
|
|
|
"""Setup FastAPI routes."""
|
|
|
|
|
|
|
|
|
|
@self.app.get("/", response_class=HTMLResponse)
|
|
|
|
|
async def get_display():
|
|
|
|
|
"""Serve the transcription display page."""
|
|
|
|
|
return self._get_html()
|
|
|
|
|
|
|
|
|
|
@self.app.websocket("/ws")
|
|
|
|
|
async def websocket_endpoint(websocket: WebSocket):
|
|
|
|
|
"""WebSocket endpoint for real-time updates."""
|
|
|
|
|
await websocket.accept()
|
|
|
|
|
self.active_connections.append(websocket)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Send recent transcriptions
|
|
|
|
|
for trans in self.transcriptions[-20:]: # Last 20
|
|
|
|
|
await websocket.send_json(trans)
|
|
|
|
|
|
|
|
|
|
# Keep connection alive
|
|
|
|
|
while True:
|
|
|
|
|
# Wait for ping/pong to keep connection alive
|
|
|
|
|
await websocket.receive_text()
|
|
|
|
|
except:
|
|
|
|
|
self.active_connections.remove(websocket)
|
|
|
|
|
|
|
|
|
|
def _get_html(self) -> str:
|
|
|
|
|
"""Generate HTML for transcription display."""
|
|
|
|
|
return f"""
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<title>Transcription Display</title>
|
|
|
|
|
<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 {{
|
|
|
|
|
color: #4CAF50;
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
margin-right: 10px;
|
|
|
|
|
}}
|
|
|
|
|
.text {{
|
|
|
|
|
color: white;
|
|
|
|
|
}}
|
|
|
|
|
@keyframes slideIn {{
|
|
|
|
|
from {{
|
|
|
|
|
opacity: 0;
|
|
|
|
|
transform: translateY(-10px);
|
|
|
|
|
}}
|
|
|
|
|
to {{
|
|
|
|
|
opacity: 1;
|
|
|
|
|
transform: translateY(0);
|
|
|
|
|
}}
|
|
|
|
|
}}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div id="transcriptions"></div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
const container = document.getElementById('transcriptions');
|
|
|
|
|
const ws = new WebSocket(`ws://${{window.location.host}}/ws`);
|
|
|
|
|
const fadeAfterSeconds = {self.fade_after_seconds};
|
|
|
|
|
|
|
|
|
|
ws.onmessage = (event) => {{
|
|
|
|
|
const data = JSON.parse(event.data);
|
|
|
|
|
addTranscription(data);
|
|
|
|
|
}};
|
|
|
|
|
|
|
|
|
|
ws.onclose = () => {{
|
|
|
|
|
console.log('WebSocket closed. Attempting to reconnect...');
|
|
|
|
|
setTimeout(() => location.reload(), 3000);
|
|
|
|
|
}};
|
|
|
|
|
|
|
|
|
|
// Send keepalive pings
|
|
|
|
|
setInterval(() => {{
|
|
|
|
|
if (ws.readyState === WebSocket.OPEN) {{
|
|
|
|
|
ws.send('ping');
|
|
|
|
|
}}
|
|
|
|
|
}}, 30000);
|
|
|
|
|
|
|
|
|
|
function addTranscription(data) {{
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
div.className = 'transcription';
|
|
|
|
|
|
|
|
|
|
let html = '';
|
|
|
|
|
if (data.timestamp) {{
|
|
|
|
|
html += `<span class="timestamp">[${{data.timestamp}}]</span>`;
|
|
|
|
|
}}
|
|
|
|
|
if (data.user_name) {{
|
|
|
|
|
html += `<span class="user">${{data.user_name}}:</span>`;
|
|
|
|
|
}}
|
|
|
|
|
html += `<span class="text">${{data.text}}</span>`;
|
|
|
|
|
|
|
|
|
|
div.innerHTML = html;
|
|
|
|
|
container.appendChild(div);
|
|
|
|
|
|
|
|
|
|
// Auto-scroll to bottom
|
|
|
|
|
container.scrollTop = container.scrollHeight;
|
|
|
|
|
|
|
|
|
|
// Set up fade-out if enabled
|
|
|
|
|
if (fadeAfterSeconds > 0) {{
|
|
|
|
|
setTimeout(() => {{
|
|
|
|
|
// Start fade animation
|
|
|
|
|
div.classList.add('fading');
|
|
|
|
|
|
|
|
|
|
// Remove element after fade completes
|
|
|
|
|
setTimeout(() => {{
|
|
|
|
|
if (div.parentNode === container) {{
|
|
|
|
|
container.removeChild(div);
|
|
|
|
|
}}
|
|
|
|
|
}}, 1000); // Match the CSS transition duration
|
|
|
|
|
}}, fadeAfterSeconds * 1000);
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
// Limit to 50 transcriptions (fallback)
|
|
|
|
|
while (container.children.length > 50) {{
|
|
|
|
|
container.removeChild(container.firstChild);
|
|
|
|
|
}}
|
|
|
|
|
}}
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
async def broadcast_transcription(self, text: str, user_name: str = "", timestamp: Optional[datetime] = None):
|
|
|
|
|
"""
|
|
|
|
|
Broadcast a transcription to all connected clients.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
text: Transcription text
|
|
|
|
|
user_name: User/speaker name
|
|
|
|
|
timestamp: Timestamp of transcription
|
|
|
|
|
"""
|
|
|
|
|
if timestamp is None:
|
|
|
|
|
timestamp = datetime.now()
|
|
|
|
|
|
|
|
|
|
trans_data = {
|
|
|
|
|
"text": text,
|
|
|
|
|
"user_name": user_name,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Only include timestamp if enabled
|
|
|
|
|
if self.show_timestamps:
|
|
|
|
|
trans_data["timestamp"] = timestamp.strftime("%H:%M:%S")
|
|
|
|
|
|
|
|
|
|
# Store transcription
|
|
|
|
|
self.transcriptions.append(trans_data)
|
|
|
|
|
if len(self.transcriptions) > 100:
|
|
|
|
|
self.transcriptions.pop(0)
|
|
|
|
|
|
|
|
|
|
# Broadcast to all connected clients
|
|
|
|
|
disconnected = []
|
|
|
|
|
for connection in self.active_connections:
|
|
|
|
|
try:
|
|
|
|
|
await connection.send_json(trans_data)
|
|
|
|
|
except:
|
|
|
|
|
disconnected.append(connection)
|
|
|
|
|
|
|
|
|
|
# Remove disconnected clients
|
|
|
|
|
for conn in disconnected:
|
|
|
|
|
self.active_connections.remove(conn)
|
|
|
|
|
|
|
|
|
|
async def start(self):
|
|
|
|
|
"""Start the web server."""
|
|
|
|
|
import uvicorn
|
2025-12-26 17:50:37 -08:00
|
|
|
import logging
|
|
|
|
|
|
|
|
|
|
# Configure uvicorn to work without console (for PyInstaller builds)
|
|
|
|
|
# Suppress uvicorn's default console logging
|
|
|
|
|
logging.getLogger("uvicorn").setLevel(logging.ERROR)
|
|
|
|
|
logging.getLogger("uvicorn.access").setLevel(logging.ERROR)
|
|
|
|
|
logging.getLogger("uvicorn.error").setLevel(logging.ERROR)
|
|
|
|
|
|
2025-12-25 18:48:23 -08:00
|
|
|
config = uvicorn.Config(
|
|
|
|
|
self.app,
|
|
|
|
|
host=self.host,
|
|
|
|
|
port=self.port,
|
2025-12-26 17:50:37 -08:00
|
|
|
log_level="error", # Only log errors
|
|
|
|
|
access_log=False, # Disable access logging
|
|
|
|
|
log_config=None # Don't use default logging config
|
2025-12-25 18:48:23 -08:00
|
|
|
)
|
|
|
|
|
server = uvicorn.Server(config)
|
|
|
|
|
await server.serve()
|