Prechádzať zdrojové kódy

Initial Display Version

Benjamin Harris 1 mesiac pred
rodič
commit
09f1d397c4
2 zmenil súbory, kde vykonal 263 pridanie a 14 odobranie
  1. 2 2
      README.md
  2. 261 12
      bridge/admin.py

+ 2 - 2
README.md

@@ -179,7 +179,7 @@ The bridge script accumulates text until a sentence boundary or natural pause (~
 - [x] Per-speaker voice sample upload
 - [x] Test recording playback for offline pipeline testing
 - [x] install.bat / start.bat — double-click operation
-- [ ] `/display` fullscreen browser display page
-- [ ] SSE or WebSocket push from admin.py to display page
+- [x] `/display` fullscreen browser display page
+- [x] SSE push from admin.py to display page (`/api/display/stream`)
 - [ ] Voice enrolment v2 (auto name matching from voice samples)
 - [ ] Church deployment trial

+ 261 - 12
bridge/admin.py

@@ -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>&#127908; Speaker Admin</h1>
     <small>Meeting Transcription for the Deaf — Speaker Name &amp; Voice Library</small>
   </div>
+  <a href="/display" target="_blank" class="btn btn-display">&#128065; 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")