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,102 @@
1
+ """macOS setup for Meeting Noter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+
10
+ def check_screen_recording_permission() -> bool:
11
+ """Check if Screen Recording permission is granted.
12
+
13
+ This permission is required to capture system audio via ScreenCaptureKit.
14
+ """
15
+ try:
16
+ from Quartz import CGPreflightScreenCaptureAccess
17
+ return CGPreflightScreenCaptureAccess()
18
+ except ImportError:
19
+ # Quartz not available, assume permission not granted
20
+ return False
21
+ except Exception:
22
+ return False
23
+
24
+
25
+ def request_screen_recording_permission() -> bool:
26
+ """Request Screen Recording permission from the user.
27
+
28
+ This will trigger the system permission dialog if not already granted.
29
+ """
30
+ try:
31
+ from Quartz import CGRequestScreenCaptureAccess
32
+ return CGRequestScreenCaptureAccess()
33
+ except ImportError:
34
+ return False
35
+ except Exception:
36
+ return False
37
+
38
+
39
+ def run_setup():
40
+ """Run the setup process for Meeting Noter.
41
+
42
+ This simplified setup:
43
+ 1. Checks for Screen Recording permission (needed for system audio)
44
+ 2. Requests permission if not granted
45
+ 3. Initializes the configuration
46
+
47
+ No virtual audio devices (BlackHole) are required anymore - we use
48
+ ScreenCaptureKit to capture system audio directly.
49
+ """
50
+ click.echo(click.style("\n=== Meeting Noter Setup ===\n", fg="blue", bold=True))
51
+
52
+ # Check macOS
53
+ if sys.platform != "darwin":
54
+ click.echo(click.style(
55
+ "Error: Meeting Noter only supports macOS.",
56
+ fg="red"
57
+ ))
58
+ sys.exit(1)
59
+
60
+ # Check/Request Screen Recording permission
61
+ click.echo("Step 1: Checking Screen Recording permission...")
62
+
63
+ if check_screen_recording_permission():
64
+ click.echo(click.style(" Screen Recording permission already granted", fg="green"))
65
+ else:
66
+ click.echo(" Screen Recording permission not yet granted")
67
+ click.echo(" This permission is needed to capture meeting audio from other participants.")
68
+ click.echo()
69
+ click.echo(" Requesting permission...")
70
+
71
+ request_screen_recording_permission()
72
+
73
+ click.echo()
74
+ click.echo(click.style(" A system dialog should appear.", fg="yellow"))
75
+ click.echo(" Please grant Screen Recording permission to Terminal (or your IDE).")
76
+ click.echo()
77
+ click.echo(" If no dialog appeared:")
78
+ click.echo(" 1. Open System Settings > Privacy & Security > Screen Recording")
79
+ click.echo(" 2. Enable the toggle for Terminal (or your IDE)")
80
+ click.echo(" 3. Restart your terminal/IDE")
81
+
82
+ click.echo()
83
+ click.echo(click.style("=== Setup Complete ===\n", fg="blue", bold=True))
84
+ click.echo("""
85
+ Meeting Noter is ready to use!
86
+
87
+ How it works:
88
+ - Your microphone captures your voice
89
+ - ScreenCaptureKit captures other participants (requires Screen Recording permission)
90
+ - Both audio sources are combined and saved to MP3
91
+
92
+ Next steps:
93
+ 1. Run: meeting-noter menubar (for menu bar control)
94
+ Or: meeting-noter gui (for desktop app)
95
+ Or: meeting-noter start (for CLI recording)
96
+
97
+ 2. When you start a meeting, Meeting Noter will detect it and offer to record.
98
+
99
+ Note: If you denied Screen Recording permission, only your microphone
100
+ will be captured. Grant permission in System Settings to capture
101
+ meeting participants' audio.
102
+ """)
@@ -0,0 +1,333 @@
1
+ """Auto-detect meetings and get meeting info from running apps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ # Meeting apps - split into primary (dedicated) and secondary (chat apps with call features)
10
+ # Primary apps are checked first and take precedence
11
+
12
+ # Primary: Dedicated meeting/video call apps
13
+ PRIMARY_MEETING_APPS = {
14
+ "zoom.us": {
15
+ "name": "Zoom",
16
+ "title_pattern": r"(?:Zoom Meeting|Zoom Webinar)(?:\s*-\s*(.+))?",
17
+ "title_contains": ["zoom meeting", "zoom webinar", "zoom"],
18
+ },
19
+ "Microsoft Teams": {
20
+ "name": "Teams",
21
+ "title_pattern": r"(.+?)(?:\s*\|\s*Microsoft Teams)?",
22
+ "title_contains": ["microsoft teams", "| teams"],
23
+ },
24
+ "Google Chrome": {
25
+ "name": "Meet",
26
+ "title_pattern": r"Meet\s*-\s*([a-z]{3,4}-[a-z]{4}-[a-z]{3,4})",
27
+ "title_contains": ["meet - "],
28
+ "title_excludes": ["google meet"],
29
+ },
30
+ "Safari": {
31
+ "name": "Meet",
32
+ "title_pattern": r"Meet\s*-\s*([a-z]{3,4}-[a-z]{4}-[a-z]{3,4})",
33
+ "title_contains": ["meet - "],
34
+ "title_excludes": ["google meet"],
35
+ },
36
+ "Arc": {
37
+ "name": "Meet",
38
+ "title_pattern": r"Meet\s*-\s*([a-z]{3,4}-[a-z]{4}-[a-z]{3,4})",
39
+ "title_contains": ["meet - "],
40
+ "title_excludes": ["google meet"],
41
+ },
42
+ "FaceTime": {
43
+ "name": "FaceTime",
44
+ "title_pattern": r"(.+)",
45
+ "title_contains": ["facetime"],
46
+ },
47
+ "Webex": {
48
+ "name": "Webex",
49
+ "title_pattern": r"(.+?)(?:\s*-\s*Webex)?",
50
+ "title_contains": ["webex", "meeting"],
51
+ },
52
+ }
53
+
54
+ # Secondary: Chat apps with huddle/call features (only checked if no primary meeting found)
55
+ SECONDARY_MEETING_APPS = {
56
+ "Slack": {
57
+ "name": "Slack Huddle",
58
+ # Only match window titles that explicitly contain huddle/call
59
+ "title_pattern": r"(?:Huddle|Call)(?:\s+(?:in|with)\s+)?(.+)?",
60
+ "title_contains": ["huddle", "slack call"],
61
+ },
62
+ "Discord": {
63
+ "name": "Discord",
64
+ "title_pattern": r"(.+)",
65
+ # Only match when actually connected to voice
66
+ "title_contains": ["voice connected"],
67
+ },
68
+ }
69
+
70
+ # Combined for backwards compatibility
71
+ MEETING_APPS = {**PRIMARY_MEETING_APPS, **SECONDARY_MEETING_APPS}
72
+
73
+
74
+ @dataclass
75
+ class MeetingInfo:
76
+ """Information about a detected meeting."""
77
+ app_name: str
78
+ meeting_name: Optional[str]
79
+ window_title: str
80
+ is_active: bool = True
81
+
82
+
83
+ def _get_running_apps() -> list[dict]:
84
+ """Get list of running applications."""
85
+ try:
86
+ from AppKit import NSWorkspace
87
+ workspace = NSWorkspace.sharedWorkspace()
88
+ apps = workspace.runningApplications()
89
+ return [
90
+ {
91
+ "name": app.localizedName(),
92
+ "bundle_id": app.bundleIdentifier(),
93
+ "is_active": app.isActive(),
94
+ }
95
+ for app in apps
96
+ if app.localizedName()
97
+ ]
98
+ except ImportError:
99
+ return []
100
+
101
+
102
+ def _get_frontmost_app() -> Optional[str]:
103
+ """Get the frontmost (active) application name."""
104
+ try:
105
+ from AppKit import NSWorkspace
106
+ workspace = NSWorkspace.sharedWorkspace()
107
+ app = workspace.frontmostApplication()
108
+ return app.localizedName() if app else None
109
+ except ImportError:
110
+ return None
111
+
112
+
113
+ def _get_window_titles(app_name: str) -> list[str]:
114
+ """Get window titles for a specific app."""
115
+ try:
116
+ import Quartz
117
+
118
+ windows = Quartz.CGWindowListCopyWindowInfo(
119
+ Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements,
120
+ Quartz.kCGNullWindowID
121
+ )
122
+
123
+ titles = []
124
+ for window in windows:
125
+ owner = window.get(Quartz.kCGWindowOwnerName, "")
126
+ title = window.get(Quartz.kCGWindowName, "")
127
+ if owner == app_name and title:
128
+ titles.append(title)
129
+
130
+ return titles
131
+ except ImportError:
132
+ return []
133
+
134
+
135
+ def _is_microphone_in_use() -> bool:
136
+ """Check if any app is currently using the microphone."""
137
+ try:
138
+ import subprocess
139
+ # Check for apps using microphone via system_profiler or log
140
+ # This is a simplified check - looks for common audio processes
141
+ result = subprocess.run(
142
+ ["lsof", "+D", "/dev"],
143
+ capture_output=True,
144
+ text=True,
145
+ timeout=5,
146
+ )
147
+ # Check for audio device access
148
+ audio_indicators = ["coreaudiod", "audiod"]
149
+ return any(ind in result.stdout.lower() for ind in audio_indicators)
150
+ except Exception:
151
+ return False
152
+
153
+
154
+ def _extract_meeting_name(window_title: str, pattern: str) -> Optional[str]:
155
+ """Extract meeting name from window title using pattern."""
156
+ match = re.search(pattern, window_title, re.IGNORECASE)
157
+ if match and match.groups():
158
+ name = match.group(1)
159
+ if name:
160
+ # Clean up the name
161
+ name = name.strip()
162
+ name = re.sub(r'\s+', ' ', name)
163
+ # Remove common suffixes
164
+ name = re.sub(r'\s*\([\d\s:APMapm]+\)$', '', name) # Remove time
165
+ return name if name else None
166
+ return None
167
+
168
+
169
+ def _check_windows_for_apps(windows, app_configs: dict) -> Optional[MeetingInfo]:
170
+ """Check windows against a set of app configurations.
171
+
172
+ Args:
173
+ windows: List of window info from CGWindowListCopyWindowInfo
174
+ app_configs: Dict mapping app patterns to their config
175
+
176
+ Returns:
177
+ MeetingInfo if a meeting is detected, None otherwise.
178
+ """
179
+ import Quartz
180
+
181
+ for window in windows:
182
+ owner = window.get(Quartz.kCGWindowOwnerName, "")
183
+ title = window.get(Quartz.kCGWindowName, "")
184
+
185
+ if not owner or not title:
186
+ continue
187
+
188
+ for app_pattern, app_info in app_configs.items():
189
+ if app_pattern.lower() not in owner.lower():
190
+ continue
191
+
192
+ # Check if title contains meeting indicators
193
+ title_lower = title.lower()
194
+ title_matches = False
195
+
196
+ if "title_contains" in app_info:
197
+ for indicator in app_info["title_contains"]:
198
+ if indicator.lower() in title_lower:
199
+ title_matches = True
200
+ break
201
+ else:
202
+ title_matches = True
203
+
204
+ if not title_matches:
205
+ continue
206
+
207
+ # Check for exclusions (e.g., "Google Meet" homepage)
208
+ if "title_excludes" in app_info:
209
+ excluded = False
210
+ for exclude in app_info["title_excludes"]:
211
+ if title_lower == exclude.lower():
212
+ excluded = True
213
+ break
214
+ if excluded:
215
+ continue
216
+
217
+ # Extract meeting name from title
218
+ pattern = app_info["title_pattern"]
219
+ meeting_name = _extract_meeting_name(title, pattern)
220
+
221
+ return MeetingInfo(
222
+ app_name=app_info["name"],
223
+ meeting_name=meeting_name,
224
+ window_title=title,
225
+ is_active=True,
226
+ )
227
+
228
+ return None
229
+
230
+
231
+ def detect_active_meeting() -> Optional[MeetingInfo]:
232
+ """Detect if there's an active meeting and get its info.
233
+
234
+ Checks primary meeting apps (Zoom, Teams, Meet, etc.) first.
235
+ Only checks secondary apps (Slack, Discord) if no primary meeting is found.
236
+
237
+ Returns MeetingInfo if a meeting is detected, None otherwise.
238
+ """
239
+ try:
240
+ from AppKit import NSWorkspace
241
+ import Quartz
242
+ except ImportError:
243
+ return None
244
+
245
+ # Get all windows
246
+ windows = Quartz.CGWindowListCopyWindowInfo(
247
+ Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements,
248
+ Quartz.kCGNullWindowID
249
+ )
250
+
251
+ # First, check for primary meeting apps (dedicated video conferencing)
252
+ result = _check_windows_for_apps(windows, PRIMARY_MEETING_APPS)
253
+ if result:
254
+ return result
255
+
256
+ # Only check secondary apps (chat apps with call features) if no primary meeting found
257
+ return _check_windows_for_apps(windows, SECONDARY_MEETING_APPS)
258
+
259
+
260
+ def get_calendar_meeting() -> Optional[str]:
261
+ """Get the name of current meeting from calendar.
262
+
263
+ Note: Requires calendar access permission.
264
+ """
265
+ try:
266
+ from EventKit import EKEventStore, EKEntityTypeEvent
267
+ from Foundation import NSDate
268
+
269
+ store = EKEventStore.alloc().init()
270
+
271
+ # Check if we have access
272
+ # Note: This will prompt for permission on first run
273
+
274
+ now = NSDate.date()
275
+ # Get events from 5 minutes ago to 5 minutes from now
276
+ start = now.dateByAddingTimeInterval_(-300)
277
+ end = now.dateByAddingTimeInterval_(300)
278
+
279
+ calendars = store.calendarsForEntityType_(EKEntityTypeEvent)
280
+ predicate = store.predicateForEventsWithStartDate_endDate_calendars_(
281
+ start, end, calendars
282
+ )
283
+ events = store.eventsMatchingPredicate_(predicate)
284
+
285
+ for event in events:
286
+ title = event.title()
287
+ if title:
288
+ return title
289
+
290
+ return None
291
+ except Exception:
292
+ return None
293
+
294
+
295
+ class MeetingMonitor:
296
+ """Monitor for active meetings."""
297
+
298
+ def __init__(self):
299
+ self.last_meeting: Optional[MeetingInfo] = None
300
+ self._was_in_meeting = False
301
+
302
+ def check(self) -> tuple[bool, bool, Optional[MeetingInfo]]:
303
+ """Check for meeting status change.
304
+
305
+ Returns:
306
+ Tuple of (meeting_started, meeting_ended, meeting_info)
307
+ - meeting_started is True if a new meeting was just detected
308
+ - meeting_ended is True if the meeting just ended
309
+ - meeting_info contains the meeting details (or None if no meeting)
310
+ """
311
+ current = detect_active_meeting()
312
+
313
+ meeting_started = False
314
+ meeting_ended = False
315
+
316
+ if current and not self._was_in_meeting:
317
+ # New meeting started
318
+ meeting_started = True
319
+ self.last_meeting = current
320
+ self._was_in_meeting = True
321
+ elif not current and self._was_in_meeting:
322
+ # Meeting ended
323
+ meeting_ended = True
324
+ self._was_in_meeting = False
325
+ elif current:
326
+ # Still in meeting, update info
327
+ self.last_meeting = current
328
+
329
+ return meeting_started, meeting_ended, current
330
+
331
+ def is_in_meeting(self) -> bool:
332
+ """Check if currently in a meeting."""
333
+ return self._was_in_meeting