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,432 @@
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.meeting_detector import MeetingMonitor, MeetingInfo
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.meeting_monitor = MeetingMonitor()
46
+ self.current_meeting_name: Optional[str] = None
47
+ self.pending_notification = False # Avoid duplicate notifications
48
+ self._no_meeting_count = 0 # Counter for auto-stop detection
49
+ self._initial_check_done = False # Flag for initial meeting check
50
+ self.menu = [
51
+ "Start Recording",
52
+ "Stop Recording",
53
+ None, # Separator
54
+ "Open Recordings",
55
+ "Open UI",
56
+ ]
57
+ self._update_title()
58
+
59
+ def _is_running(self) -> bool:
60
+ """Check if daemon is currently running."""
61
+ pid = read_pid_file(self.pid_file)
62
+ return pid is not None and is_process_running(pid)
63
+
64
+ def _get_current_recording_name(self) -> Optional[str]:
65
+ """Get the name of the current recording from the log file.
66
+
67
+ Parses the daemon log to find the most recent 'Recording started:' entry.
68
+ Returns the filename (without extension) or None if not recording.
69
+ """
70
+ if not self._is_running():
71
+ return None
72
+
73
+ log_path = Path.home() / ".meeting-noter.log"
74
+ if not log_path.exists():
75
+ return None
76
+
77
+ try:
78
+ with open(log_path, "r") as f:
79
+ lines = f.readlines()
80
+
81
+ # Find the most recent recording start/save
82
+ recording_name = None
83
+ for line in reversed(lines[-50:]): # Check last 50 lines
84
+ if "Recording started:" in line:
85
+ # Extract filename from "Recording started: filename.mp3"
86
+ parts = line.split("Recording started:")
87
+ if len(parts) > 1:
88
+ filename = parts[1].strip()
89
+ # Remove extension and timestamp prefix
90
+ name = filename.replace(".mp3", "")
91
+ # If format is timestamp_name, extract just the name
92
+ parts = name.split("_", 2) # Split max 2 times
93
+ if len(parts) >= 3:
94
+ # Format: YYYY-MM-DD_HHMMSS_MeetingName
95
+ recording_name = parts[2]
96
+ else:
97
+ recording_name = name
98
+ break
99
+ elif "Recording saved:" in line or "Recording discarded" in line:
100
+ # Recording ended, no active recording
101
+ break
102
+
103
+ return recording_name
104
+ except Exception:
105
+ return None
106
+
107
+ def _truncate_name(self, name: str, max_length: int = 15) -> str:
108
+ """Truncate a name to fit in the menu bar."""
109
+ if len(name) <= max_length:
110
+ return name
111
+ return name[:max_length - 1] + "..."
112
+
113
+ def _update_title(self):
114
+ """Update menu bar title based on daemon status."""
115
+ if self._is_running():
116
+ self.title = "▶" # Filled triangle = recording
117
+ else:
118
+ self.title = "▷" # Outline triangle = idle
119
+
120
+ def _save_recording_state(self, meeting_name: str, file_path: Optional[str] = None):
121
+ """Save current recording state to file."""
122
+ import json
123
+ state = {
124
+ "recording": True,
125
+ "meeting_name": meeting_name,
126
+ "file_path": file_path,
127
+ }
128
+ try:
129
+ with open(RECORDING_STATE_FILE, "w") as f:
130
+ json.dump(state, f)
131
+ except Exception:
132
+ pass
133
+
134
+ def _clear_recording_state(self):
135
+ """Clear recording state file."""
136
+ try:
137
+ RECORDING_STATE_FILE.unlink()
138
+ except FileNotFoundError:
139
+ pass
140
+
141
+ def _start_recording_with_name(self, meeting_name: str):
142
+ """Start recording with a specific meeting name."""
143
+ import sys
144
+
145
+ if self._is_running():
146
+ return
147
+
148
+ self.current_meeting_name = meeting_name
149
+ self._save_recording_state(meeting_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
+ # Check if there's an active meeting to get the name
171
+ meeting_info = self.meeting_monitor.last_meeting
172
+ if meeting_info and meeting_info.meeting_name:
173
+ meeting_name = meeting_info.meeting_name
174
+ else:
175
+ meeting_name = generate_meeting_name()
176
+
177
+ self._start_recording_with_name(meeting_name)
178
+
179
+ rumps.notification(
180
+ title="Meeting Noter",
181
+ subtitle="Recording Started",
182
+ message=meeting_name,
183
+ )
184
+
185
+ def _get_latest_recording(self) -> Optional[Path]:
186
+ """Get the most recent recording file."""
187
+ recordings_dir = self.config.recordings_dir
188
+ if not recordings_dir.exists():
189
+ return None
190
+ mp3_files = sorted(recordings_dir.glob("*.mp3"), key=lambda p: p.stat().st_mtime)
191
+ return mp3_files[-1] if mp3_files else None
192
+
193
+ def _get_transcript_path(self, audio_path: Path) -> Path:
194
+ """Get the transcript path for an audio file."""
195
+ return self.config.transcripts_dir / audio_path.with_suffix(".txt").name
196
+
197
+ def _transcribe_in_background(self, audio_path: Path):
198
+ """Transcribe a recording in background subprocess."""
199
+ import sys
200
+ # Use subprocess to run transcription - more reliable than threading with rumps
201
+ subprocess.Popen(
202
+ [sys.executable, "-m", "meeting_noter.cli", "transcribe", str(audio_path)],
203
+ stdout=subprocess.DEVNULL,
204
+ stderr=subprocess.DEVNULL,
205
+ )
206
+
207
+ @rumps.clicked("Stop Recording")
208
+ def stop_recording(self, _):
209
+ """Stop the running daemon and optionally transcribe."""
210
+ if not self._is_running():
211
+ rumps.notification(
212
+ title="Meeting Noter",
213
+ subtitle="Not Running",
214
+ message="The daemon is not running.",
215
+ )
216
+ return
217
+
218
+ # Get latest recording before stopping (to compare after)
219
+ latest_before = self._get_latest_recording()
220
+ before_mtime = latest_before.stat().st_mtime if latest_before else 0
221
+
222
+ stop_daemon(self.pid_file)
223
+ self._update_title()
224
+
225
+ # Check for new recording after stopping
226
+ import time
227
+ time.sleep(2) # Give daemon time to save file
228
+
229
+ # Reload config to get current settings
230
+ self.config.load()
231
+
232
+ latest_after = self._get_latest_recording()
233
+
234
+ # Check if there's a new or updated recording
235
+ is_new_recording = False
236
+ if latest_after:
237
+ after_mtime = latest_after.stat().st_mtime
238
+ if latest_before is None or str(latest_after) != str(latest_before) or after_mtime > before_mtime:
239
+ is_new_recording = True
240
+
241
+ # Clear recording state
242
+ self._clear_recording_state()
243
+ self.current_meeting_name = None
244
+
245
+ if is_new_recording:
246
+ # New recording was saved
247
+ if self.config.auto_transcribe:
248
+ rumps.notification(
249
+ title="Meeting Noter",
250
+ subtitle="Recording Saved",
251
+ message=f"Transcribing {latest_after.name}...",
252
+ )
253
+ self._transcribe_in_background(latest_after)
254
+ else:
255
+ rumps.notification(
256
+ title="Meeting Noter",
257
+ subtitle="Recording Saved",
258
+ message=latest_after.name,
259
+ )
260
+ else:
261
+ rumps.notification(
262
+ title="Meeting Noter",
263
+ subtitle="Stopped",
264
+ message="Recording daemon stopped.",
265
+ )
266
+
267
+ @rumps.clicked("Open Recordings")
268
+ def open_recordings(self, _):
269
+ """Open the recordings folder in Finder."""
270
+ recordings_dir = self.config.recordings_dir
271
+ recordings_dir.mkdir(parents=True, exist_ok=True)
272
+ subprocess.run(["open", str(recordings_dir)])
273
+
274
+ @rumps.clicked("Open UI")
275
+ def open_ui(self, _):
276
+ """Open the desktop GUI application."""
277
+ import sys
278
+ subprocess.Popen(
279
+ [sys.executable, "-m", "meeting_noter.cli", "gui"],
280
+ stdout=subprocess.DEVNULL,
281
+ stderr=subprocess.DEVNULL,
282
+ )
283
+
284
+ @rumps.timer(3)
285
+ def poll_status(self, _):
286
+ """Periodically update the menu bar title and check for meetings."""
287
+ self._update_title()
288
+
289
+ is_recording = self._is_running()
290
+
291
+ # Check for meeting status changes
292
+ meeting_started, meeting_ended, meeting_info = self.meeting_monitor.check()
293
+
294
+ # On first check, treat existing meeting as "started" to show prompt
295
+ if not self._initial_check_done:
296
+ self._initial_check_done = True
297
+ if meeting_info and not is_recording:
298
+ meeting_started = True
299
+
300
+ # Auto-stop recording when meeting ends
301
+ if meeting_ended and is_recording:
302
+ rumps.notification(
303
+ title="Meeting Noter",
304
+ subtitle="Meeting Ended",
305
+ message="Stopping recording...",
306
+ )
307
+ # Trigger stop recording (will auto-transcribe)
308
+ self._auto_stop_recording()
309
+ return # Skip other checks this cycle
310
+
311
+ # Also check: if we're recording but no meeting detected for a while
312
+ # This catches cases where meeting window changes unexpectedly
313
+ if is_recording and not meeting_info and not self.meeting_monitor.is_in_meeting():
314
+ # Double-check by waiting one more cycle
315
+ if hasattr(self, '_no_meeting_count'):
316
+ self._no_meeting_count += 1
317
+ if self._no_meeting_count >= 2: # ~6 seconds of no meeting
318
+ rumps.notification(
319
+ title="Meeting Noter",
320
+ subtitle="Meeting Ended",
321
+ message="Stopping recording...",
322
+ )
323
+ self._auto_stop_recording()
324
+ self._no_meeting_count = 0
325
+ else:
326
+ self._no_meeting_count = 1
327
+ else:
328
+ self._no_meeting_count = 0
329
+
330
+ # Auto-detect new meetings
331
+ if not is_recording and not self.pending_notification:
332
+ if meeting_started and meeting_info:
333
+ self.pending_notification = True
334
+ self._detected_meeting = meeting_info
335
+ self._pending_meeting_name = meeting_info.meeting_name or meeting_info.app_name
336
+
337
+ def _auto_stop_recording(self):
338
+ """Stop recording automatically when meeting ends."""
339
+ if not self._is_running():
340
+ return
341
+
342
+ # Get latest recording before stopping
343
+ latest_before = self._get_latest_recording()
344
+ before_mtime = latest_before.stat().st_mtime if latest_before else 0
345
+
346
+ stop_daemon(self.pid_file)
347
+ self._update_title()
348
+
349
+ # Wait for file to be saved
350
+ import time
351
+ time.sleep(2)
352
+
353
+ # Reload config
354
+ self.config.load()
355
+
356
+ latest_after = self._get_latest_recording()
357
+
358
+ # Check for new recording
359
+ is_new_recording = False
360
+ if latest_after:
361
+ after_mtime = latest_after.stat().st_mtime
362
+ if latest_before is None or str(latest_after) != str(latest_before) or after_mtime > before_mtime:
363
+ is_new_recording = True
364
+
365
+ # Clear state
366
+ self._clear_recording_state()
367
+ self.current_meeting_name = None
368
+
369
+ if is_new_recording:
370
+ if self.config.auto_transcribe:
371
+ rumps.notification(
372
+ title="Meeting Noter",
373
+ subtitle="Recording Saved",
374
+ message=f"Transcribing {latest_after.name}...",
375
+ )
376
+ self._transcribe_in_background(latest_after)
377
+ else:
378
+ rumps.notification(
379
+ title="Meeting Noter",
380
+ subtitle="Recording Saved",
381
+ message=latest_after.name,
382
+ )
383
+
384
+ @rumps.timer(1)
385
+ def check_pending_prompt(self, _):
386
+ """Check if we need to show a recording prompt (runs on main thread)."""
387
+ if self.pending_notification and hasattr(self, '_detected_meeting') and self._detected_meeting:
388
+ meeting_info = self._detected_meeting
389
+ meeting_name = self._pending_meeting_name
390
+
391
+ # Clear first to prevent re-triggering
392
+ self._detected_meeting = None
393
+ self.pending_notification = False
394
+
395
+ # Show alert on main thread
396
+ response = rumps.alert(
397
+ title=f"Meeting Detected: {meeting_info.app_name}",
398
+ message=f"Do you want to record '{meeting_name}'?",
399
+ ok="Record",
400
+ cancel="Skip",
401
+ )
402
+
403
+ if response == 1: # Record clicked
404
+ self._start_recording_with_name(meeting_name)
405
+ rumps.notification(
406
+ title="Meeting Noter",
407
+ subtitle="Recording Started",
408
+ message=meeting_name,
409
+ )
410
+
411
+
412
+ def _hide_dock_icon():
413
+ """Hide the dock icon on macOS (make it a background agent app)."""
414
+ try:
415
+ from AppKit import NSApplication, NSApplicationActivationPolicyAccessory
416
+ NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory)
417
+ except ImportError:
418
+ pass
419
+
420
+
421
+ def run_menubar():
422
+ """Run the menu bar app."""
423
+ _hide_dock_icon()
424
+ _write_menubar_pid()
425
+ atexit.register(_remove_menubar_pid)
426
+
427
+ app = MeetingNoterApp()
428
+ app.run()
429
+
430
+
431
+ if __name__ == "__main__":
432
+ run_menubar()
@@ -0,0 +1 @@
1
+ """Output handling modules."""
@@ -0,0 +1,96 @@
1
+ """Output handling for recordings and transcripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+
11
+ def format_duration(seconds: float) -> str:
12
+ """Format duration as human-readable string."""
13
+ if seconds < 60:
14
+ return f"{seconds:.0f}s"
15
+ elif seconds < 3600:
16
+ minutes = int(seconds // 60)
17
+ secs = int(seconds % 60)
18
+ return f"{minutes}m {secs}s"
19
+ else:
20
+ hours = int(seconds // 3600)
21
+ minutes = int((seconds % 3600) // 60)
22
+ return f"{hours}h {minutes}m"
23
+
24
+
25
+ def format_size(bytes: int) -> str:
26
+ """Format file size as human-readable string."""
27
+ if bytes < 1024:
28
+ return f"{bytes} B"
29
+ elif bytes < 1024 * 1024:
30
+ return f"{bytes / 1024:.1f} KB"
31
+ else:
32
+ return f"{bytes / (1024 * 1024):.1f} MB"
33
+
34
+
35
+ def get_audio_duration(filepath: Path) -> Optional[float]:
36
+ """Get duration of an audio file in seconds.
37
+
38
+ Uses a simple estimation based on file size and bitrate.
39
+ """
40
+ try:
41
+ # Rough estimation: 128kbps MP3 = 16KB per second
42
+ size = filepath.stat().st_size
43
+ return size / (128 * 1000 / 8) # 128kbps = 16000 bytes/sec
44
+ except:
45
+ return None
46
+
47
+
48
+ def list_recordings(output_dir: Path, limit: int = 10):
49
+ """List recent meeting recordings."""
50
+ if not output_dir.exists():
51
+ click.echo(click.style(f"Directory not found: {output_dir}", fg="red"))
52
+ return
53
+
54
+ # Find all MP3 files
55
+ mp3_files = sorted(
56
+ output_dir.glob("*.mp3"),
57
+ key=lambda p: p.stat().st_mtime,
58
+ reverse=True,
59
+ )
60
+
61
+ if not mp3_files:
62
+ click.echo(click.style("No recordings found.", fg="yellow"))
63
+ click.echo(f"\nRecordings directory: {output_dir}")
64
+ click.echo("Run 'meeting-noter daemon' to start capturing audio.")
65
+ return
66
+
67
+ click.echo(f"\nRecent recordings in {output_dir}:\n")
68
+
69
+ # Show header
70
+ click.echo(f" {'Date':<20} {'Duration':<12} {'Size':<10} {'Transcript':<12} File")
71
+ click.echo(" " + "-" * 75)
72
+
73
+ for mp3 in mp3_files[:limit]:
74
+ # Get file info
75
+ stat = mp3.stat()
76
+ mod_time = datetime.fromtimestamp(stat.st_mtime)
77
+ date_str = mod_time.strftime("%Y-%m-%d %H:%M")
78
+
79
+ # Duration estimate
80
+ duration = get_audio_duration(mp3)
81
+ duration_str = format_duration(duration) if duration else "?"
82
+
83
+ # File size
84
+ size_str = format_size(stat.st_size)
85
+
86
+ # Check for transcript
87
+ transcript = mp3.with_suffix(".txt")
88
+ has_transcript = click.style("Yes", fg="green") if transcript.exists() else click.style("No", fg="yellow")
89
+
90
+ click.echo(f" {date_str:<20} {duration_str:<12} {size_str:<10} {has_transcript:<12} {mp3.name}")
91
+
92
+ if len(mp3_files) > limit:
93
+ click.echo(f"\n ... and {len(mp3_files) - limit} more recordings")
94
+
95
+ click.echo(f"\nTotal: {len(mp3_files)} recordings")
96
+ click.echo("\nTo transcribe: meeting-noter transcribe [filename]")
@@ -0,0 +1 @@
1
+ """Resources package for Meeting Noter."""
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ """Transcription modules."""