meeting-noter 0.3.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 (38) 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 +176 -0
  6. meeting_noter/audio/system_audio.py +363 -0
  7. meeting_noter/cli.py +308 -0
  8. meeting_noter/config.py +197 -0
  9. meeting_noter/daemon.py +514 -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 +296 -0
  20. meeting_noter/menubar.py +432 -0
  21. meeting_noter/output/__init__.py +1 -0
  22. meeting_noter/output/writer.py +96 -0
  23. meeting_noter/resources/__init__.py +1 -0
  24. meeting_noter/resources/icon.icns +0 -0
  25. meeting_noter/resources/icon.png +0 -0
  26. meeting_noter/resources/icon_128.png +0 -0
  27. meeting_noter/resources/icon_16.png +0 -0
  28. meeting_noter/resources/icon_256.png +0 -0
  29. meeting_noter/resources/icon_32.png +0 -0
  30. meeting_noter/resources/icon_512.png +0 -0
  31. meeting_noter/resources/icon_64.png +0 -0
  32. meeting_noter/transcription/__init__.py +1 -0
  33. meeting_noter/transcription/engine.py +208 -0
  34. meeting_noter-0.3.0.dist-info/METADATA +261 -0
  35. meeting_noter-0.3.0.dist-info/RECORD +38 -0
  36. meeting_noter-0.3.0.dist-info/WHEEL +5 -0
  37. meeting_noter-0.3.0.dist-info/entry_points.txt +2 -0
  38. meeting_noter-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """Desktop GUI for Meeting Noter."""
