Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

80 changes: 80 additions & 0 deletions meeting_transcriber/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import threading
from collections import deque
from dataclasses import dataclass
from typing import Any

import numpy as np

Expand Down Expand Up @@ -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="<f4")
if frame.channel_count > 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:<positive number>") from error
if parsed <= 0:
raise ValueError("Process ID must be positive")
return parsed
40 changes: 31 additions & 9 deletions meeting_transcriber/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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

Expand All @@ -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()

Expand All @@ -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()

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
51 changes: 47 additions & 4 deletions meeting_transcriber/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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).
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"]

Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
57 changes: 57 additions & 0 deletions tests/test_pocketstation_capture.py
Original file line number Diff line number Diff line change
@@ -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="<f4")
frame = SimpleNamespace(
samples_f32le=samples.tobytes(),
channel_count=1,
sample_rate_hz=48_000,
)

class FakeCapture:
audio = [frame]

def start(self):
pass

def close(self):
pass

module = ModuleType("pocketstation")
module.capture = lambda **_kwargs: FakeCapture()
monkeypatch.setitem(sys.modules, "pocketstation", module)

output = queue.Queue()
worker = PocketStationCaptureWorker("Zoom", "Meeting", output)
worker.run()

segment = output.get_nowait()
assert segment.label == "Meeting"
assert len(segment.audio) > 15_000
assert worker.error is None