meeting-noter 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of meeting-noter might be problematic. Click here for more details.

Files changed (39) hide show
  1. meeting_noter/__init__.py +3 -0
  2. meeting_noter/__main__.py +6 -0
  3. meeting_noter/audio/__init__.py +1 -0
  4. meeting_noter/audio/capture.py +209 -0
  5. meeting_noter/audio/encoder.py +208 -0
  6. meeting_noter/audio/system_audio.py +363 -0
  7. meeting_noter/cli.py +837 -0
  8. meeting_noter/config.py +197 -0
  9. meeting_noter/daemon.py +519 -0
  10. meeting_noter/gui/__init__.py +5 -0
  11. meeting_noter/gui/__main__.py +6 -0
  12. meeting_noter/gui/app.py +53 -0
  13. meeting_noter/gui/main_window.py +50 -0
  14. meeting_noter/gui/meetings_tab.py +348 -0
  15. meeting_noter/gui/recording_tab.py +358 -0
  16. meeting_noter/gui/settings_tab.py +249 -0
  17. meeting_noter/install/__init__.py +1 -0
  18. meeting_noter/install/macos.py +102 -0
  19. meeting_noter/meeting_detector.py +333 -0
  20. meeting_noter/menubar.py +411 -0
  21. meeting_noter/mic_monitor.py +456 -0
  22. meeting_noter/output/__init__.py +1 -0
  23. meeting_noter/output/writer.py +96 -0
  24. meeting_noter/resources/__init__.py +1 -0
  25. meeting_noter/resources/icon.icns +0 -0
  26. meeting_noter/resources/icon.png +0 -0
  27. meeting_noter/resources/icon_128.png +0 -0
  28. meeting_noter/resources/icon_16.png +0 -0
  29. meeting_noter/resources/icon_256.png +0 -0
  30. meeting_noter/resources/icon_32.png +0 -0
  31. meeting_noter/resources/icon_512.png +0 -0
  32. meeting_noter/resources/icon_64.png +0 -0
  33. meeting_noter/transcription/__init__.py +1 -0
  34. meeting_noter/transcription/engine.py +234 -0
  35. meeting_noter-0.7.0.dist-info/METADATA +224 -0
  36. meeting_noter-0.7.0.dist-info/RECORD +39 -0
  37. meeting_noter-0.7.0.dist-info/WHEEL +5 -0
  38. meeting_noter-0.7.0.dist-info/entry_points.txt +2 -0
  39. meeting_noter-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,358 @@
