#!/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()