meeting-noter 0.3.5__py3-none-any.whl → 0.6.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.

@@ -1,37 +1,21 @@
1
- """MP3 encoding for audio recordings."""
1
+ """MP3 encoding for audio recordings using ffmpeg."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
- import sys
6
+ import subprocess
7
7
  import numpy as np
8
8
  from pathlib import Path
9
9
  from datetime import datetime
10
10
  from typing import Optional, Tuple
11
11
 
12
- # Try to import lameenc, provide helpful error if not available
12
+ # Get bundled ffmpeg binary path
13
13
  try:
14
- import lameenc
15
- LAMEENC_AVAILABLE = True
14
+ import imageio_ffmpeg
15
+ FFMPEG_PATH = imageio_ffmpeg.get_ffmpeg_exe()
16
16
  except ImportError:
17
- LAMEENC_AVAILABLE = False
18
- lameenc = None
19
-
20
-
21
- def _check_lameenc():
22
- """Check if lameenc is available, raise helpful error if not."""
23
- if not LAMEENC_AVAILABLE:
24
- print("\n" + "=" * 60, file=sys.stderr)
25
- print("ERROR: MP3 encoding requires the 'lame' library", file=sys.stderr)
26
- print("=" * 60, file=sys.stderr)
27
- print("\nTo fix this, run:", file=sys.stderr)
28
- print("\n brew install lame", file=sys.stderr)
29
- print("\nThen reinstall meeting-noter:", file=sys.stderr)
30
- print("\n pip install --force-reinstall meeting-noter", file=sys.stderr)
31
- print("\n" + "=" * 60 + "\n", file=sys.stderr)
32
- raise ImportError(
33
- "lameenc not available. Install LAME first: brew install lame"
34
- )
17
+ # Fallback to system ffmpeg
18
+ FFMPEG_PATH = "ffmpeg"
35
19
 
36
20
 
37
21
  def _sanitize_filename(name: str, max_length: int = 50) -> str:
@@ -62,24 +46,42 @@ def _is_timestamp_name(name: str) -> bool:
62
46
 
63
47
 
64
48
  class MP3Encoder:
65
- """Encodes audio data to MP3 format."""
49
+ """Encodes audio data to MP3 format using ffmpeg."""
66
50
 
67
51
  def __init__(
68
52
  self,
53
+ output_path: Path,
69
54
  sample_rate: int = 16000,
70
55
  channels: int = 1,
71
56
  bitrate: int = 128,
72
- quality: int = 2,
73
57
  ):
74
- _check_lameenc()
58
+ self.output_path = output_path
75
59
  self.sample_rate = sample_rate
76
60
  self.channels = channels
77
- self.encoder = lameenc.Encoder()
78
- self.encoder.set_bit_rate(bitrate)
79
- self.encoder.set_in_sample_rate(sample_rate)
80
- self.encoder.set_channels(channels)
81
- self.encoder.set_quality(quality) # 2 = high quality
82
- self._buffer = bytearray()
61
+ self.bitrate = bitrate
62
+ self._process: Optional[subprocess.Popen] = None
63
+ self._start_ffmpeg()
64
+
65
+ def _start_ffmpeg(self):
66
+ """Start ffmpeg process for encoding."""
67
+ cmd = [
68
+ FFMPEG_PATH,
69
+ "-y", # Overwrite output
70
+ "-f", "s16le", # Input format: signed 16-bit little-endian PCM
71
+ "-ar", str(self.sample_rate), # Sample rate
72
+ "-ac", str(self.channels), # Channels
73
+ "-i", "pipe:0", # Read from stdin
74
+ "-codec:a", "libmp3lame", # MP3 encoder
75
+ "-b:a", f"{self.bitrate}k", # Bitrate
76
+ "-f", "mp3", # Output format
77
+ str(self.output_path),
78
+ ]
79
+ self._process = subprocess.Popen(
80
+ cmd,
81
+ stdin=subprocess.PIPE,
82
+ stdout=subprocess.DEVNULL,
83
+ stderr=subprocess.DEVNULL,
84
+ )
83
85
 
84
86
  def encode_chunk(self, audio: np.ndarray) -> bytes:
85
87
  """Encode a chunk of audio data.
@@ -88,16 +90,29 @@ class MP3Encoder:
88
90
  audio: Float32 audio data, values between -1 and 1
89
91
 
90
92
  Returns:
91
- MP3 encoded bytes
93
+ Empty bytes (ffmpeg writes directly to file)
92
94
  """
95
+ if self._process is None or self._process.stdin is None:
96
+ return b""
97
+
93
98
  # Convert float32 to int16
94
99
  int_data = (audio * 32767).astype(np.int16)
95
- mp3_data = self.encoder.encode(int_data.tobytes())
96
- return mp3_data
100
+ try:
101
+ self._process.stdin.write(int_data.tobytes())
102
+ except BrokenPipeError:
103
+ pass
104
+ return b"" # ffmpeg writes to file, not returning data
97
105
 
98
106
  def finalize(self) -> bytes:
99
- """Finalize encoding and return remaining data."""
100
- return self.encoder.flush()
107
+ """Finalize encoding."""
108
+ if self._process is not None and self._process.stdin is not None:
109
+ try:
110
+ self._process.stdin.close()
111
+ except Exception:
112
+ pass
113
+ self._process.wait()
114
+ self._process = None
115
+ return b""
101
116
 
102
117
 
103
118
  class RecordingSession:
@@ -115,7 +130,6 @@ class RecordingSession:
115
130
  self.channels = channels
116
131
  self.meeting_name = meeting_name
117
132
  self.encoder: Optional[MP3Encoder] = None
118
- self.file_handle = None
119
133
  self.filepath: Optional[Path] = None
120
134
  self.start_time: Optional[datetime] = None
121
135
  self.total_samples = 0
@@ -139,22 +153,20 @@ class RecordingSession:
139
153
  self.filepath = self.output_dir / filename
140
154
 
141
155
  self.encoder = MP3Encoder(
156
+ output_path=self.filepath,
142
157
  sample_rate=self.sample_rate,
143
158
  channels=self.channels,
144
159
  )
145
- self.file_handle = open(self.filepath, "wb")
146
160
  self.total_samples = 0
147
161
 
148
162
  return self.filepath
149
163
 
150
164
  def write(self, audio: np.ndarray):
151
165
  """Write audio data to the recording."""
152
- if self.encoder is None or self.file_handle is None:
166
+ if self.encoder is None:
153
167
  raise RuntimeError("Recording session not started")
154
168
 
155
- mp3_data = self.encoder.encode_chunk(audio)
156
- if mp3_data:
157
- self.file_handle.write(mp3_data)
169
+ self.encoder.encode_chunk(audio)
158
170
  self.total_samples += len(audio)
159
171
 
160
172
  def stop(self) -> Tuple[Optional[Path], float]:
@@ -166,13 +178,9 @@ class RecordingSession:
166
178
  duration = 0.0
167
179
  filepath = self.filepath
168
180
 
169
- if self.encoder and self.file_handle:
170
- # Write final data
171
- final_data = self.encoder.finalize()
172
- if final_data:
173
- self.file_handle.write(final_data)
174
- self.file_handle.close()
175
-
181
+ if self.encoder:
182
+ # Finalize encoding
183
+ self.encoder.finalize()
176
184
  duration = self.total_samples / self.sample_rate
177
185
 
178
186
  # Delete if too short (less than 5 seconds)
@@ -181,7 +189,6 @@ class RecordingSession:
181
189
  filepath = None
182
190
 
183
191
  self.encoder = None
184
- self.file_handle = None
185
192
  self.filepath = None
186
193
  self.start_time = None
187
194
  self.total_samples = 0
meeting_noter/menubar.py CHANGED
@@ -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.5
3
+ Version: 0.6.0
4
4
  Summary: Offline meeting transcription for macOS with automatic meeting detection
5
5
  Author: Victor
6
6
  License: MIT
@@ -28,14 +28,13 @@ Requires-Dist: numpy>=1.24
28
28
  Requires-Dist: faster-whisper>=1.0.0
29
29
  Requires-Dist: rumps>=0.4.0
30
30
  Requires-Dist: PyQt6>=6.5.0
31
+ Requires-Dist: imageio-ffmpeg>=0.4.9
31
32
  Requires-Dist: pyobjc-framework-Cocoa>=9.0; sys_platform == "darwin"
32
33
  Requires-Dist: pyobjc-framework-Quartz>=9.0; sys_platform == "darwin"
33
34
  Requires-Dist: pyobjc-framework-ScreenCaptureKit>=9.0; sys_platform == "darwin"
34
35
  Requires-Dist: pyobjc-framework-AVFoundation>=9.0; sys_platform == "darwin"
35
36
  Requires-Dist: pyobjc-framework-CoreMedia>=9.0; sys_platform == "darwin"
36
37
  Requires-Dist: pyobjc-framework-libdispatch>=9.0; sys_platform == "darwin"
37
- Provides-Extra: mp3
38
- Requires-Dist: lameenc>=1.5.0; extra == "mp3"
39
38
  Provides-Extra: dev
40
39
  Requires-Dist: pytest>=7.0; extra == "dev"
41
40
  Requires-Dist: pytest-cov; extra == "dev"
@@ -59,11 +58,7 @@ Offline meeting transcription tool for macOS. Captures both your voice and meeti
59
58
  ## Installation
60
59
 
61
60
  ```bash
62
- # Install dependencies
63
- brew install ffmpeg lame pkg-config python@3.12
64
-
65
- # Install meeting-noter (use Python 3.12 for best compatibility)
66
- pipx install meeting-noter --python /opt/homebrew/bin/python3.12
61
+ pipx install meeting-noter
67
62
  ```
68
63
 
69
64
  Or with pip:
@@ -71,6 +66,8 @@ Or with pip:
71
66
  pip install meeting-noter
72
67
  ```
73
68
 
69
+ No system dependencies required - ffmpeg is bundled automatically.
70
+
74
71
  ## Quick Start
75
72
 
76
73
  **Menu Bar App** (recommended):
@@ -208,10 +205,7 @@ Config file: `~/.config/meeting-noter/config.json`
208
205
  ## Requirements
209
206
 
210
207
  - macOS 12.3+ (for ScreenCaptureKit)
211
- - Python 3.9+ (3.12 recommended for best compatibility)
212
- - FFmpeg (`brew install ffmpeg`) - required for audio processing
213
- - LAME (`brew install lame`) - required for MP3 encoding
214
- - pkg-config (`brew install pkg-config`) - required for building dependencies
208
+ - Python 3.9+
215
209
 
216
210
  ## License
217
211
 
@@ -4,10 +4,11 @@ meeting_noter/cli.py,sha256=w4cBrvg58inkgbApK_z90csBfb4HWBuXveLdD3J1bc0,8540
4
4
  meeting_noter/config.py,sha256=41LFBNp5o0IojYS5Hf0FJVIr7GNn7B5O1TJDE8SQkkk,5977
5
5
  meeting_noter/daemon.py,sha256=o7U11WmdoKG58SLs70-vzS64kkjMTY5qN0F0bs0eApk,16239
6
6
  meeting_noter/meeting_detector.py,sha256=I8zzSdSSmbfd3yyCOyzPL8AS-xSHttFCagrDE35qcho,9412
7
- meeting_noter/menubar.py,sha256=Wmwaw-_f2Zky6-0DLC5Ql2o6_VBG1-sWacL85dNeGwU,15366
7
+ meeting_noter/menubar.py,sha256=Gn6p8y5jA_HCWf1T3ademxH-vndpONHkf9vUlKs6XEo,14379
8
+ meeting_noter/mic_monitor.py,sha256=Dzt7RZT7-X5US7mT2I247UguR-uLWK0BZDnjiehLD-A,13634
8
9
  meeting_noter/audio/__init__.py,sha256=O7PU8CxHSHxMeHbc9Jdwt9kePLQzsPh81GQU7VHCtBY,44
9
10
  meeting_noter/audio/capture.py,sha256=fDrT5oXfva8vdFlht9cv60NviKbksw2QeJ8eOtI19uE,6469
10
- meeting_noter/audio/encoder.py,sha256=C-lGn6S-1KmvfeXMaP032XhifDNMe6sa0RKDZHoZlio,6374
11
+ meeting_noter/audio/encoder.py,sha256=6UgEYLFACSQEIx2nhH1Qq-cBh3qPJziMGkrm39k6Nz8,6401
11
12
  meeting_noter/audio/system_audio.py,sha256=jbHGjNCerI19weXap0a90Ik17lVTCT1hCEgRKYke-p8,13016
12
13
  meeting_noter/gui/__init__.py,sha256=z5GxxaeXyjqyEa9ox0dQxuL5u_BART0bi7cI6rfntEI,103
13
14
  meeting_noter/gui/__main__.py,sha256=A2HWdYod0bTgjQQIi21O7XpmgxLH36e_X0aygEUZLls,146
@@ -31,8 +32,8 @@ meeting_noter/resources/icon_512.png,sha256=o7X3ngYcppcIAAk9AcfPx94MUmrsPRp0qBTp
31
32
  meeting_noter/resources/icon_64.png,sha256=TqG7Awx3kK8YdiX1e_z1odZonosZyQI2trlkNZCzUoI,607
32
33
  meeting_noter/transcription/__init__.py,sha256=7GY9diP06DzFyoli41wddbrPv5bVDzH35bmnWlIJev4,29
33
34
  meeting_noter/transcription/engine.py,sha256=HK2J2QOBNIDm1MXW-gkagXP8C8cqUfK_WylHQD_LqOI,6320
34
- meeting_noter-0.3.5.dist-info/METADATA,sha256=zHEH6KtcSnH2QF0IK8pOB6J77gAdvCd_hFCt5P7HqXA,7032
35
- meeting_noter-0.3.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
36
- meeting_noter-0.3.5.dist-info/entry_points.txt,sha256=rKNhzjSF5-e3bLRr8LVe22FeiwcacXabCvNpoEXfu4I,56
37
- meeting_noter-0.3.5.dist-info/top_level.txt,sha256=9Tuq04_0SXM0OXOHVbOHkHkB5tG3fqkrMrfzCMpbLpY,14
38
- meeting_noter-0.3.5.dist-info/RECORD,,
35
+ meeting_noter-0.6.0.dist-info/METADATA,sha256=Yr8451hXXCNek9wMxOvcXM1ziOFaCTeX70-kiNjUvsc,6654
36
+ meeting_noter-0.6.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
37
+ meeting_noter-0.6.0.dist-info/entry_points.txt,sha256=rKNhzjSF5-e3bLRr8LVe22FeiwcacXabCvNpoEXfu4I,56
38
+ meeting_noter-0.6.0.dist-info/top_level.txt,sha256=9Tuq04_0SXM0OXOHVbOHkHkB5tG3fqkrMrfzCMpbLpY,14
39
+ meeting_noter-0.6.0.dist-info/RECORD,,