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,411 @@
1
+ """Menu bar app for Meeting Noter daemon control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import os
7
+ import subprocess
8
+ import threading
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import rumps
13
+
14
+ from meeting_noter.daemon import read_pid_file, is_process_running, stop_daemon
15
+ from meeting_noter.config import get_config, generate_meeting_name
16
+ from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title
17
+
18
+
19
+ DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
20
+ MENUBAR_PID_FILE = Path.home() / ".meeting-noter-menubar.pid"
21
+ RECORDING_STATE_FILE = Path.home() / ".meeting-noter-recording.json"
22
+
23
+
24
+ def _write_menubar_pid():
25
+ """Write the menubar PID file."""
26
+ with open(MENUBAR_PID_FILE, "w") as f:
27
+ f.write(str(os.getpid()))
28
+
29
+
30
+ def _remove_menubar_pid():
31
+ """Remove the menubar PID file."""
32
+ try:
33
+ MENUBAR_PID_FILE.unlink()
34
+ except FileNotFoundError:
35
+ pass
36
+
37
+
38
+ class MeetingNoterApp(rumps.App):
39
+ """Menu bar app for controlling the Meeting Noter daemon."""
40
+
41
+ def __init__(self):
42
+ super().__init__("Meeting Noter", title="▷")
43
+ self.pid_file = DEFAULT_PID_FILE
44
+ self.config = get_config()
45
+ self.mic_monitor = MicrophoneMonitor()
46
+ self.current_meeting_name: Optional[str] = None
47
+ self.pending_notification = False # Avoid duplicate notifications
48
+ self._pending_app_name: Optional[str] = None
49
+ self.menu = [
50
+ "Start Recording",
51
+ "Stop Recording",
52
+ None, # Separator
53
+ "Open Recordings",
54
+ "Open UI",
55
+ ]
56
+ self._update_title()
57
+
58
+ def _is_running(self) -> bool:
59
+ """Check if daemon is currently running."""
60
+ pid = read_pid_file(self.pid_file)
61
+ return pid is not None and is_process_running(pid)
62
+
63
+ def _get_current_recording_name(self) -> Optional[str]:
64
+ """Get the name of the current recording from the log file.
65
+
66
+ Parses the daemon log to find the most recent 'Recording started:' entry.
67
+ Returns the filename (without extension) or None if not recording.
68
+ """
69
+ if not self._is_running():
70
+ return None
71
+
72
+ log_path = Path.home() / ".meeting-noter.log"
73
+ if not log_path.exists():
74
+ return None
75
+
76
+ try:
77
+ with open(log_path, "r") as f:
78
+ lines = f.readlines()
79
+
80
+ # Find the most recent recording start/save
81
+ recording_name = None
82
+ for line in reversed(lines[-50:]): # Check last 50 lines
83
+ if "Recording started:" in line:
84
+ # Extract filename from "Recording started: filename.mp3"
85
+ parts = line.split("Recording started:")
86
+ if len(parts) > 1:
87
+ filename = parts[1].strip()
88
+ # Remove extension and timestamp prefix
89
+ name = filename.replace(".mp3", "")
90
+ # If format is timestamp_name, extract just the name
91
+ parts = name.split("_", 2) # Split max 2 times
92
+ if len(parts) >= 3:
93
+ # Format: YYYY-MM-DD_HHMMSS_MeetingName
94
+ recording_name = parts[2]
95
+ else:
96
+ recording_name = name
97
+ break
98
+ elif "Recording saved:" in line or "Recording discarded" in line:
99
+ # Recording ended, no active recording
100
+ break
101
+
102
+ return recording_name
103
+ except Exception:
104
+ return None
105
+
106
+ def _truncate_name(self, name: str, max_length: int = 15) -> str:
107
+ """Truncate a name to fit in the menu bar."""
108
+ if len(name) <= max_length:
109
+ return name
110
+ return name[:max_length - 1] + "..."
111
+
112
+ def _update_title(self):
113
+ """Update menu bar title based on daemon status."""
114
+ if self._is_running():
115
+ self.title = "▶" # Filled triangle = recording
116
+ else:
117
+ self.title = "▷" # Outline triangle = idle
118
+
119
+ def _save_recording_state(self, meeting_name: str, file_path: Optional[str] = None):
120
+ """Save current recording state to file."""
121
+ import json
122
+ state = {
123
+ "recording": True,
124
+ "meeting_name": meeting_name,
125
+ "file_path": file_path,
126
+ }
127
+ try:
128
+ with open(RECORDING_STATE_FILE, "w") as f:
129
+ json.dump(state, f)
130
+ except Exception:
131
+ pass
132
+
133
+ def _clear_recording_state(self):
134
+ """Clear recording state file."""
135
+ try:
136
+ RECORDING_STATE_FILE.unlink()
137
+ except FileNotFoundError:
138
+ pass
139
+
140
+ def _start_recording_with_name(self, meeting_name: str, app_name: Optional[str] = None):
141
+ """Start recording with a specific meeting name."""
142
+ import sys
143
+
144
+ if self._is_running():
145
+ return
146
+
147
+ self.current_meeting_name = meeting_name
148
+ self._save_recording_state(meeting_name)
149
+ self.mic_monitor.set_recording(True, app_name)
150
+
151
+ # Start daemon with the meeting name using current Python
152
+ subprocess.Popen(
153
+ [sys.executable, "-m", "meeting_noter.cli", "daemon", "--name", meeting_name],
154
+ stdout=subprocess.DEVNULL,
155
+ stderr=subprocess.DEVNULL,
156
+ )
157
+ self._update_title()
158
+
159
+ @rumps.clicked("Start Recording")
160
+ def start_recording(self, _):
161
+ """Start the daemon via subprocess."""
162
+ if self._is_running():
163
+ rumps.notification(
164
+ title="Meeting Noter",
165
+ subtitle="Already Running",
166
+ message="The daemon is already recording.",
167
+ )
168
+ return
169
+
170
+ meeting_name = generate_meeting_name()
171
+ self._start_recording_with_name(meeting_name)
172
+
173
+ rumps.notification(
174
+ title="Meeting Noter",
175
+ subtitle="Recording Started",
176
+ message=meeting_name,
177
+ )
178
+
179
+ def _get_latest_recording(self) -> Optional[Path]:
180
+ """Get the most recent recording file."""
181
+ recordings_dir = self.config.recordings_dir
182
+ if not recordings_dir.exists():
183
+ return None
184
+ mp3_files = sorted(recordings_dir.glob("*.mp3"), key=lambda p: p.stat().st_mtime)
185
+ return mp3_files[-1] if mp3_files else None
186
+
187
+ def _get_transcript_path(self, audio_path: Path) -> Path:
188
+ """Get the transcript path for an audio file."""
189
+ return self.config.transcripts_dir / audio_path.with_suffix(".txt").name
190
+
191
+ def _transcribe_in_background(self, audio_path: Path):
192
+ """Transcribe a recording in background subprocess."""
193
+ import sys
194
+ # Use subprocess to run transcription - more reliable than threading with rumps
195
+ subprocess.Popen(
196
+ [sys.executable, "-m", "meeting_noter.cli", "transcribe", str(audio_path)],
197
+ stdout=subprocess.DEVNULL,
198
+ stderr=subprocess.DEVNULL,
199
+ )
200
+
201
+ @rumps.clicked("Stop Recording")
202
+ def stop_recording(self, _):
203
+ """Stop the running daemon and optionally transcribe."""
204
+ if not self._is_running():
205
+ rumps.notification(
206
+ title="Meeting Noter",
207
+ subtitle="Not Running",
208
+ message="The daemon is not running.",
209
+ )
210
+ return
211
+
212
+ # Get latest recording before stopping (to compare after)
213
+ latest_before = self._get_latest_recording()
214
+ before_mtime = latest_before.stat().st_mtime if latest_before else 0
215
+
216
+ stop_daemon(self.pid_file)
217
+ self._update_title()
218
+
219
+ # Check for new recording after stopping
220
+ import time
221
+ time.sleep(2) # Give daemon time to save file
222
+
223
+ # Reload config to get current settings
224
+ self.config.load()
225
+
226
+ latest_after = self._get_latest_recording()
227
+
228
+ # Check if there's a new or updated recording
229
+ is_new_recording = False
230
+ if latest_after:
231
+ after_mtime = latest_after.stat().st_mtime
232
+ if latest_before is None or str(latest_after) != str(latest_before) or after_mtime > before_mtime:
233
+ is_new_recording = True
234
+
235
+ # Clear recording state
236
+ self._clear_recording_state()
237
+ self.current_meeting_name = None
238
+
239
+ if is_new_recording:
240
+ # New recording was saved
241
+ if self.config.auto_transcribe:
242
+ rumps.notification(
243
+ title="Meeting Noter",
244
+ subtitle="Recording Saved",
245
+ message=f"Transcribing {latest_after.name}...",
246
+ )
247
+ self._transcribe_in_background(latest_after)
248
+ else:
249
+ rumps.notification(
250
+ title="Meeting Noter",
251
+ subtitle="Recording Saved",
252
+ message=latest_after.name,
253
+ )
254
+ else:
255
+ rumps.notification(
256
+ title="Meeting Noter",
257
+ subtitle="Stopped",
258
+ message="Recording daemon stopped.",
259
+ )
260
+
261
+ @rumps.clicked("Open Recordings")
262
+ def open_recordings(self, _):
263
+ """Open the recordings folder in Finder."""
264
+ recordings_dir = self.config.recordings_dir
265
+ recordings_dir.mkdir(parents=True, exist_ok=True)
266
+ subprocess.run(["open", str(recordings_dir)])
267
+
268
+ @rumps.clicked("Open UI")
269
+ def open_ui(self, _):
270
+ """Open the desktop GUI application."""
271
+ import sys
272
+ subprocess.Popen(
273
+ [sys.executable, "-m", "meeting_noter.cli", "gui"],
274
+ stdout=subprocess.DEVNULL,
275
+ stderr=subprocess.DEVNULL,
276
+ )
277
+
278
+ @rumps.timer(2)
279
+ def poll_status(self, _):
280
+ """Periodically update the menu bar title and check for mic usage."""
281
+ self._update_title()
282
+
283
+ is_recording = self._is_running()
284
+
285
+ # Tell mic monitor our recording state
286
+ self.mic_monitor.set_recording(is_recording)
287
+
288
+ # Check for mic usage changes
289
+ mic_started, mic_stopped, app_name = self.mic_monitor.check()
290
+
291
+ # Auto-stop recording when mic stops being used
292
+ if mic_stopped and is_recording:
293
+ rumps.notification(
294
+ title="Meeting Noter",
295
+ subtitle="Call Ended",
296
+ message="Stopping recording...",
297
+ )
298
+ self._auto_stop_recording()
299
+ return
300
+
301
+ # Prompt to record when mic starts being used
302
+ if mic_started and not is_recording and not self.pending_notification:
303
+ self.pending_notification = True
304
+ self._pending_app_name = app_name or "Unknown App"
305
+ # Try to get meeting name from window title, fall back to timestamp
306
+ window_title = get_meeting_window_title()
307
+ self._pending_meeting_name = window_title or generate_meeting_name()
308
+
309
+ def _auto_stop_recording(self):
310
+ """Stop recording automatically when meeting ends."""
311
+ if not self._is_running():
312
+ return
313
+
314
+ # Get latest recording before stopping
315
+ latest_before = self._get_latest_recording()
316
+ before_mtime = latest_before.stat().st_mtime if latest_before else 0
317
+
318
+ stop_daemon(self.pid_file)
319
+ self._update_title()
320
+
321
+ # Wait for file to be saved
322
+ import time
323
+ time.sleep(2)
324
+
325
+ # Reload config
326
+ self.config.load()
327
+
328
+ latest_after = self._get_latest_recording()
329
+
330
+ # Check for new recording
331
+ is_new_recording = False
332
+ if latest_after:
333
+ after_mtime = latest_after.stat().st_mtime
334
+ if latest_before is None or str(latest_after) != str(latest_before) or after_mtime > before_mtime:
335
+ is_new_recording = True
336
+
337
+ # Clear state
338
+ self._clear_recording_state()
339
+ self.current_meeting_name = None
340
+
341
+ if is_new_recording:
342
+ if self.config.auto_transcribe:
343
+ rumps.notification(
344
+ title="Meeting Noter",
345
+ subtitle="Recording Saved",
346
+ message=f"Transcribing {latest_after.name}...",
347
+ )
348
+ self._transcribe_in_background(latest_after)
349
+ else:
350
+ rumps.notification(
351
+ title="Meeting Noter",
352
+ subtitle="Recording Saved",
353
+ message=latest_after.name,
354
+ )
355
+
356
+ @rumps.timer(1)
357
+ def check_pending_prompt(self, _):
358
+ """Check if we need to show a recording prompt (runs on main thread)."""
359
+ if self.pending_notification and hasattr(self, '_pending_meeting_name'):
360
+ app_name = self._pending_app_name or "App"
361
+ meeting_name = self._pending_meeting_name
362
+
363
+ # Clear first to prevent re-triggering
364
+ self._pending_app_name = None
365
+ self.pending_notification = False
366
+
367
+ # Build message with meeting name if available
368
+ if meeting_name and not meeting_name[0].isdigit():
369
+ # Has a real meeting name (not timestamp-based)
370
+ message = f"Meeting: {meeting_name}\n\nDo you want to record?"
371
+ else:
372
+ message = "Do you want to record this call?"
373
+
374
+ # Show alert on main thread
375
+ response = rumps.alert(
376
+ title=f"Microphone in use: {app_name}",
377
+ message=message,
378
+ ok="Record",
379
+ cancel="Skip",
380
+ )
381
+
382
+ if response == 1: # Record clicked
383
+ self._start_recording_with_name(meeting_name, app_name)
384
+ rumps.notification(
385
+ title="Meeting Noter",
386
+ subtitle="Recording Started",
387
+ message=meeting_name,
388
+ )
389
+
390
+
391
+ def _hide_dock_icon():
392
+ """Hide the dock icon on macOS (make it a background agent app)."""
393
+ try:
394
+ from AppKit import NSApplication, NSApplicationActivationPolicyAccessory
395
+ NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory)
396
+ except ImportError:
397
+ pass
398
+
399
+
400
+ def run_menubar():
401
+ """Run the menu bar app."""
402
+ _hide_dock_icon()
403
+ _write_menubar_pid()
404
+ atexit.register(_remove_menubar_pid)
405
+
406
+ app = MeetingNoterApp()
407
+ app.run()
408
+
409
+
410
+ if __name__ == "__main__":
411
+ run_menubar()