From e93676c4b7ceb55aea03b430dd2f9228a9dc6b08 Mon Sep 17 00:00:00 2001 From: Raphael Avocegamou Date: Fri, 4 Sep 2026 05:35:48 +0200 Subject: [PATCH] feat: capture desktop apps with PocketStation --- README.md | 18 +++++-- meeting_transcriber/audio.py | 80 +++++++++++++++++++++++++++++ meeting_transcriber/controller.py | 40 +++++++++++---- meeting_transcriber/ui.py | 51 ++++++++++++++++-- pyproject.toml | 3 ++ tests/test_pocketstation_capture.py | 57 ++++++++++++++++++++ 6 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 tests/test_pocketstation_capture.py diff --git a/README.md b/README.md index 06635c0..68e3071 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,20 @@ Paste a **team key** here to join a team instantly. ## 3. Capturing meeting (system) audio -OSes don't let an app record other apps' audio directly, so route system audio -through a **virtual loopback** that appears as a normal input. The app -auto-detects the common ones. +MeetGraph can capture one desktop application directly with PocketStation: + +```bash +pip install 'meetgraph[pocketstation]' +meetgraph +``` + +In **Audio sources**, enable **Desktop application (PocketStation)** and enter +the application name, such as `Zoom`. Keep **Microphone** enabled to transcribe +both sides separately. On first use, macOS asks for Microphone and Screen & +System Audio Recording access. + +The existing device recorder remains available. To use it, route system audio +through a virtual input and select that input in MeetGraph: - **macOS — BlackHole:** `brew install blackhole-2ch`, create a Multi-Output Device (your speakers + BlackHole) in *Audio MIDI Setup*, set it as output, then pick @@ -355,4 +366,3 @@ source ~/.zshrc ## License This project is licensed under the Apache License 2.0. See the [LICENSE.md](LICENSE.md) file for details. - diff --git a/meeting_transcriber/audio.py b/meeting_transcriber/audio.py index 2ea8abc..6fdcbc2 100644 --- a/meeting_transcriber/audio.py +++ b/meeting_transcriber/audio.py @@ -14,6 +14,7 @@ import threading from collections import deque from dataclasses import dataclass +from typing import Any import numpy as np @@ -240,3 +241,82 @@ def run(self) -> None: for seg in self.segmenter.flush(): self.out_queue.put(Segment(self.label, seg)) + + +class PocketStationCaptureWorker(threading.Thread): + """Capture one desktop application without a virtual audio device.""" + + def __init__( + self, + application: str, + label: str, + out_queue: "queue.Queue[Segment]", + threshold: float = 0.012, + ): + super().__init__(daemon=True, name=f"capture-{label}-pocketstation") + self.application = _application_selector(application) + self.label = label + self.out_queue = out_queue + self.segmenter = Segmenter(threshold=threshold) + self._stop_event = threading.Event() + self._capture: Any | None = None + self.error: str | None = None + + def stop(self) -> None: + self._stop_event.set() + capture = self._capture + if capture is not None: + capture.close() + + def run(self) -> None: + try: + try: + import pocketstation as pks + except ImportError as error: + raise RuntimeError( + "PocketStation is not installed. " + "Run: pip install 'meetgraph[pocketstation]'" + ) from error + + capture = pks.capture(application=self.application) + self._capture = capture + capture.start() + for frame in capture.audio: + if self._stop_event.is_set(): + break + samples = np.frombuffer(frame.samples_f32le, dtype=" 1: + samples = samples.reshape(-1, frame.channel_count).mean(axis=1) + audio16 = _resample_to_16k(samples, frame.sample_rate_hz) + for segment in self.segmenter.add(audio16): + self.out_queue.put(Segment(self.label, segment)) + except Exception as exc: + if not self._stop_event.is_set(): + self.error = f"{self.label}: {exc}" + finally: + capture = self._capture + self._capture = None + if capture is not None: + try: + capture.close() + except Exception as exc: + if not self._stop_event.is_set() and self.error is None: + self.error = f"{self.label}: {exc}" + for segment in self.segmenter.flush(): + self.out_queue.put(Segment(self.label, segment)) + + +def _application_selector(value: str) -> str | int: + value = value.strip() + if not value: + raise ValueError("Application name must not be empty") + process_id = value.removeprefix("pid:") + if process_id == value: + return value + try: + parsed = int(process_id) + except ValueError as error: + raise ValueError("Process IDs must use pid:") from error + if parsed <= 0: + raise ValueError("Process ID must be positive") + return parsed diff --git a/meeting_transcriber/controller.py b/meeting_transcriber/controller.py index fb4ea8c..716db7b 100644 --- a/meeting_transcriber/controller.py +++ b/meeting_transcriber/controller.py @@ -13,7 +13,7 @@ from PyQt6.QtCore import QObject, pyqtSignal -from .audio import CaptureWorker, InputDevice, Segment +from .audio import CaptureWorker, InputDevice, PocketStationCaptureWorker, Segment from .transcribe import make_transcriber @@ -29,11 +29,12 @@ class TranscriptionController(QObject): def __init__(self) -> None: super().__init__() self._seg_queue: "queue.Queue[Segment | None]" = queue.Queue() - self._captures: list[CaptureWorker] = [] + self._captures: list[CaptureWorker | PocketStationCaptureWorker] = [] self._worker: threading.Thread | None = None self._running = False self._paused = False self._sources: list[tuple[InputDevice, str]] = [] + self._application: str | None = None self._threshold = 0.012 self._diarizer = None @@ -45,11 +46,17 @@ def running(self) -> bool: def is_paused(self) -> bool: return self._paused - def start(self, config: dict, sources: list[tuple[InputDevice, str]]) -> None: + def start( + self, + config: dict, + sources: list[tuple[InputDevice, str]], + application: str | None = None, + ) -> None: """``config`` -> engine settings; ``sources`` -> list of (device, label).""" if self._running: return self._sources = sources + self._application = application self._threshold = float(config.get("threshold", 0.012)) threading.Thread(target=self._start_impl, args=(config,), daemon=True).start() @@ -58,6 +65,15 @@ def _start_captures(self) -> None: for device, label in self._sources: cw = CaptureWorker(device, label, self._seg_queue, threshold=self._threshold) self._captures.append(cw) + if self._application: + self._captures.append( + PocketStationCaptureWorker( + self._application, + "Meeting", + self._seg_queue, + threshold=self._threshold, + ) + ) for cw in self._captures: cw.start() @@ -130,14 +146,14 @@ def resume(self) -> None: def _transcribe_loop(self, transcriber) -> None: # noqa: ANN001 try: while True: - seg = self._seg_queue.get() + try: + seg = self._seg_queue.get(timeout=0.2) + except queue.Empty: + self._report_capture_errors() + continue if seg is None: # sentinel break - # Surface any capture-thread failures. - for cw in self._captures: - if cw.error: - self.error.emit(cw.error) - cw.error = None + self._report_capture_errors() try: text = transcriber.transcribe(seg.audio) except Exception as exc: @@ -156,6 +172,12 @@ def _transcribe_loop(self, transcriber) -> None: # noqa: ANN001 except Exception: pass + def _report_capture_errors(self) -> None: + for capture in self._captures: + if capture.error: + self.error.emit(capture.error) + capture.error = None + def stop(self) -> None: if not self._running: return diff --git a/meeting_transcriber/ui.py b/meeting_transcriber/ui.py index d067199..f37ece3 100644 --- a/meeting_transcriber/ui.py +++ b/meeting_transcriber/ui.py @@ -2052,6 +2052,14 @@ def _build_sources_box(self) -> QGroupBox: sys_row.addWidget(self.sys_combo, 1) src_layout.addLayout(sys_row) + app_row = QHBoxLayout() + self.pks_check = QCheckBox("Desktop application (PocketStation)") + app_row.addWidget(self.pks_check) + self.pks_application = QLineEdit() + self.pks_application.setPlaceholderText("Application name, or pid:1234") + app_row.addWidget(self.pks_application, 1) + src_layout.addLayout(app_row) + refresh_row = QHBoxLayout() refresh_btn = QPushButton("↻ Refresh devices") refresh_btn.clicked.connect(self._populate_devices) @@ -2795,6 +2803,8 @@ def _wire_config_persistence(self) -> None: self.compat_model.currentTextChanged.connect(self._persist_config) self.mic_combo.currentIndexChanged.connect(self._persist_config) self.sys_combo.currentIndexChanged.connect(self._persist_config) + self.pks_check.toggled.connect(self._pocketstation_capture_changed) + self.pks_application.textChanged.connect(self._persist_config) self.hf_token_edit.textChanged.connect(self._persist_config) self.compute_combo.currentIndexChanged.connect(self._persist_config) self._wire_external_persistence() @@ -2841,6 +2851,8 @@ def _persist_config(self) -> None: s("t.compat_model", self.compat_model.currentText()) s("t.mic_device", self.mic_combo.currentText()) s("t.sys_device", self.sys_combo.currentText()) + s("t.pocketstation", "1" if self.pks_check.isChecked() else "0") + s("t.pocketstation_application", self.pks_application.text().strip()) s("t.hf_token", self.hf_token_edit.text()) s("t.compute", self.compute_combo.currentData() or "auto") self._apply_hf_token() @@ -2920,6 +2932,9 @@ def _load_config_impl(self) -> None: i = combo.findText(name) if i >= 0: combo.setCurrentIndex(i) + self.pks_application.setText(g("t.pocketstation_application") or "") + self.pks_check.setChecked(g("t.pocketstation") == "1") + self._pocketstation_capture_changed(self.pks_check.isChecked()) self._load_external_config() self._load_email_config() self._load_integrations_config() @@ -4137,6 +4152,21 @@ def _populate_devices(self) -> None: self.bh_hint.setText("") else: self.bh_hint.setText(_system_audio_hint()) + if self.pks_check.isChecked(): + self._pocketstation_capture_changed(True) + + def _pocketstation_capture_changed(self, enabled: bool) -> None: + self.sys_check.setEnabled(not enabled) + self.sys_combo.setEnabled(not enabled) + self.pks_application.setEnabled(enabled) + if enabled: + self.sys_check.setChecked(False) + self.bh_hint.setText("Captures the selected application without a loopback device.") + else: + self.bh_hint.setText( + "" if find_system_audio_device(self.devices) else _system_audio_hint() + ) + self._persist_config() # ------------------------------------------------------------- controller def _wire_controller(self) -> None: @@ -4312,6 +4342,7 @@ def _on_compat_provider_changed(self) -> None: def _on_start(self) -> None: sources = [] + application = None # With diarization on, don't assume the recorder is the speaker - use a # neutral source label (the diarizer overrides it with Speaker N when # available; otherwise lines stay neutral rather than the recorder's name). @@ -4321,11 +4352,20 @@ def _on_start(self) -> None: dev = self._device_by_index(self.mic_combo.currentData()) if dev: sources.append((dev, mic_label)) - if self.sys_check.isChecked(): + if self.pks_check.isChecked(): + application = self.pks_application.text().strip() + if not application: + QMessageBox.warning( + self, + "Application required", + "Enter the desktop application you want to capture.", + ) + return + elif self.sys_check.isChecked(): dev = self._device_by_index(self.sys_combo.currentData()) if dev: sources.append((dev, "Meeting")) - if not sources: + if not sources and application is None: QMessageBox.warning(self, "No source", "Enable at least one audio source.") return @@ -4354,7 +4394,7 @@ def _on_start(self) -> None: self._started_at = datetime.now() self.transcript.started_at = self._started_at self.start_btn.setEnabled(False) - self.controller.start(config, sources) + self.controller.start(config, sources, application=application) def _on_pause_resume(self) -> None: if self.controller.is_paused: @@ -4422,9 +4462,12 @@ def _set_settings_enabled(self, enabled: bool) -> None: self.openai_model_combo, self.openai_base, self.diarize_combo, self.compat_provider, self.compat_base, self.compat_key, self.compat_model, self.lang_edit, self.mic_check, - self.sys_check, self.mic_combo, self.sys_combo, + self.sys_check, self.mic_combo, self.sys_combo, self.pks_check, + self.pks_application, ): w.setEnabled(enabled) + if enabled: + self._pocketstation_capture_changed(self.pks_check.isChecked()) _SPK_PALETTE = ["#7c3aed", "#db2777", "#ea580c", "#0891b2", "#65a30d", "#9333ea", "#c026d3", "#0d9488"] diff --git a/pyproject.toml b/pyproject.toml index 37b269d..fa83fd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ dependencies = [ "SQLAlchemy>=2.0", ] +[project.optional-dependencies] +pocketstation = ["pocketstation>=0.1.3,<0.2"] + [project.scripts] meetgraph = "meeting_transcriber.cli:main" diff --git a/tests/test_pocketstation_capture.py b/tests/test_pocketstation_capture.py new file mode 100644 index 0000000..6b07247 --- /dev/null +++ b/tests/test_pocketstation_capture.py @@ -0,0 +1,57 @@ +"""Tests for direct desktop application capture.""" + +from __future__ import annotations + +import queue +import sys +from types import ModuleType, SimpleNamespace + +import numpy as np +import pytest + +from meeting_transcriber.audio import ( + PocketStationCaptureWorker, + _application_selector, +) + + +def test_application_selector_accepts_name_and_explicit_process_id(): + assert _application_selector("Zoom") == "Zoom" + assert _application_selector("pid:1234") == 1234 + + +@pytest.mark.parametrize("value", ["", " ", "pid:nope", "pid:0"]) +def test_application_selector_rejects_invalid_values(value): + with pytest.raises(ValueError): + _application_selector(value) + + +def test_worker_sends_application_audio_to_existing_segment_queue(monkeypatch): + samples = np.full(48_000, 0.2, dtype=" 15_000 + assert worker.error is None