meeting-noter 0.3.4__tar.gz → 0.5.1__tar.gz

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 (44) hide show
  1. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/PKG-INFO +6 -5
  2. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/README.md +5 -4
  3. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/pyproject.toml +1 -1
  4. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/menubar.py +36 -57
  5. meeting_noter-0.5.1/src/meeting_noter/mic_monitor.py +404 -0
  6. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter.egg-info/PKG-INFO +6 -5
  7. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter.egg-info/SOURCES.txt +1 -0
  8. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/setup.cfg +0 -0
  9. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/__init__.py +0 -0
  10. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/__main__.py +0 -0
  11. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/audio/__init__.py +0 -0
  12. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/audio/capture.py +0 -0
  13. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/audio/encoder.py +0 -0
  14. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/audio/system_audio.py +0 -0
  15. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/cli.py +0 -0
  16. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/config.py +0 -0
  17. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/daemon.py +0 -0
  18. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/gui/__init__.py +0 -0
  19. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/gui/__main__.py +0 -0
  20. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/gui/app.py +0 -0
  21. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/gui/main_window.py +0 -0
  22. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/gui/meetings_tab.py +0 -0
  23. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/gui/recording_tab.py +0 -0
  24. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/gui/settings_tab.py +0 -0
  25. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/install/__init__.py +0 -0
  26. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/install/macos.py +0 -0
  27. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/meeting_detector.py +0 -0
  28. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/output/__init__.py +0 -0
  29. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/output/writer.py +0 -0
  30. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/resources/__init__.py +0 -0
  31. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/resources/icon.icns +0 -0
  32. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/resources/icon.png +0 -0
  33. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/resources/icon_128.png +0 -0
  34. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/resources/icon_16.png +0 -0
  35. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/resources/icon_256.png +0 -0
  36. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/resources/icon_32.png +0 -0
  37. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/resources/icon_512.png +0 -0
  38. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/resources/icon_64.png +0 -0
  39. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/transcription/__init__.py +0 -0
  40. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter/transcription/engine.py +0 -0
  41. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter.egg-info/dependency_links.txt +0 -0
  42. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter.egg-info/entry_points.txt +0 -0
  43. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter.egg-info/requires.txt +0 -0
  44. {meeting_noter-0.3.4 → meeting_noter-0.5.1}/src/meeting_noter.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meeting-noter
3
- Version: 0.3.4
3
+ Version: 0.5.1
4
4
  Summary: Offline meeting transcription for macOS with automatic meeting detection
5
5
  Author: Victor
6
6
  License: MIT
@@ -60,10 +60,10 @@ Offline meeting transcription tool for macOS. Captures both your voice and meeti
60
60
 
61
61
  ```bash
62
62
  # Install dependencies
63
- brew install ffmpeg lame pkg-config
63
+ brew install ffmpeg lame pkg-config python@3.12
64
64
 
65
- # Install meeting-noter
66
- pipx install meeting-noter
65
+ # Install meeting-noter (use Python 3.12 for best compatibility)
66
+ pipx install meeting-noter --python /opt/homebrew/bin/python3.12
67
67
  ```
68
68
 
69
69
  Or with pip:
@@ -208,9 +208,10 @@ Config file: `~/.config/meeting-noter/config.json`
208
208
  ## Requirements
209
209
 
210
210
  - macOS 12.3+ (for ScreenCaptureKit)
211
- - Python 3.9+
211
+ - Python 3.9+ (3.12 recommended for best compatibility)
212
212
  - FFmpeg (`brew install ffmpeg`) - required for audio processing
213
213
  - LAME (`brew install lame`) - required for MP3 encoding
214
+ - pkg-config (`brew install pkg-config`) - required for building dependencies
214
215
 
215
216
  ## License
216
217
 
@@ -15,10 +15,10 @@ Offline meeting transcription tool for macOS. Captures both your voice and meeti
15
15
 
16
16
  ```bash
17
17
  # Install dependencies
18
- brew install ffmpeg lame pkg-config
18
+ brew install ffmpeg lame pkg-config python@3.12
19
19
 
20
- # Install meeting-noter
21
- pipx install meeting-noter
20
+ # Install meeting-noter (use Python 3.12 for best compatibility)
21
+ pipx install meeting-noter --python /opt/homebrew/bin/python3.12
22
22
  ```
