Explorar o código

Change to Web Pages

Benjamin Harris hai 1 mes
pai
achega
fbe7c06536
Modificáronse 4 ficheiros con 620 adicións e 157 borrados
  1. 575 0
      bridge/admin.py
  2. 26 154
      bridge/bridge.py
  3. 3 0
      bridge/requirements.txt
  4. 16 3
      start.bat

+ 575 - 0
bridge/admin.py

@@ -0,0 +1,575 @@
+#!/usr/bin/env python3
+"""
+admin.py — Speaker Admin Web Server
+
+Local web interface for managing speaker names and voice recordings.
+Runs on port 8001 alongside bridge.py.
+
+Access at: http://localhost:8001
+"""
+
+import json
+import shutil
+from pathlib import Path
+
+from fastapi import FastAPI, HTTPException, UploadFile, File
+from fastapi.responses import HTMLResponse, FileResponse
+from pydantic import BaseModel
+import uvicorn
+
+SPEAKERS_FILE  = Path(__file__).parent / "speakers.json"
+RECORDINGS_DIR = Path(__file__).parent / "recordings"
+RECORDINGS_DIR.mkdir(exist_ok=True)
+
+app = FastAPI(title="Speaker Admin")
+
+# ── Data helpers ──────────────────────────────────────────────────────────────
+
+def _load() -> dict[str, str]:
+    if SPEAKERS_FILE.exists():
+        try:
+            return json.loads(SPEAKERS_FILE.read_text(encoding="utf-8"))
+        except Exception:
+            pass
+    return {}
+
+
+def _save(data: dict[str, str]) -> None:
+    SPEAKERS_FILE.write_text(
+        json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
+    )
+
+
+def _recording_path(sid: str) -> Path | None:
+    for ext in (".wav", ".mp3", ".m4a", ".ogg", ".webm", ".flac"):
+        p = RECORDINGS_DIR / f"{sid}{ext}"
+        if p.exists():
+            return p
+    return None
+
+
+# ── API ───────────────────────────────────────────────────────────────────────
+
+class NameBody(BaseModel):
+    name: str
+
+class AddBody(BaseModel):
+    id: str
+    name: str
+
+
+@app.get("/api/speakers")
+def api_list():
+    speakers = _load()
+    return {"speakers": [
+        {"id": k, "name": v, "has_recording": _recording_path(k) is not None}
+        for k, v in sorted(speakers.items())
+    ]}
+
+
+@app.post("/api/speakers")
+def api_add(body: AddBody):
+    speakers = _load()
+    sid = body.id.strip()
+    if not sid:
+        raise HTTPException(400, "Speaker ID cannot be empty")
+    if sid in speakers:
+        raise HTTPException(400, f"'{sid}' already exists")
+    speakers[sid] = body.name.strip()
+    _save(speakers)
+    return {"ok": True, "id": sid, "name": speakers[sid]}
+
+
+@app.put("/api/speakers/{sid}")
+def api_update(sid: str, body: NameBody):
+    name = body.name.strip()
+    if not name:
+        raise HTTPException(400, "Name cannot be empty")
+    speakers = _load()
+    speakers[sid] = name
+    _save(speakers)
+    return {"ok": True}
+
+
+@app.delete("/api/speakers/{sid}")
+def api_delete(sid: str):
+    speakers = _load()
+    speakers.pop(sid, None)
+    _save(speakers)
+    rec = _recording_path(sid)
+    if rec:
+        rec.unlink()
+    return {"ok": True}
+
+
+@app.post("/api/speakers/{sid}/recording")
+async def api_upload(sid: str, file: UploadFile = File(...)):
+    suffix = Path(file.filename or "audio.wav").suffix.lower() or ".wav"
+    rec = _recording_path(sid)
+    if rec:
+        rec.unlink()
+    out = RECORDINGS_DIR / f"{sid}{suffix}"
+    with out.open("wb") as f:
+        shutil.copyfileobj(file.file, f)
+    speakers = _load()
+    if sid not in speakers:
+        speakers[sid] = sid
+        _save(speakers)
+    size_kb = round(out.stat().st_size / 1024)
+    return {"ok": True, "file": out.name, "kb": size_kb}
+
+
+@app.get("/api/speakers/{sid}/recording")
+def api_playback(sid: str):
+    rec = _recording_path(sid)
+    if not rec:
+        raise HTTPException(404, "No recording found")
+    return FileResponse(rec)
+
+
+# ── Web UI ────────────────────────────────────────────────────────────────────
+
+HTML = """<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Speaker Admin</title>
+<style>
+  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+  body { font-family: system-ui, sans-serif; background: #f0f4f8; color: #1e293b; }
+
+  header {
+    background: #1e3a5f; color: white; padding: 16px 24px;
+    display: flex; align-items: center; gap: 16px;
+  }
+  header h1 { font-size: 1.2rem; font-weight: 600; flex: 1; }
+  header small { opacity: .7; font-size: .8rem; }
+
+  .toolbar {
+    background: white; padding: 12px 24px;
+    display: flex; gap: 12px; align-items: center;
+    border-bottom: 1px solid #e2e8f0;
+  }
+  .toolbar input[type=search] {
+    flex: 1; max-width: 340px; padding: 8px 12px;
+    border: 1px solid #cbd5e1; border-radius: 6px; font-size: .95rem;
+  }
+  .count { color: #64748b; font-size: .9rem; margin-left: auto; }
+
+  .btn {
+    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;
+  }
+  .btn:hover { filter: brightness(.92); }
+  .btn-primary  { background: #2563eb; color: white; }
+  .btn-danger   { background: #dc2626; color: white; }
+  .btn-ghost    { background: #e2e8f0; color: #334155; }
+  .btn-sm { padding: 4px 10px; font-size: .82rem; }
+
+  table { width: 100%; border-collapse: collapse; background: white; }
+  th {
+    text-align: left; padding: 10px 16px; font-size: .8rem;
+    font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
+    color: #64748b; background: #f8fafc; border-bottom: 1px solid #e2e8f0;
+  }
+  td { padding: 10px 16px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
+  tr:hover td { background: #f8fafc; }
+  tr.hidden { display: none; }
+
+  .sid { font-family: monospace; font-size: .85rem; color: #475569; }
+
+  .name-cell { display: flex; align-items: center; gap: 8px; }
+  .name-display { cursor: pointer; flex: 1; }
+  .name-display:hover { text-decoration: underline; }
+  .name-input {
+    flex: 1; padding: 4px 8px; border: 1px solid #2563eb;
+    border-radius: 4px; font-size: .95rem; outline: none;
+  }
+
+  .rec-badge {
+    display: inline-flex; align-items: center; gap: 4px;
+    font-size: .75rem; padding: 2px 8px; border-radius: 999px;
+    font-weight: 500;
+  }
+  .rec-yes { background: #dcfce7; color: #166534; }
+  .rec-no  { background: #f1f5f9; color: #94a3b8; }
+
+  .actions { display: flex; gap: 6px; }
+
+  /* Modal */
+  .modal-bg {
+    display: none; position: fixed; inset: 0;
+    background: rgba(0,0,0,.45); z-index: 100;
+    align-items: center; justify-content: center;
+  }
+  .modal-bg.open { display: flex; }
+  .modal {
+    background: white; border-radius: 10px; padding: 28px;
+    width: 420px; max-width: 95vw; box-shadow: 0 20px 60px rgba(0,0,0,.25);
+  }
+  .modal h2 { font-size: 1.1rem; margin-bottom: 16px; }
+  .field { margin-bottom: 14px; }
+  .field label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: 4px; }
+  .field input {
+    width: 100%; padding: 8px 10px; border: 1px solid #cbd5e1;
+    border-radius: 6px; font-size: .95rem;
+  }
+  .field input:focus { outline: 2px solid #2563eb; border-color: transparent; }
+  .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
+
+  /* Upload */
+  .upload-area {
+    border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px;
+    text-align: center; cursor: pointer; transition: border-color .2s;
+    color: #64748b; font-size: .9rem;
+  }
+  .upload-area.drag { border-color: #2563eb; background: #eff6ff; }
+  .upload-area input[type=file] { display: none; }
+
+  /* Audio player */
+  .audio-player { width: 180px; height: 32px; }
+
+  /* Toast */
+  #toast {
+    position: fixed; bottom: 24px; right: 24px;
+    background: #1e293b; color: white; padding: 10px 18px;
+    border-radius: 8px; font-size: .9rem; transform: translateY(80px);
+    transition: transform .25s; z-index: 200; pointer-events: none;
+  }
+  #toast.show { transform: translateY(0); }
+  #toast.error { background: #dc2626; }
+
+  .container { max-width: 1100px; margin: 0 auto; padding: 24px; }
+
+  audio { vertical-align: middle; }
+</style>
+</head>
+<body>
+
+<header>
+  <div>
+    <h1>&#127908; Speaker Admin</h1>
+    <small>Church Live Transcription — Speaker Name &amp; Voice Library</small>
+  </div>
+</header>
+
+<div class="toolbar">
+  <input type="search" id="search" placeholder="Search by ID or name…" oninput="filterTable()">
+  <button class="btn btn-primary" onclick="openAddModal()">&#43; Add Speaker</button>
+  <span class="count" id="count"></span>
+</div>
+
+<div class="container">
+  <table id="table">
+    <thead>
+      <tr>
+        <th>Speaker ID</th>
+        <th>Friendly Name</th>
+        <th>Voice Sample</th>
+        <th>Actions</th>
+      </tr>
+    </thead>
+    <tbody id="tbody"></tbody>
+  </table>
+</div>
+
+<!-- Add modal -->
+<div class="modal-bg" id="add-modal" onclick="closeAddModal(event)">
+  <div class="modal">
+    <h2>Add Speaker</h2>
+    <div class="field">
+      <label>Speaker ID
+        <span style="color:#64748b;font-weight:normal;font-size:.8rem">
+          (e.g. SPEAKER_00, or any unique key)
+        </span>
+      </label>
+      <input id="new-id" placeholder="SPEAKER_00">
+    </div>
+    <div class="field">
+      <label>Friendly Name</label>
+      <input id="new-name" placeholder="Pastor John">
+    </div>
+    <div class="modal-actions">
+      <button class="btn btn-ghost" onclick="closeAddModal()">Cancel</button>
+      <button class="btn btn-primary" onclick="addSpeaker()">Add</button>
+    </div>
+  </div>
+</div>
+
+<!-- Upload modal -->
+<div class="modal-bg" id="upload-modal" onclick="closeUploadModal(event)">
+  <div class="modal">
+    <h2 id="upload-title">Upload Voice Sample</h2>
+    <p style="color:#64748b;font-size:.85rem;margin-bottom:12px">
+      Upload a 10–60 second clear speech recording.<br>
+      Supported: WAV, MP3, M4A, OGG, FLAC, WebM
+    </p>
+    <div class="upload-area" id="drop-zone"
+         ondragover="onDragOver(event)" ondragleave="onDragLeave(event)"
+         ondrop="onDrop(event)" onclick="document.getElementById('file-input').click()">
+      <input type="file" id="file-input" accept="audio/*" onchange="uploadFile(this.files[0])">
+      <div>&#127926; Drag &amp; drop audio here, or click to browse</div>
+    </div>
+    <div id="upload-status" style="margin-top:10px;font-size:.85rem;color:#166534"></div>
+    <div class="modal-actions">
+      <button class="btn btn-ghost" onclick="closeUploadModal()">Close</button>
+    </div>
+  </div>
+</div>
+
+<div id="toast"></div>
+
+<script>
+let speakers = [];
+let uploadTarget = null;
+
+// ── Load & render ─────────────────────────────────────────────────────────────
+
+async function load() {
+  const res = await fetch('/api/speakers');
+  const data = await res.json();
+  speakers = data.speakers;
+  render();
+}
+
+function render() {
+  const tbody = document.getElementById('tbody');
+  tbody.innerHTML = '';
+  speakers.forEach(s => tbody.appendChild(makeRow(s)));
+  document.getElementById('count').textContent = `${speakers.length} speaker${speakers.length !== 1 ? 's' : ''}`;
+  filterTable();
+}
+
+function makeRow(s) {
+  const tr = document.createElement('tr');
+  tr.dataset.id   = s.id;
+  tr.dataset.name = s.name.toLowerCase();
+
+  const recHtml = s.has_recording
+    ? `<span class="rec-badge rec-yes">&#9654; Recorded</span>
+       <audio class="audio-player" controls preload="none"
+         src="/api/speakers/${encodeURIComponent(s.id)}/recording"></audio>`
+    : `<span class="rec-badge rec-no">No sample</span>`;
+
+  tr.innerHTML = `
+    <td class="sid">${esc(s.id)}</td>
+    <td>
+      <div class="name-cell">
+        <span class="name-display" onclick="startEdit(this, '${esc(s.id)}')"
+              title="Click to edit">${esc(s.name)}</span>
+        <input class="name-input" style="display:none"
+               onblur="saveEdit(this,'${esc(s.id)}')"
+               onkeydown="nameKeydown(event,this,'${esc(s.id)}')">
+      </div>
+    </td>
+    <td>${recHtml}</td>
+    <td>
+      <div class="actions">
+        <button class="btn btn-ghost btn-sm"
+                onclick="openUploadModal('${esc(s.id)}', '${esc(s.name)}')">
+          &#127926; ${s.has_recording ? 'Replace' : 'Upload'}
+        </button>
+        <button class="btn btn-danger btn-sm"
+                onclick="deleteSpeaker('${esc(s.id)}')">&#128465;</button>
+      </div>
+    </td>`;
+  return tr;
+}
+
+function esc(str) {
+  return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
+            .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+}
+
+// ── Search ────────────────────────────────────────────────────────────────────
+
+function filterTable() {
+  const q = document.getElementById('search').value.toLowerCase().trim();
+  let visible = 0;
+  document.querySelectorAll('#tbody tr').forEach(tr => {
+    const match = !q || tr.dataset.id.includes(q) || tr.dataset.name.includes(q);
+    tr.classList.toggle('hidden', !match);
+    if (match) visible++;
+  });
+  document.getElementById('count').textContent =
+    q ? `${visible} of ${speakers.length} speakers` : `${speakers.length} speakers`;
+}
+
+// ── Inline edit ───────────────────────────────────────────────────────────────
+
+function startEdit(span, id) {
+  const input = span.nextElementSibling;
+  input.value = span.textContent;
+  span.style.display = 'none';
+  input.style.display = '';
+  input.focus();
+  input.select();
+}
+
+function nameKeydown(e, input, id) {
+  if (e.key === 'Enter')  { input.blur(); }
+  if (e.key === 'Escape') { cancelEdit(input); }
+}
+
+function cancelEdit(input) {
+  const span = input.previousElementSibling;
+  input.style.display = 'none';
+  span.style.display = '';
+}
+
+async function saveEdit(input, id) {
+  const name = input.value.trim();
+  const span  = input.previousElementSibling;
+  if (!name || name === span.textContent) { cancelEdit(input); return; }
+  const res = await fetch(`/api/speakers/${encodeURIComponent(id)}`, {
+    method: 'PUT',
+    headers: {'Content-Type': 'application/json'},
+    body: JSON.stringify({name})
+  });
+  if (res.ok) {
+    span.textContent = name;
+    const tr = input.closest('tr');
+    tr.dataset.name = name.toLowerCase();
+    toast('Saved');
+  } else {
+    toast('Save failed', true);
+  }
+  cancelEdit(input);
+}
+
+// ── Add speaker ───────────────────────────────────────────────────────────────
+
+function openAddModal() {
+  const nums = speakers
+    .filter(s => /^SPEAKER_\d+$/.test(s.id))
+    .map(s => parseInt(s.id.split('_')[1]));
+  const next = nums.length ? Math.max(...nums) + 1 : 0;
+  document.getElementById('new-id').value   = `SPEAKER_${String(next).padStart(2,'0')}`;
+  document.getElementById('new-name').value = '';
+  document.getElementById('add-modal').classList.add('open');
+  setTimeout(() => document.getElementById('new-name').focus(), 50);
+}
+
+function closeAddModal(e) {
+  if (!e || e.target === document.getElementById('add-modal'))
+    document.getElementById('add-modal').classList.remove('open');
+}
+
+async function addSpeaker() {
+  const id   = document.getElementById('new-id').value.trim();
+  const name = document.getElementById('new-name').value.trim();
+  if (!id || !name) { toast('ID and name are required', true); return; }
+  const res = await fetch('/api/speakers', {
+    method: 'POST',
+    headers: {'Content-Type': 'application/json'},
+    body: JSON.stringify({id, name})
+  });
+  if (res.ok) {
+    closeAddModal();
+    toast(`Added ${name}`);
+    await load();
+  } else {
+    const err = await res.json().catch(() => ({detail:'Error'}));
+    toast(err.detail || 'Failed', true);
+  }
+}
+
+// ── Delete ────────────────────────────────────────────────────────────────────
+
+async function deleteSpeaker(id) {
+  const s = speakers.find(x => x.id === id);
+  if (!confirm(`Remove "${s?.name || id}" from the speaker list?`)) return;
+  const res = await fetch(`/api/speakers/${encodeURIComponent(id)}`, {method:'DELETE'});
+  if (res.ok) { toast('Removed'); await load(); }
+  else        { toast('Delete failed', true); }
+}
+
+// ── Upload modal ──────────────────────────────────────────────────────────────
+
+function openUploadModal(id, name) {
+  uploadTarget = id;
+  document.getElementById('upload-title').textContent = `Voice Sample — ${name}`;
+  document.getElementById('upload-status').textContent = '';
+  document.getElementById('file-input').value = '';
+  document.getElementById('upload-modal').classList.add('open');
+}
+
+function closeUploadModal(e) {
+  if (!e || e.target === document.getElementById('upload-modal')) {
+    document.getElementById('upload-modal').classList.remove('open');
+    uploadTarget = null;
+    load();
+  }
+}
+
+function onDragOver(e)  { e.preventDefault(); document.getElementById('drop-zone').classList.add('drag'); }
+function onDragLeave()  { document.getElementById('drop-zone').classList.remove('drag'); }
+function onDrop(e)      { e.preventDefault(); onDragLeave(); uploadFile(e.dataTransfer.files[0]); }
+
+async function uploadFile(file) {
+  if (!file || !uploadTarget) return;
+  const status = document.getElementById('upload-status');
+  status.style.color = '#2563eb';
+  status.textContent = `Uploading ${file.name} (${Math.round(file.size/1024)} KB)…`;
+  const form = new FormData();
+  form.append('file', file);
+  const res = await fetch(`/api/speakers/${encodeURIComponent(uploadTarget)}/recording`, {
+    method: 'POST', body: form
+  });
+  if (res.ok) {
+    const data = await res.json();
+    status.style.color = '#166534';
+    status.textContent = `✓ Saved — ${data.file} (${data.kb} KB)`;
+    toast('Recording saved');
+  } else {
+    status.style.color = '#dc2626';
+    status.textContent = 'Upload failed';
+    toast('Upload failed', true);
+  }
+}
+
+// ── Toast ─────────────────────────────────────────────────────────────────────
+
+let toastTimer;
+function toast(msg, error = false) {
+  const el = document.getElementById('toast');
+  el.textContent = msg;
+  el.className = 'show' + (error ? ' error' : '');
+  clearTimeout(toastTimer);
+  toastTimer = setTimeout(() => el.className = '', 2500);
+}
+
+// ── Keyboard shortcuts ────────────────────────────────────────────────────────
+
+document.addEventListener('keydown', e => {
+  if (e.key === 'Escape') {
+    closeAddModal();
+    closeUploadModal();
+  }
+  if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
+    e.preventDefault();
+    document.getElementById('search').focus();
+  }
+});
+
+// ── Boot ──────────────────────────────────────────────────────────────────────
+
+load();
+</script>
+</body>
+</html>
+"""
+
+@app.get("/", response_class=HTMLResponse)
+def index():
+    return HTML
+
+
+# ── Entry point ───────────────────────────────────────────────────────────────
+
+if __name__ == "__main__":
+    print("[Admin] Speaker admin running at http://localhost:8001")
+    uvicorn.run(app, host="0.0.0.0", port=8001, log_level="warning")