2
+
3
+ from meeting_noter.gui.app import run_gui
4
+
5
+ __all__ = ["run_gui"]
@@ -0,0 +1,6 @@
1
+ """Allow running gui as a module: python -m meeting_noter.gui"""
2
+
3
+ from meeting_noter.gui import run_gui
4
+
5
+ if __name__ == "__main__":
6
+ run_gui()
@@ -0,0 +1,53 @@
1
+ """PyQt6 application entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from PyQt6.QtGui import QIcon
9
+ from PyQt6.QtWidgets import QApplication
10
+
11
+ from meeting_noter.gui.main_window import MainWindow
12
+
13
+
14
+ def _set_macos_dock_icon(icon_path: Path):
15
+ """Set the macOS dock icon using AppKit."""
16
+ try:
17
+ from AppKit import NSApplication, NSImage
18
+ ns_app = NSApplication.sharedApplication()
19
+ icon = NSImage.alloc().initWithContentsOfFile_(str(icon_path))
20
+ if icon:
21
+ ns_app.setApplicationIconImage_(icon)
22
+ except ImportError:
23
+ pass # pyobjc not installed
24
+
25
+
26
+ def run_gui():
27
+ """Launch the Meeting Noter GUI application."""
28
+ resources = Path(__file__).parent.parent / "resources"
29
+
30
+ app = QApplication(sys.argv)
31
+ app.setApplicationName("Meeting Noter")
32
+ app.setOrganizationName("Meeting Noter")
33
+
34
+ # Set window icon
35
+ icon_path = resources / "icon.png"
36
+ if icon_path.exists():
37
+ app.setWindowIcon(QIcon(str(icon_path)))
38
+
39
+ window = MainWindow()
40
+ window.show()
41
+
42
+ # Set macOS dock icon AFTER window is shown
43
+ if sys.platform == "darwin":
44
+ icns_path = resources / "icon.icns"
45
+ if icns_path.exists():
46
+ _set_macos_dock_icon(icns_path)
47
+ app.processEvents()
48
+
49
+ sys.exit(app.exec())
50
+
51
+
52
+ if __name__ == "__main__":
53
+ run_gui()
@@ -0,0 +1,50 @@
1
+ """Main window with tab interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PyQt6.QtWidgets import QMainWindow, QTabWidget, QWidget, QVBoxLayout
6
+
7
+ from meeting_noter.gui.recording_tab import RecordingTab
8
+ from meeting_noter.gui.meetings_tab import MeetingsTab
9
+ from meeting_noter.gui.settings_tab import SettingsTab
10
+
11
+
12
+ class MainWindow(QMainWindow):
13
+ """Main application window with tabbed interface."""
14
+
15
+ def __init__(self):
16
+ super().__init__()
17
+ self.setWindowTitle("Meeting Noter")
18
+ self.setMinimumSize(800, 600)
19
+
20
+ # Create central widget with tabs
21
+ central_widget = QWidget()
22
+ self.setCentralWidget(central_widget)
23
+
24
+ layout = QVBoxLayout(central_widget)
25
+ layout.setContentsMargins(0, 0, 0, 0)
26
+
27
+ # Create tab widget
28
+ self.tabs = QTabWidget()
29
+ layout.addWidget(self.tabs)
30
+
31
+ # Create tabs
32
+ self.recording_tab = RecordingTab()
33
+ self.meetings_tab = MeetingsTab()
34
+ self.settings_tab = SettingsTab()
35
+
36
+ self.tabs.addTab(self.recording_tab, "Record")
37
+ self.tabs.addTab(self.meetings_tab, "Meetings")
38
+ self.tabs.addTab(self.settings_tab, "Settings")
39
+
40
+ # Connect settings changes to refresh meetings list
41
+ self.settings_tab.settings_saved.connect(self.meetings_tab.refresh)
42
+
43
+ # Connect recording completion to refresh meetings list
44
+ self.recording_tab.recording_saved.connect(self.meetings_tab.refresh)
45
+
46
+ def closeEvent(self, event):
47
+ """Handle window close - stop any active recording."""
48
+ if self.recording_tab.is_recording:
49
+ self.recording_tab.stop_recording()
50
+ event.accept()
@@ -0,0 +1,348 @@
1
+ """Meetings list tab for browsing and managing recordings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from PyQt6.QtCore import Qt, QUrl
12
+ from PyQt6.QtWidgets import (
13
+ QWidget,
14
+ QVBoxLayout,
15
+ QHBoxLayout,
16
+ QTableWidget,
17
+ QTableWidgetItem,
18
+ QPushButton,
19
+ QHeaderView,
20
+ QDialog,
21
+ QTextEdit,
22
+ QLineEdit,
23
+ QLabel,
24
+ QMessageBox,
25
+ QDialogButtonBox,
26
+ )
27
+ from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
28
+
29
+ from meeting_noter.config import get_config
30
+
31
+
32
+ class TranscriptDialog(QDialog):
33
+ """Dialog for viewing a transcript."""
34
+
35
+ def __init__(self, title: str, content: str, parent=None):
36
+ super().__init__(parent)
37
+ self.setWindowTitle(f"Transcript: {title}")
38
+ self.setMinimumSize(600, 400)
39
+
40
+ layout = QVBoxLayout(self)
41
+
42
+ self.text_edit = QTextEdit()
43
+ self.text_edit.setReadOnly(True)
44
+ self.text_edit.setPlainText(content)
45
+ layout.addWidget(self.text_edit)
46
+
47
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
48
+ button_box.rejected.connect(self.close)
49
+ layout.addWidget(button_box)
50
+
51
+
52
+ class RenameDialog(QDialog):
53
+ """Dialog for renaming a meeting."""
54
+
55
+ def __init__(self, current_name: str, parent=None):
56
+ super().__init__(parent)
57
+ self.setWindowTitle("Rename Meeting")
58
+ self.setMinimumWidth(400)
59
+
60
+ layout = QVBoxLayout(self)
61
+
62
+ layout.addWidget(QLabel("New name:"))
63
+
64
+ self.name_input = QLineEdit()
65
+ self.name_input.setText(current_name)
66
+ self.name_input.selectAll()
67
+ layout.addWidget(self.name_input)
68
+
69
+ button_box = QDialogButtonBox(
70
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
71
+ )
72
+ button_box.accepted.connect(self.accept)
73
+ button_box.rejected.connect(self.reject)
74
+ layout.addWidget(button_box)
75
+
76
+ def get_name(self) -> str:
77
+ """Get the entered name."""
78
+ return self.name_input.text().strip()
79
+
80
+
81
+ class MeetingsTab(QWidget):
82
+ """Tab for browsing and managing meeting recordings."""
83
+
84
+ def __init__(self):
85
+ super().__init__()
86
+ self.config = get_config()
87
+ self.player: Optional[QMediaPlayer] = None
88
+ self.audio_output: Optional[QAudioOutput] = None
89
+ self.current_playing: Optional[Path] = None
90
+
91
+ self._setup_ui()
92
+ self.refresh()
93
+
94
+ def _setup_ui(self):
95
+ """Set up the user interface."""
96
+ layout = QVBoxLayout(self)
97
+
98
+ # Table for meetings
99
+ self.table = QTableWidget()
100
+ self.table.setColumnCount(5)
101
+ self.table.setHorizontalHeaderLabels(["Date", "Name", "Duration", "Transcript", "Actions"])
102
+ self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
103
+ self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
104
+ self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
105
+ self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
106
+ self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
107
+ self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
108
+ self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
109
+ layout.addWidget(self.table)
110
+
111
+ # Bottom buttons
112
+ button_layout = QHBoxLayout()
113
+
114
+ self.refresh_button = QPushButton("Refresh")
115
+ self.refresh_button.clicked.connect(self.refresh)
116
+ button_layout.addWidget(self.refresh_button)
117
+
118
+ self.open_folder_button = QPushButton("Open Folder")
119
+ self.open_folder_button.clicked.connect(self._open_folder)
120
+ button_layout.addWidget(self.open_folder_button)
121
+
122
+ button_layout.addStretch()
123
+
124
+ layout.addLayout(button_layout)
125
+
126
+ def refresh(self):
127
+ """Refresh the meetings list."""
128
+ self.table.setRowCount(0)
129
+
130
+ recordings_dir = self.config.recordings_dir
131
+ if not recordings_dir.exists():
132
+ return
133
+
134
+ # Find all MP3 files
135
+ mp3_files = sorted(recordings_dir.glob("*.mp3"), reverse=True)
136
+
137
+ for mp3_path in mp3_files:
138
+ self._add_meeting_row(mp3_path)
139
+
140
+ def _get_transcript_path(self, mp3_path: Path) -> Path:
141
+ """Get the transcript path for an audio file."""
142
+ transcripts_dir = self.config.transcripts_dir
143
+ return transcripts_dir / mp3_path.with_suffix(".txt").name
144
+
145
+ def _add_meeting_row(self, mp3_path: Path):
146
+ """Add a row for a meeting recording."""
147
+ row = self.table.rowCount()
148
+ self.table.insertRow(row)
149
+
150
+ # Parse filename
151
+ name = mp3_path.stem
152
+ parts = name.split("_", 2)
153
+
154
+ if len(parts) >= 2:
155
+ # Format: YYYY-MM-DD_HHMMSS or YYYY-MM-DD_HHMMSS_name
156
+ try:
157
+ date_str = parts[0]
158
+ time_str = parts[1]
159
+ date_obj = datetime.strptime(f"{date_str}_{time_str}", "%Y-%m-%d_%H%M%S")
160
+ display_date = date_obj.strftime("%Y-%m-%d %H:%M")
161
+ except ValueError:
162
+ display_date = name
163
+ meeting_name = parts[2] if len(parts) > 2 else ""
164
+ else:
165
+ display_date = name
166
+ meeting_name = ""
167
+
168
+ # Date column
169
+ date_item = QTableWidgetItem(display_date)
170
+ date_item.setData(Qt.ItemDataRole.UserRole, str(mp3_path))
171
+ self.table.setItem(row, 0, date_item)
172
+
173
+ # Name column
174
+ name_item = QTableWidgetItem(meeting_name)
175
+ self.table.setItem(row, 1, name_item)
176
+
177
+ # Duration column
178
+ duration = self._get_duration(mp3_path)
179
+ duration_item = QTableWidgetItem(duration)
180
+ self.table.setItem(row, 2, duration_item)
181
+
182
+ # Transcript column
183
+ transcript_path = self._get_transcript_path(mp3_path)
184
+ has_transcript = transcript_path.exists()
185
+ transcript_item = QTableWidgetItem("Yes" if has_transcript else "No")
186
+ self.table.setItem(row, 3, transcript_item)
187
+
188
+ # Actions column - widget with buttons
189
+ actions_widget = QWidget()
190
+ actions_layout = QHBoxLayout(actions_widget)
191
+ actions_layout.setContentsMargins(4, 2, 4, 2)
192
+ actions_layout.setSpacing(4)
193
+
194
+ play_btn = QPushButton("Play")
195
+ play_btn.setFixedWidth(50)
196
+ play_btn.clicked.connect(lambda checked, p=mp3_path: self._play_audio(p))
197
+ actions_layout.addWidget(play_btn)
198
+
199
+ if has_transcript:
200
+ view_btn = QPushButton("View")
201
+ view_btn.setFixedWidth(50)
202
+ tp = transcript_path # Capture for lambda
203
+ view_btn.clicked.connect(lambda checked, p=tp: self._view_transcript(p))
204
+ actions_layout.addWidget(view_btn)
205
+ else:
206
+ transcribe_btn = QPushButton("Transcribe")
207
+ transcribe_btn.setFixedWidth(70)
208
+ transcribe_btn.clicked.connect(lambda checked, p=mp3_path: self._transcribe_recording(p))
209
+ actions_layout.addWidget(transcribe_btn)
210
+
211
+ rename_btn = QPushButton("Rename")
212
+ rename_btn.setFixedWidth(60)
213
+ rename_btn.clicked.connect(lambda checked, p=mp3_path, r=row: self._rename_meeting(p, r))
214
+ actions_layout.addWidget(rename_btn)
215
+
216
+ self.table.setCellWidget(row, 4, actions_widget)
217
+
218
+ def _get_duration(self, mp3_path: Path) -> str:
219
+ """Get the duration of an MP3 file."""
220
+ try:
221
+ # Use file size as rough estimate (128kbps = 16KB/s)
222
+ size_bytes = mp3_path.stat().st_size
223
+ duration_secs = size_bytes / (128 * 1000 / 8)
224
+ mins, secs = divmod(int(duration_secs), 60)
225
+ return f"{mins:02d}:{secs:02d}"
226
+ except Exception:
227
+ return "--:--"
228
+
229
+ def _play_audio(self, mp3_path: Path):
230
+ """Play or stop audio playback."""
231
+ # Stop current playback if any
232
+ if self.player and self.current_playing == mp3_path:
233
+ self.player.stop()
234
+ self.player = None
235
+ self.audio_output = None
236
+ self.current_playing = None
237
+ return
238
+
239
+ # Stop any existing playback
240
+ if self.player:
241
+ self.player.stop()
242
+
243
+ # Create new player
244
+ self.audio_output = QAudioOutput()
245
+ self.player = QMediaPlayer()
246
+ self.player.setAudioOutput(self.audio_output)
247
+ self.player.setSource(QUrl.fromLocalFile(str(mp3_path)))
248
+ self.player.play()
249
+ self.current_playing = mp3_path
250
+
251
+ def _view_transcript(self, transcript_path: Path):
252
+ """View a transcript in a dialog."""
253
+ try:
254
+ content = transcript_path.read_text()
255
+ dialog = TranscriptDialog(transcript_path.stem, content, self)
256
+ dialog.exec()
257
+ except Exception as e:
258
+ QMessageBox.warning(self, "Error", f"Could not read transcript: {e}")
259
+
260
+ def _rename_meeting(self, mp3_path: Path, row: int):
261
+ """Rename a meeting (both MP3 and transcript files)."""
262
+ # Extract current name
263
+ name = mp3_path.stem
264
+ parts = name.split("_", 2)
265
+ current_name = parts[2] if len(parts) > 2 else ""
266
+
267
+ dialog = RenameDialog(current_name, self)
268
+ if dialog.exec() != QDialog.DialogCode.Accepted:
269
+ return
270
+
271
+ new_name = dialog.get_name()
272
+ if not new_name:
273
+ return
274
+
275
+ # Sanitize new name
276
+ import re
277
+ sanitized = new_name.replace(" ", "_")
278
+ sanitized = re.sub(r"[^\w\-]", "", sanitized)
279
+ if len(sanitized) > 50:
280
+ sanitized = sanitized[:50].rstrip("_-")
281
+
282
+ # Build new filename
283
+ if len(parts) >= 2:
284
+ new_stem = f"{parts[0]}_{parts[1]}_{sanitized}"
285
+ else:
286
+ new_stem = f"{name}_{sanitized}"
287
+
288
+ new_mp3_path = mp3_path.with_stem(new_stem)
289
+ transcript_path = self._get_transcript_path(mp3_path)
290
+ new_transcript_path = self._get_transcript_path(new_mp3_path)
291
+
292
+ try:
293
+ # Rename MP3
294
+ mp3_path.rename(new_mp3_path)
295
+
296
+ # Rename transcript if exists
297
+ if transcript_path.exists():
298
+ transcript_path.rename(new_transcript_path)
299
+
300
+ self.refresh()
301
+ except Exception as e:
302
+ QMessageBox.warning(self, "Error", f"Could not rename files: {e}")
303
+
304
+ def _transcribe_recording(self, mp3_path: Path):
305
+ """Transcribe a recording that doesn't have a transcript."""
306
+ from PyQt6.QtCore import QThread, pyqtSignal
307
+
308
+ class TranscribeWorker(QThread):
309
+ finished = pyqtSignal(bool, str)
310
+
311
+ def __init__(self, audio_path, config):
312
+ super().__init__()
313
+ self.audio_path = audio_path
314
+ self.config = config
315
+
316
+ def run(self):
317
+ try:
318
+ from meeting_noter.transcription.engine import transcribe_file
319
+ transcribe_file(
320
+ str(self.audio_path),
321
+ self.config.recordings_dir,
322
+ self.config.whisper_model,
323
+ self.config.transcripts_dir,
324
+ )
325
+ self.finished.emit(True, "")
326
+ except Exception as e:
327
+ self.finished.emit(False, str(e))
328
+
329
+ def on_finished(success, error):
330
+ self._transcribe_worker = None
331
+ if success:
332
+ QMessageBox.information(self, "Success", f"Transcription complete: {mp3_path.name}")
333
+ self.refresh()
334
+ else:
335
+ QMessageBox.warning(self, "Error", f"Transcription failed: {error}")
336
+
337
+ # Show progress
338
+ QMessageBox.information(self, "Transcribing", f"Transcribing {mp3_path.name}...\nThis may take a while.")
339
+
340
+ self._transcribe_worker = TranscribeWorker(mp3_path, self.config)
341
+ self._transcribe_worker.finished.connect(on_finished)
342
+ self._transcribe_worker.start()
343
+
344
+ def _open_folder(self):
345
+ """Open the recordings folder in Finder."""
346
+ recordings_dir = self.config.recordings_dir
347
+ recordings_dir.mkdir(parents=True, exist_ok=True)
348
+ subprocess.run(["open", str(recordings_dir)])