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,456 @@
1
+ """Monitor microphone usage to detect when meetings start/end."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ from ctypes import c_uint32, c_int32, byref, POINTER, Structure
7
+ import time
8
+ from typing import Optional
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass
13
+ class MicStatus:
14
+ """Status of microphone usage."""
15
+ is_in_use: bool
16
+ app_name: Optional[str] = None
17
+
18
+
19
+ # CoreAudio constants
20
+ _kAudioObjectSystemObject = 1
21
+ _kAudioHardwarePropertyDefaultInputDevice = 1682533920 # 'dIn '
22
+ _kAudioDevicePropertyDeviceIsRunningSomewhere = 1735356005 # 'gone'
23
+ _kAudioObjectPropertyScopeGlobal = 1735159650 # 'glob'
24
+ _kAudioObjectPropertyElementMain = 0
25
+
26
+
27
+ class _AudioObjectPropertyAddress(Structure):
28
+ _fields_ = [
29
+ ('mSelector', c_uint32),
30
+ ('mScope', c_uint32),
31
+ ('mElement', c_uint32),
32
+ ]
33
+
34
+
35
+ # Load CoreAudio framework lazily
36
+ _core_audio = None
37
+ _AudioObjectGetPropertyDataSize = None
38
+ _AudioObjectGetPropertyData = None
39
+
40
+
41
+ def _init_coreaudio():
42
+ """Initialize CoreAudio framework."""
43
+ global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData
44
+
45
+ if _core_audio is not None:
46
+ return True
47
+
48
+ try:
49
+ _core_audio = ctypes.CDLL('/System/Library/Frameworks/CoreAudio.framework/CoreAudio')
50
+
51
+ _AudioObjectGetPropertyDataSize = _core_audio.AudioObjectGetPropertyDataSize
52
+ _AudioObjectGetPropertyDataSize.argtypes = [
53
+ c_uint32, POINTER(_AudioObjectPropertyAddress), c_uint32, ctypes.c_void_p, POINTER(c_uint32)
54
+ ]
55
+ _AudioObjectGetPropertyDataSize.restype = c_int32
56
+
57
+ _AudioObjectGetPropertyData = _core_audio.AudioObjectGetPropertyData
58
+ _AudioObjectGetPropertyData.argtypes = [
59
+ c_uint32, POINTER(_AudioObjectPropertyAddress), c_uint32, ctypes.c_void_p, POINTER(c_uint32), ctypes.c_void_p
60
+ ]
61
+ _AudioObjectGetPropertyData.restype = c_int32
62
+
63
+ return True
64
+ except Exception:
65
+ return False
66
+
67
+
68
+ def is_mic_in_use_by_another_app() -> bool:
69
+ """Check if the microphone is being used by another application.
70
+
71
+ Uses CoreAudio's kAudioDevicePropertyDeviceIsRunningSomewhere property
72
+ to detect if any app has an active audio input session.
73
+
74
+ Returns:
75
+ True if another app is using the microphone
76
+ """
77
+ if not _init_coreaudio():
78
+ return False
79
+
80
+ try:
81
+ # Get default input device
82
+ addr = _AudioObjectPropertyAddress(
83
+ _kAudioHardwarePropertyDefaultInputDevice,
84
+ _kAudioObjectPropertyScopeGlobal,
85
+ _kAudioObjectPropertyElementMain
86
+ )
87
+
88
+ size = c_uint32(4)
89
+ device_id = c_uint32(0)
90
+
91
+ err = _AudioObjectGetPropertyData(
92
+ _kAudioObjectSystemObject, byref(addr), 0, None, byref(size), byref(device_id)
93
+ )
94
+
95
+ if err != 0 or device_id.value == 0:
96
+ return False
97
+
98
+ # Check if device is running somewhere (another app using it)
99
+ addr_running = _AudioObjectPropertyAddress(
100
+ _kAudioDevicePropertyDeviceIsRunningSomewhere,
101
+ _kAudioObjectPropertyScopeGlobal,
102
+ _kAudioObjectPropertyElementMain
103
+ )
104
+
105
+ is_running = c_uint32(0)
106
+ size = c_uint32(4)
107
+
108
+ err = _AudioObjectGetPropertyData(
109
+ device_id.value, byref(addr_running), 0, None, byref(size), byref(is_running)
110
+ )
111
+
112
+ return err == 0 and is_running.value != 0
113
+
114
+ except Exception:
115
+ return False
116
+
117
+
118
+ def is_meeting_app_active() -> Optional[str]:
119
+ """Check if a meeting app has an ACTIVE MEETING window.
120
+
121
+ Prioritizes dedicated meeting apps over chat apps.
122
+ Returns the app name only if there's an active call/meeting,
123
+ not just because the app is open.
124
+ """
125
+ try:
126
+ import Quartz
127
+
128
+ # Get all on-screen windows
129
+ windows = Quartz.CGWindowListCopyWindowInfo(
130
+ Quartz.kCGWindowListOptionOnScreenOnly,
131
+ Quartz.kCGNullWindowID
132
+ )
133
+
134
+ # First pass: check for primary meeting apps (dedicated video conferencing)
135
+ for win in windows:
136
+ owner = win.get(Quartz.kCGWindowOwnerName, "")
137
+ title = win.get(Quartz.kCGWindowName, "") or ""
138
+
139
+ if not owner:
140
+ continue
141
+
142
+ owner_lower = owner.lower()
143
+ title_lower = title.lower()
144
+
145
+ # Zoom: detect meeting windows (not just "Zoom Workplace" or "Zoom")
146
+ if "zoom.us" in owner_lower:
147
+ # Skip non-meeting windows
148
+ if title_lower in ["", "zoom", "zoom workplace", "zoom.us"]:
149
+ continue
150
+ # This is likely a meeting window
151
+ return "Zoom"
152
+
153
+ # Microsoft Teams: detect call windows
154
+ if "microsoft teams" in owner_lower:
155
+ # Skip main app windows
156
+ if title_lower in ["", "microsoft teams"]:
157
+ continue
158
+ return "Teams"
159
+
160
+ # FaceTime
161
+ if "facetime" in owner_lower:
162
+ if title and title_lower != "facetime":
163
+ return "FaceTime"
164
+
165
+ # Webex
166
+ if "webex" in owner_lower:
167
+ if title and "meeting" in title_lower:
168
+ return "Webex"
169
+
170
+ # Browser-based meetings (Google Meet, etc.)
171
+ if any(browser in owner_lower for browser in ["chrome", "safari", "firefox", "edge", "brave", "arc"]):
172
+ if title_lower.startswith("meet -") or "meet.google.com" in title_lower:
173
+ return "Google Meet"
174
+ if " meeting" in title_lower and any(x in title_lower for x in ["zoom", "teams", "webex"]):
175
+ return "Browser Meeting"
176
+
177
+ # Second pass: check for secondary apps (chat apps with call features)
178
+ for win in windows:
179
+ owner = win.get(Quartz.kCGWindowOwnerName, "")
180
+ title = win.get(Quartz.kCGWindowName, "") or ""
181
+
182
+ if not owner:
183
+ continue
184
+
185
+ owner_lower = owner.lower()
186
+ title_lower = title.lower()
187
+
188
+ # Slack: detect huddle/call windows only
189
+ if "slack" in owner_lower:
190
+ if any(x in title_lower for x in ["huddle", "call"]):
191
+ return "Slack"
192
+
193
+ # Discord: detect voice channel
194
+ if "discord" in owner_lower:
195
+ if "voice connected" in title_lower:
196
+ return "Discord"
197
+
198
+ return None
199
+ except Exception:
200
+ return None
201
+
202
+
203
+ def get_meeting_window_title() -> Optional[str]:
204
+ """Get the window title of the active meeting app.
205
+
206
+ Prioritizes dedicated meeting apps (Zoom, Teams, Meet) over chat apps (Slack, Discord).
207
+ Returns the window title (meeting name) if found, or app name as fallback.
208
+ """
209
+ try:
210
+ import Quartz
211
+
212
+ # Primary meeting apps (checked first) - dedicated video conferencing
213
+ primary_apps = {
214
+ "zoom.us": "Zoom",
215
+ "microsoft teams": "Teams",
216
+ "facetime": "FaceTime",
217
+ "webex": "Webex",
218
+ }
219
+ # Secondary apps (chat apps with call features) - only used if no primary found
220
+ secondary_apps = {"slack": "Slack_Huddle", "discord": "Discord"}
221
+ browsers = ["chrome", "safari", "firefox", "edge", "brave", "arc"]
222
+
223
+ # Get all on-screen windows
224
+ windows = Quartz.CGWindowListCopyWindowInfo(
225
+ Quartz.kCGWindowListOptionOnScreenOnly,
226
+ Quartz.kCGNullWindowID
227
+ )
228
+
229
+ # First pass: check for primary meeting apps
230
+ found_primary_app = None # Track if we found a primary app (for fallback)
231
+
232
+ for win in windows:
233
+ owner = win.get(Quartz.kCGWindowOwnerName, "")
234
+ title = win.get(Quartz.kCGWindowName, "") or ""
235
+
236
+ if not owner:
237
+ continue
238
+
239
+ owner_lower = owner.lower()
240
+ title_lower = title.lower()
241
+
242
+ # Check primary meeting apps
243
+ for app_id, app_name in primary_apps.items():
244
+ if app_id.lower() in owner_lower:
245
+ # Remember we found this app (for fallback)
246
+ if found_primary_app is None:
247
+ # Check if it looks like an active meeting window
248
+ if "zoom.us" in owner_lower and title_lower not in ["", "zoom workplace"]:
249
+ found_primary_app = app_name
250
+ elif "microsoft teams" in owner_lower and title_lower not in ["", "microsoft teams"]:
251
+ found_primary_app = app_name
252
+ elif title and title_lower not in ["", app_name.lower()]:
253
+ found_primary_app = app_name
254
+
255
+ # Return specific title if meaningful
256
+ if title and _is_meaningful_title(title, owner):
257
+ return _clean_meeting_title(title)
258
+
259
+ # Check browser-based meetings (Google Meet)
260
+ if any(browser in owner_lower for browser in browsers):
261
+ if title_lower.startswith("meet -"):
262
+ return _clean_meeting_title(title)
263
+
264
+ # If we found a primary meeting app but no specific title, use app name
265
+ if found_primary_app:
266
+ return found_primary_app
267
+
268
+ # Second pass: only check secondary apps if no primary meeting found
269
+ for win in windows:
270
+ owner = win.get(Quartz.kCGWindowOwnerName, "")
271
+ title = win.get(Quartz.kCGWindowName, "") or ""
272
+
273
+ if not owner:
274
+ continue
275
+
276
+ owner_lower = owner.lower()
277
+
278
+ for app_id, app_name in secondary_apps.items():
279
+ if app_id.lower() in owner_lower:
280
+ # For Slack/Discord, only match if it looks like a call window
281
+ if "slack" in owner_lower:
282
+ if "huddle" in title.lower() or "call" in title.lower():
283
+ return _clean_meeting_title(title) if title else app_name
284
+ elif "discord" in owner_lower:
285
+ if "voice connected" in title.lower():
286
+ return _clean_meeting_title(title) if title else app_name
287
+
288
+ return None
289
+ except Exception:
290
+ return None
291
+
292
+
293
+ def _is_meaningful_title(title: str, app_name: str) -> bool:
294
+ """Check if a window title is meaningful (not just the app name)."""
295
+ # Skip generic titles
296
+ generic_titles = [
297
+ "zoom", "zoom.us", "zoom meeting", "zoom workplace",
298
+ "microsoft teams", "teams",
299
+ "slack", "discord", "facetime", "webex",
300
+ "", " "
301
+ ]
302
+
303
+ title_lower = title.lower().strip()
304
+
305
+ # Skip if it's just the app name or a generic title
306
+ if title_lower in generic_titles:
307
+ return False
308
+
309
+ # Skip Zoom's generic windows
310
+ if "zoom.us" in app_name.lower():
311
+ if title_lower in ["zoom", "zoom meeting", "zoom workplace"]:
312
+ return False
313
+
314
+ return True
315
+
316
+
317
+ def _clean_meeting_title(title: str) -> str:
318
+ """Clean up a meeting title for use as a filename."""
319
+ import re
320
+
321
+ # Remove common prefixes/suffixes
322
+ title = title.strip()
323
+
324
+ # Google Meet: "Meet - xyz-abc-123 🔊" -> "Meet_xyz-abc-123"
325
+ if title.lower().startswith("meet - "):
326
+ title = "Meet_" + title[7:]
327
+ # Remove speaker emoji and other indicators
328
+ title = re.sub(r'[🔊🔇📹]', '', title).strip()
329
+
330
+ # Remove "Zoom Meeting - " prefix
331
+ if title.lower().startswith("zoom meeting - "):
332
+ title = title[15:]
333
+
334
+ # Remove " - Zoom" suffix
335
+ if title.lower().endswith(" - zoom"):
336
+ title = title[:-7]
337
+
338
+ # Remove " | Microsoft Teams" suffix
339
+ if " | Microsoft Teams" in title:
340
+ title = title.split(" | Microsoft Teams")[0]
341
+
342
+ # Replace invalid filename characters
343
+ title = re.sub(r'[<>:"/\\|?*]', '_', title)
344
+
345
+ # Replace multiple spaces/underscores with single underscore
346
+ title = re.sub(r'[\s_]+', '_', title)
347
+
348
+ # Limit length
349
+ if len(title) > 50:
350
+ title = title[:50]
351
+
352
+ return title.strip('_')
353
+
354
+
355
+ def get_app_using_mic() -> Optional[str]:
356
+ """Get the name of the meeting app that might be using the microphone.
357
+
358
+ Note: This returns any active meeting app, but doesn't guarantee
359
+ that specific app is the one using the mic.
360
+ """
361
+ return is_meeting_app_active()
362
+
363
+
364
+ class MicrophoneMonitor:
365
+ """Monitor microphone usage to detect meeting start/end.
366
+
367
+ Start detection: Another app starts using the microphone
368
+ Stop detection: The meeting app window is no longer visible
369
+
370
+ This approach works because:
371
+ - Start: CoreAudio tells us when mic is activated (before we start recording)
372
+ - Stop: We can't rely on mic state (our recording uses it), so we check if meeting app is gone
373
+ """
374
+
375
+ def __init__(self):
376
+ self._was_mic_in_use = False
377
+ self._is_recording = False
378
+ self._recording_app: Optional[str] = None # Which app triggered recording
379
+ self._last_check_time = 0
380
+ self._on_count = 0 # Count consecutive "on" readings
381
+ self._off_count = 0 # Count consecutive "off" readings
382
+ self._on_threshold = 2 # Require 2 consecutive "on" readings to trigger start
383
+ self._off_threshold = 3 # Require 3 consecutive "off" readings to trigger stop
384
+
385
+ def set_recording(self, is_recording: bool, app_name: Optional[str] = None):
386
+ """Tell the monitor whether we're currently recording."""
387
+ self._is_recording = is_recording
388
+
389
+ if is_recording:
390
+ self._was_mic_in_use = True
391
+ self._on_count = 0
392
+ # Only set app_name if provided (don't overwrite on status updates)
393
+ if app_name:
394
+ self._recording_app = app_name
395
+ else:
396
+ self._recording_app = None
397
+
398
+ def check(self) -> tuple[bool, bool, Optional[str]]:
399
+ """Check for microphone usage changes.
400
+
401
+ Returns:
402
+ Tuple of (mic_started, mic_stopped, app_name)
403
+ - mic_started: True if another app started using mic (should prompt to record)
404
+ - mic_stopped: True if meeting app closed (should stop recording)
405
+ - app_name: Name of meeting app (if detected)
406
+ """
407
+ now = time.time()
408
+
409
+ # Debounce - check every 2 seconds
410
+ if now - self._last_check_time < 2.0:
411
+ return False, False, None
412
+ self._last_check_time = now
413
+
414
+ mic_started = False
415
+ mic_stopped = False
416
+ app_name = None
417
+
418
+ if self._is_recording:
419
+ # While recording: check if meeting app is still visible
420
+ # (Can't rely on mic state since our app is using it)
421
+ current_app = is_meeting_app_active()
422
+
423
+ if current_app is None:
424
+ # Meeting app window is gone
425
+ self._off_count += 1
426
+ if self._off_count >= self._off_threshold:
427
+ mic_stopped = True
428
+ self._was_mic_in_use = False
429
+ self._off_count = 0
430
+ else:
431
+ self._off_count = 0
432
+ else:
433
+ # Not recording: check if mic is being used by another app
434
+ mic_in_use = is_mic_in_use_by_another_app()
435
+
436
+ if mic_in_use:
437
+ self._off_count = 0
438
+ # Get app name for display (optional, doesn't affect start decision)
439
+ app_name = is_meeting_app_active()
440
+
441
+ if not self._was_mic_in_use:
442
+ self._on_count += 1
443
+ # Require consecutive readings to avoid false positives
444
+ if self._on_count >= self._on_threshold:
445
+ mic_started = True
446
+ self._was_mic_in_use = True
447
+ self._on_count = 0
448
+ else:
449
+ self._on_count = 0
450
+ self._was_mic_in_use = False
451
+
452
+ return mic_started, mic_stopped, app_name
453
+
454
+ def is_mic_active(self) -> bool:
455
+ """Check if microphone is currently in use by another app."""
456
+ return is_mic_in_use_by_another_app()
@@ -0,0 +1 @@
1
+ """Output handling modules."""
@@ -0,0 +1,96 @@
1
+ """Output handling for recordings and transcripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+
11
+ def format_duration(seconds: float) -> str:
12
+ """Format duration as human-readable string."""
13
+ if seconds < 60:
14
+ return f"{seconds:.0f}s"
15
+ elif seconds < 3600:
16
+ minutes = int(seconds // 60)
17
+ secs = int(seconds % 60)
18
+ return f"{minutes}m {secs}s"
19
+ else:
20
+ hours = int(seconds // 3600)
21
+ minutes = int((seconds % 3600) // 60)
22
+ return f"{hours}h {minutes}m"
23
+
24
+
25
+ def format_size(bytes: int) -> str:
26
+ """Format file size as human-readable string."""
27
+ if bytes < 1024:
28
+ return f"{bytes} B"
29
+ elif bytes < 1024 * 1024:
30
+ return f"{bytes / 1024:.1f} KB"
31
+ else:
32
+ return f"{bytes / (1024 * 1024):.1f} MB"
33
+
34
+
35
+ def get_audio_duration(filepath: Path) -> Optional[float]:
36
+ """Get duration of an audio file in seconds.
37
+
38
+ Uses a simple estimation based on file size and bitrate.
39
+ """
40
+ try:
41
+ # Rough estimation: 128kbps MP3 = 16KB per second
42
+ size = filepath.stat().st_size
43
+ return size / (128 * 1000 / 8) # 128kbps = 16000 bytes/sec
44
+ except:
45
+ return None
46
+
47
+
48
+ def list_recordings(output_dir: Path, limit: int = 10):
49
+ """List recent meeting recordings."""
50
+ if not output_dir.exists():
51
+ click.echo(click.style(f"Directory not found: {output_dir}", fg="red"))
52
+ return
53
+
54
+ # Find all MP3 files
55
+ mp3_files = sorted(
56
+ output_dir.glob("*.mp3"),
57
+ key=lambda p: p.stat().st_mtime,
58
+ reverse=True,
59
+ )
60
+
61
+ if not mp3_files:
62
+ click.echo(click.style("No recordings found.", fg="yellow"))
63
+ click.echo(f"\nRecordings directory: {output_dir}")
64
+ click.echo("Run 'meeting-noter daemon' to start capturing audio.")
65
+ return
66
+
67
+ click.echo(f"\nRecent recordings in {output_dir}:\n")
68
+
69
+ # Show header
70
+ click.echo(f" {'Date':<20} {'Duration':<12} {'Size':<10} {'Transcript':<12} File")
71
+ click.echo(" " + "-" * 75)
72
+
73
+ for mp3 in mp3_files[:limit]:
74
+ # Get file info
75
+ stat = mp3.stat()
76
+ mod_time = datetime.fromtimestamp(stat.st_mtime)
77
+ date_str = mod_time.strftime("%Y-%m-%d %H:%M")
78
+
79
+ # Duration estimate
80
+ duration = get_audio_duration(mp3)
81
+ duration_str = format_duration(duration) if duration else "?"
82
+
83
+ # File size
84
+ size_str = format_size(stat.st_size)
85
+
86
+ # Check for transcript
87
+ transcript = mp3.with_suffix(".txt")
88
+ has_transcript = click.style("Yes", fg="green") if transcript.exists() else click.style("No", fg="yellow")
89
+
90
+ click.echo(f" {date_str:<20} {duration_str:<12} {size_str:<10} {has_transcript:<12} {mp3.name}")
91
+
92
+ if len(mp3_files) > limit:
93
+ click.echo(f"\n ... and {len(mp3_files) - limit} more recordings")
94
+
95
+ click.echo(f"\nTotal: {len(mp3_files)} recordings")
96
+ click.echo("\nTo transcribe: meeting-noter transcribe [filename]")
@@ -0,0 +1 @@
1
+ """Resources package for Meeting Noter."""
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ """Transcription modules."""