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.
- meeting_noter/__init__.py +3 -0
- meeting_noter/__main__.py +6 -0
- meeting_noter/audio/__init__.py +1 -0
- meeting_noter/audio/capture.py +209 -0
- meeting_noter/audio/encoder.py +176 -0
- meeting_noter/audio/system_audio.py +363 -0
- meeting_noter/cli.py +308 -0
- meeting_noter/config.py +197 -0
- meeting_noter/daemon.py +514 -0
- meeting_noter/gui/__init__.py +5 -0
- meeting_noter/gui/__main__.py +6 -0
- meeting_noter/gui/app.py +53 -0
- meeting_noter/gui/main_window.py +50 -0
- meeting_noter/gui/meetings_tab.py +348 -0
- meeting_noter/gui/recording_tab.py +358 -0
- meeting_noter/gui/settings_tab.py +249 -0
- meeting_noter/install/__init__.py +1 -0
- meeting_noter/install/macos.py +102 -0
- meeting_noter/meeting_detector.py +296 -0
- meeting_noter/menubar.py +432 -0
- meeting_noter/output/__init__.py +1 -0
- meeting_noter/output/writer.py +96 -0
- meeting_noter/resources/__init__.py +1 -0
- meeting_noter/resources/icon.icns +0 -0
- meeting_noter/resources/icon.png +0 -0
- meeting_noter/resources/icon_128.png +0 -0
- meeting_noter/resources/icon_16.png +0 -0
- meeting_noter/resources/icon_256.png +0 -0
- meeting_noter/resources/icon_32.png +0 -0
- meeting_noter/resources/icon_512.png +0 -0
- meeting_noter/resources/icon_64.png +0 -0
- meeting_noter/transcription/__init__.py +1 -0
- meeting_noter/transcription/engine.py +208 -0
- meeting_noter-0.3.0.dist-info/METADATA +261 -0
- meeting_noter-0.3.0.dist-info/RECORD +38 -0
- meeting_noter-0.3.0.dist-info/WHEEL +5 -0
- meeting_noter-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|