|
|
@@ -3,22 +3,28 @@
|
|
|
admin.py — Speaker Admin Web Server
|
|
|
|
|
|
Local web interface for managing speaker names, voice recordings,
|
|
|
-and test recording playback.
|
|
|
+and test recording playback. Also serves the fullscreen display
|
|
|
+page for tablets / TVs and the SSE stream that feeds it.
|
|
|
+
|
|
|
Runs on port 8001 alongside bridge.py.
|
|
|
|
|
|
-Access at: http://localhost:8001
|
|
|
+Access at:
|
|
|
+ http://localhost:8001 ← speaker admin
|
|
|
+ http://[PC-IP]:8001/display ← fullscreen display for tablets
|
|
|
"""
|
|
|
|
|
|
import asyncio
|
|
|
import json
|
|
|
import shutil
|
|
|
+import threading
|
|
|
+from contextlib import asynccontextmanager
|
|
|
from pathlib import Path
|
|
|
|
|
|
+import paho.mqtt.client as mqtt
|
|
|
from fastapi import FastAPI, HTTPException, UploadFile, File
|
|
|
-from fastapi.responses import HTMLResponse, FileResponse
|
|
|
+from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
|
|
|
from pydantic import BaseModel
|
|
|
import uvicorn
|
|
|
-import websockets
|
|
|
|
|
|
SPEAKERS_FILE = Path(__file__).parent / "speakers.json"
|
|
|
RECORDINGS_DIR = Path(__file__).parent / "recordings"
|
|
|
@@ -27,9 +33,75 @@ RECORDINGS_DIR.mkdir(exist_ok=True)
|
|
|
TEST_RECORDINGS_DIR.mkdir(exist_ok=True)
|
|
|
|
|
|
ALLOWED_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aiff"}
|
|
|
-WS_URL = "ws://localhost:8000/asr"
|
|
|
|
|
|
-app = FastAPI(title="Speaker Admin")
|
|
|
+MQTT_HOST = "localhost"
|
|
|
+MQTT_PORT = 1883
|
|
|
+MQTT_TOPIC_TEXT = "display/text"
|
|
|
+MQTT_TOPIC_CLEAR = "display/clear"
|
|
|
+
|
|
|
+# ── SSE broadcast state ───────────────────────────────────────────────────────
|
|
|
+
|
|
|
+_sse_clients: set[asyncio.Queue] = set()
|
|
|
+_sse_lock = threading.Lock()
|
|
|
+_event_loop: asyncio.AbstractEventLoop | None = None
|
|
|
+
|
|
|
+
|
|
|
+def _broadcast(data: str) -> None:
|
|
|
+ if _event_loop is None:
|
|
|
+ return
|
|
|
+ with _sse_lock:
|
|
|
+ for q in list(_sse_clients):
|
|
|
+ try:
|
|
|
+ _event_loop.call_soon_threadsafe(q.put_nowait, data)
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+# ── MQTT subscriber ───────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+def _build_mqtt_subscriber() -> mqtt.Client:
|
|
|
+ client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
|
|
|
+
|
|
|
+ def on_connect(client, userdata, flags, rc, props):
|
|
|
+ if rc == 0:
|
|
|
+ client.subscribe([(MQTT_TOPIC_TEXT, 0), (MQTT_TOPIC_CLEAR, 0)])
|
|
|
+ print("[Admin] MQTT subscribed to display/text and display/clear")
|
|
|
+ else:
|
|
|
+ print(f"[Admin] MQTT connect failed: {rc}")
|
|
|
+
|
|
|
+ def on_message(client, userdata, msg):
|
|
|
+ payload = msg.payload.decode("utf-8", errors="replace")
|
|
|
+ if msg.topic == MQTT_TOPIC_CLEAR:
|
|
|
+ _broadcast("event: clear\ndata: {}\n\n")
|
|
|
+ else:
|
|
|
+ _broadcast(f"event: text\ndata: {payload}\n\n")
|
|
|
+
|
|
|
+ client.on_connect = on_connect
|
|
|
+ client.on_message = on_message
|
|
|
+ client.reconnect_delay_set(min_delay=1, max_delay=30)
|
|
|
+ client.connect_async(MQTT_HOST, MQTT_PORT)
|
|
|
+ client.loop_start()
|
|
|
+ return client
|
|
|
+
|
|
|
+
|
|
|
+# ── App lifecycle ─────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+_mqtt_sub: mqtt.Client | None = None
|
|
|
+
|
|
|
+
|
|
|
+@asynccontextmanager
|
|
|
+async def lifespan(app: FastAPI):
|
|
|
+ global _mqtt_sub, _event_loop
|
|
|
+ _event_loop = asyncio.get_running_loop()
|
|
|
+ _mqtt_sub = _build_mqtt_subscriber()
|
|
|
+ yield
|
|
|
+ if _mqtt_sub:
|
|
|
+ _mqtt_sub.loop_stop()
|
|
|
+ _mqtt_sub.disconnect()
|
|
|
+
|
|
|
+
|
|
|
+app = FastAPI(title="Speaker Admin", lifespan=lifespan)
|
|
|
+
|
|
|
|
|
|
# ── Speaker data helpers ──────────────────────────────────────────────────────
|
|
|
|
|
|
@@ -147,9 +219,9 @@ _playback_status: dict = {
|
|
|
"error": None,
|
|
|
}
|
|
|
|
|
|
-
|
|
|
BRIDGE_INJECT_URL = "http://127.0.0.1:8002/inject"
|
|
|
|
|
|
+
|
|
|
async def _stream_file(filepath: Path, speed: float) -> None:
|
|
|
global _playback_status
|
|
|
try:
|
|
|
@@ -200,6 +272,8 @@ async def _stream_file(filepath: Path, speed: float) -> None:
|
|
|
except Exception as exc:
|
|
|
_playback_status.update({"state": "error", "error": str(exc), "progress": 0})
|
|
|
print(f"[Playback] {exc}")
|
|
|
+
|
|
|
+
|
|
|
# ── Test recording API ────────────────────────────────────────────────────────
|
|
|
|
|
|
@app.post("/api/test/upload")
|
|
|
@@ -207,7 +281,6 @@ async def api_test_upload(file: UploadFile = File(...)):
|
|
|
suffix = Path(file.filename or "recording.wav").suffix.lower()
|
|
|
if suffix not in ALLOWED_AUDIO_EXTS:
|
|
|
raise HTTPException(400, f"Unsupported format '{suffix}'")
|
|
|
- # Sanitise filename — replace spaces with underscores
|
|
|
stem = Path(file.filename).stem[:80].replace(" ", "_")
|
|
|
out = TEST_RECORDINGS_DIR / f"{stem}{suffix}"
|
|
|
try:
|
|
|
@@ -278,7 +351,41 @@ def api_test_status():
|
|
|
return _playback_status
|
|
|
|
|
|
|
|
|
-# ── Web UI ────────────────────────────────────────────────────────────────────
|
|
|
+# ── SSE display stream ────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+@app.get("/api/display/stream")
|
|
|
+async def display_stream():
|
|
|
+ q: asyncio.Queue[str] = asyncio.Queue(maxsize=50)
|
|
|
+ with _sse_lock:
|
|
|
+ _sse_clients.add(q)
|
|
|
+
|
|
|
+ async def generator():
|
|
|
+ try:
|
|
|
+ yield ": heartbeat\n\n"
|
|
|
+ while True:
|
|
|
+ try:
|
|
|
+ data = await asyncio.wait_for(q.get(), timeout=25)
|
|
|
+ yield data
|
|
|
+ except asyncio.TimeoutError:
|
|
|
+ yield ": heartbeat\n\n"
|
|
|
+ except (asyncio.CancelledError, GeneratorExit):
|
|
|
+ pass
|
|
|
+ finally:
|
|
|
+ with _sse_lock:
|
|
|
+ _sse_clients.discard(q)
|
|
|
+
|
|
|
+ return StreamingResponse(
|
|
|
+ generator(),
|
|
|
+ media_type="text/event-stream",
|
|
|
+ headers={
|
|
|
+ "Cache-Control": "no-cache",
|
|
|
+ "Connection": "keep-alive",
|
|
|
+ "X-Accel-Buffering": "no",
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+# ── Web UI (admin) ────────────────────────────────────────────────────────────
|
|
|
|
|
|
HTML = """<!DOCTYPE html>
|
|
|
<html lang="en">
|
|
|
@@ -312,12 +419,14 @@ HTML = """<!DOCTYPE html>
|
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
|
padding: 8px 16px; border-radius: 6px; border: none;
|
|
|
cursor: pointer; font-size: .9rem; font-weight: 500; transition: filter .15s;
|
|
|
+ text-decoration: none;
|
|
|
}
|
|
|
.btn:hover:not(:disabled) { filter: brightness(.92); }
|
|
|
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
|
|
.btn-primary { background: #2563eb; color: white; }
|
|
|
.btn-danger { background: #dc2626; color: white; }
|
|
|
.btn-ghost { background: #e2e8f0; color: #334155; }
|
|
|
+ .btn-display { background: #059669; color: white; }
|
|
|
.btn-sm { padding: 4px 10px; font-size: .82rem; }
|
|
|
|
|
|
table { width: 100%; border-collapse: collapse; background: white; }
|
|
|
@@ -440,10 +549,11 @@ HTML = """<!DOCTYPE html>
|
|
|
<body>
|
|
|
|
|
|
<header>
|
|
|
- <div>
|
|
|
+ <div style="flex:1">
|
|
|
<h1>🎤 Speaker Admin</h1>
|
|
|
<small>Meeting Transcription for the Deaf — Speaker Name & Voice Library</small>
|
|
|
</div>
|
|
|
+ <a href="/display" target="_blank" class="btn btn-display">👁 View Display</a>
|
|
|
</header>
|
|
|
|
|
|
<div class="toolbar">
|
|
|
@@ -477,7 +587,8 @@ HTML = """<!DOCTYPE html>
|
|
|
<p class="pb-hint">
|
|
|
Upload a full church service recording (WAV, MP3, FLAC, OGG, M4A) to test the
|
|
|
transcription pipeline offline. The file streams to WhisperLiveKit exactly as a
|
|
|
- live microphone would — results appear on the e-ink display in real time.
|
|
|
+ live microphone would — results appear on the
|
|
|
+ <a href="/display" target="_blank">display page</a> in real time.
|
|
|
</p>
|
|
|
|
|
|
<div class="pb-layout">
|
|
|
@@ -1017,13 +1128,151 @@ loadTestFiles();
|
|
|
</html>
|
|
|
"""
|
|
|
|
|
|
+# ── Display page (fullscreen tablet / TV) ─────────────────────────────────────
|
|
|
+
|
|
|
+DISPLAY_HTML = """<!DOCTYPE html>
|
|
|
+<html lang="en">
|
|
|
+<head>
|
|
|
+<meta charset="UTF-8">
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
+<title>Live Transcription</title>
|
|
|
+<style>
|
|
|
+ *, *::before, *::after { box-sizing: border-box; }
|
|
|
+
|
|
|
+ html, body {
|
|
|
+ margin: 0; padding: 0;
|
|
|
+ width: 100%; height: 100%;
|
|
|
+ background: #0d0d0d;
|
|
|
+ color: #eeeeee;
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+
|
|
|
+ #screen {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100vh;
|
|
|
+ padding: 5vh 6vw;
|
|
|
+ gap: 4vh;
|
|
|
+ justify-content: flex-start;
|
|
|
+ }
|
|
|
+
|
|
|
+ .display-line {
|
|
|
+ font-family: Georgia, 'Times New Roman', serif;
|
|
|
+ font-size: clamp(22px, 8vw, 110px);
|
|
|
+ line-height: 1.25;
|
|
|
+ letter-spacing: 0.02em;
|
|
|
+ min-height: 1.25em;
|
|
|
+ word-break: break-word;
|
|
|
+ transition: opacity 0.15s;
|
|
|
+ }
|
|
|
+
|
|
|
+ .display-line.is-speaker {
|
|
|
+ font-size: clamp(16px, 5.5vw, 72px);
|
|
|
+ color: #f5c518;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: 0.1em;
|
|
|
+ font-family: system-ui, sans-serif;
|
|
|
+ padding-bottom: 1.5vh;
|
|
|
+ border-bottom: 2px solid #2a2a2a;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Small status indicator in the corner */
|
|
|
+ #conn {
|
|
|
+ position: fixed;
|
|
|
+ bottom: 12px;
|
|
|
+ right: 16px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ font-family: system-ui, sans-serif;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #333;
|
|
|
+ pointer-events: none;
|
|
|
+ }
|
|
|
+ #conn-dot {
|
|
|
+ width: 8px; height: 8px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #555;
|
|
|
+ transition: background 0.4s;
|
|
|
+ }
|
|
|
+ #conn-dot.live { background: #22c55e; }
|
|
|
+ #conn-dot.lost { background: #dc2626; }
|
|
|
+</style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+<div id="screen">
|
|
|
+ <div class="display-line" id="line0"></div>
|
|
|
+ <div class="display-line" id="line1"></div>
|
|
|
+ <div class="display-line" id="line2"></div>
|
|
|
+</div>
|
|
|
+
|
|
|
+<div id="conn">
|
|
|
+ <div id="conn-dot"></div>
|
|
|
+ <span id="conn-label">connecting</span>
|
|
|
+</div>
|
|
|
+
|
|
|
+<script>
|
|
|
+ const SPEAKER_RE = /^\\[(.+)\\]$/;
|
|
|
+
|
|
|
+ function applyLines(lines) {
|
|
|
+ for (let i = 0; i < 3; i++) {
|
|
|
+ const el = document.getElementById('line' + i);
|
|
|
+ const txt = String(lines[i] || '').trim();
|
|
|
+ const m = txt.match(SPEAKER_RE);
|
|
|
+ if (m) {
|
|
|
+ el.textContent = m[1];
|
|
|
+ el.className = 'display-line is-speaker';
|
|
|
+ } else {
|
|
|
+ el.textContent = txt;
|
|
|
+ el.className = 'display-line';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function setStatus(ok) {
|
|
|
+ const dot = document.getElementById('conn-dot');
|
|
|
+ const label = document.getElementById('conn-label');
|
|
|
+ dot.className = ok ? 'live' : 'lost';
|
|
|
+ label.textContent = ok ? 'live' : 'reconnecting…';
|
|
|
+ }
|
|
|
+
|
|
|
+ function connect() {
|
|
|
+ const es = new EventSource('/api/display/stream');
|
|
|
+
|
|
|
+ es.addEventListener('text', e => {
|
|
|
+ try { applyLines(JSON.parse(e.data).lines || []); } catch {}
|
|
|
+ });
|
|
|
+
|
|
|
+ es.addEventListener('clear', () => applyLines(['', '', '']));
|
|
|
+
|
|
|
+ es.onopen = () => setStatus(true);
|
|
|
+ es.onerror = () => {
|
|
|
+ setStatus(false);
|
|
|
+ es.close();
|
|
|
+ setTimeout(connect, 4000);
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ connect();
|
|
|
+</script>
|
|
|
+</body>
|
|
|
+</html>
|
|
|
+"""
|
|
|
+
|
|
|
+
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
|
def index():
|
|
|
return HTML
|
|
|
|
|
|
|
|
|
+@app.get("/display", response_class=HTMLResponse)
|
|
|
+def display():
|
|
|
+ return DISPLAY_HTML
|
|
|
+
|
|
|
+
|
|
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
- print("[Admin] Speaker admin running at http://localhost:8001")
|
|
|
+ print("[Admin] Speaker admin at http://localhost:8001")
|
|
|
+ print("[Admin] Display page at http://localhost:8001/display")
|
|
|
uvicorn.run(app, host="0.0.0.0", port=8001, log_level="warning")
|