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.
- meeting_noter/__init__.py +3 -0
- meeting_noter/__main__.py +6 -0
- meeting_noter/audio/__init__.py +1 -0
- meeting_noter/audio/capture.py +209 -0
- meeting_noter/audio/encoder.py +208 -0
- meeting_noter/audio/system_audio.py +363 -0
- meeting_noter/cli.py +837 -0
- meeting_noter/config.py +197 -0
- meeting_noter/daemon.py +519 -0
- meeting_noter/gui/__init__.py +5 -0
- meeting_noter/gui/__main__.py +6 -0
- meeting_noter/gui/app.py +53 -0
- meeting_noter/gui/main_window.py +50 -0
- meeting_noter/gui/meetings_tab.py +348 -0
- meeting_noter/gui/recording_tab.py +358 -0
- meeting_noter/gui/settings_tab.py +249 -0
- meeting_noter/install/__init__.py +1 -0
- meeting_noter/install/macos.py +102 -0
- meeting_noter/meeting_detector.py +333 -0
- meeting_noter/menubar.py +411 -0
- meeting_noter/mic_monitor.py +456 -0
- meeting_noter/output/__init__.py +1 -0
- meeting_noter/output/writer.py +96 -0
- meeting_noter/resources/__init__.py +1 -0
- meeting_noter/resources/icon.icns +0 -0
- meeting_noter/resources/icon.png +0 -0
- meeting_noter/resources/icon_128.png +0 -0
- meeting_noter/resources/icon_16.png +0 -0
- meeting_noter/resources/icon_256.png +0 -0
- meeting_noter/resources/icon_32.png +0 -0
- meeting_noter/resources/icon_512.png +0 -0
- meeting_noter/resources/icon_64.png +0 -0
- meeting_noter/transcription/__init__.py +1 -0
- meeting_noter/transcription/engine.py +234 -0
- meeting_noter-0.7.0.dist-info/METADATA +224 -0
- meeting_noter-0.7.0.dist-info/RECORD +39 -0
- meeting_noter-0.7.0.dist-info/WHEEL +5 -0
- meeting_noter-0.7.0.dist-info/entry_points.txt +2 -0
- 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
|