1
+ """Recording tab for starting/stopping meeting recordings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
9
+ from PyQt6.QtWidgets import (
10
+ QWidget,
11
+ QVBoxLayout,
12
+ QHBoxLayout,
13
+ QLabel,
14
+ QLineEdit,
15
+ QPushButton,
16
+ QTextEdit,
17
+ QGroupBox,
18
+ )
19
+
20
+ from meeting_noter.config import get_config, generate_meeting_name, is_default_meeting_name
21
+
22
+
23
+ class RecordingWorker(QThread):
24
+ """Background worker thread for audio recording."""
25
+
26
+ log_message = pyqtSignal(str)
27
+ recording_started = pyqtSignal(str) # filepath
28
+ recording_stopped = pyqtSignal(str, float) # filepath, duration
29
+ silence_stopped = pyqtSignal() # stopped due to silence
30
+ error = pyqtSignal(str)
31
+
32
+ def __init__(self, output_dir: Path, meeting_name: str, silence_timeout_minutes: int = 5):
33
+ super().__init__()
34
+ self.output_dir = output_dir
35
+ self.meeting_name = meeting_name
36
+ self.silence_timeout_minutes = silence_timeout_minutes
37
+ self._stop_requested = False
38
+ self.saved_filepath: Optional[Path] = None
39
+
40
+ def run(self):
41
+ """Run the recording in background thread."""
42
+ # Import audio modules in thread
43
+ try:
44
+ from meeting_noter.audio.capture import SilenceDetector
45
+ from meeting_noter.audio.system_audio import CombinedAudioCapture
46
+ from meeting_noter.audio.encoder import RecordingSession
47
+ except ImportError as e:
48
+ self.error.emit(f"Import error: {e}")
49
+ return
50
+
51
+ from meeting_noter.daemon import check_audio_available
52
+ if not check_audio_available():
53
+ self.error.emit("No audio input device found. Please check your microphone.")
54
+ return
55
+
56
+ try:
57
+ # Use CombinedAudioCapture for mic + system audio
58
+ capture = CombinedAudioCapture()
59
+ capture.start()
60
+ except Exception as e:
61
+ self.error.emit(f"Audio capture error: {e}")
62
+ return
63
+
64
+ session = RecordingSession(
65
+ self.output_dir,
66
+ sample_rate=capture.sample_rate,
67
+ channels=capture.channels,
68
+ meeting_name=self.meeting_name,
69
+ )
70
+
71
+ # Silence detection
72
+ silence_detector = SilenceDetector(
73
+ threshold=0.01,
74
+ silence_duration=self.silence_timeout_minutes * 60.0,
75
+ sample_rate=capture.sample_rate,
76
+ )
77
+ stopped_by_silence = False
78
+
79
+ # Log capture mode
80
+ if capture.has_system_audio:
81
+ self.log_message.emit("Capturing: microphone + system audio")
82
+ else:
83
+ self.log_message.emit("Capturing: microphone only")
84
+
85
+ try:
86
+ filepath = session.start()
87
+ self.recording_started.emit(str(filepath))
88
+ self.log_message.emit(f"Will stop after {self.silence_timeout_minutes} min of silence")
89
+
90
+ while not self._stop_requested:
91
+ audio = capture.get_audio(timeout=0.5)
92
+ if audio is None:
93
+ continue
94
+
95
+ if audio.ndim > 1:
96
+ audio = audio.flatten()
97
+
98
+ session.write(audio)
99
+
100
+ # Check for extended silence
101
+ if silence_detector.update(audio):
102
+ self.log_message.emit("Stopped: silence timeout reached")
103
+ stopped_by_silence = True
104
+ break
105
+
106
+ except Exception as e:
107
+ self.error.emit(f"Recording error: {e}")
108
+ finally:
109
+ capture.stop()
110
+
111
+ if session.is_active:
112
+ filepath, duration = session.stop()
113
+ if filepath:
114
+ self.saved_filepath = filepath
115
+ self.recording_stopped.emit(str(filepath), duration)
116
+ if stopped_by_silence:
117
+ self.silence_stopped.emit()
118
+ else:
119
+ self.log_message.emit("Recording discarded (too short)")
120
+
121
+ def stop(self):
122
+ """Request the recording to stop."""
123
+ self._stop_requested = True
124
+
125
+
126
+ class RecordingTab(QWidget):
127
+ """Tab for recording meetings."""
128
+
129
+ recording_saved = pyqtSignal()
130
+
131
+ def __init__(self):
132
+ super().__init__()
133
+ self.config = get_config()
134
+ self.worker: Optional[RecordingWorker] = None
135
+ self.is_recording = False
136
+
137
+ self._setup_ui()
138
+ self._setup_meeting_detection()
139
+
140
+ def _setup_ui(self):
141
+ """Set up the user interface."""
142
+ layout = QVBoxLayout(self)
143
+ layout.setSpacing(16)
144
+
145
+ # Meeting name input
146
+ name_group = QGroupBox("Meeting Name")
147
+ name_layout = QHBoxLayout(name_group)
148
+
149
+ self.name_input = QLineEdit()
150
+ self.name_input.setPlaceholderText("Enter meeting name or leave blank to auto-detect")
151
+ self.name_input.returnPressed.connect(self._on_start_clicked)
152
+ name_layout.addWidget(self.name_input)
153
+
154
+ self.detect_button = QPushButton("Detect")
155
+ self.detect_button.setMaximumWidth(80)
156
+ self.detect_button.clicked.connect(self._detect_meeting)
157
+ name_layout.addWidget(self.detect_button)
158
+
159
+ layout.addWidget(name_group)
160
+
161
+ # Control buttons
162
+ button_layout = QHBoxLayout()
163
+
164
+ self.start_button = QPushButton("Start Recording")
165
+ self.start_button.setMinimumHeight(50)
166
+ self.start_button.clicked.connect(self._on_start_clicked)
167
+ button_layout.addWidget(self.start_button)
168
+
169
+ self.stop_button = QPushButton("Stop Recording")
170
+ self.stop_button.setMinimumHeight(50)
171
+ self.stop_button.setEnabled(False)
172
+ self.stop_button.clicked.connect(self._on_stop_clicked)
173
+ button_layout.addWidget(self.stop_button)
174
+
175
+ layout.addLayout(button_layout)
176
+
177
+ # Status
178
+ self.status_label = QLabel("Ready to record")
179
+ self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
180
+ self.status_label.setStyleSheet("font-size: 14px; padding: 10px;")
181
+ layout.addWidget(self.status_label)
182
+
183
+ # Log output
184
+ log_group = QGroupBox("Log")
185
+ log_layout = QVBoxLayout(log_group)
186
+
187
+ self.log_output = QTextEdit()
188
+ self.log_output.setReadOnly(True)
189
+ self.log_output.setMaximumHeight(200)
190
+ log_layout.addWidget(self.log_output)
191
+
192
+ layout.addWidget(log_group)
193
+
194
+ # Stretch to push everything up
195
+ layout.addStretch()
196
+
197
+ def _setup_meeting_detection(self):
198
+ """Set up periodic meeting detection."""
199
+ self._detection_timer = QTimer()
200
+ self._detection_timer.timeout.connect(self._check_for_meeting)
201
+ self._detection_timer.start(3000) # Check every 3 seconds
202
+
203
+ # Initial check
204
+ self._detect_meeting()
205
+
206
+ def _check_for_meeting(self):
207
+ """Check for active meeting and update UI if not recording."""
208
+ if self.is_recording:
209
+ return
210
+
211
+ # Only auto-update if field is empty or has default timestamp
212
+ current = self.name_input.text().strip()
213
+ if current and not is_default_meeting_name(current):
214
+ return
215
+
216
+ try:
217
+ from meeting_noter.meeting_detector import detect_active_meeting
218
+ meeting = detect_active_meeting()
219
+ if meeting and meeting.meeting_name:
220
+ self.name_input.setText(meeting.meeting_name)
221
+ self.status_label.setText(f"Meeting detected: {meeting.app_name}")
222
+ except Exception:
223
+ pass
224
+
225
+ def _detect_meeting(self):
226
+ """Manually detect current meeting."""
227
+ try:
228
+ from meeting_noter.meeting_detector import detect_active_meeting
229
+ meeting = detect_active_meeting()
230
+ if meeting:
231
+ name = meeting.meeting_name or meeting.app_name
232
+ self.name_input.setText(name)
233
+ self._log(f"Detected: {meeting.app_name} - {name}")
234
+ else:
235
+ self.name_input.setText(generate_meeting_name())
236
+ self._log("No meeting detected, using timestamp")
237
+ except Exception as e:
238
+ self._log(f"Detection error: {e}")
239
+ self.name_input.setText(generate_meeting_name())
240
+
241
+ def _on_start_clicked(self):
242
+ """Handle start button click."""
243
+ meeting_name = self.name_input.text().strip()
244
+
245
+ # Auto-detect or generate name if empty
246
+ if not meeting_name:
247
+ try:
248
+ from meeting_noter.meeting_detector import detect_active_meeting
249
+ meeting = detect_active_meeting()
250
+ if meeting and meeting.meeting_name:
251
+ meeting_name = meeting.meeting_name
252
+ else:
253
+ meeting_name = generate_meeting_name()
254
+ except Exception:
255
+ meeting_name = generate_meeting_name()
256
+ self.name_input.setText(meeting_name)
257
+
258
+ self.start_recording(meeting_name)
259
+
260
+ def _on_stop_clicked(self):
261
+ """Handle stop button click."""
262
+ self.stop_recording()
263
+
264
+ def start_recording(self, meeting_name: str):
265
+ """Start a new recording."""
266
+ if self.is_recording:
267
+ return
268
+
269
+ self.is_recording = True
270
+ self.start_button.setEnabled(False)
271
+ self.stop_button.setEnabled(True)
272
+ self.name_input.setEnabled(False)
273
+ self.detect_button.setEnabled(False)
274
+
275
+ self.status_label.setText(f"Recording: {meeting_name}")
276
+ self.status_label.setStyleSheet(
277
+ "font-size: 14px; padding: 10px; color: white; background-color: #c0392b;"
278
+ )
279
+
280
+ self.log_output.clear()
281
+ self._log(f"Starting recording: {meeting_name}")
282
+
283
+ # Start worker thread
284
+ output_dir = self.config.recordings_dir
285
+ output_dir.mkdir(parents=True, exist_ok=True)
286
+
287
+ self.worker = RecordingWorker(
288
+ output_dir,
289
+ meeting_name,
290
+ silence_timeout_minutes=self.config.silence_timeout,
291
+ )
292
+ self.worker.log_message.connect(self._log)
293
+ self.worker.recording_started.connect(self._on_recording_started)
294
+ self.worker.recording_stopped.connect(self._on_recording_stopped)
295
+ self.worker.error.connect(self._on_error)
296
+ self.worker.finished.connect(self._on_worker_finished)
297
+ self.worker.start()
298
+
299
+ def stop_recording(self):
300
+ """Stop the current recording."""
301
+ if not self.is_recording or not self.worker:
302
+ return
303
+
304
+ self._log("Stopping recording...")
305
+ self.worker.stop()
306
+ self.worker.wait(5000) # Wait up to 5 seconds
307
+
308
+ def _on_recording_started(self, filepath: str):
309
+ """Handle recording started."""
310
+ self._log(f"Recording to: {Path(filepath).name}")
311
+
312
+ def _on_recording_stopped(self, filepath: str, duration: float):
313
+ """Handle recording stopped."""
314
+ mins, secs = divmod(int(duration), 60)
315
+ self._log(f"Saved: {Path(filepath).name} ({mins:02d}:{secs:02d})")
316
+
317
+ # Auto-transcribe if enabled
318
+ if self.config.auto_transcribe:
319
+ self._log("Auto-transcribing...")
320
+ try:
321
+ from meeting_noter.transcription.engine import transcribe_file
322
+ transcribe_file(
323
+ filepath,
324
+ self.config.recordings_dir,
325
+ self.config.whisper_model,
326
+ self.config.transcripts_dir,
327
+ )
328
+ self._log("Transcription complete")
329
+ except Exception as e:
330
+ self._log(f"Transcription error: {e}")
331
+
332
+ self.recording_saved.emit()
333
+
334
+ def _on_error(self, message: str):
335
+ """Handle error from worker."""
336
+ self._log(f"Error: {message}")
337
+ self.status_label.setText(f"Error: {message}")
338
+ self.status_label.setStyleSheet("font-size: 14px; padding: 10px; color: red;")
339
+
340
+ def _on_worker_finished(self):
341
+ """Handle worker thread finished."""
342
+ self.is_recording = False
343
+ self.start_button.setEnabled(True)
344
+ self.stop_button.setEnabled(False)
345
+ self.name_input.setEnabled(True)
346
+ self.detect_button.setEnabled(True)
347
+
348
+ # Clear the name field for next recording
349
+ self.name_input.clear()
350
+
351
+ self.status_label.setText("Ready to record")
352
+ self.status_label.setStyleSheet("font-size: 14px; padding: 10px;")
353
+
354
+ self.worker = None
355
+
356
+ def _log(self, message: str):
357
+ """Add a message to the log output."""
358
+ self.log_output.append(message)
@@ -0,0 +1,249 @@
1
+ """Settings tab for configuring Meeting Noter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from PyQt6.QtCore import pyqtSignal
10
+ from PyQt6.QtWidgets import (
11
+ QWidget,
12
+ QVBoxLayout,
13
+ QHBoxLayout,
14
+ QFormLayout,
15
+ QLabel,
16
+ QLineEdit,
17
+ QPushButton,
18
+ QComboBox,
19
+ QCheckBox,
20
+ QGroupBox,
21
+ QFileDialog,
22
+ QMessageBox,
23
+ QSpinBox,
24
+ )
25
+
26
+ from meeting_noter.config import get_config
27
+ from meeting_noter.daemon import read_pid_file, is_process_running
28
+
29
+
30
+ class SettingsTab(QWidget):
31
+ """Tab for configuring application settings."""
32
+
33
+ settings_saved = pyqtSignal()
34
+
35
+ def __init__(self):
36
+ super().__init__()
37
+ self.config = get_config()
38
+ self._setup_ui()
39
+ self._load_settings()
40
+
41
+ def _setup_ui(self):
42
+ """Set up the user interface."""
43
+ layout = QVBoxLayout(self)
44
+ layout.setSpacing(16)
45
+
46
+ # Directories group
47
+ dirs_group = QGroupBox("Directories")
48
+ dirs_layout = QFormLayout(dirs_group)
49
+
50
+ # Recordings directory
51
+ recordings_layout = QHBoxLayout()
52
+ self.recordings_input = QLineEdit()
53
+ self.recordings_input.setReadOnly(True)
54
+ recordings_layout.addWidget(self.recordings_input)
55
+
56
+ recordings_browse = QPushButton("Browse...")
57
+ recordings_browse.clicked.connect(self._browse_recordings_dir)
58
+ recordings_layout.addWidget(recordings_browse)
59
+
60
+ dirs_layout.addRow("Recordings:", recordings_layout)
61
+
62
+ # Transcripts directory
63
+ transcripts_layout = QHBoxLayout()
64
+ self.transcripts_input = QLineEdit()
65
+ self.transcripts_input.setReadOnly(True)
66
+ transcripts_layout.addWidget(self.transcripts_input)
67
+
68
+ transcripts_browse = QPushButton("Browse...")
69
+ transcripts_browse.clicked.connect(self._browse_transcripts_dir)
70
+ transcripts_layout.addWidget(transcripts_browse)
71
+
72
+ dirs_layout.addRow("Transcripts:", transcripts_layout)
73
+
74
+ layout.addWidget(dirs_group)
75
+
76
+ # Transcription group
77
+ transcription_group = QGroupBox("Transcription")
78
+ transcription_layout = QFormLayout(transcription_group)
79
+
80
+ # Whisper model
81
+ self.model_combo = QComboBox()
82
+ self.model_combo.addItems([
83
+ "tiny.en",
84
+ "base.en",
85
+ "small.en",
86
+ "medium.en",
87
+ "large-v3",
88
+ ])
89
+ transcription_layout.addRow("Whisper Model:", self.model_combo)
90
+
91
+ # Model descriptions
92
+ model_desc = QLabel(
93
+ "tiny.en: Fastest, least accurate (~1GB RAM)\n"
94
+ "base.en: Fast, decent accuracy (~1GB RAM)\n"
95
+ "small.en: Good balance (~2GB RAM)\n"
96
+ "medium.en: Better accuracy (~5GB RAM)\n"
97
+ "large-v3: Best accuracy (~10GB RAM)"
98
+ )
99
+ model_desc.setStyleSheet("color: gray; font-size: 11px;")
100
+ transcription_layout.addRow("", model_desc)
101
+
102
+ # Auto-transcribe
103
+ self.auto_transcribe_checkbox = QCheckBox("Automatically transcribe after recording stops")
104
+ transcription_layout.addRow("", self.auto_transcribe_checkbox)
105
+
106
+ layout.addWidget(transcription_group)
107
+
108
+ # Recording group
109
+ recording_group = QGroupBox("Recording")
110
+ recording_layout = QFormLayout(recording_group)
111
+
112
+ # Silence timeout
113
+ silence_layout = QHBoxLayout()
114
+ self.silence_timeout_spin = QSpinBox()
115
+ self.silence_timeout_spin.setRange(1, 60)
116
+ self.silence_timeout_spin.setSuffix(" minutes")
117
+ self.silence_timeout_spin.setToolTip("Stop recording after this many minutes of silence")
118
+ silence_layout.addWidget(self.silence_timeout_spin)
119
+ silence_layout.addStretch()
120
+
121
+ recording_layout.addRow("Silence timeout:", silence_layout)
122
+
123
+ silence_desc = QLabel("Recording stops automatically after this duration of silence")
124
+ silence_desc.setStyleSheet("color: gray; font-size: 11px;")
125
+ recording_layout.addRow("", silence_desc)
126
+
127
+ layout.addWidget(recording_group)
128
+
129
+ # Appearance group
130
+ appearance_group = QGroupBox("Appearance")
131
+ appearance_layout = QFormLayout(appearance_group)
132
+
133
+ self.show_menubar_checkbox = QCheckBox("Show menu bar icon (MN)")
134
+ appearance_layout.addRow("", self.show_menubar_checkbox)
135
+
136
+ layout.addWidget(appearance_group)
137
+
138
+ # Save button
139
+ button_layout = QHBoxLayout()
140
+ button_layout.addStretch()
141
+
142
+ self.save_button = QPushButton("Save Settings")
143
+ self.save_button.setMinimumWidth(120)
144
+ self.save_button.clicked.connect(self._save_settings)
145
+ button_layout.addWidget(self.save_button)
146
+
147
+ layout.addLayout(button_layout)
148
+
149
+ # Stretch to push everything up
150
+ layout.addStretch()
151
+
152
+ def _load_settings(self):
153
+ """Load current settings into the UI."""
154
+ self.recordings_input.setText(str(self.config.recordings_dir))
155
+ self.transcripts_input.setText(str(self.config.transcripts_dir))
156
+
157
+ # Set combo box to current model
158
+ model = self.config.whisper_model
159
+ index = self.model_combo.findText(model)
160
+ if index >= 0:
161
+ self.model_combo.setCurrentIndex(index)
162
+
163
+ self.auto_transcribe_checkbox.setChecked(self.config.auto_transcribe)
164
+ self.silence_timeout_spin.setValue(self.config.silence_timeout)
165
+ self.show_menubar_checkbox.setChecked(self.config.show_menubar)
166
+
167
+ def _browse_recordings_dir(self):
168
+ """Browse for recordings directory."""
169
+ current = self.recordings_input.text()
170
+ directory = QFileDialog.getExistingDirectory(
171
+ self,
172
+ "Select Recordings Directory",
173
+ current,
174
+ )
175
+ if directory:
176
+ self.recordings_input.setText(directory)
177
+
178
+ def _browse_transcripts_dir(self):
179
+ """Browse for transcripts directory."""
180
+ current = self.transcripts_input.text()
181
+ directory = QFileDialog.getExistingDirectory(
182
+ self,
183
+ "Select Transcripts Directory",
184
+ current,
185
+ )
186
+ if directory:
187
+ self.transcripts_input.setText(directory)
188
+
189
+ def _save_settings(self):
190
+ """Save settings to config file."""
191
+ recordings_dir = Path(self.recordings_input.text())
192
+ transcripts_dir = Path(self.transcripts_input.text())
193
+
194
+ # Validate directories
195
+ try:
196
+ recordings_dir.mkdir(parents=True, exist_ok=True)
197
+ transcripts_dir.mkdir(parents=True, exist_ok=True)
198
+ except Exception as e:
199
+ QMessageBox.warning(self, "Error", f"Could not create directories: {e}")
200
+ return
201
+
202
+ # Check if menubar setting changed
203
+ old_show_menubar = self.config.show_menubar
204
+ new_show_menubar = self.show_menubar_checkbox.isChecked()
205
+
206
+ # Update config
207
+ self.config.recordings_dir = recordings_dir
208
+ self.config.transcripts_dir = transcripts_dir
209
+ self.config.whisper_model = self.model_combo.currentText()
210
+ self.config.auto_transcribe = self.auto_transcribe_checkbox.isChecked()
211
+ self.config.silence_timeout = self.silence_timeout_spin.value()
212
+ self.config.show_menubar = new_show_menubar
213
+
214
+ try:
215
+ self.config.save()
216
+
217
+ # Handle menubar start/stop
218
+ if new_show_menubar and not old_show_menubar:
219
+ self._start_menubar()
220
+ elif not new_show_menubar and old_show_menubar:
221
+ self._stop_menubar()
222
+
223
+ self.settings_saved.emit()
224
+ QMessageBox.information(self, "Success", "Settings saved successfully.")
225
+ except Exception as e:
226
+ QMessageBox.warning(self, "Error", f"Could not save settings: {e}")
227
+
228
+ def _start_menubar(self):
229
+ """Start the menu bar app in background."""
230
+ subprocess.Popen(
231
+ [sys.executable, "-m", "meeting_noter.menubar"],
232
+ stdout=subprocess.DEVNULL,
233
+ stderr=subprocess.DEVNULL,
234
+ start_new_session=True,
235
+ )
236
+
237
+ def _stop_menubar(self):
238
+ """Stop the menu bar app if running."""
239
+ import os
240
+ import signal
241
+
242
+ menubar_pid_file = Path.home() / ".meeting-noter-menubar.pid"
243
+
244
+ pid = read_pid_file(menubar_pid_file)
245
+ if pid and is_process_running(pid):
246
+ try:
247
+ os.kill(pid, signal.SIGTERM)
248
+ except (OSError, ProcessLookupError):
249
+ pass
@@ -0,0 +1 @@
1
+ """Installation and setup modules."""