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.

Files changed (38) 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 +176 -0
  6. meeting_noter/audio/system_audio.py +363 -0
  7. meeting_noter/cli.py +308 -0
  8. meeting_noter/config.py +197 -0
  9. meeting_noter/daemon.py +514 -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 +296 -0
  20. meeting_noter/menubar.py +432 -0
  21. meeting_noter/output/__init__.py +1 -0
  22. meeting_noter/output/writer.py +96 -0
  23. meeting_noter/resources/__init__.py +1 -0
  24. meeting_noter/resources/icon.icns +0 -0
  25. meeting_noter/resources/icon.png +0 -0
  26. meeting_noter/resources/icon_128.png +0 -0
  27. meeting_noter/resources/icon_16.png +0 -0
  28. meeting_noter/resources/icon_256.png +0 -0
  29. meeting_noter/resources/icon_32.png +0 -0
  30. meeting_noter/resources/icon_512.png +0 -0
  31. meeting_noter/resources/icon_64.png +0 -0
  32. meeting_noter/transcription/__init__.py +1 -0
  33. meeting_noter/transcription/engine.py +208 -0
  34. meeting_noter-0.3.0.dist-info/METADATA +261 -0
  35. meeting_noter-0.3.0.dist-info/RECORD +38 -0
  36. meeting_noter-0.3.0.dist-info/WHEEL +5 -0
  37. meeting_noter-0.3.0.dist-info/entry_points.txt +2 -0
  38. 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()