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,363 @@
1
+ """System audio capture using ScreenCaptureKit (macOS 12.3+).
2
+
3
+ This captures system audio by requesting Screen Recording permission,
4
+ similar to how Notion and other apps work. No special audio devices needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ctypes
10
+ import numpy as np
11
+ from collections import deque
12
+ from queue import Queue, Empty
13
+ from threading import Event, Thread
14
+ from typing import Optional
15
+ import sys
16
+
17
+
18
+ SAMPLE_RATE = 48000
19
+ CHANNELS = 2
20
+
21
+
22
+ class ScreenCaptureAudio:
23
+ """Captures system audio using ScreenCaptureKit.
24
+
25
+ Requires Screen Recording permission (System Settings > Privacy > Screen Recording).
26
+ """
27
+
28
+ def __init__(self, sample_rate: int = SAMPLE_RATE, channels: int = CHANNELS):
29
+ self.sample_rate = sample_rate
30
+ self.channels = channels
31
+ self.audio_queue: Queue[np.ndarray] = Queue()
32
+ self._is_running = False
33
+ self._stream = None
34
+ self._delegate = None
35
+ self._dispatch_queue = None
36
+
37
+ def _check_availability(self) -> bool:
38
+ """Check if ScreenCaptureKit is available."""
39
+ if sys.platform != "darwin":
40
+ return False
41
+ try:
42
+ import ScreenCaptureKit
43
+ return True
44
+ except ImportError:
45
+ return False
46
+
47
+ def start(self) -> bool:
48
+ """Start capturing system audio.
49
+
50
+ Returns True if successful, False if not available or permission denied.
51
+ """
52
+ if not self._check_availability():
53
+ return False
54
+
55
+ try:
56
+ import ScreenCaptureKit as SCK
57
+ import CoreMedia as CM
58
+ from Foundation import NSObject
59
+ import threading
60
+
61
+ # Try to import dispatch queue creation
62
+ dispatch_queue_create = None
63
+ DISPATCH_QUEUE_SERIAL = None
64
+ try:
65
+ from libdispatch import dispatch_queue_create, DISPATCH_QUEUE_SERIAL
66
+ except ImportError:
67
+ try:
68
+ from dispatch import dispatch_queue_create, DISPATCH_QUEUE_SERIAL
69
+ except ImportError:
70
+ pass # Will use None for queue, ScreenCaptureKit uses default
71
+
72
+ audio_queue = self.audio_queue
73
+ parent = self
74
+
75
+ # Create delegate class for receiving audio data
76
+ class StreamOutput(NSObject):
77
+ def stream_didOutputSampleBuffer_ofType_(self, stream, sampleBuffer, outputType):
78
+ # Only process audio (type 1), ignore video (type 0)
79
+ if outputType != 1:
80
+ return
81
+
82
+ try:
83
+ blockBuffer = CM.CMSampleBufferGetDataBuffer(sampleBuffer)
84
+ if blockBuffer is None:
85
+ return
86
+
87
+ length = CM.CMBlockBufferGetDataLength(blockBuffer)
88
+ if length == 0:
89
+ return
90
+
91
+ # Create contiguous buffer copy
92
+ result = CM.CMBlockBufferCreateContiguous(
93
+ None, blockBuffer, None, None, 0, length, 0, None
94
+ )
95
+
96
+ if result[0] == 0: # noErr
97
+ contigBuffer = result[1]
98
+ dataResult = CM.CMBlockBufferGetDataPointer(
99
+ contigBuffer, 0, None, None, None
100
+ )
101
+
102
+ if dataResult[0] == 0:
103
+ dataPtr = dataResult[3]
104
+
105
+ # First element of tuple is the memory address
106
+ if isinstance(dataPtr, tuple) and len(dataPtr) > 0:
107
+ ptr_addr = dataPtr[0]
108
+ num_floats = length // 4
109
+
110
+ # Cast pointer to float array
111
+ float_ptr = ctypes.cast(
112
+ ptr_addr, ctypes.POINTER(ctypes.c_float)
113
+ )
114
+ audio = np.ctypeslib.as_array(
115
+ float_ptr, shape=(num_floats,)
116
+ ).copy()
117
+
118
+ if len(audio) > 0:
119
+ audio_queue.put(audio)
120
+
121
+ except Exception:
122
+ pass
123
+
124
+ def stream_didStopWithError_(self, stream, error):
125
+ parent._is_running = False
126
+
127
+ self._delegate = StreamOutput.alloc().init()
128
+
129
+ # Create dispatch queue if available, otherwise use None (default queue)
130
+ if dispatch_queue_create is not None:
131
+ self._dispatch_queue = dispatch_queue_create(
132
+ b"com.meetingnoter.screencapture",
133
+ DISPATCH_QUEUE_SERIAL
134
+ )
135
+ else:
136
+ self._dispatch_queue = None
137
+
138
+ # Get shareable content
139
+ content_ready = threading.Event()
140
+ content_result = [None, None]
141
+
142
+ def on_content(content, error):
143
+ content_result[0] = content
144
+ content_result[1] = error
145
+ content_ready.set()
146
+
147
+ SCK.SCShareableContent.getShareableContentWithCompletionHandler_(on_content)
148
+
149
+ if not content_ready.wait(timeout=5.0):
150
+ return False
151
+
152
+ content, error = content_result
153
+ if error or not content:
154
+ return False
155
+
156
+ if not content.displays() or len(content.displays()) == 0:
157
+ return False
158
+
159
+ # Create filter for main display
160
+ display = content.displays()[0]
161
+ contentFilter = SCK.SCContentFilter.alloc().initWithDisplay_excludingWindows_(
162
+ display, []
163
+ )
164
+
165
+ # Configure stream
166
+ config = SCK.SCStreamConfiguration.alloc().init()
167
+
168
+ # Audio settings
169
+ config.setCapturesAudio_(True)
170
+ config.setExcludesCurrentProcessAudio_(False)
171
+ config.setSampleRate_(self.sample_rate)
172
+ config.setChannelCount_(self.channels)
173
+
174
+ # Minimal video settings (required for audio to work)
175
+ # Note: 1x1 causes errors, need at least ~100x100
176
+ config.setWidth_(100)
177
+ config.setHeight_(100)
178
+ config.setMinimumFrameInterval_(CM.CMTimeMake(1, 2)) # 0.5 fps
179
+
180
+ # Create stream
181
+ self._stream = SCK.SCStream.alloc().initWithFilter_configuration_delegate_(
182
+ contentFilter, config, self._delegate
183
+ )
184
+
185
+ if self._stream is None:
186
+ return False
187
+
188
+ # Add both video and audio outputs (both required for audio to work)
189
+ self._stream.addStreamOutput_type_sampleHandlerQueue_error_(
190
+ self._delegate, 0, self._dispatch_queue, None # Video
191
+ )
192
+ self._stream.addStreamOutput_type_sampleHandlerQueue_error_(
193
+ self._delegate, 1, self._dispatch_queue, None # Audio
194
+ )
195
+
196
+ # Start capture
197
+ start_ready = threading.Event()
198
+ start_error = [None]
199
+
200
+ def on_start(error):
201
+ start_error[0] = error
202
+ start_ready.set()
203
+
204
+ self._stream.startCaptureWithCompletionHandler_(on_start)
205
+
206
+ if not start_ready.wait(timeout=5.0):
207
+ return False
208
+
209
+ if start_error[0]:
210
+ error = start_error[0]
211
+ error_code = error.code()
212
+ if error_code == 1003:
213
+ print("Screen Recording permission required.")
214
+ print(" Go to: System Settings > Privacy & Security > Screen Recording")
215
+ print(" Enable permission for Terminal/your app, then restart.")
216
+ return False
217
+
218
+ self._is_running = True
219
+ return True
220
+
221
+ except Exception as e:
222
+ print(f"ScreenCaptureKit error: {e}")
223
+ return False
224
+
225
+ def stop(self):
226
+ """Stop capturing."""
227
+ self._is_running = False
228
+
229
+ if self._stream:
230
+ try:
231
+ stop_done = Event()
232
+ self._stream.stopCaptureWithCompletionHandler_(lambda e: stop_done.set())
233
+ stop_done.wait(timeout=2.0)
234
+ except Exception:
235
+ pass
236
+ self._stream = None
237
+
238
+ def get_audio(self, timeout: float = 1.0) -> Optional[np.ndarray]:
239
+ """Get captured audio data."""
240
+ try:
241
+ return self.audio_queue.get(timeout=timeout)
242
+ except Empty:
243
+ return None
244
+
245
+ @property
246
+ def is_running(self) -> bool:
247
+ return self._is_running
248
+
249
+
250
+ class CombinedAudioCapture:
251
+ """Captures both microphone and system audio (meeting participants).
252
+
253
+ Uses default microphone for user's voice and ScreenCaptureKit for system audio.
254
+ """
255
+
256
+ def __init__(self, sample_rate: int = SAMPLE_RATE):
257
+ self._sample_rate = sample_rate
258
+ self._channels = CHANNELS
259
+ self.audio_queue: Queue[np.ndarray] = Queue()
260
+ self.stop_event = Event()
261
+
262
+ self._mic_capture = None
263
+ self._system_capture = None
264
+ self._has_system_audio = False
265
+ self._output_thread = None
266
+
267
+ def start(self):
268
+ """Start capturing from microphone and system audio."""
269
+ from meeting_noter.audio.capture import AudioCapture
270
+
271
+ self.stop_event.clear()
272
+
273
+ # Start microphone capture
274
+ self._mic_capture = AudioCapture(sample_rate=self._sample_rate)
275
+ self._mic_capture.start()
276
+ self._channels = self._mic_capture.channels
277
+
278
+ # Try ScreenCaptureKit for system audio
279
+ self._system_capture = ScreenCaptureAudio(
280
+ sample_rate=self._sample_rate,
281
+ channels=self._channels
282
+ )
283
+ self._has_system_audio = self._system_capture.start()
284
+
285
+ if self._has_system_audio:
286
+ print("Capturing: microphone + system audio (ScreenCaptureKit)")
287
+ else:
288
+ print("Capturing: microphone only")
289
+ print(" Grant Screen Recording permission to capture other participants")
290
+
291
+ # Start output thread
292
+ self._output_thread = Thread(target=self._process_audio, daemon=True)
293
+ self._output_thread.start()
294
+
295
+ def _process_audio(self):
296
+ """Process and output audio from both sources."""
297
+ # Ring buffers for accumulating audio
298
+ mic_buffer = deque(maxlen=self._sample_rate) # 1 second buffer
299
+ sys_buffer = deque(maxlen=self._sample_rate)
300
+
301
+ # Target chunk size (20ms at sample_rate)
302
+ chunk_size = int(self._sample_rate * 0.02)
303
+
304
+ while not self.stop_event.is_set():
305
+ # Collect mic audio
306
+ if self._mic_capture:
307
+ mic_audio = self._mic_capture.get_audio(timeout=0.01)
308
+ if mic_audio is not None:
309
+ mic_buffer.extend(mic_audio.flatten())
310
+
311
+ # Collect system audio
312
+ if self._system_capture and self._system_capture.is_running:
313
+ sys_audio = self._system_capture.get_audio(timeout=0.01)
314
+ if sys_audio is not None:
315
+ sys_buffer.extend(sys_audio.flatten())
316
+
317
+ # Output when we have enough mic samples
318
+ if len(mic_buffer) >= chunk_size:
319
+ # Get mic chunk
320
+ mic_chunk = np.array([mic_buffer.popleft() for _ in range(min(chunk_size, len(mic_buffer)))], dtype=np.float32)
321
+
322
+ # Get matching system audio if available
323
+ if self._has_system_audio and len(sys_buffer) >= chunk_size:
324
+ sys_chunk = np.array([sys_buffer.popleft() for _ in range(min(chunk_size, len(sys_buffer)))], dtype=np.float32)
325
+
326
+ # Mix: average the two sources
327
+ min_len = min(len(mic_chunk), len(sys_chunk))
328
+ if min_len > 0:
329
+ mixed = np.clip(mic_chunk[:min_len] * 0.7 + sys_chunk[:min_len] * 0.7, -1.0, 1.0)
330
+ self.audio_queue.put(mixed)
331
+ else:
332
+ self.audio_queue.put(mic_chunk)
333
+ else:
334
+ # Just output mic
335
+ self.audio_queue.put(mic_chunk)
336
+
337
+ def stop(self):
338
+ """Stop all capture."""
339
+ self.stop_event.set()
340
+
341
+ if self._mic_capture:
342
+ self._mic_capture.stop()
343
+ if self._system_capture:
344
+ self._system_capture.stop()
345
+
346
+ def get_audio(self, timeout: float = 1.0) -> Optional[np.ndarray]:
347
+ """Get mixed audio data."""
348
+ try:
349
+ return self.audio_queue.get(timeout=timeout)
350
+ except Empty:
351
+ return None
352
+
353
+ @property
354
+ def channels(self) -> int:
355
+ return self._channels
356
+
357
+ @property
358
+ def sample_rate(self) -> int:
359
+ return self._sample_rate
360
+
361
+ @property
362
+ def has_system_audio(self) -> bool:
363
+ return self._has_system_audio