From 76cf99237309272905f80b238ac95961bdb274f9 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Tue, 8 Sep 2026 21:37:17 +0200 Subject: [PATCH] Desktop GUI: Library tab with live sync to the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new Record/Library tabs (like the mobile app); the recording UI is unchanged inside the Record tab - Library tab: recording list (date, duration, language, device), detail view with summary (scrollable, re-triggerable), agenda checklist with coverage marks, and the full timestamped transcript; Library URL field (defaults to MEETREC_LIBRARY_URL) - sync: auto-refresh every 10 s — recordings made on the phone appear on the desktop while the tab is open; pending summaries update to done automatically and re-fetch - shared library client in meetrec.py: library_list/library_get_file/ library_trigger_summary - validated headlessly (offscreen) against the live server: 6 recordings from phone and desktop listed, 68-min meeting detail with 1333-char summary and ~100k-char transcript --- README.md | 13 +++ meetrec.py | 36 +++++++ meetrec_gui.py | 284 ++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 330 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4b338da..6b9e2e0 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,19 @@ MEETREC_DIARIZE_URL=http://100.103.83.12:8086 \ - `--agenda-file` supplies the agenda items (one per line). - `--diarize-url` adds a second tinydiarize pass whose speaker-turn times are merged onto the transcript as `Sprecher 1:/Sprecher 2:` labels (2 speakers, best-effort on non-English audio; failures keep the transcript unlabeled). - The GUI has the same options as fields (Library URL, Diarize URL, agenda editor). + +## Library tab (GUI) + +The desktop GUI has **Record** and **Library** tabs like the mobile app. The +Library tab browses the server's recordings with summary, agenda coverage +and the full transcript, and **auto-refreshes every 10 s** — record on the +phone, and the meeting appears on the desktop while the tab is open: + +```sh +MEETREC_LIBRARY_URL=http://100.103.83.12:8090 python meetrec_gui.py +``` + +(The URL can also be typed into the tab's Library URL field.) - `--device` / `--compute-type` do not apply (GPU vs. CPU is decided by the whisper.cpp build). ## Project layout diff --git a/meetrec.py b/meetrec.py index c752f1c..aabd909 100644 --- a/meetrec.py +++ b/meetrec.py @@ -693,6 +693,42 @@ def upload_recording(library_url: str, wav_path: str, out_stem: str, f"{e.reason}") from None +def library_list(library_url: str): + """List the recording library (newest first); dicts match the + meetrec-server index (id, started_at, duration_ms, language, device, + agenda, files, summary, agenda_status, agenda_results).""" + url = library_url.rstrip("/") + "/api/recordings" + try: + with urllib.request.urlopen(url, timeout=15) as r: + return json.load(r) + except urllib.error.URLError as e: + raise RuntimeError(f"cannot reach library at {library_url}: {e.reason}") from None + + +def library_get_file(library_url: str, rid: str, name: str) -> str: + """Fetch one stored file (meeting.txt/json, summary.md, agenda.json).""" + url = library_url.rstrip("/") + f"/api/recordings/{rid}/files/{name}" + try: + with urllib.request.urlopen(url, timeout=60) as r: + return r.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + raise RuntimeError(f"library returned {e.code} for {name}") from None + except urllib.error.URLError as e: + raise RuntimeError(f"cannot reach library at {library_url}: {e.reason}") from None + + +def library_trigger_summary(library_url: str, rid: str, force: bool = False) -> None: + """Ask the server to (re)generate the summary via Ollama.""" + url = (library_url.rstrip("/") + f"/api/recordings/{rid}/summary" + + ("?force=true" if force else "")) + req = urllib.request.Request(url, data=b"", method="POST") + try: + with urllib.request.urlopen(req, timeout=30) as r: + r.read() + except urllib.error.URLError as e: + raise RuntimeError(f"cannot reach library at {library_url}: {e.reason}") from None + + def load_engine(model_name, engine="faster-whisper", device="auto", compute_type="int8", server_url=None): """Create a transcription engine. See the Engines section above.""" diff --git a/meetrec_gui.py b/meetrec_gui.py index 0cfdd03..63a9371 100644 --- a/meetrec_gui.py +++ b/meetrec_gui.py @@ -15,6 +15,7 @@ Run: python meetrec_gui.py """ +import json import queue import sys import threading @@ -23,8 +24,9 @@ from types import SimpleNamespace from PySide6.QtCore import Qt, QThread, QTimer, Signal from PySide6.QtWidgets import ( QApplication, QCheckBox, QComboBox, QHBoxLayout, QLabel, QLineEdit, - QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, - QTextEdit, QVBoxLayout, QWidget, + QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit, + QProgressBar, QPushButton, QSpinBox, QSplitter, QTabWidget, QTextEdit, + QVBoxLayout, QWidget, ) import sounddevice as sd @@ -141,7 +143,9 @@ class MeetRecWindow(QWidget): # ------------------------------ UI layout ----------------------------- def _build_ui(self): - root = QVBoxLayout(self) + self.tabs = QTabWidget() + record_tab = QWidget() + root = QVBoxLayout(record_tab) # row 1: microphone source r1 = QHBoxLayout() @@ -302,6 +306,14 @@ class MeetRecWindow(QWidget): bar.addWidget(clear) root.addLayout(bar) + self.tabs.addTab(record_tab, "Record") + self.library_tab = LibraryTab() + self.tabs.addTab(self.library_tab, "Library") + self.tabs.currentChanged.connect( + lambda i: self.library_tab.refresh() if i == 1 else None) + outer = QVBoxLayout(self) + outer.addWidget(self.tabs) + # ------------------------------ helpers ------------------------------- def _fill_sources(self): @@ -550,6 +562,272 @@ class MeetRecWindow(QWidget): ev.accept() +class LibraryWorker(QThread): + """One-shot library operation (list / detail / summary trigger).""" + + done = Signal(str, object) + failed = Signal(str, str) + + def __init__(self, op, url, rid=None, force=False, parent=None): + super().__init__(parent) + self.op = op + self.url = url + self.rid = rid + self.force = force + + def run(self): + try: + if self.op == "list": + self.done.emit("list", meetrec.library_list(self.url)) + elif self.op == "detail": + it = next(x for x in meetrec.library_list(self.url) + if x["id"] == self.rid) + json_text = None + if "meeting.json" in it.get("files", []): + json_text = meetrec.library_get_file( + self.url, self.rid, "meeting.json") + summary_text = None + if it.get("summary") == "done": + summary_text = meetrec.library_get_file( + self.url, self.rid, "summary.md") + elif isinstance(it.get("summary"), str) and \ + it["summary"].startswith("error"): + summary_text = it["summary"] + self.done.emit("detail", (it, json_text, summary_text)) + elif self.op == "summary": + meetrec.library_trigger_summary(self.url, self.rid, + force=self.force) + self.done.emit("summary", self.rid) + except Exception as e: + self.failed.emit(self.op, str(e)) + + +class LibraryTab(QWidget): + """Server library browser: recordings with summary, agenda coverage + and transcript. Auto-refreshes every 10 s, so recordings made on the + phone show up here while the tab is open (the desktop 'sync').""" + + def __init__(self): + super().__init__() + self.url = os.environ.get("MEETREC_LIBRARY_URL", "") + self.items = [] + self.current_id = None + self._worker = None + self._summary_shown = None # summary status last rendered for current + self._build_ui() + + self.poll = QTimer(self) + self.poll.setInterval(10_000) + self.poll.timeout.connect(self.refresh) + self.poll.start() + self.refresh() + + def _build_ui(self): + root = QVBoxLayout(self) + + top = QHBoxLayout() + refresh = QPushButton("Refresh") + refresh.clicked.connect(self.refresh) + self.auto_cb = QCheckBox("auto (10 s)") + self.auto_cb.setChecked(True) + self.auto_cb.toggled.connect(self._auto_toggled) + self.lib_status = QLabel("") + top.addWidget(refresh) + top.addWidget(self.auto_cb) + top.addStretch(1) + top.addWidget(self.lib_status) + root.addLayout(top) + + self.url_edit = QLineEdit(self.url) + self.url_edit.setPlaceholderText("Library URL (meetrec-server)") + self.url_edit.editingFinished.connect(self._url_changed) + root.addWidget(self.url_edit) + + split = QSplitter(Qt.Horizontal) + self.listw = QListWidget() + self.listw.currentItemChanged.connect(self._on_select) + split.addWidget(self.listw) + + detail = QWidget() + d = QVBoxLayout(detail) + d.setSpacing(6) + self.d_title = QLabel("") + self.d_title.setStyleSheet("font-size: 15px; font-weight: bold;") + self.d_meta = QLabel("") + d.addWidget(self.d_title) + d.addWidget(self.d_meta) + + d.addWidget(QLabel("Zusammenfassung")) + self.d_summary = QTextEdit() + self.d_summary.setReadOnly(True) + self.d_summary.setMaximumHeight(180) + d.addWidget(self.d_summary) + regen = QPushButton("Zusammenfassung neu erstellen") + regen.clicked.connect(self._regenerate) + d.addWidget(regen) + + d.addWidget(QLabel("Agenda")) + self.d_agenda = QListWidget() + self.d_agenda.setMaximumHeight(150) + d.addWidget(self.d_agenda) + + d.addWidget(QLabel("Transkript")) + self.d_transcript = QTextEdit() + self.d_transcript.setReadOnly(True) + d.addWidget(self.d_transcript, 1) + + split.addWidget(detail) + split.setSizes([320, 780]) + root.addWidget(split, 1) + + # ------------------------------ actions ------------------------------ + + def _auto_toggled(self, on): + if on: + self.poll.start(10_000) + else: + self.poll.stop() + + def _url_changed(self): + self.url = self.url_edit.text().strip() + if self.url: + self.refresh() + + def refresh(self): + if not self.url or (self._worker and self._worker.isRunning()): + return + self._worker = LibraryWorker("list", self.url, parent=self) + self._worker.done.connect(self._on_list) + self._worker.failed.connect( + lambda tag, msg: self.lib_status.setText(f"error: {msg}")) + self._worker.start() + + def _on_list(self, tag, items): + self.items = items or [] + self.lib_status.setText(f"{len(self.items)} recording(s)") + self.listw.blockSignals(True) + self.listw.clear() + keep_row = -1 + for row, it in enumerate(self.items): + self.listw.addItem(QListWidgetItem(self._label(it))) + self.listw.item(row).setData(Qt.UserRole, it["id"]) + if it["id"] == self.current_id: + keep_row = row + if keep_row >= 0: + self.listw.setCurrentRow(keep_row) + self.listw.blockSignals(False) + + if self.current_id is None and self.listw.count() > 0: + self.listw.setCurrentRow(0) # newest, like the sync feel + self._on_select(self.listw.item(0), None) + + # detail refresh when the selected recording's summary state changed + cur = self._current_item() + if cur and cur.get("summary") != self._summary_shown: + self._load_detail() + + def _label(self, it): + started = it.get("started_at", "") + stamp = (f"{started[0:4]}-{started[4:6]}-{started[6:8]} " + f"{started[9:11]}:{started[11:13]}") if len(started) >= 13 \ + else started + dur = fmt_hms(it.get("duration_ms", 0) / 1000) + bits = [stamp, dur] + if it.get("language"): + bits.append(it["language"]) + if it.get("device"): + bits.append(it["device"]) + return " · ".join(bits) + + def _current_item(self): + return next((x for x in self.items if x["id"] == self.current_id), None) + + def _on_select(self, current, _previous): + if current is None: + return + rid = current.data(Qt.UserRole) + if rid != self.current_id: + self.current_id = rid + self._summary_shown = None + self.d_transcript.setPlainText("") + self.d_summary.setPlainText("") + self._load_detail() + + def _load_detail(self): + if not self.current_id: + return + cur = self._current_item() + if cur: + self._summary_shown = cur.get("summary") + self.d_title.setText(self._label(cur)) + self.d_meta.setText(self._meta_line(cur)) + self._render_agenda(cur) + if cur.get("summary") == "pending": + self.d_summary.setPlainText("wird erstellt …") + if self._worker and self._worker.isRunning(): + return + self._worker = LibraryWorker("detail", self.url, rid=self.current_id, + parent=self) + self._worker.done.connect(self._on_detail) + self._worker.failed.connect( + lambda tag, msg: self.lib_status.setText(f"error: {msg}")) + self._worker.start() + + def _meta_line(self, it): + parts = [] + if it.get("summary") in (None, "pending"): + parts.append("Zusammenfassung: " + + ("wird erstellt …" if it.get("summary") else "—")) + n = len(it.get("agenda") or []) + if n: + parts.append(f"Agenda: {n} Punkte") + return " ".join(parts) + + def _on_detail(self, tag, payload): + it, json_text, summary_text = payload + if it["id"] != self.current_id: + return # selection moved on + if summary_text: + self.d_summary.setPlainText(summary_text) + if json_text: + try: + segs = json.loads(json_text).get("segments", []) + self.d_transcript.setPlainText("\n".join( + f"[{fmt_hms(s['start'])}] {s['text'].strip()}" + for s in segs if s.get("text", "").strip())) + except ValueError: + self.d_transcript.setPlainText("(transcript unavailable)") + + def _render_agenda(self, it): + self.d_agenda.clear() + results = it.get("agenda_results") or [] + if results and "error" not in (results[0] or {}): + for r in results: + mark = "✓" if r.get("covered") else "✗" + line = f"{mark} {r.get('item', '')}" + if r.get("time"): + line += f" ({r['time']})" + self.d_agenda.addItem(QListWidgetItem(line)) + elif it.get("agenda"): + for a in it["agenda"]: + self.d_agenda.addItem(QListWidgetItem(f"• {a}")) + + def _regenerate(self): + if not self.current_id: + return + cur = self._current_item() + if cur: + cur["summary"] = "pending" + self._summary_shown = None + self._load_detail() + self._worker = LibraryWorker("summary", self.url, rid=self.current_id, + force=True, parent=self) + self._worker.done.connect(lambda _t, _p: None) + self._worker.failed.connect( + lambda tag, msg: self.lib_status.setText(f"error: {msg}")) + self._worker.start() + + def main(): app = QApplication(sys.argv) app.setApplicationName("MeetRec")