76cf992373
- 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
841 lines
31 KiB
Python
841 lines
31 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
meetrec_gui.py — Qt (PySide6) GUI for meetrec.
|
|
|
|
Records a meeting from any PipeWire/ALSA input, shows a live rolling
|
|
transcript while recording, and on Stop runs the final full transcription.
|
|
Reuses all recording/transcription logic from meetrec.py (the CLI).
|
|
|
|
Setup (Arch):
|
|
sudo pacman -S python pipewire pipewire-pulse python-pyside6
|
|
# or: pip install pyside6
|
|
# (plus the usual deps: pip install numpy sounddevice faster-whisper)
|
|
|
|
Run:
|
|
python meetrec_gui.py
|
|
"""
|
|
|
|
import json
|
|
import queue
|
|
import sys
|
|
import threading
|
|
from types import SimpleNamespace
|
|
|
|
from PySide6.QtCore import Qt, QThread, QTimer, Signal
|
|
from PySide6.QtWidgets import (
|
|
QApplication, QCheckBox, QComboBox, QHBoxLayout, QLabel, QLineEdit,
|
|
QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit,
|
|
QProgressBar, QPushButton, QSpinBox, QSplitter, QTabWidget, QTextEdit,
|
|
QVBoxLayout, QWidget,
|
|
)
|
|
|
|
import sounddevice as sd
|
|
|
|
import meetrec
|
|
import os
|
|
from meetrec import (
|
|
Recorder,
|
|
final_transcribe,
|
|
fmt_hms,
|
|
live_worker,
|
|
resolve_source,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Worker threads
|
|
# --------------------------------------------------------------------------
|
|
|
|
class ModelLoader(QThread):
|
|
"""Loads (and on first run downloads) the Whisper model off the GUI thread."""
|
|
|
|
loaded = Signal(object)
|
|
failed = Signal(str)
|
|
|
|
def __init__(self, model_name, engine="faster-whisper", device="auto",
|
|
compute_type="int8", server_url=None, parent=None):
|
|
super().__init__(parent)
|
|
self.model_name = model_name
|
|
self.engine = engine
|
|
self.device = device
|
|
self.compute_type = compute_type
|
|
self.server_url = server_url
|
|
|
|
def run(self):
|
|
try:
|
|
self.model = meetrec.load_engine(
|
|
self.model_name, engine=self.engine,
|
|
device=self.device, compute_type=self.compute_type,
|
|
server_url=self.server_url)
|
|
self.loaded.emit(self.model)
|
|
except Exception as e:
|
|
self.failed.emit(str(e))
|
|
|
|
|
|
class FinalWorker(QThread):
|
|
"""Runs the final full-pass transcription without blocking the GUI."""
|
|
|
|
info = Signal(str)
|
|
segment = Signal(float, float, str)
|
|
done = Signal(str) # output stem
|
|
failed = Signal(str)
|
|
|
|
def __init__(self, model, wav_path, out_stem, language,
|
|
diarize_url=None, agenda=None, library_url=None, parent=None):
|
|
super().__init__(parent)
|
|
self.model = model
|
|
self.wav_path = wav_path
|
|
self.out_stem = out_stem
|
|
self.language = language
|
|
self.diarize_url = diarize_url
|
|
self.agenda = agenda
|
|
self.library_url = library_url
|
|
|
|
def run(self):
|
|
try:
|
|
final_transcribe(
|
|
self.model, self.wav_path, self.out_stem, self.language,
|
|
on_info=self.info.emit,
|
|
on_segment=self.segment.emit,
|
|
on_done=lambda: self.done.emit(self.out_stem),
|
|
diarize_url=self.diarize_url,
|
|
agenda=self.agenda,
|
|
library_url=self.library_url,
|
|
)
|
|
except Exception as e:
|
|
self.failed.emit(str(e))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Main window
|
|
# --------------------------------------------------------------------------
|
|
|
|
class MeetRecWindow(QWidget):
|
|
MODELS = ["tiny", "base", "small", "medium", "large-v3"]
|
|
LANGS = ["autodetect", "en", "de", "fr", "es", "it", "nl", "pl",
|
|
"pt", "ru", "zh", "ja", "ko"]
|
|
DEVICES = ["auto", "cpu", "cuda"]
|
|
COMPUTE_TYPES = ["int8", "int8_float16", "float16", "float32"]
|
|
ENGINES = ["faster-whisper", "whisper-cpp", "whisper-server"]
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle("MeetRec — record & transcribe meetings")
|
|
self.setMinimumSize(700, 600)
|
|
|
|
self.model = None
|
|
self.model_key = None # (engine, model, device, compute) loaded
|
|
self.rec = None
|
|
self.state = "idle" # idle | loading | record | finalizing
|
|
self.decay = 0.0
|
|
self.stop_evt = threading.Event()
|
|
self.line_q: "queue.Queue" = queue.Queue()
|
|
self._final_header = False
|
|
self._args = None
|
|
|
|
self._build_ui()
|
|
|
|
self.ui_timer = QTimer(self)
|
|
self.ui_timer.setInterval(120)
|
|
self.ui_timer.timeout.connect(self._tick)
|
|
self.ui_timer.start()
|
|
|
|
# ------------------------------ UI layout -----------------------------
|
|
|
|
def _build_ui(self):
|
|
self.tabs = QTabWidget()
|
|
record_tab = QWidget()
|
|
root = QVBoxLayout(record_tab)
|
|
|
|
# row 1: microphone source
|
|
r1 = QHBoxLayout()
|
|
r1.addWidget(QLabel("Microphone:"))
|
|
self.src = QComboBox()
|
|
self.src.setEditable(True) # free text = name substring, like the CLI
|
|
self.src.addItem("default")
|
|
self.src.setMinimumWidth(280)
|
|
self._fill_sources()
|
|
refresh = QPushButton("\u21bb")
|
|
refresh.setFixedWidth(36)
|
|
refresh.setToolTip("Rescan input devices")
|
|
refresh.clicked.connect(self._fill_sources)
|
|
r1.addWidget(self.src, 1)
|
|
r1.addWidget(refresh)
|
|
root.addLayout(r1)
|
|
|
|
# row 2: model + language
|
|
r2 = QHBoxLayout()
|
|
self.model_lbl = QLabel("Model:")
|
|
r2.addWidget(self.model_lbl)
|
|
self.model_cb = QComboBox()
|
|
self.model_cb.addItems(self.MODELS)
|
|
self.model_cb.setCurrentText("small")
|
|
r2.addWidget(self.model_cb)
|
|
r2.addStretch(1)
|
|
r2.addWidget(QLabel("Language:"))
|
|
self.lang = QComboBox()
|
|
self.lang.setEditable(True)
|
|
self.lang.addItems(self.LANGS)
|
|
self.lang.setCurrentText("autodetect")
|
|
self.lang.setMinimumWidth(110)
|
|
r2.addWidget(self.lang)
|
|
root.addLayout(r2)
|
|
|
|
# row 2b: engine + compute device + precision
|
|
r2b = QHBoxLayout()
|
|
r2b.addWidget(QLabel("Engine:"))
|
|
self.engine_cb = QComboBox()
|
|
self.engine_cb.addItems(self.ENGINES)
|
|
self.engine_cb.setCurrentText("faster-whisper")
|
|
self.engine_cb.activated.connect(self._engine_changed)
|
|
self.engine_cb.setToolTip("faster-whisper: NVIDIA CUDA or CPU.\n"
|
|
"whisper-cpp: GPU via Vulkan (AMD, Intel, "
|
|
"NVIDIA) or CPU — preferred on AMD.")
|
|
r2b.addWidget(self.engine_cb)
|
|
self.device_lbl = QLabel("Device:")
|
|
r2b.addWidget(self.device_lbl)
|
|
self.device_cb = QComboBox()
|
|
self.device_cb.addItems(self.DEVICES)
|
|
self.device_cb.setCurrentText("auto")
|
|
r2b.addWidget(self.device_cb)
|
|
self.compute_lbl = QLabel("Compute:")
|
|
r2b.addWidget(self.compute_lbl)
|
|
self.compute_cb = QComboBox()
|
|
self.compute_cb.addItems(self.COMPUTE_TYPES)
|
|
self.compute_cb.setCurrentText("int8")
|
|
r2b.addWidget(self.compute_cb)
|
|
r2b.addStretch(1)
|
|
root.addLayout(r2b)
|
|
|
|
# row 2c: whisper-server URL
|
|
r2c = QHBoxLayout()
|
|
self.server_lbl = QLabel("Server:")
|
|
r2c.addWidget(self.server_lbl)
|
|
self.server_url = QLineEdit("http://100.103.83.12:8085")
|
|
self.server_url.setToolTip(
|
|
"whisper.cpp server (see server/whisper-server/) — "
|
|
"the model lives on the server")
|
|
r2c.addWidget(self.server_url, 1)
|
|
root.addLayout(r2c)
|
|
|
|
# row 2d: library + diarization servers
|
|
r2d = QHBoxLayout()
|
|
self.library_url = QLineEdit(os.environ.get("MEETREC_LIBRARY_URL", ""))
|
|
self.library_url.setPlaceholderText("Library URL (meetrec-server)")
|
|
self.diarize_url = QLineEdit(os.environ.get("MEETREC_DIARIZE_URL", ""))
|
|
self.diarize_url.setPlaceholderText("Diarize URL (Sprecher 1/2)")
|
|
r2d.addWidget(self.library_url, 1)
|
|
r2d.addWidget(self.diarize_url, 1)
|
|
root.addLayout(r2d)
|
|
|
|
# agenda items (one per line), used for the server-side coverage check
|
|
self.agenda_edit = QPlainTextEdit()
|
|
self.agenda_edit.setPlaceholderText("Agenda (one item per line)")
|
|
self.agenda_edit.setFixedHeight(64)
|
|
root.addWidget(self.agenda_edit)
|
|
|
|
# row 3: live options + output name
|
|
r3 = QHBoxLayout()
|
|
r3.addWidget(QLabel("Live:"))
|
|
self.live_cb = QCheckBox("transcribe while recording every")
|
|
self.live_cb.setChecked(True)
|
|
r3.addWidget(self.live_cb)
|
|
self.live_spin = QSpinBox()
|
|
self.live_spin.setRange(3, 60)
|
|
self.live_spin.setValue(8)
|
|
self.live_spin.setSuffix(" s")
|
|
r3.addWidget(self.live_spin)
|
|
r3.addStretch(1)
|
|
r3.addWidget(QLabel("Output:"))
|
|
self.out = QLineEdit("meeting")
|
|
self.out.setPlaceholderText("file stem, e.g. meeting")
|
|
self.out.setMinimumWidth(160)
|
|
r3.addWidget(self.out)
|
|
root.addLayout(r3)
|
|
|
|
# row 4: record / stop
|
|
r4 = QHBoxLayout()
|
|
self.record_btn = QPushButton("\u23fa Record")
|
|
self.record_btn.setMinimumHeight(44)
|
|
self.record_btn.setStyleSheet(
|
|
"QPushButton { background: #1a7f37; color: white; font-weight: bold; }"
|
|
"QPushButton:disabled { background: #888; }")
|
|
self.record_btn.clicked.connect(self.on_record)
|
|
self.stop_btn = QPushButton("\u23f9 Stop & transcribe")
|
|
self.stop_btn.setMinimumHeight(44)
|
|
self.stop_btn.setEnabled(False)
|
|
self.stop_btn.setStyleSheet(
|
|
"QPushButton { background: #c0392b; color: white; font-weight: bold; }"
|
|
"QPushButton:disabled { background: #888; }")
|
|
self.stop_btn.clicked.connect(self.on_stop)
|
|
r4.addWidget(self.record_btn, 1)
|
|
r4.addWidget(self.stop_btn, 1)
|
|
root.addLayout(r4)
|
|
|
|
# row 5: level meter + elapsed
|
|
r5 = QHBoxLayout()
|
|
self.level = QProgressBar()
|
|
self.level.setRange(0, 100)
|
|
self.level.setTextVisible(False)
|
|
self.level.setFixedHeight(10)
|
|
self.level.setStyleSheet(
|
|
"QProgressBar::chunk { background: #2ea043; }")
|
|
self.elapsed = QLabel("00:00:00")
|
|
self.elapsed.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
self.elapsed.setMinimumWidth(80)
|
|
self.elapsed.setStyleSheet("font-size: 15px; font-weight: bold;")
|
|
r5.addWidget(self.level, 1)
|
|
r5.addWidget(self.elapsed)
|
|
root.addLayout(r5)
|
|
|
|
# transcript
|
|
self.transcript = QTextEdit()
|
|
self.transcript.setReadOnly(True)
|
|
self.transcript.setPlaceholderText(
|
|
"Live transcript appears here while recording.\n"
|
|
"After stopping, the final (higher-quality) transcript is shown "
|
|
"and saved as .txt / .srt / .json next to the .wav.")
|
|
root.addWidget(self.transcript, 1)
|
|
|
|
# status bar
|
|
bar = QHBoxLayout()
|
|
self.status = QLabel("")
|
|
bar.addWidget(self.status, 1)
|
|
clear = QPushButton("Clear transcript")
|
|
clear.clicked.connect(lambda: self.transcript.clear())
|
|
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):
|
|
keep = self.src.currentText()
|
|
self.src.blockSignals(True)
|
|
self.src.clear()
|
|
self.src.addItem("default")
|
|
seen = set()
|
|
try:
|
|
for d in sd.query_devices():
|
|
if d["max_input_channels"] > 0 and d["name"] not in seen:
|
|
seen.add(d["name"])
|
|
self.src.addItem(d["name"])
|
|
except Exception:
|
|
pass
|
|
self.src.blockSignals(False)
|
|
idx = self.src.findText(keep)
|
|
if idx >= 0:
|
|
self.src.setCurrentIndex(idx)
|
|
else:
|
|
self.src.setCurrentText(keep)
|
|
|
|
def _engine_changed(self, _idx):
|
|
# What applies per engine:
|
|
# model: faster-whisper + whisper-cpp (server owns the
|
|
# model otherwise)
|
|
# device/compute: faster-whisper only (CTranslate2)
|
|
# server url: whisper-server only
|
|
engine = self.engine_cb.currentText()
|
|
has_model = engine != "whisper-server"
|
|
has_dc = engine == "faster-whisper"
|
|
self.model_lbl.setText("Model:" if has_model else "Model (n/a):")
|
|
self.device_lbl.setText("Device:" if has_dc else "Device (n/a):")
|
|
self.compute_lbl.setText("Compute:" if has_dc else "Compute (n/a):")
|
|
self.model_cb.setEnabled(has_model)
|
|
self.device_cb.setEnabled(has_dc)
|
|
self.compute_cb.setEnabled(has_dc)
|
|
self.server_url.setEnabled(engine == "whisper-server")
|
|
|
|
def _set_inputs_enabled(self, enabled: bool):
|
|
for w in (self.src, self.model_cb, self.engine_cb, self.device_cb,
|
|
self.compute_cb, self.server_url, self.lang, self.live_cb,
|
|
self.live_spin, self.out):
|
|
w.setEnabled(enabled)
|
|
if enabled:
|
|
self._engine_changed(-1) # whisper-cpp: keep device/compute off
|
|
|
|
def _to_idle(self):
|
|
self.state = "idle"
|
|
self._set_inputs_enabled(True)
|
|
self.record_btn.setEnabled(True)
|
|
self.stop_btn.setEnabled(False)
|
|
|
|
def _append(self, text: str):
|
|
self.transcript.moveCursor(QTextEdit.End)
|
|
self.transcript.insertPlainText(text)
|
|
sb = self.transcript.verticalScrollBar()
|
|
sb.setValue(sb.maximum())
|
|
|
|
def _push_line(self, a: float, text: str):
|
|
"""Thread-safe: called from the live worker thread."""
|
|
self.line_q.put((a, text))
|
|
|
|
# ------------------------------ state machine --------------------------
|
|
|
|
def on_record(self):
|
|
if self.state != "idle":
|
|
return
|
|
lang = self.lang.currentText().strip()
|
|
live = self.live_spin.value() if self.live_cb.isChecked() else 0
|
|
self._args = SimpleNamespace(
|
|
source=self.src.currentText().strip() or "default",
|
|
model=self.model_cb.currentText(),
|
|
output=self.out.text().strip() or "meeting",
|
|
live=live,
|
|
language=None if lang in ("", "autodetect") else lang,
|
|
library_url=self.library_url.text().strip() or None,
|
|
diarize_url=self.diarize_url.text().strip() or None,
|
|
agenda=[ln.strip() for ln in self.agenda_edit.toPlainText()
|
|
.splitlines() if ln.strip()],
|
|
)
|
|
self._set_inputs_enabled(False)
|
|
self.transcript.clear()
|
|
self._final_header = False
|
|
self.decay = 0.0
|
|
self.level.setValue(0)
|
|
self.elapsed.setText("00:00:00")
|
|
|
|
key = (self.engine_cb.currentText(), self._args.model,
|
|
self.device_cb.currentText(), self.compute_cb.currentText(),
|
|
self.server_url.text().strip())
|
|
if self.model is None or self.model_key != key:
|
|
self.state = "loading"
|
|
if key[0] == "whisper-server":
|
|
self.status.setText(f"Using whisper server {key[4]} ...")
|
|
else:
|
|
self.status.setText(
|
|
f"Loading Whisper model {self._args.model!r} via {key[0]} "
|
|
"(first run downloads it, this can take a minute)...")
|
|
self._pending_key = key
|
|
self.loader = ModelLoader(
|
|
self._args.model,
|
|
engine=key[0],
|
|
device=self.device_cb.currentText(),
|
|
compute_type=self.compute_cb.currentText(),
|
|
server_url=key[4] or None,
|
|
parent=self)
|
|
self.loader.loaded.connect(self._model_ready)
|
|
self.loader.failed.connect(self._model_failed)
|
|
self.loader.start()
|
|
else:
|
|
self._start_recording()
|
|
|
|
def _model_ready(self, model):
|
|
self.model = model
|
|
self.model_key = self._pending_key
|
|
self._start_recording()
|
|
|
|
def _model_failed(self, err):
|
|
self._to_idle()
|
|
self.status.setText("Failed to load model")
|
|
QMessageBox.critical(self, "MeetRec", f"Could not load Whisper model:\n\n{err}")
|
|
|
|
def _start_recording(self):
|
|
try:
|
|
device = resolve_source(self._args.source)
|
|
except SystemExit:
|
|
self._to_idle()
|
|
QMessageBox.critical(
|
|
self, "MeetRec",
|
|
f"No input device matching {self._args.source!r}.\n"
|
|
"Pick one from the dropdown or fix the name.")
|
|
return
|
|
|
|
try:
|
|
self.rec = Recorder(device, self._args.output + ".wav")
|
|
self.rec.start()
|
|
except Exception as e:
|
|
self._to_idle()
|
|
QMessageBox.critical(self, "MeetRec",
|
|
f"Could not open the microphone:\n\n{e}")
|
|
return
|
|
|
|
self.stop_evt.clear()
|
|
self.state = "record"
|
|
self.record_btn.setEnabled(False)
|
|
self.stop_btn.setEnabled(True)
|
|
|
|
try:
|
|
idx = device if device is not None else sd.default.device[0]
|
|
name = sd.query_devices(idx)["name"]
|
|
except Exception:
|
|
name = self._args.source
|
|
self.status.setText(
|
|
f"Recording from: {name} @ {self.rec.rate} Hz → "
|
|
f"{self._args.output}.wav")
|
|
|
|
if self._args.live:
|
|
self.live_thread = threading.Thread(
|
|
target=live_worker,
|
|
args=(self.model, self.rec, self._args,
|
|
self._args.output + ".live.srt", self.stop_evt,
|
|
self._push_line),
|
|
daemon=True)
|
|
self.live_thread.start()
|
|
|
|
def on_stop(self):
|
|
if self.state != "record":
|
|
return
|
|
self.stop_evt.set()
|
|
self.status.setText("Stopped — running final transcription...")
|
|
if self.rec:
|
|
self.rec.stop() # flushes the WAV writer (a few ms)
|
|
|
|
self.state = "finalizing"
|
|
self.stop_btn.setEnabled(False)
|
|
self.worker = FinalWorker(
|
|
self.model,
|
|
self._args.output + ".wav",
|
|
self._args.output,
|
|
self._args.language,
|
|
diarize_url=self._args.diarize_url,
|
|
agenda=self._args.agenda,
|
|
library_url=self._args.library_url,
|
|
parent=self)
|
|
self.worker.info.connect(lambda m: self.status.setText(m))
|
|
self.worker.segment.connect(self._final_segment)
|
|
self.worker.done.connect(self._final_done)
|
|
self.worker.failed.connect(self._final_failed)
|
|
self.worker.start()
|
|
|
|
def _final_segment(self, a: float, b: float, text: str):
|
|
if not self._final_header:
|
|
self._append("\n— final transcript —\n")
|
|
self._final_header = True
|
|
self._append(f"[{fmt_hms(a)}] {text}\n")
|
|
|
|
def _final_done(self, stem: str):
|
|
self._to_idle()
|
|
self.status.setText(
|
|
f"Done. Saved: {stem}.wav {stem}.txt {stem}.srt {stem}.json")
|
|
|
|
def _final_failed(self, err):
|
|
self._to_idle()
|
|
self.status.setText("Final transcription failed")
|
|
QMessageBox.critical(self, "MeetRec",
|
|
f"Final transcription failed:\n\n{err}\n\n"
|
|
"The .wav recording is still saved.")
|
|
|
|
# ------------------------------ UI tick --------------------------------
|
|
|
|
def _tick(self):
|
|
# drain live lines produced by the worker thread
|
|
try:
|
|
while True:
|
|
a, text = self.line_q.get_nowait()
|
|
self._append(f"[{fmt_hms(a)}] {text}\n")
|
|
except queue.Empty:
|
|
pass
|
|
|
|
if self.state == "record" and self.rec is not None:
|
|
self.decay = max(self.rec.peak, self.decay * 0.70)
|
|
self.level.setValue(int(min(1.0, self.decay) * 100))
|
|
self.elapsed.setText(fmt_hms(self.rec.duration()))
|
|
|
|
# ------------------------------ close ----------------------------------
|
|
|
|
def closeEvent(self, ev):
|
|
if self.state in ("loading", "record", "finalizing"):
|
|
ret = QMessageBox.question(
|
|
self, "MeetRec",
|
|
"Stop and exit now?\n\n"
|
|
"The .wav file is kept, but the final transcription "
|
|
"will be aborted (if it hasn't finished).",
|
|
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
|
if ret != QMessageBox.Yes:
|
|
ev.ignore()
|
|
return
|
|
self.stop_evt.set()
|
|
if self.rec:
|
|
self.rec.stop()
|
|
if self.state == "finalizing" and self.worker:
|
|
self.worker.wait(5000)
|
|
elif self.state == "loading" and self.loader:
|
|
self.loader.wait(5000)
|
|
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")
|
|
win = MeetRecWindow()
|
|
win.show()
|
|
sys.exit(app.exec())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|