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.
- meeting_noter/__init__.py +3 -0
- meeting_noter/__main__.py +6 -0
- meeting_noter/audio/__init__.py +1 -0
- meeting_noter/audio/capture.py +209 -0
- meeting_noter/audio/encoder.py +208 -0
- meeting_noter/audio/system_audio.py +363 -0
- meeting_noter/cli.py +837 -0
- meeting_noter/config.py +197 -0
- meeting_noter/daemon.py +519 -0
- meeting_noter/gui/__init__.py +5 -0
- meeting_noter/gui/__main__.py +6 -0
- meeting_noter/gui/app.py +53 -0
- meeting_noter/gui/main_window.py +50 -0
- meeting_noter/gui/meetings_tab.py +348 -0
- meeting_noter/gui/recording_tab.py +358 -0
- meeting_noter/gui/settings_tab.py +249 -0
- meeting_noter/install/__init__.py +1 -0
- meeting_noter/install/macos.py +102 -0
- meeting_noter/meeting_detector.py +333 -0
- meeting_noter/menubar.py +411 -0
- meeting_noter/mic_monitor.py +456 -0
- meeting_noter/output/__init__.py +1 -0
- meeting_noter/output/writer.py +96 -0
- meeting_noter/resources/__init__.py +1 -0
- meeting_noter/resources/icon.icns +0 -0
- meeting_noter/resources/icon.png +0 -0
- meeting_noter/resources/icon_128.png +0 -0
- meeting_noter/resources/icon_16.png +0 -0
- meeting_noter/resources/icon_256.png +0 -0
- meeting_noter/resources/icon_32.png +0 -0
- meeting_noter/resources/icon_512.png +0 -0
- meeting_noter/resources/icon_64.png +0 -0
- meeting_noter/transcription/__init__.py +1 -0
- meeting_noter/transcription/engine.py +234 -0
- meeting_noter-0.7.0.dist-info/METADATA +224 -0
- meeting_noter-0.7.0.dist-info/RECORD +39 -0
- meeting_noter-0.7.0.dist-info/WHEEL +5 -0
- meeting_noter-0.7.0.dist-info/entry_points.txt +2 -0
- 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."""
|