meeting-noter 0.6.1__py3-none-any.whl → 1.0.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/audio/encoder.py +8 -3
- meeting_noter/cli.py +773 -101
- meeting_noter/daemon.py +100 -16
- meeting_noter/meeting_detector.py +97 -60
- meeting_noter/mic_monitor.py +77 -22
- meeting_noter/transcription/live_transcription.py +250 -0
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/METADATA +14 -3
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/RECORD +11 -10
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/entry_points.txt +1 -0
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/WHEEL +0 -0
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/top_level.txt +0 -0
meeting_noter/daemon.py
CHANGED
|
@@ -157,7 +157,11 @@ def run_daemon(
|
|
|
157
157
|
remove_pid_file(pid_file)
|
|
158
158
|
|
|
159
159
|
|
|
160
|
-
def _run_capture_loop(
|
|
160
|
+
def _run_capture_loop(
|
|
161
|
+
output_dir: Path,
|
|
162
|
+
meeting_name: Optional[str] = None,
|
|
163
|
+
enable_live_transcription: bool = True,
|
|
164
|
+
):
|
|
161
165
|
"""Main capture loop.
|
|
162
166
|
|
|
163
167
|
Audio imports happen HERE, safely AFTER the fork.
|
|
@@ -171,6 +175,16 @@ def _run_capture_loop(output_dir: Path, meeting_name: Optional[str] = None):
|
|
|
171
175
|
|
|
172
176
|
config = get_config()
|
|
173
177
|
|
|
178
|
+
# Live transcription (imported here to avoid loading Whisper before fork)
|
|
179
|
+
live_transcriber = None
|
|
180
|
+
if enable_live_transcription:
|
|
181
|
+
try:
|
|
182
|
+
from meeting_noter.transcription.live_transcription import LiveTranscriber
|
|
183
|
+
LiveTranscriber # Just verify import works, create later
|
|
184
|
+
except ImportError as e:
|
|
185
|
+
print(f"Live transcription not available: {e}")
|
|
186
|
+
enable_live_transcription = False
|
|
187
|
+
|
|
174
188
|
print(f"Meeting Noter daemon started. Saving to {output_dir}")
|
|
175
189
|
sys.stdout.flush()
|
|
176
190
|
if meeting_name:
|
|
@@ -253,15 +267,42 @@ def _run_capture_loop(output_dir: Path, meeting_name: Optional[str] = None):
|
|
|
253
267
|
print(f"Recording started: {filepath.name}")
|
|
254
268
|
recording_started = True
|
|
255
269
|
audio_detected = True
|
|
270
|
+
|
|
271
|
+
# Start live transcription
|
|
272
|
+
if enable_live_transcription:
|
|
273
|
+
try:
|
|
274
|
+
from meeting_noter.transcription.live_transcription import LiveTranscriber
|
|
275
|
+
live_transcriber = LiveTranscriber(
|
|
276
|
+
output_path=filepath,
|
|
277
|
+
sample_rate=sample_rate,
|
|
278
|
+
channels=channels,
|
|
279
|
+
window_seconds=5.0,
|
|
280
|
+
slide_seconds=2.0,
|
|
281
|
+
model_size=config.whisper_model,
|
|
282
|
+
)
|
|
283
|
+
live_transcriber.start()
|
|
284
|
+
print(f"Live transcription: {live_transcriber.live_file_path.name}")
|
|
285
|
+
except Exception as e:
|
|
286
|
+
print(f"Failed to start live transcription: {e}")
|
|
287
|
+
live_transcriber = None
|
|
256
288
|
else:
|
|
257
289
|
# Currently recording
|
|
258
290
|
session.write(audio)
|
|
259
291
|
|
|
292
|
+
# Feed audio to live transcriber
|
|
293
|
+
if live_transcriber is not None:
|
|
294
|
+
live_transcriber.write(audio)
|
|
295
|
+
|
|
260
296
|
if has_audio:
|
|
261
297
|
audio_detected = True
|
|
262
298
|
|
|
263
299
|
# Check for extended silence (meeting ended)
|
|
264
300
|
if is_silence and audio_detected:
|
|
301
|
+
# Stop live transcription first
|
|
302
|
+
if live_transcriber is not None:
|
|
303
|
+
live_transcriber.stop()
|
|
304
|
+
live_transcriber = None
|
|
305
|
+
|
|
265
306
|
filepath, duration = session.stop()
|
|
266
307
|
if filepath:
|
|
267
308
|
print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
|
|
@@ -275,6 +316,10 @@ def _run_capture_loop(output_dir: Path, meeting_name: Optional[str] = None):
|
|
|
275
316
|
finally:
|
|
276
317
|
capture.stop()
|
|
277
318
|
|
|
319
|
+
# Stop live transcription
|
|
320
|
+
if live_transcriber is not None:
|
|
321
|
+
live_transcriber.stop()
|
|
322
|
+
|
|
278
323
|
# Save any ongoing recording
|
|
279
324
|
if 'session' in locals() and session.is_active:
|
|
280
325
|
filepath, duration = session.stop()
|
|
@@ -377,6 +422,7 @@ def run_foreground_capture(
|
|
|
377
422
|
whisper_model: str = "tiny.en",
|
|
378
423
|
transcripts_dir: Optional[Path] = None,
|
|
379
424
|
silence_timeout_minutes: int = 5,
|
|
425
|
+
enable_live_transcription: bool = True,
|
|
380
426
|
) -> Optional[Path]:
|
|
381
427
|
"""Run audio capture in foreground with a named meeting.
|
|
382
428
|
|
|
@@ -390,6 +436,7 @@ def run_foreground_capture(
|
|
|
390
436
|
whisper_model: Whisper model to use for transcription
|
|
391
437
|
transcripts_dir: Directory for transcripts
|
|
392
438
|
silence_timeout_minutes: Stop after this many minutes of silence
|
|
439
|
+
enable_live_transcription: Whether to enable real-time transcription
|
|
393
440
|
|
|
394
441
|
Returns:
|
|
395
442
|
Path to the saved recording, or None if recording was too short
|
|
@@ -401,6 +448,9 @@ def run_foreground_capture(
|
|
|
401
448
|
|
|
402
449
|
config = get_config()
|
|
403
450
|
|
|
451
|
+
# Initialize live transcriber
|
|
452
|
+
live_transcriber = None
|
|
453
|
+
|
|
404
454
|
# Check audio device
|
|
405
455
|
if not check_audio_available():
|
|
406
456
|
click.echo(click.style("Error: ", fg="red") + "No audio input device found.")
|
|
@@ -432,30 +482,56 @@ def run_foreground_capture(
|
|
|
432
482
|
click.echo(click.style(f"Error: {e}", fg="red"))
|
|
433
483
|
return None
|
|
434
484
|
|
|
435
|
-
session = RecordingSession(
|
|
436
|
-
output_dir,
|
|
437
|
-
sample_rate=capture.sample_rate,
|
|
438
|
-
channels=capture.channels,
|
|
439
|
-
meeting_name=meeting_name,
|
|
440
|
-
)
|
|
441
|
-
|
|
442
|
-
# Silence detection
|
|
443
|
-
silence_detector = SilenceDetector(
|
|
444
|
-
threshold=0.01,
|
|
445
|
-
silence_duration=silence_timeout_minutes * 60.0, # Convert to seconds
|
|
446
|
-
sample_rate=capture.sample_rate,
|
|
447
|
-
)
|
|
448
|
-
|
|
449
485
|
saved_filepath = None
|
|
450
486
|
stopped_by_silence = False
|
|
451
487
|
|
|
452
488
|
try:
|
|
489
|
+
# Start capture FIRST (CombinedAudioCapture updates channels during start)
|
|
453
490
|
capture.start()
|
|
454
491
|
|
|
492
|
+
# Get sample rate and channels AFTER start
|
|
493
|
+
sample_rate = capture.sample_rate
|
|
494
|
+
channels = capture.channels
|
|
495
|
+
|
|
496
|
+
session = RecordingSession(
|
|
497
|
+
output_dir,
|
|
498
|
+
sample_rate=sample_rate,
|
|
499
|
+
channels=channels,
|
|
500
|
+
meeting_name=meeting_name,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# Silence detection
|
|
504
|
+
silence_detector = SilenceDetector(
|
|
505
|
+
threshold=0.01,
|
|
506
|
+
silence_duration=silence_timeout_minutes * 60.0, # Convert to seconds
|
|
507
|
+
sample_rate=sample_rate,
|
|
508
|
+
)
|
|
509
|
+
|
|
455
510
|
# Start recording immediately
|
|
456
511
|
filepath = session.start()
|
|
457
512
|
click.echo(click.style("Recording: ", fg="green") + filepath.name)
|
|
458
513
|
|
|
514
|
+
# Start live transcription
|
|
515
|
+
if enable_live_transcription:
|
|
516
|
+
try:
|
|
517
|
+
from meeting_noter.transcription.live_transcription import LiveTranscriber
|
|
518
|
+
live_transcriber = LiveTranscriber(
|
|
519
|
+
output_path=filepath,
|
|
520
|
+
sample_rate=sample_rate,
|
|
521
|
+
channels=channels,
|
|
522
|
+
window_seconds=5.0,
|
|
523
|
+
slide_seconds=2.0,
|
|
524
|
+
model_size=whisper_model,
|
|
525
|
+
)
|
|
526
|
+
live_transcriber.start()
|
|
527
|
+
click.echo(
|
|
528
|
+
click.style("Live transcript: ", fg="cyan") +
|
|
529
|
+
str(live_transcriber.live_file_path)
|
|
530
|
+
)
|
|
531
|
+
except Exception as e:
|
|
532
|
+
click.echo(click.style(f"Live transcription not available: {e}", fg="yellow"))
|
|
533
|
+
live_transcriber = None
|
|
534
|
+
|
|
459
535
|
while not _stop_event.is_set():
|
|
460
536
|
audio = capture.get_audio(timeout=0.5)
|
|
461
537
|
if audio is None:
|
|
@@ -467,6 +543,10 @@ def run_foreground_capture(
|
|
|
467
543
|
|
|
468
544
|
session.write(audio)
|
|
469
545
|
|
|
546
|
+
# Feed audio to live transcriber
|
|
547
|
+
if live_transcriber is not None:
|
|
548
|
+
live_transcriber.write(audio)
|
|
549
|
+
|
|
470
550
|
# Check for extended silence
|
|
471
551
|
if silence_detector.update(audio):
|
|
472
552
|
click.echo("\n" + click.style("Stopped: ", fg="yellow") + "silence timeout reached")
|
|
@@ -482,10 +562,14 @@ def run_foreground_capture(
|
|
|
482
562
|
except Exception as e:
|
|
483
563
|
click.echo(click.style(f"\nError: {e}", fg="red"))
|
|
484
564
|
finally:
|
|
565
|
+
# Stop live transcription
|
|
566
|
+
if live_transcriber is not None:
|
|
567
|
+
live_transcriber.stop()
|
|
568
|
+
|
|
485
569
|
capture.stop()
|
|
486
570
|
|
|
487
571
|
# Save recording
|
|
488
|
-
if session.is_active:
|
|
572
|
+
if 'session' in locals() and session.is_active:
|
|
489
573
|
saved_filepath, duration = session.stop()
|
|
490
574
|
if not stopped_by_silence:
|
|
491
575
|
click.echo() # New line after duration display
|
|
@@ -6,8 +6,11 @@ import re
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from typing import Optional
|
|
8
8
|
|
|
9
|
-
# Meeting apps and
|
|
10
|
-
|
|
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 = {
|
|
11
14
|
"zoom.us": {
|
|
12
15
|
"name": "Zoom",
|
|
13
16
|
"title_pattern": r"(?:Zoom Meeting|Zoom Webinar)(?:\s*-\s*(.+))?",
|
|
@@ -21,8 +24,8 @@ MEETING_APPS = {
|
|
|
21
24
|
"Google Chrome": {
|
|
22
25
|
"name": "Meet",
|
|
23
26
|
"title_pattern": r"Meet\s*-\s*([a-z]{3,4}-[a-z]{4}-[a-z]{3,4})",
|
|
24
|
-
"title_contains": ["meet - "],
|
|
25
|
-
"title_excludes": ["google meet"],
|
|
27
|
+
"title_contains": ["meet - "],
|
|
28
|
+
"title_excludes": ["google meet"],
|
|
26
29
|
},
|
|
27
30
|
"Safari": {
|
|
28
31
|
"name": "Meet",
|
|
@@ -36,16 +39,6 @@ MEETING_APPS = {
|
|
|
36
39
|
"title_contains": ["meet - "],
|
|
37
40
|
"title_excludes": ["google meet"],
|
|
38
41
|
},
|
|
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
42
|
"FaceTime": {
|
|
50
43
|
"name": "FaceTime",
|
|
51
44
|
"title_pattern": r"(.+)",
|
|
@@ -58,6 +51,25 @@ MEETING_APPS = {
|
|
|
58
51
|
},
|
|
59
52
|
}
|
|
60
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
|
+
|
|
61
73
|
|
|
62
74
|
@dataclass
|
|
63
75
|
class MeetingInfo:
|
|
@@ -154,9 +166,74 @@ def _extract_meeting_name(window_title: str, pattern: str) -> Optional[str]:
|
|
|
154
166
|
return None
|
|
155
167
|
|
|
156
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
|
+
|
|
157
231
|
def detect_active_meeting() -> Optional[MeetingInfo]:
|
|
158
232
|
"""Detect if there's an active meeting and get its info.
|
|
159
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
|
+
|
|
160
237
|
Returns MeetingInfo if a meeting is detected, None otherwise.
|
|
161
238
|
"""
|
|
162
239
|
try:
|
|
@@ -171,53 +248,13 @@ def detect_active_meeting() -> Optional[MeetingInfo]:
|
|
|
171
248
|
Quartz.kCGNullWindowID
|
|
172
249
|
)
|
|
173
250
|
|
|
174
|
-
for
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
)
|
|
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
|
|
219
255
|
|
|
220
|
-
|
|
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)
|
|
221
258
|
|
|
222
259
|
|
|
223
260
|
def get_calendar_meeting() -> Optional[str]:
|
meeting_noter/mic_monitor.py
CHANGED
|
@@ -118,6 +118,7 @@ def is_mic_in_use_by_another_app() -> bool:
|
|
|
118
118
|
def is_meeting_app_active() -> Optional[str]:
|
|
119
119
|
"""Check if a meeting app has an ACTIVE MEETING window.
|
|
120
120
|
|
|
121
|
+
Prioritizes dedicated meeting apps over chat apps.
|
|
121
122
|
Returns the app name only if there's an active call/meeting,
|
|
122
123
|
not just because the app is open.
|
|
123
124
|
"""
|
|
@@ -130,6 +131,7 @@ def is_meeting_app_active() -> Optional[str]:
|
|
|
130
131
|
Quartz.kCGNullWindowID
|
|
131
132
|
)
|
|
132
133
|
|
|
134
|
+
# First pass: check for primary meeting apps (dedicated video conferencing)
|
|
133
135
|
for win in windows:
|
|
134
136
|
owner = win.get(Quartz.kCGWindowOwnerName, "")
|
|
135
137
|
title = win.get(Quartz.kCGWindowName, "") or ""
|
|
@@ -153,16 +155,8 @@ def is_meeting_app_active() -> Optional[str]:
|
|
|
153
155
|
# Skip main app windows
|
|
154
156
|
if title_lower in ["", "microsoft teams"]:
|
|
155
157
|
continue
|
|
156
|
-
# Check for call indicators
|
|
157
|
-
if any(x in title_lower for x in ["call", "meeting", "chat"]):
|
|
158
|
-
return "Teams"
|
|
159
158
|
return "Teams"
|
|
160
159
|
|
|
161
|
-
# Slack: detect huddle/call windows
|
|
162
|
-
if "slack" in owner_lower:
|
|
163
|
-
if any(x in title_lower for x in ["huddle", "call"]):
|
|
164
|
-
return "Slack"
|
|
165
|
-
|
|
166
160
|
# FaceTime
|
|
167
161
|
if "facetime" in owner_lower:
|
|
168
162
|
if title and title_lower != "facetime":
|
|
@@ -173,21 +167,37 @@ def is_meeting_app_active() -> Optional[str]:
|
|
|
173
167
|
if title and "meeting" in title_lower:
|
|
174
168
|
return "Webex"
|
|
175
169
|
|
|
176
|
-
# Discord: detect voice channel
|
|
177
|
-
if "discord" in owner_lower:
|
|
178
|
-
# Discord shows voice channel name when in call
|
|
179
|
-
if title and title_lower != "discord":
|
|
180
|
-
return "Discord"
|
|
181
|
-
|
|
182
170
|
# Browser-based meetings (Google Meet, etc.)
|
|
183
171
|
if any(browser in owner_lower for browser in ["chrome", "safari", "firefox", "edge", "brave", "arc"]):
|
|
184
|
-
#
|
|
172
|
+
# Skip video streaming sites (YouTube, Vimeo, etc.)
|
|
173
|
+
if any(x in title_lower for x in ["youtube", "vimeo", "twitch", "netflix"]):
|
|
174
|
+
continue
|
|
185
175
|
if title_lower.startswith("meet -") or "meet.google.com" in title_lower:
|
|
186
176
|
return "Google Meet"
|
|
187
|
-
# Generic "Meeting" in browser title
|
|
188
177
|
if " meeting" in title_lower and any(x in title_lower for x in ["zoom", "teams", "webex"]):
|
|
189
178
|
return "Browser Meeting"
|
|
190
179
|
|
|
180
|
+
# Second pass: check for secondary apps (chat apps with call features)
|
|
181
|
+
for win in windows:
|
|
182
|
+
owner = win.get(Quartz.kCGWindowOwnerName, "")
|
|
183
|
+
title = win.get(Quartz.kCGWindowName, "") or ""
|
|
184
|
+
|
|
185
|
+
if not owner:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
owner_lower = owner.lower()
|
|
189
|
+
title_lower = title.lower()
|
|
190
|
+
|
|
191
|
+
# Slack: detect huddle/call windows only
|
|
192
|
+
if "slack" in owner_lower:
|
|
193
|
+
if any(x in title_lower for x in ["huddle", "call"]):
|
|
194
|
+
return "Slack"
|
|
195
|
+
|
|
196
|
+
# Discord: detect voice channel
|
|
197
|
+
if "discord" in owner_lower:
|
|
198
|
+
if "voice connected" in title_lower:
|
|
199
|
+
return "Discord"
|
|
200
|
+
|
|
191
201
|
return None
|
|
192
202
|
except Exception:
|
|
193
203
|
return None
|
|
@@ -196,13 +206,21 @@ def is_meeting_app_active() -> Optional[str]:
|
|
|
196
206
|
def get_meeting_window_title() -> Optional[str]:
|
|
197
207
|
"""Get the window title of the active meeting app.
|
|
198
208
|
|
|
199
|
-
|
|
209
|
+
Prioritizes dedicated meeting apps (Zoom, Teams, Meet) over chat apps (Slack, Discord).
|
|
210
|
+
Returns the window title (meeting name) if found, or app name as fallback.
|
|
200
211
|
"""
|
|
201
212
|
try:
|
|
202
213
|
import Quartz
|
|
203
214
|
|
|
204
|
-
#
|
|
205
|
-
|
|
215
|
+
# Primary meeting apps (checked first) - dedicated video conferencing
|
|
216
|
+
primary_apps = {
|
|
217
|
+
"zoom.us": "Zoom",
|
|
218
|
+
"microsoft teams": "Teams",
|
|
219
|
+
"facetime": "FaceTime",
|
|
220
|
+
"webex": "Webex",
|
|
221
|
+
}
|
|
222
|
+
# Secondary apps (chat apps with call features) - only used if no primary found
|
|
223
|
+
secondary_apps = {"slack": "Slack_Huddle", "discord": "Discord"}
|
|
206
224
|
browsers = ["chrome", "safari", "firefox", "edge", "brave", "arc"]
|
|
207
225
|
|
|
208
226
|
# Get all on-screen windows
|
|
@@ -211,6 +229,9 @@ def get_meeting_window_title() -> Optional[str]:
|
|
|
211
229
|
Quartz.kCGNullWindowID
|
|
212
230
|
)
|
|
213
231
|
|
|
232
|
+
# First pass: check for primary meeting apps
|
|
233
|
+
found_primary_app = None # Track if we found a primary app (for fallback)
|
|
234
|
+
|
|
214
235
|
for win in windows:
|
|
215
236
|
owner = win.get(Quartz.kCGWindowOwnerName, "")
|
|
216
237
|
title = win.get(Quartz.kCGWindowName, "") or ""
|
|
@@ -221,18 +242,52 @@ def get_meeting_window_title() -> Optional[str]:
|
|
|
221
242
|
owner_lower = owner.lower()
|
|
222
243
|
title_lower = title.lower()
|
|
223
244
|
|
|
224
|
-
# Check
|
|
225
|
-
for app_id in
|
|
245
|
+
# Check primary meeting apps
|
|
246
|
+
for app_id, app_name in primary_apps.items():
|
|
226
247
|
if app_id.lower() in owner_lower:
|
|
248
|
+
# Remember we found this app (for fallback)
|
|
249
|
+
if found_primary_app is None:
|
|
250
|
+
# Check if it looks like an active meeting window
|
|
251
|
+
if "zoom.us" in owner_lower and title_lower not in ["", "zoom workplace"]:
|
|
252
|
+
found_primary_app = app_name
|
|
253
|
+
elif "microsoft teams" in owner_lower and title_lower not in ["", "microsoft teams"]:
|
|
254
|
+
found_primary_app = app_name
|
|
255
|
+
elif title and title_lower not in ["", app_name.lower()]:
|
|
256
|
+
found_primary_app = app_name
|
|
257
|
+
|
|
258
|
+
# Return specific title if meaningful
|
|
227
259
|
if title and _is_meaningful_title(title, owner):
|
|
228
260
|
return _clean_meeting_title(title)
|
|
229
261
|
|
|
230
262
|
# Check browser-based meetings (Google Meet)
|
|
231
263
|
if any(browser in owner_lower for browser in browsers):
|
|
232
264
|
if title_lower.startswith("meet -"):
|
|
233
|
-
# Extract meeting code/name from "Meet - xyz-abc-123 🔊"
|
|
234
265
|
return _clean_meeting_title(title)
|
|
235
266
|
|
|
267
|
+
# If we found a primary meeting app but no specific title, use app name
|
|
268
|
+
if found_primary_app:
|
|
269
|
+
return found_primary_app
|
|
270
|
+
|
|
271
|
+
# Second pass: only check secondary apps if no primary meeting found
|
|
272
|
+
for win in windows:
|
|
273
|
+
owner = win.get(Quartz.kCGWindowOwnerName, "")
|
|
274
|
+
title = win.get(Quartz.kCGWindowName, "") or ""
|
|
275
|
+
|
|
276
|
+
if not owner:
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
owner_lower = owner.lower()
|
|
280
|
+
|
|
281
|
+
for app_id, app_name in secondary_apps.items():
|
|
282
|
+
if app_id.lower() in owner_lower:
|
|
283
|
+
# For Slack/Discord, only match if it looks like a call window
|
|
284
|
+
if "slack" in owner_lower:
|
|
285
|
+
if "huddle" in title.lower() or "call" in title.lower():
|
|
286
|
+
return _clean_meeting_title(title) if title else app_name
|
|
287
|
+
elif "discord" in owner_lower:
|
|
288
|
+
if "voice connected" in title.lower():
|
|
289
|
+
return _clean_meeting_title(title) if title else app_name
|
|
290
|
+
|
|
236
291
|
return None
|
|
237
292
|
except Exception:
|
|
238
293
|
return None
|