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
meeting_noter/cli.py ADDED
@@ -0,0 +1,837 @@
1
+ """CLI commands for Meeting Noter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import click
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from meeting_noter import __version__
11
+ from meeting_noter.config import (
12
+ get_config,
13
+ require_setup,
14
+ is_setup_complete,
15
+ generate_meeting_name,
16
+ )
17
+
18
+
19
+ # Default paths
20
+ DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
21
+
22
+
23
+ class SuggestGroup(click.Group):
24
+ """Custom Click group that suggests similar commands on typos."""
25
+
26
+ # Use SuggestCommand for all commands in this group
27
+ command_class = None # Will be set after SuggestCommand is defined
28
+
29
+ def resolve_command(self, ctx, args):
30
+ """Override to suggest similar commands on error."""
31
+ try:
32
+ return super().resolve_command(ctx, args)
33
+ except click.UsageError as e:
34
+ if args:
35
+ cmd_name = args[0]
36
+ # Find similar commands
37
+ matches = difflib.get_close_matches(
38
+ cmd_name, self.list_commands(ctx), n=3, cutoff=0.5
39
+ )
40
+ if matches:
41
+ suggestion = f"\n\nDid you mean: {', '.join(matches)}?"
42
+ raise click.UsageError(str(e) + suggestion)
43
+ raise
44
+
45
+ def get_command(self, ctx, cmd_name):
46
+ """Override to handle partial command matching."""
47
+ # Try exact match first
48
+ rv = super().get_command(ctx, cmd_name)
49
+ if rv is not None:
50
+ return rv
51
+
52
+ # Try prefix match
53
+ matches = [cmd for cmd in self.list_commands(ctx) if cmd.startswith(cmd_name)]
54
+ if len(matches) == 1:
55
+ return super().get_command(ctx, matches[0])
56
+
57
+ return None
58
+
59
+
60
+ class SuggestCommand(click.Command):
61
+ """Custom Click command that suggests similar options on typos."""
62
+
63
+ def make_context(self, info_name, args, parent=None, **extra):
64
+ """Override to catch and improve option errors."""
65
+ try:
66
+ return super().make_context(info_name, args, parent, **extra)
67
+ except click.UsageError as e:
68
+ error_msg = str(e)
69
+ if "No such option:" in error_msg:
70
+ # Extract the bad option
71
+ bad_opt = error_msg.split("No such option:")[-1].strip()
72
+ # Get available options
73
+ all_opts = []
74
+ for param in self.params:
75
+ all_opts.extend(param.opts)
76
+
77
+ matches = difflib.get_close_matches(bad_opt, all_opts, n=3, cutoff=0.4)
78
+ if matches:
79
+ suggestion = f"\n\nDid you mean: {', '.join(matches)}?"
80
+ raise click.UsageError(error_msg + suggestion)
81
+ else:
82
+ # Show available options
83
+ raise click.UsageError(
84
+ error_msg + f"\n\nAvailable options: {', '.join(sorted(all_opts))}"
85
+ )
86
+ raise
87
+
88
+
89
+ # Set the default command class for SuggestGroup
90
+ SuggestGroup.command_class = SuggestCommand
91
+
92
+
93
+ def _launch_gui_background():
94
+ """Launch the GUI in background and return immediately."""
95
+ import subprocess
96
+ import sys
97
+
98
+ subprocess.Popen(
99
+ [sys.executable, "-m", "meeting_noter.gui"],
100
+ stdout=subprocess.DEVNULL,
101
+ stderr=subprocess.DEVNULL,
102
+ start_new_session=True,
103
+ )
104
+ click.echo("Meeting Noter GUI launched.")
105
+
106
+
107
+ @click.group(cls=SuggestGroup, invoke_without_command=True)
108
+ @click.version_option(version=__version__)
109
+ @click.pass_context
110
+ def cli(ctx):
111
+ """Meeting Noter - Offline meeting transcription.
112
+
113
+ \b
114
+ Quick start:
115
+ meeting-noter Start watching for meetings (background)
116
+ meeting-noter status Show current status
117
+ meeting-noter shutdown Stop all processes
118
+ meeting-noter open Open recordings in Finder
119
+
120
+ \b
121
+ Configuration:
122
+ meeting-noter config Show all settings
123
+ meeting-noter config recordings-dir ~/path Set recordings directory
124
+ meeting-noter config whisper-model base.en Set transcription model
125
+ meeting-noter config auto-transcribe false Disable auto-transcribe
126
+ """
127
+ if ctx.invoked_subcommand is None:
128
+ # No subcommand - start background watcher
129
+ ctx.invoke(watcher)
130
+
131
+
132
+ @cli.command()
133
+ @click.argument("name", required=False)
134
+ @require_setup
135
+ def start(name: Optional[str]):
136
+ """Start an interactive foreground recording session.
137
+
138
+ NAME is the meeting name (optional). If not provided, uses a timestamp
139
+ like "29_Jan_2026_1430".
140
+
141
+ Examples:
142
+ meeting-noter start # Uses timestamp name
143
+ meeting-noter start "Weekly Standup" # Uses custom name
144
+
145
+ Press Ctrl+C to stop recording. The recording will be automatically
146
+ transcribed if auto_transcribe is enabled in settings.
147
+ """
148
+ from meeting_noter.daemon import run_foreground_capture
149
+
150
+ config = get_config()
151
+ output_dir = config.recordings_dir
152
+ output_dir.mkdir(parents=True, exist_ok=True)
153
+
154
+ # Use default timestamp name if not provided
155
+ meeting_name = name if name else generate_meeting_name()
156
+
157
+ run_foreground_capture(
158
+ output_dir=output_dir,
159
+ meeting_name=meeting_name,
160
+ auto_transcribe=config.auto_transcribe,
161
+ whisper_model=config.whisper_model,
162
+ transcripts_dir=config.transcripts_dir,
163
+ silence_timeout_minutes=config.silence_timeout,
164
+ )
165
+
166
+
167
+ @cli.command(hidden=True) # Internal command used by watcher
168
+ @click.option("--output-dir", "-o", type=click.Path(), default=None)
169
+ @click.option("--foreground", "-f", is_flag=True)
170
+ @click.option("--name", "-n", default=None)
171
+ @require_setup
172
+ def daemon(output_dir: Optional[str], foreground: bool, name: Optional[str]):
173
+ """Internal: Start recording daemon."""
174
+ from meeting_noter.daemon import run_daemon
175
+
176
+ config = get_config()
177
+ output_path = Path(output_dir) if output_dir else config.recordings_dir
178
+ output_path.mkdir(parents=True, exist_ok=True)
179
+
180
+ run_daemon(
181
+ output_path,
182
+ foreground=foreground,
183
+ pid_file=DEFAULT_PID_FILE,
184
+ meeting_name=name,
185
+ )
186
+
187
+
188
+ @cli.command()
189
+ def status():
190
+ """Show Meeting Noter status."""
191
+ import os
192
+ from meeting_noter.daemon import read_pid_file, is_process_running
193
+
194
+ # Check watcher
195
+ watcher_running = False
196
+ if WATCHER_PID_FILE.exists():
197
+ try:
198
+ pid = int(WATCHER_PID_FILE.read_text().strip())
199
+ os.kill(pid, 0)
200
+ watcher_running = True
201
+ except (ProcessLookupError, ValueError, FileNotFoundError):
202
+ pass
203
+
204
+ # Check menubar
205
+ menubar_running = False
206
+ menubar_pid_file = Path.home() / ".meeting-noter-menubar.pid"
207
+ if menubar_pid_file.exists():
208
+ try:
209
+ pid = int(menubar_pid_file.read_text().strip())
210
+ os.kill(pid, 0)
211
+ menubar_running = True
212
+ except (ProcessLookupError, ValueError, FileNotFoundError):
213
+ pass
214
+
215
+ # Check daemon (recording)
216
+ daemon_running = False
217
+ daemon_pid = read_pid_file(DEFAULT_PID_FILE)
218
+ if daemon_pid and is_process_running(daemon_pid):
219
+ daemon_running = True
220
+
221
+ # Determine status
222
+ click.echo()
223
+ if daemon_running:
224
+ # Get current recording name from log
225
+ recording_name = _get_current_recording_name()
226
+ click.echo(f"🔴 Recording: {recording_name or 'In progress'}")
227
+ elif watcher_running or menubar_running:
228
+ mode = "watcher" if watcher_running else "menubar"
229
+ click.echo(f"👀 Ready to record ({mode} active)")
230
+ else:
231
+ click.echo("⏹️ Stopped (run 'meeting-noter' to start)")
232
+
233
+ click.echo()
234
+
235
+ # Show details
236
+ click.echo("Components:")
237
+ click.echo(f" Watcher: {'running' if watcher_running else 'stopped'}")
238
+ click.echo(f" Menubar: {'running' if menubar_running else 'stopped'}")
239
+ click.echo(f" Recorder: {'recording' if daemon_running else 'idle'}")
240
+ click.echo()
241
+
242
+
243
+ def _get_current_recording_name() -> str | None:
244
+ """Get the name of the current recording from the log file."""
245
+ log_path = Path.home() / ".meeting-noter.log"
246
+ if not log_path.exists():
247
+ return None
248
+
249
+ try:
250
+ with open(log_path, "r") as f:
251
+ lines = f.readlines()
252
+
253
+ for line in reversed(lines[-50:]):
254
+ if "Recording started:" in line:
255
+ parts = line.split("Recording started:")
256
+ if len(parts) > 1:
257
+ filename = parts[1].strip().replace(".mp3", "")
258
+ # Extract name from timestamp_name format
259
+ name_parts = filename.split("_", 2)
260
+ if len(name_parts) >= 3:
261
+ return name_parts[2]
262
+ return filename
263
+ elif "Recording saved:" in line or "Recording discarded" in line:
264
+ break
265
+ return None
266
+ except Exception:
267
+ return None
268
+
269
+
270
+ @cli.command()
271
+ def shutdown():
272
+ """Stop all Meeting Noter processes (daemon, watcher, menubar, GUI)."""
273
+ import subprocess
274
+ import os
275
+ import signal
276
+ from meeting_noter.daemon import stop_daemon
277
+
278
+ stopped = []
279
+
280
+ # Stop daemon
281
+ if DEFAULT_PID_FILE.exists():
282
+ stop_daemon(DEFAULT_PID_FILE)
283
+ stopped.append("daemon")
284
+
285
+ # Stop watcher
286
+ if WATCHER_PID_FILE.exists():
287
+ try:
288
+ pid = int(WATCHER_PID_FILE.read_text().strip())
289
+ os.kill(pid, signal.SIGTERM)
290
+ WATCHER_PID_FILE.unlink()
291
+ stopped.append("watcher")
292
+ except (ProcessLookupError, ValueError):
293
+ WATCHER_PID_FILE.unlink(missing_ok=True)
294
+
295
+ # Stop menubar
296
+ menubar_pid_file = Path.home() / ".meeting-noter-menubar.pid"
297
+ if menubar_pid_file.exists():
298
+ try:
299
+ pid = int(menubar_pid_file.read_text().strip())
300
+ os.kill(pid, signal.SIGTERM)
301
+ menubar_pid_file.unlink()
302
+ stopped.append("menubar")
303
+ except (ProcessLookupError, ValueError):
304
+ menubar_pid_file.unlink(missing_ok=True)
305
+
306
+ # Kill any remaining meeting-noter processes
307
+ result = subprocess.run(
308
+ ["pkill", "-f", "meeting_noter"],
309
+ capture_output=True
310
+ )
311
+ if result.returncode == 0:
312
+ stopped.append("other processes")
313
+
314
+ if stopped:
315
+ click.echo(f"Stopped: {', '.join(stopped)}")
316
+ else:
317
+ click.echo("No Meeting Noter processes were running.")
318
+
319
+
320
+ @cli.command()
321
+ @click.option("--follow", "-f", is_flag=True, help="Follow log output (like tail -f)")
322
+ @click.option("--lines", "-n", default=50, help="Number of lines to show")
323
+ def logs(follow: bool, lines: int):
324
+ """View Meeting Noter logs."""
325
+ import subprocess
326
+
327
+ log_file = Path.home() / ".meeting-noter.log"
328
+
329
+ if not log_file.exists():
330
+ click.echo("No log file found.")
331
+ return
332
+
333
+ if follow:
334
+ click.echo(f"Following {log_file} (Ctrl+C to stop)...")
335
+ try:
336
+ subprocess.run(["tail", "-f", str(log_file)])
337
+ except KeyboardInterrupt:
338
+ pass
339
+ else:
340
+ subprocess.run(["tail", f"-{lines}", str(log_file)])
341
+
342
+
343
+ @cli.command("list")
344
+ @click.option(
345
+ "--output-dir", "-o",
346
+ type=click.Path(exists=True),
347
+ default=None,
348
+ help="Directory containing recordings (overrides config)",
349
+ )
350
+ @click.option(
351
+ "--limit", "-n",
352
+ type=int,
353
+ default=10,
354
+ help="Number of recordings to show",
355
+ )
356
+ @require_setup
357
+ def list_recordings(output_dir: Optional[str], limit: int):
358
+ """List recent meeting recordings."""
359
+ from meeting_noter.output.writer import list_recordings as _list_recordings
360
+
361
+ config = get_config()
362
+ path = Path(output_dir) if output_dir else config.recordings_dir
363
+ _list_recordings(path, limit)
364
+
365
+
366
+ @cli.command()
367
+ @click.argument("file", required=False)
368
+ @click.option(
369
+ "--output-dir", "-o",
370
+ type=click.Path(exists=True),
371
+ default=None,
372
+ help="Directory containing recordings (overrides config)",
373
+ )
374
+ @click.option(
375
+ "--model", "-m",
376
+ type=click.Choice(["tiny.en", "base.en", "small.en", "medium.en", "large-v3"]),
377
+ default=None,
378
+ help="Whisper model size (overrides config)",
379
+ )
380
+ @click.option(
381
+ "--live", "-l",
382
+ is_flag=True,
383
+ help="Real-time transcription of current recording",
384
+ )
385
+ @require_setup
386
+ def transcribe(file: Optional[str], output_dir: Optional[str], model: Optional[str], live: bool):
387
+ """Transcribe a meeting recording.
388
+
389
+ If no FILE is specified, transcribes the most recent recording.
390
+ Use --live for real-time transcription of an ongoing meeting.
391
+ """
392
+ from meeting_noter.transcription.engine import transcribe_file, transcribe_live
393
+
394
+ config = get_config()
395
+ output_path = Path(output_dir) if output_dir else config.recordings_dir
396
+ whisper_model = model or config.whisper_model
397
+
398
+ if live:
399
+ transcribe_live(output_path, whisper_model)
400
+ else:
401
+ transcribe_file(file, output_path, whisper_model, config.transcripts_dir)
402
+
403
+
404
+ @cli.command()
405
+ @click.option(
406
+ "--foreground", "-f",
407
+ is_flag=True,
408
+ help="Run in foreground instead of background",
409
+ )
410
+ @require_setup
411
+ def menubar(foreground: bool):
412
+ """Launch menu bar app for daemon control.
413
+
414
+ Adds a menu bar icon for one-click start/stop of the recording daemon.
415
+ The icon shows "MN" when idle and "MN [filename]" when recording.
416
+
417
+ By default, runs in background. Use -f for foreground (debugging).
418
+ """
419
+ import subprocess
420
+ import sys
421
+
422
+ if foreground:
423
+ from meeting_noter.menubar import run_menubar
424
+ run_menubar()
425
+ else:
426
+ # Spawn as background process
427
+ subprocess.Popen(
428
+ [sys.executable, "-m", "meeting_noter.menubar"],
429
+ stdout=subprocess.DEVNULL,
430
+ stderr=subprocess.DEVNULL,
431
+ start_new_session=True,
432
+ )
433
+ click.echo("Menu bar app started in background.")
434
+
435
+
436
+ @cli.command()
437
+ @click.option(
438
+ "--foreground", "-f",
439
+ is_flag=True,
440
+ help="Run in foreground instead of background",
441
+ )
442
+ @require_setup
443
+ def gui(foreground: bool):
444
+ """Launch the desktop GUI application.
445
+
446
+ Opens a window with tabs for:
447
+ - Recording: Start/stop recordings with meeting names
448
+ - Meetings: Browse, play, and manage recordings
449
+ - Settings: Configure directories, models, and preferences
450
+
451
+ By default runs in background. Use -f for foreground.
452
+ """
453
+ if foreground:
454
+ from meeting_noter.gui import run_gui
455
+ run_gui()
456
+ else:
457
+ _launch_gui_background()
458
+
459
+
460
+ # Config key mappings (CLI name -> config attribute)
461
+ CONFIG_KEYS = {
462
+ "recordings-dir": ("recordings_dir", "path", "Directory for audio recordings"),
463
+ "transcripts-dir": ("transcripts_dir", "path", "Directory for transcripts"),
464
+ "whisper-model": ("whisper_model", "choice:tiny.en,base.en,small.en,medium.en,large-v3", "Whisper model for transcription"),
465
+ "auto-transcribe": ("auto_transcribe", "bool", "Auto-transcribe after recording"),
466
+ "silence-timeout": ("silence_timeout", "int", "Minutes of silence before auto-stop"),
467
+ "capture-system-audio": ("capture_system_audio", "bool", "Capture meeting participants via ScreenCaptureKit"),
468
+ }
469
+
470
+
471
+ @cli.command()
472
+ @click.argument("key", required=False)
473
+ @click.argument("value", required=False)
474
+ def config(key: Optional[str], value: Optional[str]):
475
+ """View or set configuration options.
476
+
477
+ \b
478
+ Examples:
479
+ meeting-noter config # Show all settings
480
+ meeting-noter config recordings-dir # Get specific setting
481
+ meeting-noter config recordings-dir ~/meetings # Set setting
482
+
483
+ \b
484
+ Available settings:
485
+ recordings-dir Directory for audio recordings
486
+ transcripts-dir Directory for transcripts
487
+ whisper-model Model: tiny.en, base.en, small.en, medium.en, large-v3
488
+ auto-transcribe Auto-transcribe after recording (true/false)
489
+ silence-timeout Minutes of silence before auto-stop
490
+ capture-system-audio Capture system audio (true/false)
491
+ """
492
+ cfg = get_config()
493
+
494
+ if key is None:
495
+ # Show all settings
496
+ click.echo()
497
+ click.echo("Meeting Noter Configuration")
498
+ click.echo("=" * 40)
499
+ for cli_key, (attr, _, desc) in CONFIG_KEYS.items():
500
+ val = getattr(cfg, attr)
501
+ click.echo(f" {cli_key}: {val}")
502
+ click.echo()
503
+ click.echo(f"Config file: {cfg.config_path}")
504
+ click.echo()
505
+ return
506
+
507
+ # Normalize key (allow underscores too)
508
+ key = key.replace("_", "-").lower()
509
+
510
+ if key not in CONFIG_KEYS:
511
+ click.echo(f"Unknown config key: {key}")
512
+ click.echo(f"Available keys: {', '.join(CONFIG_KEYS.keys())}")
513
+ return
514
+
515
+ attr, val_type, desc = CONFIG_KEYS[key]
516
+
517
+ if value is None:
518
+ # Get setting
519
+ click.echo(getattr(cfg, attr))
520
+ return
521
+
522
+ # Set setting
523
+ try:
524
+ if val_type == "bool":
525
+ if value.lower() in ("true", "1", "yes", "on"):
526
+ parsed = True
527
+ elif value.lower() in ("false", "0", "no", "off"):
528
+ parsed = False
529
+ else:
530
+ raise ValueError(f"Invalid boolean: {value}")
531
+ elif val_type == "int":
532
+ parsed = int(value)
533
+ elif val_type == "path":
534
+ parsed = Path(value).expanduser()
535
+ # Create directory if it doesn't exist
536
+ parsed.mkdir(parents=True, exist_ok=True)
537
+ elif val_type.startswith("choice:"):
538
+ choices = val_type.split(":")[1].split(",")
539
+ if value not in choices:
540
+ raise ValueError(f"Must be one of: {', '.join(choices)}")
541
+ parsed = value
542
+ else:
543
+ parsed = value
544
+
545
+ setattr(cfg, attr, parsed)
546
+ cfg.save()
547
+ click.echo(f"Set {key} = {parsed}")
548
+
549
+ except ValueError as e:
550
+ click.echo(f"Invalid value: {e}")
551
+
552
+
553
+ WATCHER_PID_FILE = Path.home() / ".meeting-noter-watcher.pid"
554
+
555
+
556
+ @cli.command()
557
+ @click.option(
558
+ "--foreground", "-f",
559
+ is_flag=True,
560
+ help="Run in foreground instead of background",
561
+ )
562
+ @require_setup
563
+ def watcher(foreground: bool):
564
+ """Start background watcher that auto-detects and records meetings.
565
+
566
+ This is the default command when running 'meeting-noter' without arguments.
567
+ Runs in background by default. Use 'meeting-noter shutdown' to stop.
568
+
569
+ Use -f/--foreground for interactive mode (shows prompts in terminal).
570
+ """
571
+ import subprocess
572
+ import sys
573
+ import os
574
+
575
+ if foreground:
576
+ _run_watcher_loop()
577
+ else:
578
+ # Check if already running
579
+ if WATCHER_PID_FILE.exists():
580
+ try:
581
+ pid = int(WATCHER_PID_FILE.read_text().strip())
582
+ os.kill(pid, 0) # Check if process exists
583
+ click.echo(f"Watcher already running (PID {pid}). Use 'meeting-noter shutdown' to stop.")
584
+ return
585
+ except (ProcessLookupError, ValueError):
586
+ WATCHER_PID_FILE.unlink(missing_ok=True)
587
+
588
+ # Start in background
589
+ subprocess.Popen(
590
+ [sys.executable, "-m", "meeting_noter.cli", "watcher", "-f"],
591
+ stdout=subprocess.DEVNULL,
592
+ stderr=subprocess.DEVNULL,
593
+ start_new_session=True,
594
+ )
595
+ click.echo("Meeting Noter watcher started in background.")
596
+ click.echo("Use 'meeting-noter shutdown' to stop.")
597
+
598
+
599
+ def _run_watcher_loop():
600
+ """Run the watcher loop (foreground)."""
601
+ import time
602
+ import sys
603
+ import os
604
+ import atexit
605
+ from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title, is_meeting_app_active
606
+ from meeting_noter.daemon import is_process_running, read_pid_file, stop_daemon
607
+
608
+ # Write PID file
609
+ WATCHER_PID_FILE.write_text(str(os.getpid()))
610
+ atexit.register(lambda: WATCHER_PID_FILE.unlink(missing_ok=True))
611
+
612
+ config = get_config()
613
+ output_dir = config.recordings_dir
614
+ output_dir.mkdir(parents=True, exist_ok=True)
615
+
616
+ mic_monitor = MicrophoneMonitor()
617
+ current_meeting_name = None
618
+
619
+ try:
620
+ while True:
621
+ mic_started, mic_stopped, app_name = mic_monitor.check()
622
+
623
+ is_recording = read_pid_file(DEFAULT_PID_FILE) is not None and \
624
+ is_process_running(read_pid_file(DEFAULT_PID_FILE))
625
+
626
+ if mic_started and not is_recording:
627
+ # Meeting detected - auto-start recording silently
628
+ app_name = app_name or is_meeting_app_active() or "Unknown"
629
+ meeting_name = get_meeting_window_title() or generate_meeting_name()
630
+ current_meeting_name = meeting_name
631
+
632
+ # Start daemon
633
+ import subprocess
634
+ subprocess.Popen(
635
+ [sys.executable, "-m", "meeting_noter.cli", "daemon", "--name", meeting_name],
636
+ stdout=subprocess.DEVNULL,
637
+ stderr=subprocess.DEVNULL,
638
+ )
639
+ mic_monitor.set_recording(True, app_name)
640
+
641
+ elif mic_stopped and is_recording:
642
+ # Meeting ended - stop silently
643
+ stop_daemon(DEFAULT_PID_FILE)
644
+ mic_monitor.set_recording(False)
645
+
646
+ # Auto-transcribe
647
+ if config.auto_transcribe:
648
+ time.sleep(2)
649
+ mp3_files = sorted(output_dir.glob("*.mp3"), key=lambda p: p.stat().st_mtime)
650
+ if mp3_files:
651
+ latest = mp3_files[-1]
652
+ import subprocess
653
+ subprocess.Popen(
654
+ [sys.executable, "-m", "meeting_noter.cli", "transcribe", str(latest)],
655
+ stdout=subprocess.DEVNULL,
656
+ stderr=subprocess.DEVNULL,
657
+ )
658
+
659
+ current_meeting_name = None
660
+
661
+ time.sleep(2)
662
+
663
+ except KeyboardInterrupt:
664
+ if is_recording:
665
+ stop_daemon(DEFAULT_PID_FILE)
666
+
667
+
668
+ @cli.command()
669
+ @require_setup
670
+ def watch():
671
+ """Watch for meetings interactively (foreground with prompts).
672
+
673
+ Like 'meeting-noter' but runs in foreground and prompts before recording.
674
+ Press Ctrl+C to exit.
675
+ """
676
+ import time
677
+ import sys
678
+ from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title, is_meeting_app_active
679
+ from meeting_noter.daemon import is_process_running, read_pid_file, stop_daemon
680
+
681
+ config = get_config()
682
+ output_dir = config.recordings_dir
683
+ output_dir.mkdir(parents=True, exist_ok=True)
684
+
685
+ mic_monitor = MicrophoneMonitor()
686
+ current_meeting_name = None
687
+
688
+ click.echo("👀 Watching for meetings... (Ctrl+C to exit)")
689
+ click.echo()
690
+
691
+ try:
692
+ while True:
693
+ mic_started, mic_stopped, app_name = mic_monitor.check()
694
+
695
+ is_recording = read_pid_file(DEFAULT_PID_FILE) is not None and \
696
+ is_process_running(read_pid_file(DEFAULT_PID_FILE))
697
+
698
+ if mic_started and not is_recording:
699
+ app_name = app_name or is_meeting_app_active() or "Unknown"
700
+ meeting_name = get_meeting_window_title() or generate_meeting_name()
701
+
702
+ click.echo(f"🎤 Meeting detected: {app_name}")
703
+ click.echo(f" Name: {meeting_name}")
704
+
705
+ if click.confirm(" Start recording?", default=True):
706
+ current_meeting_name = meeting_name
707
+ click.echo(f"🔴 Recording: {meeting_name}")
708
+
709
+ import subprocess
710
+ subprocess.Popen(
711
+ [sys.executable, "-m", "meeting_noter.cli", "daemon", "--name", meeting_name],
712
+ stdout=subprocess.DEVNULL,
713
+ stderr=subprocess.DEVNULL,
714
+ )
715
+ mic_monitor.set_recording(True, app_name)
716
+ else:
717
+ click.echo(" Skipped.")
718
+ mic_monitor._was_mic_in_use = True
719
+
720
+ click.echo()
721
+
722
+ elif mic_stopped and is_recording:
723
+ click.echo(f"📴 Meeting ended: {current_meeting_name or 'Unknown'}")
724
+ stop_daemon(DEFAULT_PID_FILE)
725
+ mic_monitor.set_recording(False)
726
+ current_meeting_name = None
727
+
728
+ if config.auto_transcribe:
729
+ click.echo("📝 Auto-transcribing...")
730
+ time.sleep(2)
731
+ mp3_files = sorted(output_dir.glob("*.mp3"), key=lambda p: p.stat().st_mtime)
732
+ if mp3_files:
733
+ latest = mp3_files[-1]
734
+ click.echo(f" Transcribing: {latest.name}")
735
+ import subprocess
736
+ subprocess.Popen(
737
+ [sys.executable, "-m", "meeting_noter.cli", "transcribe", str(latest)],
738
+ stdout=subprocess.DEVNULL,
739
+ stderr=subprocess.DEVNULL,
740
+ )
741
+
742
+ click.echo()
743
+ click.echo("👀 Watching for meetings... (Ctrl+C to exit)")
744
+ click.echo()
745
+
746
+ time.sleep(2)
747
+
748
+ except KeyboardInterrupt:
749
+ click.echo()
750
+ if is_recording:
751
+ click.echo("Stopping recording...")
752
+ stop_daemon(DEFAULT_PID_FILE)
753
+ click.echo("Stopped watching.")
754
+
755
+
756
+ @cli.command("open")
757
+ @click.argument("what", type=click.Choice(["recordings", "transcripts", "config"]), default="recordings")
758
+ def open_folder(what: str):
759
+ """Open recordings, transcripts, or config folder in Finder.
760
+
761
+ \b
762
+ Examples:
763
+ meeting-noter open # Open recordings folder
764
+ meeting-noter open recordings # Open recordings folder
765
+ meeting-noter open transcripts # Open transcripts folder
766
+ meeting-noter open config # Open config folder
767
+ """
768
+ import subprocess
769
+
770
+ config = get_config()
771
+
772
+ paths = {
773
+ "recordings": config.recordings_dir,
774
+ "transcripts": config.transcripts_dir,
775
+ "config": config.config_path.parent,
776
+ }
777
+
778
+ path = paths[what]
779
+ path.mkdir(parents=True, exist_ok=True)
780
+
781
+ subprocess.run(["open", str(path)])
782
+ click.echo(f"Opened: {path}")
783
+
784
+
785
+ @cli.command()
786
+ @click.option("--shell", type=click.Choice(["zsh", "bash", "fish"]), default="zsh")
787
+ def completion(shell: str):
788
+ """Install shell tab completion.
789
+
790
+ \b
791
+ For zsh (default on macOS):
792
+ eval "$(_MEETING_NOTER_COMPLETE=zsh_source meeting-noter)"
793
+
794
+ Add to your ~/.zshrc for permanent completion.
795
+ """
796
+ import os
797
+
798
+ shell_configs = {
799
+ "zsh": ("~/.zshrc", '_MEETING_NOTER_COMPLETE=zsh_source meeting-noter'),
800
+ "bash": ("~/.bashrc", '_MEETING_NOTER_COMPLETE=bash_source meeting-noter'),
801
+ "fish": ("~/.config/fish/completions/meeting-noter.fish", '_MEETING_NOTER_COMPLETE=fish_source meeting-noter'),
802
+ }
803
+
804
+ config_file, env_cmd = shell_configs[shell]
805
+ config_path = Path(config_file).expanduser()
806
+
807
+ completion_line = f'eval "$({env_cmd})"'
808
+
809
+ # Check if already installed
810
+ if config_path.exists():
811
+ content = config_path.read_text()
812
+ if "MEETING_NOTER_COMPLETE" in content:
813
+ click.echo(f"Completion already installed in {config_file}")
814
+ return
815
+
816
+ # Install
817
+ click.echo(f"Installing {shell} completion...")
818
+
819
+ if shell == "fish":
820
+ config_path.parent.mkdir(parents=True, exist_ok=True)
821
+ import subprocess
822
+ result = subprocess.run(
823
+ ["sh", "-c", f"{env_cmd}"],
824
+ capture_output=True, text=True
825
+ )
826
+ config_path.write_text(result.stdout)
827
+ else:
828
+ with open(config_path, "a") as f:
829
+ f.write(f"\n# Meeting Noter tab completion\n{completion_line}\n")
830
+
831
+ click.echo(f"Added to {config_file}")
832
+ click.echo(f"Run: source {config_file}")
833
+ click.echo("Or restart your terminal.")
834
+
835
+
836
+ if __name__ == "__main__":
837
+ cli()