meeting-noter 0.3.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 +176 -0
- meeting_noter/audio/system_audio.py +363 -0
- meeting_noter/cli.py +308 -0
- meeting_noter/config.py +197 -0
- meeting_noter/daemon.py +514 -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 +296 -0
- meeting_noter/menubar.py +432 -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 +208 -0
- meeting_noter-0.3.0.dist-info/METADATA +261 -0
- meeting_noter-0.3.0.dist-info/RECORD +38 -0
- meeting_noter-0.3.0.dist-info/WHEEL +5 -0
- meeting_noter-0.3.0.dist-info/entry_points.txt +2 -0
- meeting_noter-0.3.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
|
meeting_noter/cli.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""CLI commands for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from meeting_noter import __version__
|
|
10
|
+
from meeting_noter.config import (
|
|
11
|
+
get_config,
|
|
12
|
+
require_setup,
|
|
13
|
+
is_setup_complete,
|
|
14
|
+
generate_meeting_name,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Default paths
|
|
19
|
+
DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _launch_gui_background():
|
|
23
|
+
"""Launch the GUI in background and return immediately."""
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
subprocess.Popen(
|
|
28
|
+
[sys.executable, "-m", "meeting_noter.gui"],
|
|
29
|
+
stdout=subprocess.DEVNULL,
|
|
30
|
+
stderr=subprocess.DEVNULL,
|
|
31
|
+
start_new_session=True,
|
|
32
|
+
)
|
|
33
|
+
click.echo("Meeting Noter GUI launched.")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.group(invoke_without_command=True)
|
|
37
|
+
@click.version_option(version=__version__)
|
|
38
|
+
@click.pass_context
|
|
39
|
+
def cli(ctx):
|
|
40
|
+
"""Meeting Noter - Offline meeting transcription.
|
|
41
|
+
|
|
42
|
+
Run 'meeting-noter' to launch the GUI, or use subcommands:
|
|
43
|
+
- setup: One-time setup (Screen Recording permission)
|
|
44
|
+
- start <name>: Interactive foreground recording
|
|
45
|
+
- gui: Launch desktop GUI
|
|
46
|
+
- menubar: Launch menu bar app
|
|
47
|
+
"""
|
|
48
|
+
if ctx.invoked_subcommand is None:
|
|
49
|
+
# No subcommand - launch GUI in background
|
|
50
|
+
_launch_gui_background()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@cli.command()
|
|
54
|
+
def setup():
|
|
55
|
+
"""Set up Meeting Noter and initialize configuration.
|
|
56
|
+
|
|
57
|
+
This is a one-time setup that:
|
|
58
|
+
1. Requests Screen Recording permission (for capturing meeting audio)
|
|
59
|
+
2. Initializes configuration file
|
|
60
|
+
3. Creates recording directories
|
|
61
|
+
"""
|
|
62
|
+
from meeting_noter.install.macos import run_setup
|
|
63
|
+
|
|
64
|
+
config = get_config()
|
|
65
|
+
|
|
66
|
+
# Run the setup
|
|
67
|
+
run_setup()
|
|
68
|
+
|
|
69
|
+
# Mark setup as complete and ensure directories exist
|
|
70
|
+
config.setup_complete = True
|
|
71
|
+
config.recordings_dir.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
config.transcripts_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
config.save()
|
|
74
|
+
|
|
75
|
+
click.echo(f"Recordings will be saved to: {config.recordings_dir}")
|
|
76
|
+
click.echo(f"Whisper model: {config.whisper_model}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@cli.command()
|
|
80
|
+
@click.argument("name", required=False)
|
|
81
|
+
@require_setup
|
|
82
|
+
def start(name: Optional[str]):
|
|
83
|
+
"""Start an interactive foreground recording session.
|
|
84
|
+
|
|
85
|
+
NAME is the meeting name (optional). If not provided, uses a timestamp
|
|
86
|
+
like "29_Jan_2026_1430".
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
meeting-noter start # Uses timestamp name
|
|
90
|
+
meeting-noter start "Weekly Standup" # Uses custom name
|
|
91
|
+
|
|
92
|
+
Press Ctrl+C to stop recording. The recording will be automatically
|
|
93
|
+
transcribed if auto_transcribe is enabled in settings.
|
|
94
|
+
"""
|
|
95
|
+
from meeting_noter.daemon import run_foreground_capture
|
|
96
|
+
|
|
97
|
+
config = get_config()
|
|
98
|
+
output_dir = config.recordings_dir
|
|
99
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
# Use default timestamp name if not provided
|
|
102
|
+
meeting_name = name if name else generate_meeting_name()
|
|
103
|
+
|
|
104
|
+
run_foreground_capture(
|
|
105
|
+
output_dir=output_dir,
|
|
106
|
+
meeting_name=meeting_name,
|
|
107
|
+
auto_transcribe=config.auto_transcribe,
|
|
108
|
+
whisper_model=config.whisper_model,
|
|
109
|
+
transcripts_dir=config.transcripts_dir,
|
|
110
|
+
silence_timeout_minutes=config.silence_timeout,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@cli.command()
|
|
115
|
+
@click.option(
|
|
116
|
+
"--output-dir", "-o",
|
|
117
|
+
type=click.Path(),
|
|
118
|
+
default=None,
|
|
119
|
+
help="Directory to save recordings (overrides config)",
|
|
120
|
+
)
|
|
121
|
+
@click.option(
|
|
122
|
+
"--foreground", "-f",
|
|
123
|
+
is_flag=True,
|
|
124
|
+
help="Run in foreground instead of as daemon",
|
|
125
|
+
)
|
|
126
|
+
@click.option(
|
|
127
|
+
"--name", "-n",
|
|
128
|
+
default=None,
|
|
129
|
+
help="Meeting name for the recording",
|
|
130
|
+
)
|
|
131
|
+
@require_setup
|
|
132
|
+
def daemon(output_dir: Optional[str], foreground: bool, name: Optional[str]):
|
|
133
|
+
"""Start the background daemon to capture meeting audio.
|
|
134
|
+
|
|
135
|
+
The daemon captures your microphone and system audio (via ScreenCaptureKit)
|
|
136
|
+
and records to MP3 files. Files are automatically segmented when
|
|
137
|
+
silence is detected (indicating a meeting has ended).
|
|
138
|
+
"""
|
|
139
|
+
from meeting_noter.daemon import run_daemon
|
|
140
|
+
|
|
141
|
+
config = get_config()
|
|
142
|
+
output_path = Path(output_dir) if output_dir else config.recordings_dir
|
|
143
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
|
|
145
|
+
run_daemon(
|
|
146
|
+
output_path,
|
|
147
|
+
foreground=foreground,
|
|
148
|
+
pid_file=DEFAULT_PID_FILE,
|
|
149
|
+
meeting_name=name,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@cli.command()
|
|
154
|
+
@require_setup
|
|
155
|
+
def status():
|
|
156
|
+
"""Check if the daemon is running."""
|
|
157
|
+
from meeting_noter.daemon import check_status
|
|
158
|
+
check_status(DEFAULT_PID_FILE)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@cli.command()
|
|
162
|
+
@require_setup
|
|
163
|
+
def stop():
|
|
164
|
+
"""Stop the running daemon."""
|
|
165
|
+
from meeting_noter.daemon import stop_daemon
|
|
166
|
+
stop_daemon(DEFAULT_PID_FILE)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@cli.command("list")
|
|
170
|
+
@click.option(
|
|
171
|
+
"--output-dir", "-o",
|
|
172
|
+
type=click.Path(exists=True),
|
|
173
|
+
default=None,
|
|
174
|
+
help="Directory containing recordings (overrides config)",
|
|
175
|
+
)
|
|
176
|
+
@click.option(
|
|
177
|
+
"--limit", "-n",
|
|
178
|
+
type=int,
|
|
179
|
+
default=10,
|
|
180
|
+
help="Number of recordings to show",
|
|
181
|
+
)
|
|
182
|
+
@require_setup
|
|
183
|
+
def list_recordings(output_dir: Optional[str], limit: int):
|
|
184
|
+
"""List recent meeting recordings."""
|
|
185
|
+
from meeting_noter.output.writer import list_recordings as _list_recordings
|
|
186
|
+
|
|
187
|
+
config = get_config()
|
|
188
|
+
path = Path(output_dir) if output_dir else config.recordings_dir
|
|
189
|
+
_list_recordings(path, limit)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@cli.command()
|
|
193
|
+
@click.argument("file", required=False)
|
|
194
|
+
@click.option(
|
|
195
|
+
"--output-dir", "-o",
|
|
196
|
+
type=click.Path(exists=True),
|
|
197
|
+
default=None,
|
|
198
|
+
help="Directory containing recordings (overrides config)",
|
|
199
|
+
)
|
|
200
|
+
@click.option(
|
|
201
|
+
"--model", "-m",
|
|
202
|
+
type=click.Choice(["tiny.en", "base.en", "small.en", "medium.en", "large-v3"]),
|
|
203
|
+
default=None,
|
|
204
|
+
help="Whisper model size (overrides config)",
|
|
205
|
+
)
|
|
206
|
+
@click.option(
|
|
207
|
+
"--live", "-l",
|
|
208
|
+
is_flag=True,
|
|
209
|
+
help="Real-time transcription of current recording",
|
|
210
|
+
)
|
|
211
|
+
@require_setup
|
|
212
|
+
def transcribe(file: Optional[str], output_dir: Optional[str], model: Optional[str], live: bool):
|
|
213
|
+
"""Transcribe a meeting recording.
|
|
214
|
+
|
|
215
|
+
If no FILE is specified, transcribes the most recent recording.
|
|
216
|
+
Use --live for real-time transcription of an ongoing meeting.
|
|
217
|
+
"""
|
|
218
|
+
from meeting_noter.transcription.engine import transcribe_file, transcribe_live
|
|
219
|
+
|
|
220
|
+
config = get_config()
|
|
221
|
+
output_path = Path(output_dir) if output_dir else config.recordings_dir
|
|
222
|
+
whisper_model = model or config.whisper_model
|
|
223
|
+
|
|
224
|
+
if live:
|
|
225
|
+
transcribe_live(output_path, whisper_model)
|
|
226
|
+
else:
|
|
227
|
+
transcribe_file(file, output_path, whisper_model, config.transcripts_dir)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@cli.command()
|
|
231
|
+
@click.option(
|
|
232
|
+
"--foreground", "-f",
|
|
233
|
+
is_flag=True,
|
|
234
|
+
help="Run in foreground instead of background",
|
|
235
|
+
)
|
|
236
|
+
@require_setup
|
|
237
|
+
def menubar(foreground: bool):
|
|
238
|
+
"""Launch menu bar app for daemon control.
|
|
239
|
+
|
|
240
|
+
Adds a menu bar icon for one-click start/stop of the recording daemon.
|
|
241
|
+
The icon shows "MN" when idle and "MN [filename]" when recording.
|
|
242
|
+
|
|
243
|
+
By default, runs in background. Use -f for foreground (debugging).
|
|
244
|
+
"""
|
|
245
|
+
import subprocess
|
|
246
|
+
import sys
|
|
247
|
+
|
|
248
|
+
if foreground:
|
|
249
|
+
from meeting_noter.menubar import run_menubar
|
|
250
|
+
run_menubar()
|
|
251
|
+
else:
|
|
252
|
+
# Spawn as background process
|
|
253
|
+
subprocess.Popen(
|
|
254
|
+
[sys.executable, "-m", "meeting_noter.menubar"],
|
|
255
|
+
stdout=subprocess.DEVNULL,
|
|
256
|
+
stderr=subprocess.DEVNULL,
|
|
257
|
+
start_new_session=True,
|
|
258
|
+
)
|
|
259
|
+
click.echo("Menu bar app started in background.")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@cli.command()
|
|
263
|
+
@click.option(
|
|
264
|
+
"--foreground", "-f",
|
|
265
|
+
is_flag=True,
|
|
266
|
+
help="Run in foreground instead of background",
|
|
267
|
+
)
|
|
268
|
+
@require_setup
|
|
269
|
+
def gui(foreground: bool):
|
|
270
|
+
"""Launch the desktop GUI application.
|
|
271
|
+
|
|
272
|
+
Opens a window with tabs for:
|
|
273
|
+
- Recording: Start/stop recordings with meeting names
|
|
274
|
+
- Meetings: Browse, play, and manage recordings
|
|
275
|
+
- Settings: Configure directories, models, and preferences
|
|
276
|
+
|
|
277
|
+
By default runs in background. Use -f for foreground.
|
|
278
|
+
"""
|
|
279
|
+
if foreground:
|
|
280
|
+
from meeting_noter.gui import run_gui
|
|
281
|
+
run_gui()
|
|
282
|
+
else:
|
|
283
|
+
_launch_gui_background()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@cli.command()
|
|
287
|
+
def devices():
|
|
288
|
+
"""List available audio devices."""
|
|
289
|
+
import sounddevice as sd
|
|
290
|
+
|
|
291
|
+
devices = sd.query_devices()
|
|
292
|
+
click.echo("\nAvailable Audio Devices:\n")
|
|
293
|
+
|
|
294
|
+
for i, device in enumerate(devices):
|
|
295
|
+
device_type = []
|
|
296
|
+
if device["max_input_channels"] > 0:
|
|
297
|
+
device_type.append("IN")
|
|
298
|
+
if device["max_output_channels"] > 0:
|
|
299
|
+
device_type.append("OUT")
|
|
300
|
+
|
|
301
|
+
type_str = "/".join(device_type) if device_type else "N/A"
|
|
302
|
+
click.echo(f" [{i}] {device['name']} ({type_str})")
|
|
303
|
+
|
|
304
|
+
click.echo()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
cli()
|