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,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,296 @@
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 and their window title patterns
10
+ MEETING_APPS = {
11
+ "zoom.us": {
12
+ "name": "Zoom",
13
+ "title_pattern": r"(?:Zoom Meeting|Zoom Webinar)(?:\s*-\s*(.+))?",
14
+ "title_contains": ["zoom meeting", "zoom webinar", "zoom"],
15
+ },
16
+ "Microsoft Teams": {
17
+ "name": "Teams",
18
+ "title_pattern": r"(.+?)(?:\s*\|\s*Microsoft Teams)?",
19
+ "title_contains": ["microsoft teams", "| teams"],
20
+ },
21
+ "Google Chrome": {
22
+ "name": "Meet",
23
+ "title_pattern": r"Meet\s*-\s*([a-z]{3,4}-[a-z]{4}-[a-z]{3,4})",
24
+ "title_contains": ["meet - "], # Must have "meet - " (active meeting has code after)
25
+ "title_excludes": ["google meet"], # Exclude homepage "Google Meet"
26
+ },
27
+ "Safari": {
28
+ "name": "Meet",
29
+ "title_pattern": r"Meet\s*-\s*([a-z]{3,4}-[a-z]{4}-[a-z]{3,4})",
30
+ "title_contains": ["meet - "],
31
+ "title_excludes": ["google meet"],
32
+ },
33
+ "Arc": {
34
+ "name": "Meet",
35
+ "title_pattern": r"Meet\s*-\s*([a-z]{3,4}-[a-z]{4}-[a-z]{3,4})",
36
+ "title_contains": ["meet - "],
37
+ "title_excludes": ["google meet"],
38
+ },
39
+ "Slack": {
40
+ "name": "Slack",
41
+ "title_pattern": r"(?:Slack\s*(?:call|huddle)?\s*(?:with\s+)?)?(.+)?",
42
+ "title_contains": ["huddle", "slack call"],
43
+ },
44
+ "Discord": {
45
+ "name": "Discord",
46
+ "title_pattern": r"(.+)",
47
+ "title_contains": ["voice connected", "#"],
48
+ },
49
+ "FaceTime": {
50
+ "name": "FaceTime",
51
+ "title_pattern": r"(.+)",
52
+ "title_contains": ["facetime"],
53
+ },
54
+ "Webex": {
55
+ "name": "Webex",
56
+ "title_pattern": r"(.+?)(?:\s*-\s*Webex)?",
57
+ "title_contains": ["webex", "meeting"],
58
+ },
59
+ }
60
+
61
+
62
+ @dataclass
63
+ class MeetingInfo:
64
+ """Information about a detected meeting."""
65
+ app_name: str
66
+ meeting_name: Optional[str]
67
+ window_title: str
68
+ is_active: bool = True
69
+
70
+
71
+ def _get_running_apps() -> list[dict]:
72
+ """Get list of running applications."""
73
+ try:
74
+ from AppKit import NSWorkspace
75
+ workspace = NSWorkspace.sharedWorkspace()
76
+ apps = workspace.runningApplications()
77
+ return [
78
+ {
79
+ "name": app.localizedName(),
80
+ "bundle_id": app.bundleIdentifier(),
81
+ "is_active": app.isActive(),
82
+ }
83
+ for app in apps
84
+ if app.localizedName()
85
+ ]
86
+ except ImportError:
87
+ return []
88
+
89
+
90
+ def _get_frontmost_app() -> Optional[str]:
91
+ """Get the frontmost (active) application name."""
92
+ try:
93
+ from AppKit import NSWorkspace
94
+ workspace = NSWorkspace.sharedWorkspace()
95
+ app = workspace.frontmostApplication()
96
+ return app.localizedName() if app else None
97
+ except ImportError:
98
+ return None
99
+
100
+
101
+ def _get_window_titles(app_name: str) -> list[str]:
102
+ """Get window titles for a specific app."""
103
+ try:
104
+ import Quartz
105
+
106
+ windows = Quartz.CGWindowListCopyWindowInfo(
107
+ Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements,
108
+ Quartz.kCGNullWindowID
109
+ )
110
+
111
+ titles = []
112
+ for window in windows:
113
+ owner = window.get(Quartz.kCGWindowOwnerName, "")
114
+ title = window.get(Quartz.kCGWindowName, "")
115
+ if owner == app_name and title:
116
+ titles.append(title)
117
+
118
+ return titles
119
+ except ImportError:
120
+ return []
121
+
122
+
123
+ def _is_microphone_in_use() -> bool:
124
+ """Check if any app is currently using the microphone."""
125
+ try:
126
+ import subprocess
127
+ # Check for apps using microphone via system_profiler or log
128
+ # This is a simplified check - looks for common audio processes
129
+ result = subprocess.run(
130
+ ["lsof", "+D", "/dev"],
131
+ capture_output=True,
132
+ text=True,
133
+ timeout=5,
134
+ )
135
+ # Check for audio device access
136
+ audio_indicators = ["coreaudiod", "audiod"]
137
+ return any(ind in result.stdout.lower() for ind in audio_indicators)
138
+ except Exception:
139
+ return False
140
+
141
+
142
+ def _extract_meeting_name(window_title: str, pattern: str) -> Optional[str]:
143
+ """Extract meeting name from window title using pattern."""
144
+ match = re.search(pattern, window_title, re.IGNORECASE)
145
+ if match and match.groups():
146
+ name = match.group(1)
147
+ if name:
148
+ # Clean up the name
149
+ name = name.strip()
150
+ name = re.sub(r'\s+', ' ', name)
151
+ # Remove common suffixes
152
+ name = re.sub(r'\s*\([\d\s:APMapm]+\)$', '', name) # Remove time
153
+ return name if name else None
154
+ return None
155
+
156
+
157
+ def detect_active_meeting() -> Optional[MeetingInfo]:
158
+ """Detect if there's an active meeting and get its info.
159
+
160
+ Returns MeetingInfo if a meeting is detected, None otherwise.
161
+ """
162
+ try:
163
+ from AppKit import NSWorkspace
164
+ import Quartz
165
+ except ImportError:
166
+ return None
167
+
168
+ # Get all windows
169
+ windows = Quartz.CGWindowListCopyWindowInfo(
170
+ Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements,
171
+ Quartz.kCGNullWindowID
172
+ )
173
+
174
+ for window in windows:
175
+ owner = window.get(Quartz.kCGWindowOwnerName, "")
176
+ title = window.get(Quartz.kCGWindowName, "")
177
+
178
+ if not owner or not title:
179
+ continue
180
+
181
+ # Check against known meeting apps
182
+ for app_pattern, app_info in MEETING_APPS.items():
183
+ if app_pattern.lower() in owner.lower():
184
+ # Check if title contains meeting indicators
185
+ title_lower = title.lower()
186
+ title_matches = False
187
+
188
+ if "title_contains" in app_info:
189
+ for indicator in app_info["title_contains"]:
190
+ if indicator.lower() in title_lower:
191
+ title_matches = True
192
+ break
193
+ else:
194
+ title_matches = True
195
+
196
+ if not title_matches:
197
+ continue
198
+
199
+ # Check for exclusions (e.g., "Google Meet" homepage)
200
+ if "title_excludes" in app_info:
201
+ excluded = False
202
+ for exclude in app_info["title_excludes"]:
203
+ if title_lower == exclude.lower():
204
+ excluded = True
205
+ break
206
+ if excluded:
207
+ continue
208
+
209
+ # Extract meeting name from title
210
+ pattern = app_info["title_pattern"]
211
+ meeting_name = _extract_meeting_name(title, pattern)
212
+
213
+ return MeetingInfo(
214
+ app_name=app_info["name"],
215
+ meeting_name=meeting_name,
216
+ window_title=title,
217
+ is_active=True,
218
+ )
219
+
220
+ return None
221
+
222
+
223
+ def get_calendar_meeting() -> Optional[str]:
224
+ """Get the name of current meeting from calendar.
225
+
226
+ Note: Requires calendar access permission.
227
+ """
228
+ try:
229
+ from EventKit import EKEventStore, EKEntityTypeEvent
230
+ from Foundation import NSDate
231
+
232
+ store = EKEventStore.alloc().init()
233
+
234
+ # Check if we have access
235
+ # Note: This will prompt for permission on first run
236
+
237
+ now = NSDate.date()
238
+ # Get events from 5 minutes ago to 5 minutes from now
239
+ start = now.dateByAddingTimeInterval_(-300)
240
+ end = now.dateByAddingTimeInterval_(300)
241
+
242
+ calendars = store.calendarsForEntityType_(EKEntityTypeEvent)
243
+ predicate = store.predicateForEventsWithStartDate_endDate_calendars_(
244
+ start, end, calendars
245
+ )
246
+ events = store.eventsMatchingPredicate_(predicate)
247
+
248
+ for event in events:
249
+ title = event.title()
250
+ if title:
251
+ return title
252
+
253
+ return None
254
+ except Exception:
255
+ return None
256
+
257
+
258
+ class MeetingMonitor:
259
+ """Monitor for active meetings."""
260
+
261
+ def __init__(self):
262
+ self.last_meeting: Optional[MeetingInfo] = None
263
+ self._was_in_meeting = False
264
+
265
+ def check(self) -> tuple[bool, bool, Optional[MeetingInfo]]:
266
+ """Check for meeting status change.
267
+
268
+ Returns:
269
+ Tuple of (meeting_started, meeting_ended, meeting_info)
270
+ - meeting_started is True if a new meeting was just detected
271
+ - meeting_ended is True if the meeting just ended
272
+ - meeting_info contains the meeting details (or None if no meeting)
273
+ """
274
+ current = detect_active_meeting()
275
+
276
+ meeting_started = False
277
+ meeting_ended = False
278
+
279
+ if current and not self._was_in_meeting:
280
+ # New meeting started
281
+ meeting_started = True
282
+ self.last_meeting = current
283
+ self._was_in_meeting = True
284
+ elif not current and self._was_in_meeting:
285
+ # Meeting ended
286
+ meeting_ended = True
287
+ self._was_in_meeting = False
288
+ elif current:
289
+ # Still in meeting, update info
290
+ self.last_meeting = current
291
+
292
+ return meeting_started, meeting_ended, current
293
+
294
+ def is_in_meeting(self) -> bool:
295
+ """Check if currently in a meeting."""
296
+ return self._was_in_meeting