23
23
 
24
24
  Or with pip:
@@ -163,9 +163,10 @@ Config file: `~/.config/meeting-noter/config.json`
163
163
  ## Requirements
164
164
 
165
165
  - macOS 12.3+ (for ScreenCaptureKit)
166
- - Python 3.9+
166
+ - Python 3.9+ (3.12 recommended for best compatibility)
167
167
  - FFmpeg (`brew install ffmpeg`) - required for audio processing
168
168
  - LAME (`brew install lame`) - required for MP3 encoding
169
+ - pkg-config (`brew install pkg-config`) - required for building dependencies
169
170
 
170
171
  ## License
171
172
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meeting-noter"
7
- version = "0.3.4"
7
+ version = "0.5.1"
8
8
  description = "Offline meeting transcription for macOS with automatic meeting detection"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -13,7 +13,7 @@ import rumps
13
13
 
14
14
  from meeting_noter.daemon import read_pid_file, is_process_running, stop_daemon
15
15
  from meeting_noter.config import get_config, generate_meeting_name
16
- from meeting_noter.meeting_detector import MeetingMonitor, MeetingInfo
16
+ from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title
17
17
 
18
18
 
19
19
  DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
@@ -42,11 +42,10 @@ class MeetingNoterApp(rumps.App):
42
42
  super().__init__("Meeting Noter", title="▷")
43
43
  self.pid_file = DEFAULT_PID_FILE
44
44
  self.config = get_config()
45
- self.meeting_monitor = MeetingMonitor()
45
+ self.mic_monitor = MicrophoneMonitor()
46
46
  self.current_meeting_name: Optional[str] = None
47
47
  self.pending_notification = False # Avoid duplicate notifications
