Desktop GUI: Library tab with live sync to the server

- 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
This commit is contained in:
2026-09-08 21:37:17 +02:00
parent c88af68fd9
commit 76cf992373
3 changed files with 330 additions and 3 deletions
+13
View File
@@ -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
+36
View File
@@ -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."""
+281 -3
View File
@@ -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")