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/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(output_dir: Path, meeting_name: Optional[str] = None):
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 their window title patterns
10
- MEETING_APPS = {
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 - "], # Must have "meet - " (active meeting has code after)
25
- "title_excludes": ["google meet"], # Exclude homepage "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 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
- )
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
- return None
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]:
@@ -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
- # Google Meet: title starts with "Meet -" or contains "meet.google.com"
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
- Returns the window title (meeting name) if found, None otherwise.
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
- # Known meeting apps
205
- meeting_apps = ["zoom.us", "Microsoft Teams", "Slack", "Discord", "FaceTime", "Webex"]
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 native meeting apps
225
- for app_id in meeting_apps:
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