48
- self._no_meeting_count = 0 # Counter for auto-stop detection
49
- self._initial_check_done = False # Flag for initial meeting check
48
+ self._pending_app_name: Optional[str] = None
50
49
  self.menu = [
51
50
  "Start Recording",
52
51
  "Stop Recording",
@@ -138,7 +137,7 @@ class MeetingNoterApp(rumps.App):
138
137
  except FileNotFoundError:
139
138
  pass
140
139
 
141
- def _start_recording_with_name(self, meeting_name: str):
140
+ def _start_recording_with_name(self, meeting_name: str, app_name: Optional[str] = None):
142
141
  """Start recording with a specific meeting name."""
143
142
  import sys
144
143
 
@@ -147,6 +146,7 @@ class MeetingNoterApp(rumps.App):
147
146
 
148
147
  self.current_meeting_name = meeting_name
149
148
  self._save_recording_state(meeting_name)
149
+ self.mic_monitor.set_recording(True, app_name)
150
150
 
151
151
  # Start daemon with the meeting name using current Python
152
152
  subprocess.Popen(
@@ -167,13 +167,7 @@ class MeetingNoterApp(rumps.App):
167
167
  )
168
168
  return
169
169
 
170
- # Check if there's an active meeting to get the name
171
- meeting_info = self.meeting_monitor.last_meeting
172
- if meeting_info and meeting_info.meeting_name:
173
- meeting_name = meeting_info.meeting_name
174
- else:
175
- meeting_name = generate_meeting_name()
176
-
170
+ meeting_name = generate_meeting_name()
177
171
  self._start_recording_with_name(meeting_name)
178
172
 
179
173
  rumps.notification(
@@ -281,58 +275,36 @@ class MeetingNoterApp(rumps.App):
281
275
  stderr=subprocess.DEVNULL,
282
276
  )
283
277
 
284
- @rumps.timer(3)
278
+ @rumps.timer(2)
285
279
  def poll_status(self, _):
286
- """Periodically update the menu bar title and check for meetings."""
280
+ """Periodically update the menu bar title and check for mic usage."""
287
281
  self._update_title()
288
282
 
289
283
  is_recording = self._is_running()
290
284
 
291
- # Check for meeting status changes
292
- meeting_started, meeting_ended, meeting_info = self.meeting_monitor.check()
285
+ # Tell mic monitor our recording state
286
+ self.mic_monitor.set_recording(is_recording)
293
287
 
294
- # On first check, treat existing meeting as "started" to show prompt
295
- if not self._initial_check_done:
296
- self._initial_check_done = True
297
- if meeting_info and not is_recording:
298
- meeting_started = True
288
+ # Check for mic usage changes
289
+ mic_started, mic_stopped, app_name = self.mic_monitor.check()
299
290
 
300
- # Auto-stop recording when meeting ends
301
- if meeting_ended and is_recording:
291
+ # Auto-stop recording when mic stops being used
292
+ if mic_stopped and is_recording:
302
293
  rumps.notification(
303
294
  title="Meeting Noter",
304
- subtitle="Meeting Ended",
295
+ subtitle="Call Ended",
305
296
  message="Stopping recording...",
306
297
  )
307
- # Trigger stop recording (will auto-transcribe)
308
298
  self._auto_stop_recording()
309
- return # Skip other checks this cycle
310
-
311
- # Also check: if we're recording but no meeting detected for a while
312
- # This catches cases where meeting window changes unexpectedly
313
- if is_recording and not meeting_info and not self.meeting_monitor.is_in_meeting():
314
- # Double-check by waiting one more cycle
315
- if hasattr(self, '_no_meeting_count'):
316
- self._no_meeting_count += 1
317
- if self._no_meeting_count >= 2: # ~6 seconds of no meeting
318
- rumps.notification(
319
- title="Meeting Noter",
320
- subtitle="Meeting Ended",
321
- message="Stopping recording...",
322
- )
323
- self._auto_stop_recording()
324
- self._no_meeting_count = 0
325
- else:
326
- self._no_meeting_count = 1
327
- else:
328
- self._no_meeting_count = 0
299
+ return
329
300
 
330
- # Auto-detect new meetings
331
- if not is_recording and not self.pending_notification:
332
- if meeting_started and meeting_info:
333
- self.pending_notification = True
334
- self._detected_meeting = meeting_info
335
- self._pending_meeting_name = meeting_info.meeting_name or meeting_info.app_name
301
+ # Prompt to record when mic starts being used
302
+ if mic_started and not is_recording and not self.pending_notification:
303
+ self.pending_notification = True
304
+ self._pending_app_name = app_name or "Unknown App"
305
+ # Try to get meeting name from window title, fall back to timestamp
306
+ window_title = get_meeting_window_title()
307
+ self._pending_meeting_name = window_title or generate_meeting_name()
336
308
 
337
309
  def _auto_stop_recording(self):
338
310
  """Stop recording automatically when meeting ends."""
@@ -384,24 +356,31 @@ class MeetingNoterApp(rumps.App):
384
356
  @rumps.timer(1)
385
357
  def check_pending_prompt(self, _):
386
358
  """Check if we need to show a recording prompt (runs on main thread)."""
387
- if self.pending_notification and hasattr(self, '_detected_meeting') and self._detected_meeting:
388
- meeting_info = self._detected_meeting
359
+ if self.pending_notification and hasattr(self, '_pending_meeting_name'):
360
+ app_name = self._pending_app_name or "App"
389
361
  meeting_name = self._pending_meeting_name
390
362
 
391
363
  # Clear first to prevent re-triggering
392
- self._detected_meeting = None
364
+ self._pending_app_name = None
393
365
  self.pending_notification = False
394
366
 
367
+ # Build message with meeting name if available
368
+ if meeting_name and not meeting_name[0].isdigit():
369
+ # Has a real meeting name (not timestamp-based)
370
+ message = f"Meeting: {meeting_name}\n\nDo you want to record?"
371
+ else:
372
+ message = "Do you want to record this call?"
373
+
395
374
  # Show alert on main thread
396
375
  response = rumps.alert(
397
- title=f"Meeting Detected: {meeting_info.app_name}",
398
- message=f"Do you want to record '{meeting_name}'?",
376
+ title=f"Microphone in use: {app_name}",
377
+ message=message,
399
378
  ok="Record",
400
379
  cancel="Skip",
401
380
  )
402
381
 
403
382
  if response == 1: # Record clicked
404
- self._start_recording_with_name(meeting_name)
383
+ self._start_recording_with_name(meeting_name, app_name)
405
384
  rumps.notification(
406
385
  title="Meeting Noter",
407
386
  subtitle="Recording Started",
@@ -0,0 +1,404 @@
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
+ Returns the app name only if there's an active call/meeting,
122
+ not just because the app is open.
123
+ """
124
+ try:
125
+ import Quartz
126
+
127
+ # Get all on-screen windows
128
+ windows = Quartz.CGWindowListCopyWindowInfo(
129
+ Quartz.kCGWindowListOptionOnScreenOnly,
130
+ Quartz.kCGNullWindowID
131
+ )
132
+
133
+ for win in windows:
134
+ owner = win.get(Quartz.kCGWindowOwnerName, "")
135
+ title = win.get(Quartz.kCGWindowName, "") or ""
136
+
137
+ if not owner:
138
+ continue
139
+
140
+ owner_lower = owner.lower()
141
+ title_lower = title.lower()
142
+
143
+ # Zoom: detect meeting windows (not just "Zoom Workplace" or "Zoom")
144
+ if "zoom.us" in owner_lower:
145
+ # Skip non-meeting windows
146
+ if title_lower in ["", "zoom", "zoom workplace", "zoom.us"]:
147
+ continue
148
+ # This is likely a meeting window
149
+ return "Zoom"
150
+
151
+ # Microsoft Teams: detect call windows
152
+ if "microsoft teams" in owner_lower:
153
+ # Skip main app windows
154
+ if title_lower in ["", "microsoft teams"]:
155
+ continue
156
+ # Check for call indicators
157
+ if any(x in title_lower for x in ["call", "meeting", "chat"]):
158
+ return "Teams"
159
+ return "Teams"
160
+
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
+ # FaceTime
167
+ if "facetime" in owner_lower:
168
+ if title and title_lower != "facetime":
169
+ return "FaceTime"
170
+
171
+ # Webex
172
+ if "webex" in owner_lower:
173
+ if title and "meeting" in title_lower:
174
+ return "Webex"
175
+
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
+ # Browser-based meetings (Google Meet, etc.)
183
+ 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"
185
+ if title_lower.startswith("meet -") or "meet.google.com" in title_lower:
186
+ return "Google Meet"
187
+ # Generic "Meeting" in browser title
188
+ if " meeting" in title_lower and any(x in title_lower for x in ["zoom", "teams", "webex"]):
189
+ return "Browser Meeting"
190
+
191
+ return None
192
+ except Exception:
193
+ return None
194
+
195
+
196
+ def get_meeting_window_title() -> Optional[str]:
197
+ """Get the window title of the active meeting app.
198
+
199
+ Returns the window title (meeting name) if found, None otherwise.
200
+ """
201
+ try:
202
+ import Quartz
203
+
204
+ # Known meeting apps
205
+ meeting_apps = ["zoom.us", "Microsoft Teams", "Slack", "Discord", "FaceTime", "Webex"]
206
+ browsers = ["chrome", "safari", "firefox", "edge", "brave", "arc"]
207
+
208
+ # Get all on-screen windows
209
+ windows = Quartz.CGWindowListCopyWindowInfo(
210
+ Quartz.kCGWindowListOptionOnScreenOnly,
211
+ Quartz.kCGNullWindowID
212
+ )
213
+
214
+ for win in windows:
215
+ owner = win.get(Quartz.kCGWindowOwnerName, "")
216
+ title = win.get(Quartz.kCGWindowName, "") or ""
217
+
218
+ if not owner:
219
+ continue
220
+
221
+ owner_lower = owner.lower()
222
+ title_lower = title.lower()
223
+
224
+ # Check native meeting apps
225
+ for app_id in meeting_apps:
226
+ if app_id.lower() in owner_lower:
227
+ if title and _is_meaningful_title(title, owner):
228
+ return _clean_meeting_title(title)
229
+
230
+ # Check browser-based meetings (Google Meet)
231
+ if any(browser in owner_lower for browser in browsers):
232
+ if title_lower.startswith("meet -"):
233
+ # Extract meeting code/name from "Meet - xyz-abc-123 🔊"
234
+ return _clean_meeting_title(title)
235
+
236
+ return None
237
+ except Exception:
238
+ return None
239
+
240
+
241
+ def _is_meaningful_title(title: str, app_name: str) -> bool:
242
+ """Check if a window title is meaningful (not just the app name)."""
243
+ # Skip generic titles
244
+ generic_titles = [
245
+ "zoom", "zoom.us", "zoom meeting", "zoom workplace",
246
+ "microsoft teams", "teams",
247
+ "slack", "discord", "facetime", "webex",
248
+ "", " "
249
+ ]
250
+
251
+ title_lower = title.lower().strip()
252
+
253
+ # Skip if it's just the app name or a generic title
254
+ if title_lower in generic_titles:
255
+ return False
256
+
257
+ # Skip Zoom's generic windows
258
+ if "zoom.us" in app_name.lower():
259
+ if title_lower in ["zoom", "zoom meeting", "zoom workplace"]:
260
+ return False
261
+
262
+ return True
263
+
264
+
265
+ def _clean_meeting_title(title: str) -> str:
266
+ """Clean up a meeting title for use as a filename."""
267
+ import re
268
+
269
+ # Remove common prefixes/suffixes
270
+ title = title.strip()
271
+
272
+ # Google Meet: "Meet - xyz-abc-123 🔊" -> "Meet_xyz-abc-123"
273
+ if title.lower().startswith("meet - "):
274
+ title = "Meet_" + title[7:]
275
+ # Remove speaker emoji and other indicators
276
+ title = re.sub(r'[🔊🔇📹]', '', title).strip()
277
+
278
+ # Remove "Zoom Meeting - " prefix
279
+ if title.lower().startswith("zoom meeting - "):
280
+ title = title[15:]
281
+
282
+ # Remove " - Zoom" suffix
283
+ if title.lower().endswith(" - zoom"):
284
+ title = title[:-7]
285
+
286
+ # Remove " | Microsoft Teams" suffix
287
+ if " | Microsoft Teams" in title:
288
+ title = title.split(" | Microsoft Teams")[0]
289
+
290
+ # Replace invalid filename characters
291
+ title = re.sub(r'[<>:"/\\|?*]', '_', title)
292
+
293
+ # Replace multiple spaces/underscores with single underscore
294
+ title = re.sub(r'[\s_]+', '_', title)
295
+
296
+ # Limit length
297
+ if len(title) > 50:
298
+ title = title[:50]
299
+
300
+ return title.strip('_')
301
+
302
+
303
+ def get_app_using_mic() -> Optional[str]:
304
+ """Get the name of the meeting app that might be using the microphone.
305
+
306
+ Note: This returns any active meeting app, but doesn't guarantee
307
+ that specific app is the one using the mic.
308
+ """
309
+ return is_meeting_app_active()
310
+
311
+
312
+ class MicrophoneMonitor:
313
+ """Monitor microphone usage to detect meeting start/end.
314
+
315
+ Start detection: Another app starts using the microphone
316
+ Stop detection: The meeting app window is no longer visible
317
+
318
+ This approach works because:
319
+ - Start: CoreAudio tells us when mic is activated (before we start recording)
320
+ - Stop: We can't rely on mic state (our recording uses it), so we check if meeting app is gone
321
+ """
322
+
323
+ def __init__(self):
324
+ self._was_mic_in_use = False
325
+ self._is_recording = False
326
+ self._recording_app: Optional[str] = None # Which app triggered recording
327
+ self._last_check_time = 0
328
+ self._on_count = 0 # Count consecutive "on" readings
329
+ self._off_count = 0 # Count consecutive "off" readings
330
+ self._on_threshold = 2 # Require 2 consecutive "on" readings to trigger start
331
+ self._off_threshold = 3 # Require 3 consecutive "off" readings to trigger stop
332
+
333
+ def set_recording(self, is_recording: bool, app_name: Optional[str] = None):
334
+ """Tell the monitor whether we're currently recording."""
335
+ self._is_recording = is_recording
336
+
337
+ if is_recording:
338
+ self._was_mic_in_use = True
339
+ self._on_count = 0
340
+ # Only set app_name if provided (don't overwrite on status updates)
341
+ if app_name:
342
+ self._recording_app = app_name
343
+ else:
344
+ self._recording_app = None
345
+
346
+ def check(self) -> tuple[bool, bool, Optional[str]]:
347
+ """Check for microphone usage changes.
348
+
349
+ Returns:
350
+ Tuple of (mic_started, mic_stopped, app_name)
351
+ - mic_started: True if another app started using mic (should prompt to record)
352
+ - mic_stopped: True if meeting app closed (should stop recording)
353
+ - app_name: Name of meeting app (if detected)
354
+ """
355
+ now = time.time()
356
+
357
+ # Debounce - check every 2 seconds
358
+ if now - self._last_check_time < 2.0:
359
+ return False, False, None
360
+ self._last_check_time = now
361
+
362
+ mic_started = False
363
+ mic_stopped = False
364
+ app_name = None
365
+
366
+ if self._is_recording:
367
+ # While recording: check if meeting app is still visible
368
+ # (Can't rely on mic state since our app is using it)
369
+ current_app = is_meeting_app_active()
370
+
371
+ if current_app is None:
372
+ # Meeting app window is gone
373
+ self._off_count += 1
374
+ if self._off_count >= self._off_threshold:
375
+ mic_stopped = True
376
+ self._was_mic_in_use = False
377
+ self._off_count = 0
378
+ else:
379
+ self._off_count = 0
380
+ else:
381
+ # Not recording: check if mic is being used by another app
382
+ mic_in_use = is_mic_in_use_by_another_app()
383
+
384
+ if mic_in_use:
385
+ self._off_count = 0
386
+ # Get app name for display (optional, doesn't affect start decision)
387
+ app_name = is_meeting_app_active()
388
+
389
+ if not self._was_mic_in_use:
390
+ self._on_count += 1
391
+ # Require consecutive readings to avoid false positives
392
+ if self._on_count >= self._on_threshold:
393
+ mic_started = True
394
+ self._was_mic_in_use = True
395
+ self._on_count = 0
396
+ else:
397
+ self._on_count = 0
398
+ self._was_mic_in_use = False
399
+
400
+ return mic_started, mic_stopped, app_name
401
+
402
+ def is_mic_active(self) -> bool:
403
+ """Check if microphone is currently in use by another app."""
404
+ return is_mic_in_use_by_another_app()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meeting-noter
3
- Version: 0.3.4
3
+ Version: 0.5.1
4
4
  Summary: Offline meeting transcription for macOS with automatic meeting detection
5
5
  Author: Victor
6
6
  License: MIT
@@ -60,10 +60,10 @@ Offline meeting transcription tool for macOS. Captures both your voice and meeti
60
60
 
61
61
  ```bash
62
62
  # Install dependencies
63
- brew install ffmpeg lame pkg-config
63
+ brew install ffmpeg lame pkg-config python@3.12
64
64
 
65
- # Install meeting-noter
66
- pipx install meeting-noter
65
+ # Install meeting-noter (use Python 3.12 for best compatibility)
66
+ pipx install meeting-noter --python /opt/homebrew/bin/python3.12
67
67
  ```
68
68
 
69
69
  Or with pip:
@@ -208,9 +208,10 @@ Config file: `~/.config/meeting-noter/config.json`
208
208
  ## Requirements
209
209
 
210
210
  - macOS 12.3+ (for ScreenCaptureKit)
211
- - Python 3.9+
211
+ - Python 3.9+ (3.12 recommended for best compatibility)
212
212
  - FFmpeg (`brew install ffmpeg`) - required for audio processing
213
213
  - LAME (`brew install lame`) - required for MP3 encoding
214
+ - pkg-config (`brew install pkg-config`) - required for building dependencies
214
215
 
215
216
  ## License
216
217
 
@@ -7,6 +7,7 @@ src/meeting_noter/config.py
7
7
  src/meeting_noter/daemon.py
8
8
  src/meeting_noter/meeting_detector.py
9
9
  src/meeting_noter/menubar.py
10
+ src/meeting_noter/mic_monitor.py
10
11
  src/meeting_noter.egg-info/PKG-INFO
11
12
  src/meeting_noter.egg-info/SOURCES.txt
12
13
  src/meeting_noter.egg-info/dependency_links.txt
File without changes