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,197 @@
1
+ """Configuration management for Meeting Noter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import json
7
+ import re
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import click
13
+
14
+
15
+ def generate_meeting_name() -> str:
16
+ """Generate a default meeting name with current timestamp."""
17
+ now = datetime.now()
18
+ return now.strftime("%d_%b_%Y_%H%M") # e.g., "29_Jan_2026_1430"
19
+
20
+
21
+ def is_default_meeting_name(name: str) -> bool:
22
+ """Check if a name matches the default timestamp pattern."""
23
+ # Pattern: DD_Mon_YYYY_HHMM (e.g., 29_Jan_2026_1430)
24
+ return bool(re.match(r"^\d{2}_[A-Z][a-z]{2}_\d{4}_\d{4}$", name))
25
+
26
+
27
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "meeting-noter"
28
+ DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.json"
29
+
30
+ DEFAULT_CONFIG = {
31
+ "recordings_dir": str(Path.home() / "meetings"),
32
+ "transcripts_dir": str(Path.home() / "meetings"),
33
+ "whisper_model": "tiny.en",
34
+ "auto_transcribe": True,
35
+ "silence_timeout": 5, # Minutes of silence before stopping recording
36
+ "capture_system_audio": True, # Capture other participants via ScreenCaptureKit
37
+ "show_menubar": False,
38
+ "setup_complete": False,
39
+ }
40
+
41
+
42
+ class Config:
43
+ """Configuration manager for Meeting Noter."""
44
+
45
+ def __init__(self, config_path: Path = DEFAULT_CONFIG_FILE):
46
+ self.config_path = config_path
47
+ self._data: dict[str, Any] = {}
48
+ self.load()
49
+
50
+ def load(self) -> None:
51
+ """Load configuration from disk."""
52
+ if self.config_path.exists():
53
+ try:
54
+ with open(self.config_path, "r") as f:
55
+ self._data = json.load(f)
56
+ except (json.JSONDecodeError, IOError):
57
+ self._data = {}
58
+ else:
59
+ self._data = {}
60
+
61
+ # Fill in missing defaults
62
+ for key, value in DEFAULT_CONFIG.items():
63
+ if key not in self._data:
64
+ self._data[key] = value
65
+
66
+ def save(self) -> None:
67
+ """Save configuration to disk."""
68
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
69
+ with open(self.config_path, "w") as f:
70
+ json.dump(self._data, f, indent=4)
71
+
72
+ @property
73
+ def recordings_dir(self) -> Path:
74
+ """Get recordings directory."""
75
+ return Path(self._data["recordings_dir"]).expanduser()
76
+
77
+ @recordings_dir.setter
78
+ def recordings_dir(self, value: Path | str) -> None:
79
+ """Set recordings directory."""
80
+ self._data["recordings_dir"] = str(value)
81
+
82
+ @property
83
+ def transcripts_dir(self) -> Path:
84
+ """Get transcripts directory."""
85
+ return Path(self._data["transcripts_dir"]).expanduser()
86
+
87
+ @transcripts_dir.setter
88
+ def transcripts_dir(self, value: Path | str) -> None:
89
+ """Set transcripts directory."""
90
+ self._data["transcripts_dir"] = str(value)
91
+
92
+ @property
93
+ def whisper_model(self) -> str:
94
+ """Get Whisper model name."""
95
+ return self._data["whisper_model"]
96
+
97
+ @whisper_model.setter
98
+ def whisper_model(self, value: str) -> None:
99
+ """Set Whisper model name."""
100
+ self._data["whisper_model"] = value
101
+
102
+ @property
103
+ def auto_transcribe(self) -> bool:
104
+ """Get auto-transcribe setting."""
105
+ return self._data["auto_transcribe"]
106
+
107
+ @auto_transcribe.setter
108
+ def auto_transcribe(self, value: bool) -> None:
109
+ """Set auto-transcribe setting."""
110
+ self._data["auto_transcribe"] = value
111
+
112
+ @property
113
+ def silence_timeout(self) -> int:
114
+ """Get silence timeout in minutes."""
115
+ return self._data.get("silence_timeout", 5)
116
+
117
+ @silence_timeout.setter
118
+ def silence_timeout(self, value: int) -> None:
119
+ """Set silence timeout in minutes."""
120
+ self._data["silence_timeout"] = value
121
+
122
+ @property
123
+ def capture_system_audio(self) -> bool:
124
+ """Get capture system audio setting."""
125
+ return self._data.get("capture_system_audio", True)
126
+
127
+ @capture_system_audio.setter
128
+ def capture_system_audio(self, value: bool) -> None:
129
+ """Set capture system audio setting."""
130
+ self._data["capture_system_audio"] = value
131
+
132
+ @property
133
+ def show_menubar(self) -> bool:
134
+ """Get show menubar setting."""
135
+ return self._data.get("show_menubar", False)
136
+
137
+ @show_menubar.setter
138
+ def show_menubar(self, value: bool) -> None:
139
+ """Set show menubar setting."""
140
+ self._data["show_menubar"] = value
141
+
142
+ @property
143
+ def setup_complete(self) -> bool:
144
+ """Check if setup has been completed."""
145
+ return self._data.get("setup_complete", False)
146
+
147
+ @setup_complete.setter
148
+ def setup_complete(self, value: bool) -> None:
149
+ """Set setup completion status."""
150
+ self._data["setup_complete"] = value
151
+
152
+ def __getitem__(self, key: str) -> Any:
153
+ """Get config value by key."""
154
+ return self._data.get(key, DEFAULT_CONFIG.get(key))
155
+
156
+ def __setitem__(self, key: str, value: Any) -> None:
157
+ """Set config value by key."""
158
+ self._data[key] = value
159
+
160
+
161
+ # Global config instance (lazy loaded)
162
+ _config: Config | None = None
163
+
164
+
165
+ def get_config() -> Config:
166
+ """Get the global config instance."""
167
+ global _config
168
+ if _config is None:
169
+ _config = Config()
170
+ return _config
171
+
172
+
173
+ def is_setup_complete() -> bool:
174
+ """Check if setup has been completed."""
175
+ return get_config().setup_complete
176
+
177
+
178
+ def require_setup(f):
179
+ """Decorator that ensures basic config exists.
180
+
181
+ Now more lenient - just ensures config directories exist.
182
+ Setup is optional since we can use any microphone.
183
+
184
+ Usage:
185
+ @cli.command()
186
+ @require_setup
187
+ def my_command():
188
+ ...
189
+ """
190
+ @functools.wraps(f)
191
+ def wrapper(*args, **kwargs):
192
+ config = get_config()
193
+ # Ensure directories exist
194
+ config.recordings_dir.mkdir(parents=True, exist_ok=True)
195
+ config.transcripts_dir.mkdir(parents=True, exist_ok=True)
196
+ return f(*args, **kwargs)
197
+ return wrapper
@@ -0,0 +1,514 @@
1
+ """Background daemon for capturing meeting audio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import signal
8
+ import time
9
+ import click
10
+ from pathlib import Path
11
+ from threading import Event
12
+ from typing import Optional
13
+
14
+ # IMPORTANT: Do NOT import audio modules at top level!
15
+ # CoreAudio crashes when Python forks after loading audio libraries.
16
+ # Audio imports are deferred until after daemonize() is called.
17
+
18
+
19
+ # Global stop event for signal handling
20
+ _stop_event = Event()
21
+
22
+
23
+ def _signal_handler(signum, frame):
24
+ """Handle termination signals."""
25
+ _stop_event.set()
26
+
27
+
28
+ def daemonize():
29
+ """Fork the process to run as a daemon."""
30
+ # First fork
31
+ try:
32
+ pid = os.fork()
33
+ if pid > 0:
34
+ # Parent exits
35
+ sys.exit(0)
36
+ except OSError as e:
37
+ sys.stderr.write(f"Fork #1 failed: {e}\n")
38
+ sys.exit(1)
39
+
40
+ # Decouple from parent environment
41
+ os.chdir("/")
42
+ os.setsid()
43
+ os.umask(0)
44
+
45
+ # Second fork
46
+ try:
47
+ pid = os.fork()
48
+ if pid > 0:
49
+ sys.exit(0)
50
+ except OSError as e:
51
+ sys.stderr.write(f"Fork #2 failed: {e}\n")
52
+ sys.exit(1)
53
+
54
+ # Redirect standard file descriptors
55
+ sys.stdout.flush()
56
+ sys.stderr.flush()
57
+
58
+ with open("/dev/null", "r") as devnull:
59
+ os.dup2(devnull.fileno(), sys.stdin.fileno())
60
+
61
+ # Keep stdout/stderr for logging to a file
62
+ log_path = Path.home() / ".meeting-noter.log"
63
+ log_file = open(log_path, "a")
64
+ os.dup2(log_file.fileno(), sys.stdout.fileno())
65
+ os.dup2(log_file.fileno(), sys.stderr.fileno())
66
+
67
+
68
+ def write_pid_file(pid_file: Path):
69
+ """Write the current PID to file."""
70
+ with open(pid_file, "w") as f:
71
+ f.write(str(os.getpid()))
72
+
73
+
74
+ def remove_pid_file(pid_file: Path):
75
+ """Remove the PID file."""
76
+ try:
77
+ pid_file.unlink()
78
+ except FileNotFoundError:
79
+ pass
80
+
81
+
82
+ def read_pid_file(pid_file: Path) -> Optional[int]:
83
+ """Read PID from file."""
84
+ try:
85
+ with open(pid_file, "r") as f:
86
+ return int(f.read().strip())
87
+ except (FileNotFoundError, ValueError):
88
+ return None
89
+
90
+
91
+ def is_process_running(pid: int) -> bool:
92
+ """Check if a process with given PID is running."""
93
+ try:
94
+ os.kill(pid, 0)
95
+ return True
96
+ except (OSError, ProcessLookupError):
97
+ return False
98
+
99
+
100
+ def check_audio_available() -> bool:
101
+ """Check if any audio input device is available."""
102
+ import sounddevice as sd
103
+ try:
104
+ devices = sd.query_devices()
105
+ for device in devices:
106
+ if device["max_input_channels"] > 0:
107
+ return True
108
+ except Exception:
109
+ pass
110
+ return False
111
+
112
+
113
+ def run_daemon(
114
+ output_dir: Path,
115
+ foreground: bool = False,
116
+ pid_file: Optional[Path] = None,
117
+ meeting_name: Optional[str] = None,
118
+ ):
119
+ """Run the audio capture daemon.
120
+
121
+ IMPORTANT: We must NOT import any audio libraries (sounddevice, etc.) before
122
+ forking, because CoreAudio crashes when Python forks after loading audio libs.
123
+ All audio-related imports happen in _run_capture_loop() AFTER daemonize().
124
+ """
125
+ # Check if already running (no audio imports needed)
126
+ if pid_file:
127
+ existing_pid = read_pid_file(pid_file)
128
+ if existing_pid and is_process_running(existing_pid):
129
+ click.echo(click.style(
130
+ f"Daemon already running (PID {existing_pid})",
131
+ fg="yellow"
132
+ ))
133
+ return
134
+
135
+ if not foreground:
136
+ click.echo("Starting daemon in background...")
137
+ daemonize()
138
+
139
+ # NOW it's safe to check audio (after fork)
140
+ if not check_audio_available():
141
+ print("Error: No audio input device found.")
142
+ print("Please check your microphone settings.")
143
+ return
144
+
145
+ # Set up signal handlers
146
+ signal.signal(signal.SIGTERM, _signal_handler)
147
+ signal.signal(signal.SIGINT, _signal_handler)
148
+
149
+ # Write PID file
150
+ if pid_file:
151
+ write_pid_file(pid_file)
152
+
153
+ try:
154
+ _run_capture_loop(output_dir, meeting_name=meeting_name)
155
+ finally:
156
+ if pid_file:
157
+ remove_pid_file(pid_file)
158
+
159
+
160
+ def _run_capture_loop(output_dir: Path, meeting_name: Optional[str] = None):
161
+ """Main capture loop.
162
+
163
+ Audio imports happen HERE, safely AFTER the fork.
164
+ """
165
+ import sys
166
+ from meeting_noter.config import get_config
167
+
168
+ # Import audio modules AFTER fork to avoid CoreAudio crash
169
+ from meeting_noter.audio.capture import AudioCapture, SilenceDetector
170
+ from meeting_noter.audio.encoder import RecordingSession
171
+
172
+ config = get_config()
173
+
174
+ print(f"Meeting Noter daemon started. Saving to {output_dir}")
175
+ sys.stdout.flush()
176
+ if meeting_name:
177
+ print(f"Meeting: {meeting_name}")
178
+ sys.stdout.flush()
179
+
180
+ # Use combined capture (mic + system audio) if enabled
181
+ use_combined = False
182
+ if config.capture_system_audio:
183
+ try:
184
+ from meeting_noter.audio.system_audio import CombinedAudioCapture
185
+ capture = CombinedAudioCapture()
186
+ use_combined = True
187
+ except Exception as e:
188
+ print(f"Combined capture not available: {e}")
189
+ sys.stdout.flush()
190
+
191
+ if not use_combined:
192
+ print("Listening for audio...")
193
+ sys.stdout.flush()
194
+ try:
195
+ capture = AudioCapture()
196
+ except RuntimeError as e:
197
+ print(f"Error creating AudioCapture: {e}")
198
+ sys.stdout.flush()
199
+ return
200
+ except Exception as e:
201
+ print(f"Unexpected error creating AudioCapture: {type(e).__name__}: {e}")
202
+ sys.stdout.flush()
203
+ return
204
+
205
+ # Start capture first (CombinedAudioCapture updates channels during start)
206
+ try:
207
+ capture.start()
208
+ print("Capture started. Waiting for audio...")
209
+ sys.stdout.flush()
210
+ except Exception as e:
211
+ print(f"Error starting capture: {type(e).__name__}: {e}")
212
+ sys.stdout.flush()
213
+ return
214
+
215
+ try:
216
+ # Get sample rate and channels from capture device AFTER start
217
+ sample_rate = capture.sample_rate
218
+ channels = capture.channels
219
+
220
+ silence_detector = SilenceDetector(
221
+ threshold=0.01, # Higher threshold to ignore background noise
222
+ silence_duration=30.0, # 30 seconds of silence = meeting ended
223
+ sample_rate=sample_rate,
224
+ )
225
+
226
+ # Use same sample rate and channels as capture device
227
+ session = RecordingSession(
228
+ output_dir,
229
+ sample_rate=sample_rate,
230
+ channels=channels,
231
+ meeting_name=meeting_name,
232
+ )
233
+ recording_started = False
234
+ audio_detected = False
235
+
236
+ while not _stop_event.is_set():
237
+ audio = capture.get_audio(timeout=0.5)
238
+ if audio is None:
239
+ continue
240
+
241
+ # Flatten if needed
242
+ if audio.ndim > 1:
243
+ audio = audio.flatten()
244
+
245
+ has_audio = silence_detector.is_audio_present(audio)
246
+ is_silence = silence_detector.update(audio)
247
+
248
+ # State machine for recording
249
+ if not session.is_active:
250
+ # Not recording - wait for audio to start
251
+ if has_audio:
252
+ filepath = session.start()
253
+ print(f"Recording started: {filepath.name}")
254
+ recording_started = True
255
+ audio_detected = True
256
+ else:
257
+ # Currently recording
258
+ session.write(audio)
259
+
260
+ if has_audio:
261
+ audio_detected = True
262
+
263
+ # Check for extended silence (meeting ended)
264
+ if is_silence and audio_detected:
265
+ filepath, duration = session.stop()
266
+ if filepath:
267
+ print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
268
+ else:
269
+ print("Recording discarded (too short)")
270
+ silence_detector.reset()
271
+ audio_detected = False
272
+
273
+ except Exception as e:
274
+ print(f"Error in capture loop: {e}")
275
+ finally:
276
+ capture.stop()
277
+
278
+ # Save any ongoing recording
279
+ if 'session' in locals() and session.is_active:
280
+ filepath, duration = session.stop()
281
+ if filepath:
282
+ print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
283
+
284
+ print("Daemon stopped.")
285
+
286
+
287
+ def check_screencapturekit_available() -> bool:
288
+ """Check if ScreenCaptureKit is available and has permission."""
289
+ try:
290
+ from Quartz import CGPreflightScreenCaptureAccess
291
+ return CGPreflightScreenCaptureAccess()
292
+ except Exception:
293
+ return False
294
+
295
+
296
+ def check_status(pid_file: Path):
297
+ """Check daemon status."""
298
+ # Check audio devices
299
+ audio_ok = check_audio_available()
300
+ screencapture_ok = check_screencapturekit_available()
301
+
302
+ if audio_ok:
303
+ click.echo("Microphone: " + click.style("available", fg="green"))
304
+ else:
305
+ click.echo("Microphone: " + click.style("not found", fg="red"))
306
+ click.echo(" Please check your microphone settings")
307
+
308
+ # System audio capture
309
+ if screencapture_ok:
310
+ click.echo("System audio: " + click.style("enabled", fg="green") + " (Screen Recording permission granted)")
311
+ else:
312
+ click.echo("System audio: " + click.style("not available", fg="yellow"))
313
+ click.echo(" Grant Screen Recording permission to capture other participants")
314
+
315
+ # Check daemon
316
+ pid = read_pid_file(pid_file)
317
+
318
+ if pid is None:
319
+ click.echo("Daemon: " + click.style("not running", fg="red"))
320
+ return
321
+
322
+ if is_process_running(pid):
323
+ click.echo("Daemon: " + click.style("running", fg="green") + f" (PID {pid})")
324
+
325
+ # Show log tail
326
+ log_path = Path.home() / ".meeting-noter.log"
327
+ if log_path.exists():
328
+ click.echo("\nRecent log entries:")
329
+ with open(log_path, "r") as f:
330
+ lines = f.readlines()
331
+ for line in lines[-5:]:
332
+ click.echo(f" {line.rstrip()}")
333
+ else:
334
+ click.echo("Daemon: " + click.style("not running", fg="red") + " (stale PID file)")
335
+ remove_pid_file(pid_file)
336
+
337
+
338
+ def stop_daemon(pid_file: Path):
339
+ """Stop the running daemon."""
340
+ pid = read_pid_file(pid_file)
341
+
342
+ if pid is None:
343
+ click.echo("Daemon is not running")
344
+ return
345
+
346
+ if not is_process_running(pid):
347
+ click.echo("Daemon is not running (cleaning up stale PID file)")
348
+ remove_pid_file(pid_file)
349
+ return
350
+
351
+ click.echo(f"Stopping daemon (PID {pid})...")
352
+
353
+ try:
354
+ os.kill(pid, signal.SIGTERM)
355
+
356
+ # Wait for process to stop
357
+ for _ in range(10):
358
+ time.sleep(0.5)
359
+ if not is_process_running(pid):
360
+ break
361
+
362
+ if is_process_running(pid):
363
+ click.echo("Daemon did not stop gracefully, forcing...")
364
+ os.kill(pid, signal.SIGKILL)
365
+
366
+ click.echo(click.style("Daemon stopped", fg="green"))
367
+ except ProcessLookupError:
368
+ click.echo("Daemon already stopped")
369
+ finally:
370
+ remove_pid_file(pid_file)
371
+
372
+
373
+ def run_foreground_capture(
374
+ output_dir: Path,
375
+ meeting_name: str,
376
+ auto_transcribe: bool = True,
377
+ whisper_model: str = "tiny.en",
378
+ transcripts_dir: Optional[Path] = None,
379
+ silence_timeout_minutes: int = 5,
380
+ ) -> Optional[Path]:
381
+ """Run audio capture in foreground with a named meeting.
382
+
383
+ This function is used by the 'start' command for interactive recording.
384
+ Records until Ctrl+C is pressed or silence timeout, then optionally transcribes.
385
+
386
+ Args:
387
+ output_dir: Directory to save recordings
388
+ meeting_name: Name of the meeting (used in filename)
389
+ auto_transcribe: Whether to transcribe after recording stops
390
+ whisper_model: Whisper model to use for transcription
391
+ transcripts_dir: Directory for transcripts
392
+ silence_timeout_minutes: Stop after this many minutes of silence
393
+
394
+ Returns:
395
+ Path to the saved recording, or None if recording was too short
396
+ """
397
+ # Import audio modules (safe since no fork)
398
+ from meeting_noter.audio.capture import AudioCapture, SilenceDetector
399
+ from meeting_noter.audio.encoder import RecordingSession
400
+ from meeting_noter.config import get_config
401
+
402
+ config = get_config()
403
+
404
+ # Check audio device
405
+ if not check_audio_available():
406
+ click.echo(click.style("Error: ", fg="red") + "No audio input device found.")
407
+ click.echo("Please check your microphone settings.")
408
+ return None
409
+
410
+ # Set up signal handlers
411
+ signal.signal(signal.SIGTERM, _signal_handler)
412
+ signal.signal(signal.SIGINT, _signal_handler)
413
+
414
+ click.echo(f"Meeting: {click.style(meeting_name, fg='cyan', bold=True)}")
415
+ click.echo(f"Output: {output_dir}")
416
+ click.echo(f"Silence timeout: {silence_timeout_minutes} minutes")
417
+ click.echo("Press Ctrl+C to stop recording.\n")
418
+
419
+ # Use combined capture (mic + system audio) if enabled
420
+ capture = None
421
+ if config.capture_system_audio:
422
+ try:
423
+ from meeting_noter.audio.system_audio import CombinedAudioCapture
424
+ capture = CombinedAudioCapture()
425
+ except Exception as e:
426
+ click.echo(click.style(f"Combined capture not available: {e}", fg="yellow"))
427
+
428
+ if capture is None:
429
+ try:
430
+ capture = AudioCapture()
431
+ except RuntimeError as e:
432
+ click.echo(click.style(f"Error: {e}", fg="red"))
433
+ return None
434
+
435
+ session = RecordingSession(
436
+ output_dir,
437
+ sample_rate=capture.sample_rate,
438
+ channels=capture.channels,
439
+ meeting_name=meeting_name,
440
+ )
441
+
442
+ # Silence detection
443
+ silence_detector = SilenceDetector(
444
+ threshold=0.01,
445
+ silence_duration=silence_timeout_minutes * 60.0, # Convert to seconds
446
+ sample_rate=capture.sample_rate,
447
+ )
448
+
449
+ saved_filepath = None
450
+ stopped_by_silence = False
451
+
452
+ try:
453
+ capture.start()
454
+
455
+ # Start recording immediately
456
+ filepath = session.start()
457
+ click.echo(click.style("Recording: ", fg="green") + filepath.name)
458
+
459
+ while not _stop_event.is_set():
460
+ audio = capture.get_audio(timeout=0.5)
461
+ if audio is None:
462
+ continue
463
+
464
+ # Flatten if needed
465
+ if audio.ndim > 1:
466
+ audio = audio.flatten()
467
+
468
+ session.write(audio)
469
+
470
+ # Check for extended silence
471
+ if silence_detector.update(audio):
472
+ click.echo("\n" + click.style("Stopped: ", fg="yellow") + "silence timeout reached")
473
+ stopped_by_silence = True
474
+ break
475
+
476
+ # Show live duration every few seconds
477
+ duration = session.duration
478
+ if int(duration) % 5 == 0 and duration > 0:
479
+ mins, secs = divmod(int(duration), 60)
480
+ click.echo(f"\r Duration: {mins:02d}:{secs:02d}", nl=False)
481
+
482
+ except Exception as e:
483
+ click.echo(click.style(f"\nError: {e}", fg="red"))
484
+ finally:
485
+ capture.stop()
486
+
487
+ # Save recording
488
+ if session.is_active:
489
+ saved_filepath, duration = session.stop()
490
+ if not stopped_by_silence:
491
+ click.echo() # New line after duration display
492
+
493
+ if saved_filepath:
494
+ mins, secs = divmod(int(duration), 60)
495
+ click.echo(
496
+ click.style("\nSaved: ", fg="green") +
497
+ f"{saved_filepath.name} ({mins:02d}:{secs:02d})"
498
+ )
499
+
500
+ # Auto-transcribe if enabled
501
+ if auto_transcribe:
502
+ click.echo(click.style("\nTranscribing...", fg="cyan"))
503
+ try:
504
+ from meeting_noter.transcription.engine import transcribe_file
505
+ transcribe_file(str(saved_filepath), output_dir, whisper_model, transcripts_dir)
506
+ except Exception as e:
507
+ click.echo(click.style(f"Transcription error: {e}", fg="red"))
508
+ else:
509
+ click.echo(click.style("\nRecording discarded", fg="yellow") + " (too short)")
510
+
511
+ # Reset stop event for potential future use
512
+ _stop_event.clear()
513
+
514
+ return saved_filepath