|
11 | 11 |
|
12 | 12 | from dotenv import load_dotenv |
13 | 13 | from PySide6.QtCore import QTimer, QUrl |
14 | | -from PySide6.QtGui import QAction, QActionGroup, QDesktopServices, QIcon |
| 14 | +from PySide6.QtGui import QAction, QDesktopServices, QIcon |
15 | 15 | from PySide6.QtWidgets import ( |
16 | 16 | QApplication, |
17 | 17 | QDialog, |
@@ -131,6 +131,76 @@ def _save_forward_audio_setting(mode: str, path: Path = COLLECTOR_CONFIG) -> Non |
131 | 131 | path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") |
132 | 132 |
|
133 | 133 |
|
| 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 | + |
134 | 204 | def _save_capture_settings( |
135 | 205 | audio_mode: str, |
136 | 206 | screen_enabled: bool, |
@@ -186,43 +256,28 @@ def __init__(self, state: SharedState, manager: VaultSyncManager) -> None: |
186 | 256 | self._service_actions(menu, "ScreenPipe", "screenpipe.service", capture=True) |
187 | 257 | self._service_actions(menu, "Collector", "chronicle-screenpipe.service") |
188 | 258 | 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 | + ) |
203 | 276 | ) |
204 | | - self.audio_group.addAction(action) |
205 | | - self.audio_actions[mode] = action |
| 277 | + self.audio_actions[source] = {"record": record, "forward": forward} |
206 | 278 | self.screen_capture = settings_menu.addAction("Screen capture") |
207 | 279 | self.screen_capture.setCheckable(True) |
208 | 280 | 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 |
226 | 281 | menu.addSeparator() |
227 | 282 | self.sync_status = menu.addAction("Vault sync: starting…") |
228 | 283 | self.sync_status.setEnabled(False) |
@@ -295,34 +350,47 @@ def save_capture_settings(self, audio_mode: str | None = None) -> None: |
295 | 350 | def refresh_capture_settings(self) -> None: |
296 | 351 | try: |
297 | 352 | 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) |
299 | 360 | self.screen_capture.setChecked(screen_enabled) |
300 | | - for action in self.audio_actions.values(): |
301 | | - action.setEnabled(True) |
302 | 361 | 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) |
307 | 367 | self.screen_capture.setEnabled(False) |
| 368 | + |
| 369 | + def save_audio_source(self, source: str, setting: str, enabled: bool) -> None: |
308 | 370 | 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: |
322 | 384 | 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) |
326 | 394 | self.refresh_capture_settings() |
327 | 395 |
|
328 | 396 | def refresh(self) -> None: |
|
0 commit comments