+ 26 - 154
bridge/bridge.py

@@ -26,8 +26,6 @@ import numpy as np
 import paho.mqtt.client as mqtt
 import sounddevice as sd
 import websockets
-import tkinter as tk
-from tkinter import ttk
 
 # ── Configuration ─────────────────────────────────────────────────────────────
 
@@ -249,6 +247,23 @@ async def _flusher(state: BridgeState, mqtt_client: mqtt.Client) -> None:
         state.maybe_timeout_flush(mqtt_client)
 
 
+async def _speaker_reloader(state: BridgeState) -> None:
+    """Reload speakers.json every 5 s so admin UI changes take effect live."""
+    last_mtime = 0.0
+    while True:
+        await asyncio.sleep(5.0)
+        try:
+            mtime = SPEAKERS_FILE.stat().st_mtime
+            if mtime != last_mtime:
+                fresh = _load_speakers()
+                with state._lock:
+                    state.speaker_names = fresh
+                last_mtime = mtime
+                print("[Bridge] Speaker names reloaded from disk")
+        except OSError:
+            pass
+
+
 def _choose_audio_device() -> int | None:
     """
     List all input devices and return the index to use.
@@ -319,7 +334,8 @@ async def audio_ws_loop(state: BridgeState, mqtt_client: mqtt.Client) -> None:
         blocksize=BLOCKSIZE,
         callback=audio_callback,
     ):
-        flusher = asyncio.create_task(_flusher(state, mqtt_client))
+        flusher   = asyncio.create_task(_flusher(state, mqtt_client))
+        reloader  = asyncio.create_task(_speaker_reloader(state))
         try:
             while True:
                 try:
@@ -341,161 +357,13 @@ async def audio_ws_loop(state: BridgeState, mqtt_client: mqtt.Client) -> None:
                     await asyncio.sleep(3)
         finally:
             flusher.cancel()
+            reloader.cancel()
 
 
 def run_async_loop(state: BridgeState, mqtt_client: mqtt.Client) -> None:
     asyncio.run(audio_ws_loop(state, mqtt_client))
 
 
-# ── Speaker UI ────────────────────────────────────────────────────────────────
-
-def run_speaker_ui(state: BridgeState, mqtt_client: mqtt.Client) -> None:
-    root = tk.Tk()
-    root.title("Transcription Bridge — Speaker Names")
-    root.attributes("-topmost", True)
-    root.minsize(440, 360)
-    root.geometry("460x480")
-
-    # ── Header ────────────────────────────────────────────────────────────────
-    tk.Label(root, text="Speaker Name Mapping", font=("Helvetica", 12, "bold")).pack(
-        pady=(12, 2)
-    )
-    tk.Label(
-        root,
-        text="Names are saved to speakers.json and restored each session.\n"
-             "New speakers detected by diarization appear here automatically.",
-        font=("Helvetica", 9), fg="gray", justify="center",
-    ).pack(pady=(0, 6))
-
-    # ── Scrollable list ───────────────────────────────────────────────────────
-    list_outer = tk.Frame(root, relief="sunken", bd=1)
-    list_outer.pack(fill="both", expand=True, padx=12, pady=(0, 4))
-
-    canvas    = tk.Canvas(list_outer, highlightthickness=0)
-    scrollbar = ttk.Scrollbar(list_outer, orient="vertical", command=canvas.yview)
-    rows_frame = tk.Frame(canvas)
-
-    rows_frame.bind(
-        "<Configure>",
-        lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
-    )
-    canvas.create_window((0, 0), window=rows_frame, anchor="nw")
-    canvas.configure(yscrollcommand=scrollbar.set)
-
-    canvas.pack(side="left", fill="both", expand=True)
-    scrollbar.pack(side="right", fill="y")
-
-    canvas.bind_all(
-        "<MouseWheel>",
-        lambda e: canvas.yview_scroll(int(-1 * e.delta / 120), "units"),
-    )
-
-    # Column headers
-    hdr = tk.Frame(rows_frame, bg="#e8e8e8")
-    hdr.pack(fill="x", pady=(2, 0))
-    tk.Label(hdr, text="  Speaker ID",   bg="#e8e8e8", font=("Helvetica", 9, "bold"), width=14, anchor="w").pack(side="left")
-    tk.Label(hdr, text="Friendly Name",  bg="#e8e8e8", font=("Helvetica", 9, "bold"), width=18, anchor="w").pack(side="left")
-
-    # ── Row management ────────────────────────────────────────────────────────
-    rendered_sids: set[str] = set()
-
-    def _add_row(sid: str, name: str) -> None:
-        if sid in rendered_sids:
-            return
-        rendered_sids.add(sid)
-
-        row = tk.Frame(rows_frame)
-        row.pack(fill="x", padx=4, pady=2)
-
-        tk.Label(row, text=sid, font=("Courier", 9), width=14, anchor="w").pack(side="left")
-
-        entry = tk.Entry(row, font=("Helvetica", 10), width=18)
-        entry.insert(0, name)
-        entry.pack(side="left", padx=4)
-
-        saved_lbl = tk.Label(row, text="", font=("Helvetica", 8), fg="#2a7a2a", width=5)
-        saved_lbl.pack(side="left")
-
-        def _save(s=sid, e=entry, lbl=saved_lbl):
-            n = e.get().strip()
-            if not n:
-                return
-            state.set_speaker_name(s, n)
-            lbl.config(text="Saved")
-            row.after(2000, lambda: lbl.config(text=""))
-            print(f"[UI] {s} → {n!r}")
-
-        def _delete(s=sid, r=row):
-            state.delete_speaker(s)
-            rendered_sids.discard(s)
-            r.destroy()
-            print(f"[UI] Removed {s}")
-
-        entry.bind("<Return>", lambda _e, s=sid, e=entry, lbl=saved_lbl: _save(s, e, lbl))
-
-        tk.Button(row, text="Save", command=_save, width=5).pack(side="left", padx=2)
-        tk.Button(row, text="✕",    command=_delete, fg="red", width=3).pack(side="left")
-
-    # Populate from persisted state (sorted so order is stable)
-    for sid, name in sorted(state.speaker_names.items()):
-        _add_row(sid, name)
-
-    # Poll every 2 s for speaker IDs newly seen from Whisper this session
-    def _poll():
-        for sid in sorted(state.seen_speakers_snapshot() - rendered_sids):
-            _add_row(sid, state.speaker_names.get(sid, sid))
-        root.after(2000, _poll)
-
-    _poll()
-
-    # ── Add row manually ──────────────────────────────────────────────────────
-    ttk.Separator(root, orient="horizontal").pack(fill="x", padx=12, pady=4)
-
-    add_row = tk.Frame(root)
-    add_row.pack(fill="x", padx=12)
-
-    tk.Label(add_row, text="Add:", font=("Helvetica", 9)).pack(side="left")
-
-    add_id = tk.Entry(add_row, font=("Courier", 9), width=13)
-    add_id.insert(0, "SPEAKER_04")
-    add_id.pack(side="left", padx=4)
-
-    tk.Label(add_row, text="→").pack(side="left")
-
-    add_name = tk.Entry(add_row, font=("Helvetica", 10), width=16)
-    add_name.pack(side="left", padx=4)
-
-    def _add_manual():
-        sid  = add_id.get().strip()
-        name = add_name.get().strip()
-        if sid and name:
-            state.set_speaker_name(sid, name)
-            _add_row(sid, name)
-            add_name.delete(0, tk.END)
-            print(f"[UI] Added {sid} → {name!r}")
-
-    add_name.bind("<Return>", lambda _e: _add_manual())
-    tk.Button(add_row, text="Add", command=_add_manual, width=5).pack(side="left", padx=2)
-
-    # ── Footer buttons ────────────────────────────────────────────────────────
-    ttk.Separator(root, orient="horizontal").pack(fill="x", padx=12, pady=6)
-
-    footer = tk.Frame(root)
-    footer.pack(fill="x", padx=12, pady=(0, 12))
-
-    tk.Label(
-        footer, text="Changes save instantly to speakers.json",
-        font=("Helvetica", 8), fg="gray",
-    ).pack(side="left")
-
-    tk.Button(
-        footer, text="Clear Display", fg="red", width=14,
-        command=lambda: state.clear(mqtt_client),
-    ).pack(side="right")
-
-    root.mainloop()
-
-
 # ── Entry point ───────────────────────────────────────────────────────────────
 
 def main() -> None:
@@ -507,9 +375,13 @@ def main() -> None:
     )
     ws_thread.start()
     print(f"[Bridge] Speaker names loaded from {SPEAKERS_FILE}")
-    print("[Bridge] Audio pipeline running — close this window to quit")
+    print("[Bridge] Audio pipeline running — speaker admin at http://localhost:8001")
+    print("[Bridge] Close this window to quit")
 
-    run_speaker_ui(state, mqtt_client)
+    try:
+        ws_thread.join()
+    except KeyboardInterrupt:
+        pass
 
 
 if __name__ == "__main__":

+ 3 - 0
bridge/requirements.txt

@@ -2,3 +2,6 @@ paho-mqtt>=2.0
 websockets>=12.0
 sounddevice>=0.4.6
 numpy>=1.24
+fastapi>=0.111
+uvicorn>=0.29
+python-multipart>=0.0.9

+ 16 - 3
start.bat

@@ -68,14 +68,27 @@ echo Close this window or both others to shut down.
 echo.
 
 :: Activate venv and launch WhisperLiveKit in its own window
-start "Whisper Transcription Server" cmd /k "call .venv\Scripts\activate.bat && set HF_TOKEN=%HF_TOKEN% && echo Starting WhisperLiveKit (%WHISPER_MODEL%) with diarization... && wlk --model %WHISPER_MODEL% --lan en"
+start "Whisper Transcription Server" cmd /k "call .venv\Scripts\activate.bat && set HF_TOKEN=%HF_TOKEN% && echo Starting WhisperLiveKit (%WHISPER_MODEL%)... && wlk --model %WHISPER_MODEL% --lan en"
 
 :: Brief pause so Whisper can begin loading before the bridge connects
 timeout /t 5 /nobreak >nul
 
-:: Activate venv and launch the bridge (speaker UI opens in this process)
+:: Activate venv and launch the bridge (headless audio pipeline)
 start "Transcription Bridge" cmd /k "call .venv\Scripts\activate.bat && echo Starting bridge... && python bridge\bridge.py"
 
-echo Both windows launched. You can minimise this window.
+:: Activate venv and launch the speaker admin web server
+start "Speaker Admin" cmd /k "call .venv\Scripts\activate.bat && echo Starting speaker admin... && python bridge\admin.py"
+
+:: Wait for servers to be ready then open browser tabs
+echo Waiting for servers to start...
+timeout /t 12 /nobreak >nul
+start http://localhost:8000
+start http://localhost:8001
+
+echo.
+echo All three windows must stay open during the service.
+echo.
+echo  http://localhost:8000  ^<-- Whisper web UI ^(verify transcription^)
+echo  http://localhost:8001  ^<-- Speaker admin   ^(manage names + recordings^)
 echo.
 pause