Skip to content

Commit 79354ea

Browse files
committed
fix: make tray audio controls source-oriented
1 parent edf362e commit 79354ea

2 files changed

Lines changed: 155 additions & 57 deletions

File tree

‎extras/vault-sync/menu_linux.py‎

Lines changed: 125 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from dotenv import load_dotenv
1313
from PySide6.QtCore import QTimer, QUrl
14-
from PySide6.QtGui import QAction, QActionGroup, QDesktopServices, QIcon
14+
from PySide6.QtGui import QAction, QDesktopServices, QIcon
1515
from PySide6.QtWidgets import (
1616
QApplication,
1717
QDialog,
@@ -131,6 +131,76 @@ def _save_forward_audio_setting(mode: str, path: Path = COLLECTOR_CONFIG) -> Non
131131
path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
132132

133133

134+
def _audio_sources(mode: str, *, forwarding: bool = False) -> set[str]:
135+
"""Expand a persisted audio mode into its enabled source names."""
136+
modes = (
137+
{
138+
"none": set(),
139+
"output": {"system"},
140+
"input": {"mic"},
141+
"both": {"system", "mic"},
142+
}
143+
if forwarding
144+
else {
145+
"off": set(),
146+
"system": {"system"},
147+
"mic": {"mic"},
148+
"both": {"system", "mic"},
149+
}
150+
)
151+
if mode not in modes:
152+
raise ValueError(f"unsupported audio mode: {mode}")
153+
return modes[mode]
154+
155+
156+
def _audio_modes(captured: set[str], forwarded: set[str]) -> tuple[str, str]:
157+
"""Collapse source sets into ScreenPipe and collector configuration values."""
158+
if not forwarded <= captured:
159+
raise ValueError("forwarded audio sources must also be recorded locally")
160+
capture_modes = {
161+
frozenset(): "off",
162+
frozenset({"system"}): "system",
163+
frozenset({"mic"}): "mic",
164+
frozenset({"system", "mic"}): "both",
165+
}
166+
forwarding_modes = {
167+
frozenset(): "none",
168+
frozenset({"system"}): "output",
169+
frozenset({"mic"}): "input",
170+
frozenset({"system", "mic"}): "both",
171+
}
172+
try:
173+
return capture_modes[frozenset(captured)], forwarding_modes[frozenset(forwarded)]
174+
except KeyError as error:
175+
raise ValueError(f"unsupported audio sources: {error.args[0]}") from error
176+
177+
178+
def _updated_audio_modes(
179+
capture_mode: str,
180+
forwarding_mode: str,
181+
source: str,
182+
setting: str,
183+
enabled: bool,
184+
) -> tuple[str, str]:
185+
"""Apply one tray toggle while keeping forwarding dependent on capture."""
186+
if source not in {"system", "mic"}:
187+
raise ValueError(f"unsupported audio source: {source}")
188+
if setting not in {"record", "forward"}:
189+
raise ValueError(f"unsupported audio setting: {setting}")
190+
captured = _audio_sources(capture_mode)
191+
forwarded = _audio_sources(forwarding_mode, forwarding=True)
192+
target = captured if setting == "record" else forwarded
193+
if enabled:
194+
target.add(source)
195+
if setting == "forward":
196+
captured.add(source)
197+
else:
198+
target.discard(source)
199+
if setting == "record":
200+
forwarded.discard(source)
201+
return _audio_modes(captured, forwarded)
202+
203+
134204
def _save_capture_settings(
135205
audio_mode: str,
136206
screen_enabled: bool,
@@ -186,43 +256,28 @@ def __init__(self, state: SharedState, manager: VaultSyncManager) -> None:
186256
self._service_actions(menu, "ScreenPipe", "screenpipe.service", capture=True)
187257
self._service_actions(menu, "Collector", "chronicle-screenpipe.service")
188258
settings_menu = menu.addMenu("Settings")
189-
audio_menu = settings_menu.addMenu("Audio capture")
190-
self.audio_group = QActionGroup(audio_menu)
191-
self.audio_group.setExclusive(True)
192-
self.audio_actions: dict[str, QAction] = {}
193-
for mode, title in (
194-
("off", "Off"),
195-
("system", "System audio"),
196-
("mic", "Microphone"),
197-
("both", "System audio + microphone"),
198-
):
199-
action = audio_menu.addAction(title)
200-
action.setCheckable(True)
201-
action.triggered.connect(
202-
lambda _checked=False, selected=mode: self.save_capture_settings(selected)
259+
audio_menu = settings_menu.addMenu("Audio")
260+
self.audio_actions: dict[str, dict[str, QAction]] = {}
261+
for source, title in (("system", "System audio"), ("mic", "Microphone")):
262+
source_menu = audio_menu.addMenu(title)
263+
record = source_menu.addAction("Record locally")
264+
record.setCheckable(True)
265+
record.triggered.connect(
266+
lambda checked=False, selected=source: self.save_audio_source(
267+
selected, "record", checked
268+
)
269+
)
270+
forward = source_menu.addAction("Send to Chronicle")
271+
forward.setCheckable(True)
272+
forward.triggered.connect(
273+
lambda checked=False, selected=source: self.save_audio_source(
274+
selected, "forward", checked
275+
)
203276
)
204-
self.audio_group.addAction(action)
205-
self.audio_actions[mode] = action
277+
self.audio_actions[source] = {"record": record, "forward": forward}
206278
self.screen_capture = settings_menu.addAction("Screen capture")
207279
self.screen_capture.setCheckable(True)
208280
self.screen_capture.triggered.connect(self.save_capture_settings)
209-
forwarding_menu = settings_menu.addMenu("Send audio to Chronicle")
210-
self.forwarding_group = QActionGroup(forwarding_menu)
211-
self.forwarding_group.setExclusive(True)
212-
self.forwarding_actions: dict[str, QAction] = {}
213-
for mode, title in (
214-
("none", "None"),
215-
("output", "System audio"),
216-
("input", "Microphone"),
217-
("both", "System audio + microphone"),
218-
):
219-
action = forwarding_menu.addAction(title)
220-
action.setCheckable(True)
221-
action.triggered.connect(
222-
lambda _checked=False, selected=mode: self.save_forwarding(selected)
223-
)
224-
self.forwarding_group.addAction(action)
225-
self.forwarding_actions[mode] = action
226281
menu.addSeparator()
227282
self.sync_status = menu.addAction("Vault sync: starting…")
228283
self.sync_status.setEnabled(False)
@@ -295,34 +350,47 @@ def save_capture_settings(self, audio_mode: str | None = None) -> None:
295350
def refresh_capture_settings(self) -> None:
296351
try:
297352
audio_mode, screen_enabled = _capture_settings()
298-
self.audio_actions[audio_mode].setChecked(True)
353+
captured = _audio_sources(audio_mode)
354+
forwarded = _audio_sources(_forward_audio_setting(), forwarding=True)
355+
for source, actions in self.audio_actions.items():
356+
actions["record"].setChecked(source in captured)
357+
actions["forward"].setChecked(source in forwarded)
358+
actions["record"].setEnabled(True)
359+
actions["forward"].setEnabled(True)
299360
self.screen_capture.setChecked(screen_enabled)
300-
for action in self.audio_actions.values():
301-
action.setEnabled(True)
302361
self.screen_capture.setEnabled(True)
303-
except (OSError, StopIteration, ValueError):
304-
logger.exception("Could not read ScreenPipe settings")
305-
for action in self.audio_actions.values():
306-
action.setEnabled(False)
362+
except (OSError, KeyError, StopIteration, ValueError, json.JSONDecodeError):
363+
logger.exception("Could not read audio settings")
364+
for actions in self.audio_actions.values():
365+
actions["record"].setEnabled(False)
366+
actions["forward"].setEnabled(False)
307367
self.screen_capture.setEnabled(False)
368+
369+
def save_audio_source(self, source: str, setting: str, enabled: bool) -> None:
308370
try:
309-
forwarding = _forward_audio_setting()
310-
self.forwarding_actions[forwarding].setChecked(True)
311-
for action in self.forwarding_actions.values():
312-
action.setEnabled(True)
313-
except (OSError, KeyError, ValueError, json.JSONDecodeError):
314-
logger.exception("Could not read collector audio forwarding settings")
315-
for action in self.forwarding_actions.values():
316-
action.setEnabled(False)
317-
318-
def save_forwarding(self, mode: str) -> None:
319-
try:
320-
_save_forward_audio_setting(mode)
321-
if _unit_state("chronicle-screenpipe.service") == "active":
371+
was_capture_active = _unit_state("screenpipe.service") == "active"
372+
was_collector_active = _unit_state("chronicle-screenpipe.service") == "active"
373+
capture_mode, screen_enabled = _capture_settings()
374+
forwarding_mode = _forward_audio_setting()
375+
capture_mode, forwarding_mode = _updated_audio_modes(
376+
capture_mode, forwarding_mode, source, setting, enabled
377+
)
378+
_save_capture_settings(capture_mode, screen_enabled)
379+
_save_forward_audio_setting(forwarding_mode)
380+
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
381+
if was_capture_active:
382+
self.service("restart", "screenpipe.service")
383+
if was_collector_active:
322384
self.service("restart", "chronicle-screenpipe.service")
323-
except (OSError, ValueError, json.JSONDecodeError) as error:
324-
logger.exception("Could not save collector audio forwarding settings")
325-
self.showMessage("Chronicle collector", str(error), QSystemTrayIcon.Warning)
385+
except (
386+
OSError,
387+
StopIteration,
388+
ValueError,
389+
json.JSONDecodeError,
390+
subprocess.CalledProcessError,
391+
) as error:
392+
logger.exception("Could not save audio settings")
393+
self.showMessage("Audio settings", str(error), QSystemTrayIcon.Warning)
326394
self.refresh_capture_settings()
327395

328396
def refresh(self) -> None:

‎extras/vault-sync/tests/test_menu_linux.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
66

77
from menu_linux import (
8+
_audio_modes,
9+
_audio_sources,
810
_capture_settings,
911
_forward_audio_setting,
1012
_save_capture_settings,
1113
_save_forward_audio_setting,
14+
_updated_audio_modes,
1215
)
1316

1417

@@ -86,3 +89,30 @@ def test_audio_forwarding_setting_is_independent_from_capture_unit(tmp_path):
8689

8790
assert _forward_audio_setting(path) == "output"
8891
assert json.loads(path.read_text())["source_id"] == "rainbow"
92+
93+
94+
def test_audio_source_modes_round_trip():
95+
assert _audio_sources("both") == {"system", "mic"}
96+
assert _audio_sources("input", forwarding=True) == {"mic"}
97+
assert _audio_modes({"system", "mic"}, {"system"}) == ("both", "output")
98+
99+
100+
def test_forwarding_source_automatically_enables_local_recording():
101+
assert _updated_audio_modes("mic", "none", "system", "forward", True) == (
102+
"both",
103+
"output",
104+
)
105+
106+
107+
def test_disabling_local_recording_also_disables_forwarding():
108+
assert _updated_audio_modes("both", "both", "system", "record", False) == (
109+
"mic",
110+
"input",
111+
)
112+
113+
114+
def test_disabling_forwarding_keeps_local_recording():
115+
assert _updated_audio_modes("both", "both", "mic", "forward", False) == (
116+
"both",
117+
"output",
118+
)

0 commit comments

Comments
 (0)