|
@@ -20,6 +20,7 @@ import textwrap
|
|
|
import threading
|
|
import threading
|
|
|
import time
|
|
import time
|
|
|
from collections import Counter
|
|
from collections import Counter
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
|
|
|
import numpy as np
|
|
import numpy as np
|
|
|
import paho.mqtt.client as mqtt
|
|
import paho.mqtt.client as mqtt
|
|
@@ -44,25 +45,77 @@ SENTENCE_TIMEOUT = 4.0 # seconds of silence before forcing a flush
|
|
|
MAX_LINE_CHARS = 38 # characters per line (~24pt font at 800 px wide)
|
|
MAX_LINE_CHARS = 38 # characters per line (~24pt font at 800 px wide)
|
|
|
DISPLAY_LINES = 3
|
|
DISPLAY_LINES = 3
|
|
|
|
|
|
|
|
|
|
+# Set to a device index (integer) to force a specific microphone.
|
|
|
|
|
+# Leave as None to use the Windows default input device.
|
|
|
|
|
+# Run bridge.py once to see available device indices printed at startup.
|
|
|
|
|
+AUDIO_DEVICE: int | None = None
|
|
|
|
|
+
|
|
|
|
|
+SPEAKERS_FILE = Path(__file__).parent / "speakers.json"
|
|
|
|
|
+
|
|
|
|
|
+DEFAULT_SPEAKERS: dict[str, str] = {
|
|
|
|
|
+ "SPEAKER_00": "Pastor",
|
|
|
|
|
+ "SPEAKER_01": "Reader",
|
|
|
|
|
+ "SPEAKER_02": "Guest",
|
|
|
|
|
+ "SPEAKER_03": "Choir",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# ── Speaker persistence ───────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def _load_speakers() -> dict[str, str]:
|
|
|
|
|
+ if SPEAKERS_FILE.exists():
|
|
|
|
|
+ try:
|
|
|
|
|
+ data = json.loads(SPEAKERS_FILE.read_text(encoding="utf-8"))
|
|
|
|
|
+ if isinstance(data, dict):
|
|
|
|
|
+ return data
|
|
|
|
|
+ except (json.JSONDecodeError, OSError):
|
|
|
|
|
+ pass
|
|
|
|
|
+ # First run — seed with defaults and save
|
|
|
|
|
+ _write_speakers(DEFAULT_SPEAKERS)
|
|
|
|
|
+ return dict(DEFAULT_SPEAKERS)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _write_speakers(names: dict[str, str]) -> None:
|
|
|
|
|
+ try:
|
|
|
|
|
+ SPEAKERS_FILE.write_text(
|
|
|
|
|
+ json.dumps(names, indent=2, ensure_ascii=False),
|
|
|
|
|
+ encoding="utf-8",
|
|
|
|
|
+ )
|
|
|
|
|
+ except OSError as exc:
|
|
|
|
|
+ print(f"[Speakers] Save failed: {exc}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
# ── State ─────────────────────────────────────────────────────────────────────
|
|
# ── State ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
class BridgeState:
|
|
class BridgeState:
|
|
|
"""All mutable state, protected by a single lock."""
|
|
"""All mutable state, protected by a single lock."""
|
|
|
|
|
|
|
|
def __init__(self):
|
|
def __init__(self):
|
|
|
- self._lock = threading.Lock()
|
|
|
|
|
- self.speaker_names: dict[str, str] = {} # "SPEAKER_00" → "Pastor"
|
|
|
|
|
|
|
+ self._lock = threading.Lock()
|
|
|
|
|
+ self.speaker_names: dict[str, str] = _load_speakers()
|
|
|
|
|
+ self._seen: set[str] = set(self.speaker_names)
|
|
|
self._current_speaker: str | None = None
|
|
self._current_speaker: str | None = None
|
|
|
self._speaker_changed = False
|
|
self._speaker_changed = False
|
|
|
self._text_buffer = ""
|
|
self._text_buffer = ""
|
|
|
self._display: list[str] = [""] * DISPLAY_LINES
|
|
self._display: list[str] = [""] * DISPLAY_LINES
|
|
|
self._last_final_time = time.monotonic()
|
|
self._last_final_time = time.monotonic()
|
|
|
|
|
|
|
|
- # ── Speaker name mapping ──────────────────────────────────────────────────
|
|
|
|
|
|
|
+ # ── Speaker name management ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
def set_speaker_name(self, speaker_id: str, name: str) -> None:
|
|
def set_speaker_name(self, speaker_id: str, name: str) -> None:
|
|
|
with self._lock:
|
|
with self._lock:
|
|
|
self.speaker_names[speaker_id] = name.strip()
|
|
self.speaker_names[speaker_id] = name.strip()
|
|
|
|
|
+ self._seen.add(speaker_id)
|
|
|
|
|
+ _write_speakers(self.speaker_names)
|
|
|
|
|
+
|
|
|
|
|
+ def delete_speaker(self, speaker_id: str) -> None:
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ self.speaker_names.pop(speaker_id, None)
|
|
|
|
|
+ self._seen.discard(speaker_id)
|
|
|
|
|
+ _write_speakers(self.speaker_names)
|
|
|
|
|
+
|
|
|
|
|
+ def seen_speakers_snapshot(self) -> set[str]:
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ return set(self._seen)
|
|
|
|
|
|
|
|
def _resolve(self, speaker_id: str | None) -> str | None:
|
|
def _resolve(self, speaker_id: str | None) -> str | None:
|
|
|
if not speaker_id:
|
|
if not speaker_id:
|
|
@@ -74,11 +127,13 @@ class BridgeState:
|
|
|
def push_final(self, text: str, speaker_id: str | None, mqtt_client: mqtt.Client) -> None:
|
|
def push_final(self, text: str, speaker_id: str | None, mqtt_client: mqtt.Client) -> None:
|
|
|
"""Accept a finalised segment; flush on sentence boundary or speaker change."""
|
|
"""Accept a finalised segment; flush on sentence boundary or speaker change."""
|
|
|
with self._lock:
|
|
with self._lock:
|
|
|
- resolved = self._resolve(speaker_id)
|
|
|
|
|
|
|
+ if speaker_id:
|
|
|
|
|
+ self._seen.add(speaker_id)
|
|
|
|
|
|
|
|
|
|
+ resolved = self._resolve(speaker_id)
|
|
|
if resolved != self._current_speaker:
|
|
if resolved != self._current_speaker:
|
|
|
if self._text_buffer:
|
|
if self._text_buffer:
|
|
|
- self._flush(mqtt_client) # push previous speaker's words first
|
|
|
|
|
|
|
+ self._flush(mqtt_client)
|
|
|
self._current_speaker = resolved
|
|
self._current_speaker = resolved
|
|
|
self._speaker_changed = True
|
|
self._speaker_changed = True
|
|
|
|
|
|
|
@@ -107,7 +162,6 @@ class BridgeState:
|
|
|
self._speaker_changed = False
|
|
self._speaker_changed = False
|
|
|
|
|
|
|
|
new_lines.extend(textwrap.wrap(text, MAX_LINE_CHARS) or [""])
|
|
new_lines.extend(textwrap.wrap(text, MAX_LINE_CHARS) or [""])
|
|
|
-
|
|
|
|
|
self._display.extend(new_lines)
|
|
self._display.extend(new_lines)
|
|
|
self._display = self._display[-DISPLAY_LINES:]
|
|
self._display = self._display[-DISPLAY_LINES:]
|
|
|
while len(self._display) < DISPLAY_LINES:
|
|
while len(self._display) < DISPLAY_LINES:
|
|
@@ -134,20 +188,13 @@ def _is_sentence_end(text: str) -> bool:
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_speaker(data: dict) -> str | None:
|
|
def _extract_speaker(data: dict) -> str | None:
|
|
|
- """
|
|
|
|
|
- Extract speaker ID from a WhisperLiveKit response dict.
|
|
|
|
|
- Handles segment-level {"speaker": "SPEAKER_00"} and word-level
|
|
|
|
|
- {"words": [{"speaker": "SPEAKER_00", ...}, ...]} formats.
|
|
|
|
|
- """
|
|
|
|
|
if "speaker" in data:
|
|
if "speaker" in data:
|
|
|
return data["speaker"] or None
|
|
return data["speaker"] or None
|
|
|
-
|
|
|
|
|
words = data.get("words", [])
|
|
words = data.get("words", [])
|
|
|
if words:
|
|
if words:
|
|
|
ids = [w.get("speaker") for w in words if w.get("speaker")]
|
|
ids = [w.get("speaker") for w in words if w.get("speaker")]
|
|
|
if ids:
|
|
if ids:
|
|
|
return Counter(ids).most_common(1)[0][0]
|
|
return Counter(ids).most_common(1)[0][0]
|
|
|
-
|
|
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
@@ -173,7 +220,7 @@ def build_mqtt_client() -> mqtt.Client:
|
|
|
# ── WebSocket + audio pipeline ────────────────────────────────────────────────
|
|
# ── WebSocket + audio pipeline ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
async def _sender(ws, queue: asyncio.Queue) -> None:
|
|
async def _sender(ws, queue: asyncio.Queue) -> None:
|
|
|
- while not queue.empty(): # drain stale audio before streaming
|
|
|
|
|
|
|
+ while not queue.empty():
|
|
|
queue.get_nowait()
|
|
queue.get_nowait()
|
|
|
while True:
|
|
while True:
|
|
|
chunk = await queue.get()
|
|
chunk = await queue.get()
|
|
@@ -202,6 +249,48 @@ async def _flusher(state: BridgeState, mqtt_client: mqtt.Client) -> None:
|
|
|
state.maybe_timeout_flush(mqtt_client)
|
|
state.maybe_timeout_flush(mqtt_client)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _choose_audio_device() -> int | None:
|
|
|
|
|
+ """
|
|
|
|
|
+ List all input devices and return the index to use.
|
|
|
|
|
+ Prefers AUDIO_DEVICE if set, otherwise the system default,
|
|
|
|
|
+ otherwise the first device with input channels.
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ devices = sd.query_devices()
|
|
|
|
|
+ default_in = sd.default.device[0] # may be -1 if unset
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ print(f"[Audio] Cannot query devices: {exc}")
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ print("[Audio] Available input devices:")
|
|
|
|
|
+ input_devices: list[tuple[int, str]] = []
|
|
|
|
|
+ for i, dev in enumerate(devices):
|
|
|
|
|
+ if dev["max_input_channels"] > 0:
|
|
|
|
|
+ marker = " ← default" if i == default_in else ""
|
|
|
|
|
+ print(f" [{i}] {dev['name']}{marker}")
|
|
|
|
|
+ input_devices.append((i, dev["name"]))
|
|
|
|
|
+
|
|
|
|
|
+ if not input_devices:
|
|
|
|
|
+ print("[Audio] ERROR: No input devices found. Connect a microphone and restart.")
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ # Explicit override from config
|
|
|
|
|
+ if AUDIO_DEVICE is not None:
|
|
|
|
|
+ print(f"[Audio] Using configured device [{AUDIO_DEVICE}]")
|
|
|
|
|
+ return AUDIO_DEVICE
|
|
|
|
|
+
|
|
|
|
|
+ # System default (if valid)
|
|
|
|
|
+ if default_in >= 0:
|
|
|
|
|
+ print(f"[Audio] Using default input device [{default_in}]")
|
|
|
|
|
+ return default_in
|
|
|
|
|
+
|
|
|
|
|
+ # Fall back to first available input
|
|
|
|
|
+ idx, name = input_devices[0]
|
|
|
|
|
+ print(f"[Audio] No system default set — using [{idx}] {name}")
|
|
|
|
|
+ print("[Audio] To choose a different device, set AUDIO_DEVICE in bridge.py")
|
|
|
|
|
+ return idx
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
async def audio_ws_loop(state: BridgeState, mqtt_client: mqtt.Client) -> None:
|
|
async def audio_ws_loop(state: BridgeState, mqtt_client: mqtt.Client) -> None:
|
|
|
audio_queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=120)
|
|
audio_queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=120)
|
|
|
loop = asyncio.get_running_loop()
|
|
loop = asyncio.get_running_loop()
|
|
@@ -217,7 +306,13 @@ async def audio_ws_loop(state: BridgeState, mqtt_client: mqtt.Client) -> None:
|
|
|
pass
|
|
pass
|
|
|
loop.call_soon_threadsafe(_put)
|
|
loop.call_soon_threadsafe(_put)
|
|
|
|
|
|
|
|
|
|
+ device = _choose_audio_device()
|
|
|
|
|
+ if device is None:
|
|
|
|
|
+ print("[Audio] No input device available — audio pipeline cannot start.")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
with sd.InputStream(
|
|
with sd.InputStream(
|
|
|
|
|
+ device=device,
|
|
|
samplerate=SAMPLE_RATE,
|
|
samplerate=SAMPLE_RATE,
|
|
|
channels=CHANNELS,
|
|
channels=CHANNELS,
|
|
|
dtype="int16",
|
|
dtype="int16",
|
|
@@ -252,93 +347,152 @@ def run_async_loop(state: BridgeState, mqtt_client: mqtt.Client) -> None:
|
|
|
asyncio.run(audio_ws_loop(state, mqtt_client))
|
|
asyncio.run(audio_ws_loop(state, mqtt_client))
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ── Speaker name-mapping UI ───────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-PRESET_SPEAKERS = [
|
|
|
|
|
- ("SPEAKER_00", "Pastor"),
|
|
|
|
|
- ("SPEAKER_01", "Reader"),
|
|
|
|
|
- ("SPEAKER_02", "Guest"),
|
|
|
|
|
- ("SPEAKER_03", "Choir"),
|
|
|
|
|
-]
|
|
|
|
|
-
|
|
|
|
|
|
|
+# ── Speaker UI ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
def run_speaker_ui(state: BridgeState, mqtt_client: mqtt.Client) -> None:
|
|
def run_speaker_ui(state: BridgeState, mqtt_client: mqtt.Client) -> None:
|
|
|
root = tk.Tk()
|
|
root = tk.Tk()
|
|
|
root.title("Transcription Bridge — Speaker Names")
|
|
root.title("Transcription Bridge — Speaker Names")
|
|
|
root.attributes("-topmost", True)
|
|
root.attributes("-topmost", True)
|
|
|
- root.resizable(False, False)
|
|
|
|
|
|
|
+ root.minsize(440, 360)
|
|
|
|
|
+ root.geometry("460x480")
|
|
|
|
|
|
|
|
- tk.Label(root, text="Speaker Name Mapping", font=("Helvetica", 12, "bold")).grid(
|
|
|
|
|
- row=0, column=0, columnspan=3, pady=(12, 2), padx=12
|
|
|
|
|
|
|
+ # ── Header ────────────────────────────────────────────────────────────────
|
|
|
|
|
+ tk.Label(root, text="Speaker Name Mapping", font=("Helvetica", 12, "bold")).pack(
|
|
|
|
|
+ pady=(12, 2)
|
|
|
)
|
|
)
|
|
|
tk.Label(
|
|
tk.Label(
|
|
|
root,
|
|
root,
|
|
|
- text="Diarization is automatic. Assign readable names to each speaker ID.",
|
|
|
|
|
|
|
+ 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",
|
|
font=("Helvetica", 9), fg="gray", justify="center",
|
|
|
- ).grid(row=1, column=0, columnspan=3, pady=(0, 8))
|
|
|
|
|
|
|
+ ).pack(pady=(0, 6))
|
|
|
|
|
|
|
|
- tk.Label(root, text="Speaker ID", font=("Helvetica", 10, "bold")).grid(row=2, column=0, padx=8)
|
|
|
|
|
- tk.Label(root, text="Friendly Name", font=("Helvetica", 10, "bold")).grid(row=2, column=1, padx=8)
|
|
|
|
|
|
|
+ # ── Scrollable list ───────────────────────────────────────────────────────
|
|
|
|
|
+ list_outer = tk.Frame(root, relief="sunken", bd=1)
|
|
|
|
|
+ list_outer.pack(fill="both", expand=True, padx=12, pady=(0, 4))
|
|
|
|
|
|
|
|
- entries: list[tuple[str, tk.Entry]] = []
|
|
|
|
|
- for i, (sid, default) in enumerate(PRESET_SPEAKERS):
|
|
|
|
|
- tk.Label(root, text=sid, font=("Courier", 10)).grid(row=3+i, column=0, sticky="e", padx=8, pady=3)
|
|
|
|
|
- e = tk.Entry(root, width=16, font=("Helvetica", 10))
|
|
|
|
|
- e.insert(0, default)
|
|
|
|
|
- e.grid(row=3+i, column=1, padx=8, pady=3)
|
|
|
|
|
- entries.append((sid, e))
|
|
|
|
|
|
|
+ canvas = tk.Canvas(list_outer, highlightthickness=0)
|
|
|
|
|
+ scrollbar = ttk.Scrollbar(list_outer, orient="vertical", command=canvas.yview)
|
|
|
|
|
+ rows_frame = tk.Frame(canvas)
|
|
|
|
|
|
|
|
- def _apply(s=sid, entry=e):
|
|
|
|
|
- state.set_speaker_name(s, entry.get())
|
|
|
|
|
- print(f"[UI] {s} → {entry.get()!r}")
|
|
|
|
|
|
|
+ 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)
|
|
|
|
|
|
|
|
- tk.Button(root, text="Apply", command=_apply, width=6).grid(row=3+i, column=2, padx=6)
|
|
|
|
|
|
|
+ canvas.pack(side="left", fill="both", expand=True)
|
|
|
|
|
+ scrollbar.pack(side="right", fill="y")
|
|
|
|
|
|
|
|
- ttk.Separator(root, orient="horizontal").grid(
|
|
|
|
|
- row=7, column=0, columnspan=3, sticky="ew", padx=8, pady=8
|
|
|
|
|
|
|
+ canvas.bind_all(
|
|
|
|
|
+ "<MouseWheel>",
|
|
|
|
|
+ lambda e: canvas.yview_scroll(int(-1 * e.delta / 120), "units"),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- # Custom ID row
|
|
|
|
|
- tk.Label(root, text="Custom ID:").grid(row=8, column=0, sticky="e", padx=8)
|
|
|
|
|
- cid = tk.Entry(root, width=14, font=("Courier", 10))
|
|
|
|
|
- cid.insert(0, "SPEAKER_04")
|
|
|
|
|
- cid.grid(row=8, column=1, sticky="w", padx=8, pady=2)
|
|
|
|
|
|
|
+ # 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)
|
|
|
|
|
|
|
|
- tk.Label(root, text="Name:").grid(row=9, column=0, sticky="e", padx=8)
|
|
|
|
|
- cname = tk.Entry(root, width=14, font=("Helvetica", 10))
|
|
|
|
|
- cname.grid(row=9, column=1, sticky="w", padx=8, pady=2)
|
|
|
|
|
|
|
+ saved_lbl = tk.Label(row, text="", font=("Helvetica", 8), fg="#2a7a2a", width=5)
|
|
|
|
|
+ saved_lbl.pack(side="left")
|
|
|
|
|
|
|
|
- def _apply_custom():
|
|
|
|
|
- s, n = cid.get().strip(), cname.get().strip()
|
|
|
|
|
- if s and n:
|
|
|
|
|
|
|
+ def _save(s=sid, e=entry, lbl=saved_lbl):
|
|
|
|
|
+ n = e.get().strip()
|
|
|
|
|
+ if not n:
|
|
|
|
|
+ return
|
|
|
state.set_speaker_name(s, n)
|
|
state.set_speaker_name(s, n)
|
|
|
- print(f"[UI] Custom: {s} → {n!r}")
|
|
|
|
|
|
|
+ lbl.config(text="Saved")
|
|
|
|
|
+ row.after(2000, lambda: lbl.config(text=""))
|
|
|
|
|
+ print(f"[UI] {s} → {n!r}")
|
|
|
|
|
|
|
|
- tk.Button(root, text="Apply", command=_apply_custom, width=6).grid(row=9, column=2, padx=6)
|
|
|
|
|
|
|
+ def _delete(s=sid, r=row):
|
|
|
|
|
+ state.delete_speaker(s)
|
|
|
|
|
+ rendered_sids.discard(s)
|
|
|
|
|
+ r.destroy()
|
|
|
|
|
+ print(f"[UI] Removed {s}")
|
|
|
|
|
|
|
|
- ttk.Separator(root, orient="horizontal").grid(
|
|
|
|
|
- row=10, column=0, columnspan=3, sticky="ew", padx=8, pady=8
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ entry.bind("<Return>", lambda _e, s=sid, e=entry, lbl=saved_lbl: _save(s, e, lbl))
|
|
|
|
|
|
|
|
- def _apply_all():
|
|
|
|
|
- for sid, entry in entries:
|
|
|
|
|
- state.set_speaker_name(sid, entry.get())
|
|
|
|
|
- print("[UI] All names applied")
|
|
|
|
|
|
|
+ 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")
|
|
|
|
|
|
|
|
- tk.Button(root, text="Apply All Names", width=18, command=_apply_all).grid(
|
|
|
|
|
- row=11, column=0, columnspan=2, padx=8, pady=4, sticky="w"
|
|
|
|
|
- )
|
|
|
|
|
- tk.Button(root, text="Clear Display", width=14, fg="red",
|
|
|
|
|
- command=lambda: state.clear(mqtt_client)).grid(
|
|
|
|
|
- row=11, column=2, padx=8, pady=4
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ # Populate from persisted state (sorted so order is stable)
|
|
|
|
|
+ for sid, name in sorted(state.speaker_names.items()):
|
|
|
|
|
+ _add_row(sid, name)
|
|
|
|
|
|
|
|
- tk.Label(root, text="Speaker labels appear on the display when the speaker changes.",
|
|
|
|
|
- font=("Helvetica", 8), fg="gray").grid(
|
|
|
|
|
- row=12, column=0, columnspan=3, pady=(0, 10)
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ # 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")
|
|
|
|
|
|
|
|
- _apply_all() # activate defaults immediately
|
|
|
|
|
root.mainloop()
|
|
root.mainloop()
|
|
|
|
|
|
|
|
|
|
|
|
@@ -352,6 +506,7 @@ def main() -> None:
|
|
|
target=run_async_loop, args=(state, mqtt_client), daemon=True
|
|
target=run_async_loop, args=(state, mqtt_client), daemon=True
|
|
|
)
|
|
)
|
|
|
ws_thread.start()
|
|
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 — close this window to quit")
|
|
|
|
|
|
|
|
run_speaker_ui(state, mqtt_client)
|
|
run_speaker_ui(state, mqtt_client)
|