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:
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user