meeting-noter 1.2.0__py3-none-any.whl → 2.0.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.
meeting_noter/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Meeting Noter - Offline meeting transcription with virtual audio devices."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "2.0.0"
meeting_noter/cli.py CHANGED
@@ -262,7 +262,12 @@ def daemon(output_dir: Optional[str], foreground: bool, name: Optional[str]):
262
262
 
263
263
  @cli.command()
264
264
  def status():
265
- """Show Meeting Noter status."""
265
+ """Show Meeting Noter status.
266
+
267
+ \b
268
+ Examples:
269
+ meeting-noter status # Check if recording or watching
270
+ """
266
271
  import os
267
272
  from meeting_noter.daemon import read_pid_file, is_process_running
268
273
 
@@ -331,7 +336,12 @@ def _get_current_recording_name() -> str | None:
331
336
 
332
337
  @cli.command()
333
338
  def shutdown():
334
- """Stop all Meeting Noter processes (daemon, watcher)."""
339
+ """Stop all Meeting Noter processes (daemon, watcher).
340
+
341
+ \b
342
+ Examples:
343
+ meeting-noter shutdown # Stop recording and watcher
344
+ """
335
345
  import subprocess
336
346
  import os
337
347
  import signal
@@ -372,7 +382,14 @@ def shutdown():
372
382
  @click.option("--follow", "-f", is_flag=True, help="Follow log output (like tail -f)")
373
383
  @click.option("--lines", "-n", default=50, help="Number of lines to show")
374
384
  def logs(follow: bool, lines: int):
375
- """View Meeting Noter logs."""
385
+ """View Meeting Noter logs.
386
+
387
+ \b
388
+ Examples:
389
+ meeting-noter logs # Show last 50 lines
390
+ meeting-noter logs -n 100 # Show last 100 lines
391
+ meeting-noter logs -f # Follow log output (Ctrl+C to stop)
392
+ """
376
393
  import subprocess
377
394
 
378
395
  log_file = Path.home() / ".meeting-noter.log"
@@ -392,6 +409,11 @@ def logs(follow: bool, lines: int):
392
409
 
393
410
 
394
411
  @cli.command("list")
412
+ @click.option(
413
+ "--transcripts", "-t",
414
+ is_flag=True,
415
+ help="List transcripts instead of recordings",
416
+ )
395
417
  @click.option(
396
418
  "--output-dir", "-o",
397
419
  type=click.Path(exists=True),
@@ -402,22 +424,118 @@ def logs(follow: bool, lines: int):
402
424
  "--limit", "-n",
403
425
  type=int,
404
426
  default=10,
405
- help="Number of recordings to show",
427
+ help="Number of items to show",
428
+ )
429
+ @require_setup
430
+ def list_recordings(transcripts: bool, output_dir: Optional[str], limit: int):
431
+ """List recent meeting recordings or transcripts.
432
+
433
+ \b
434
+ Examples:
435
+ meeting-noter list # Show last 10 recordings
436
+ meeting-noter list -t # Show last 10 transcripts
437
+ meeting-noter list -t -n 20 # Show last 20 transcripts
438
+ """
439
+ config = get_config()
440
+
441
+ if transcripts:
442
+ from meeting_noter.output.favorites import list_transcripts_with_favorites
443
+ path = Path(output_dir) if output_dir else config.transcripts_dir
444
+ list_transcripts_with_favorites(path, limit)
445
+ else:
446
+ from meeting_noter.output.writer import list_recordings as _list_recordings
447
+ path = Path(output_dir) if output_dir else config.recordings_dir
448
+ _list_recordings(path, limit)
449
+
450
+
451
+ @cli.command("search")
452
+ @click.argument("query")
453
+ @click.option(
454
+ "--case-sensitive", "-c",
455
+ is_flag=True,
456
+ help="Case-sensitive search",
457
+ )
458
+ @click.option(
459
+ "--limit", "-n",
460
+ type=int,
461
+ default=20,
462
+ help="Max results to show",
463
+ )
464
+ @click.option(
465
+ "--transcripts-dir", "-d",
466
+ type=click.Path(),
467
+ default=None,
468
+ help="Override transcripts directory",
406
469
  )
407
470
  @require_setup
408
- def list_recordings(output_dir: Optional[str], limit: int):
409
- """List recent meeting recordings.
471
+ def search(query: str, case_sensitive: bool, limit: int, transcripts_dir: Optional[str]):
472
+ """Search across all meeting transcripts.
410
473
 
411
474
  \b
412
475
  Examples:
413
- meeting-noter list # Show last 10 recordings
414
- meeting-noter list -n 20 # Show last 20 recordings
476
+ meeting-noter search "action items"
477
+ meeting-noter search "API" --case-sensitive
478
+ meeting-noter search "standup" -n 5
415
479
  """
416
- from meeting_noter.output.writer import list_recordings as _list_recordings
480
+ from meeting_noter.output.searcher import search_transcripts
417
481
 
418
482
  config = get_config()
419
- path = Path(output_dir) if output_dir else config.recordings_dir
420
- _list_recordings(path, limit)
483
+ path = Path(transcripts_dir) if transcripts_dir else config.transcripts_dir
484
+ search_transcripts(path, query, case_sensitive, limit)
485
+
486
+
487
+ @cli.group("favorites", invoke_without_command=True)
488
+ @click.pass_context
489
+ @require_setup
490
+ def favorites(ctx):
491
+ """Manage favorite transcripts.
492
+
493
+ \b
494
+ Examples:
495
+ meeting-noter favorites # List all favorites
496
+ meeting-noter favorites add file.txt # Add to favorites
497
+ meeting-noter favorites add --latest # Add most recent transcript
498
+ meeting-noter favorites remove file # Remove from favorites
499
+ """
500
+ if ctx.invoked_subcommand is None:
501
+ # Default: list favorites
502
+ from meeting_noter.output.favorites import list_favorites
503
+
504
+ config = get_config()
505
+ list_favorites(config.transcripts_dir)
506
+
507
+
508
+ @favorites.command("add")
509
+ @click.argument("filename", required=False)
510
+ @click.option("--latest", "-l", is_flag=True, help="Add the most recent transcript")
511
+ @require_setup
512
+ def favorites_add(filename: Optional[str], latest: bool):
513
+ """Add a transcript to favorites.
514
+
515
+ \b
516
+ Examples:
517
+ meeting-noter favorites add meeting.txt
518
+ meeting-noter favorites add --latest
519
+ """
520
+ from meeting_noter.output.favorites import add_favorite
521
+
522
+ config = get_config()
523
+ add_favorite(config.transcripts_dir, filename, latest)
524
+
525
+
526
+ @favorites.command("remove")
527
+ @click.argument("filename")
528
+ @require_setup
529
+ def favorites_remove(filename: str):
530
+ """Remove a transcript from favorites.
531
+
532
+ \b
533
+ Examples:
534
+ meeting-noter favorites remove meeting.txt
535
+ """
536
+ from meeting_noter.output.favorites import remove_favorite
537
+
538
+ remove_favorite(filename)
421
539
 
422
540
 
423
541
  @cli.command()
@@ -686,6 +804,25 @@ def watcher(foreground: bool):
686
804
  click.echo("Use 'meeting-noter shutdown' to stop.")
687
805
 
688
806
 
807
+ def _disable_app_nap():
808
+ """Disable App Nap for this process to prevent macOS from throttling it.
809
+
810
+ App Nap can suspend background processes, causing meeting detection to fail
811
+ after long idle periods.
812
+ """
813
+ try:
814
+ from Foundation import NSProcessInfo
815
+ info = NSProcessInfo.processInfo()
816
+ # Disable App Nap and sudden termination
817
+ # NSActivityUserInitiated keeps the process responsive
818
+ info.beginActivityWithOptions_reason_(
819
+ 0x00FFFFFF, # NSActivityUserInitiatedAllowingIdleSystemSleep
820
+ "Meeting detection watcher"
821
+ )
822
+ except Exception:
823
+ pass # Not critical if this fails
824
+
825
+
689
826
  def _run_watcher_loop():
690
827
  """Run the watcher loop (foreground)."""
691
828
  import time
@@ -695,6 +832,9 @@ def _run_watcher_loop():
695
832
  from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title, is_meeting_app_active
696
833
  from meeting_noter.daemon import is_process_running, read_pid_file, stop_daemon
697
834
 
835
+ # Disable App Nap to prevent macOS from throttling this process
836
+ _disable_app_nap()
837
+
698
838
  # Write PID file
699
839
  WATCHER_PID_FILE.write_text(str(os.getpid()))
700
840
  atexit.register(lambda: WATCHER_PID_FILE.unlink(missing_ok=True))
@@ -706,8 +846,23 @@ def _run_watcher_loop():
706
846
  mic_monitor = MicrophoneMonitor()
707
847
  current_meeting_name = None
708
848
 
849
+ # Heartbeat tracking - log every 30 minutes to confirm watcher is alive
850
+ last_heartbeat = time.time()
851
+ heartbeat_interval = 30 * 60 # 30 minutes
852
+
709
853
  try:
710
854
  while True:
855
+ # Periodic heartbeat to confirm watcher is running
856
+ now = time.time()
857
+ if now - last_heartbeat >= heartbeat_interval:
858
+ # Write to log file so user can verify watcher is alive
859
+ log_path = Path.home() / ".meeting-noter.log"
860
+ with open(log_path, "a") as f:
861
+ from datetime import datetime
862
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
863
+ f.write(f"{timestamp} Watcher heartbeat - still monitoring for meetings\n")
864
+ last_heartbeat = now
865
+
711
866
  mic_started, mic_stopped, app_name = mic_monitor.check()
712
867
 
713
868
  is_recording = read_pid_file(DEFAULT_PID_FILE) is not None and \
@@ -872,6 +1027,30 @@ def open_folder(what: str):
872
1027
  click.echo(f"Opened: {path}")
873
1028
 
874
1029
 
1030
+ @cli.command()
1031
+ def ui():
1032
+ """Launch the Terminal User Interface.
1033
+
1034
+ Opens an interactive TUI for managing recordings, searching transcripts,
1035
+ and configuring settings.
1036
+
1037
+ \b
1038
+ Examples:
1039
+ meeting-noter ui # Launch the TUI
1040
+
1041
+ \b
1042
+ Keyboard shortcuts:
1043
+ 1-5 Switch between screens
1044
+ q Quit
1045
+ ? Help
1046
+ r Start/stop recording (on dashboard)
1047
+ """
1048
+ from meeting_noter.ui import MeetingNoterApp
1049
+
1050
+ app = MeetingNoterApp()
1051
+ app.run()
1052
+
1053
+
875
1054
  @cli.command()
876
1055
  @click.option("--shell", type=click.Choice(["zsh", "bash", "fish"]), default="zsh")
877
1056
  def completion(shell: str):
meeting_noter/config.py CHANGED
@@ -160,6 +160,40 @@ class Config:
160
160
  """Set setup completion status."""
161
161
  self._data["setup_complete"] = value
162
162
 
163
+ @property
164
+ def favorites(self) -> list[str]:
165
+ """Get list of favorite transcript filenames."""
166
+ return self._data.get("favorites", [])
167
+
168
+ @favorites.setter
169
+ def favorites(self, value: list[str]) -> None:
170
+ """Set list of favorite transcript filenames."""
171
+ self._data["favorites"] = value
172
+
173
+ def add_favorite(self, filename: str) -> bool:
174
+ """Add a transcript to favorites. Returns True if added, False if already exists."""
175
+ favorites = self.favorites
176
+ if filename not in favorites:
177
+ favorites.append(filename)
178
+ self.favorites = favorites
179
+ self.save()
180
+ return True
181
+ return False
182
+
183
+ def remove_favorite(self, filename: str) -> bool:
184
+ """Remove a transcript from favorites. Returns True if removed, False if not found."""
185
+ favorites = self.favorites
186
+ if filename in favorites:
187
+ favorites.remove(filename)
188
+ self.favorites = favorites
189
+ self.save()
190
+ return True
191
+ return False
192
+
193
+ def is_favorite(self, filename: str) -> bool:
194
+ """Check if a transcript is a favorite."""
195
+ return filename in self.favorites
196
+
163
197
  def __getitem__(self, key: str) -> Any:
164
198
  """Get config value by key."""
165
199
  return self._data.get(key, DEFAULT_CONFIG.get(key))
meeting_noter/daemon.py CHANGED
@@ -174,6 +174,7 @@ def _run_capture_loop(
174
174
  from meeting_noter.audio.encoder import RecordingSession
175
175
 
176
176
  config = get_config()
177
+ saved_filepath = None # Track saved file for auto-transcription
177
178
 
178
179
  # Live transcription (imported here to avoid loading Whisper before fork)
179
180
  live_transcriber = None
@@ -306,6 +307,23 @@ def _run_capture_loop(
306
307
  filepath, duration = session.stop()
307
308
  if filepath:
308
309
  print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
310
+ saved_filepath = filepath
311
+ # Auto-transcribe if enabled
312
+ if config.auto_transcribe:
313
+ print("Auto-transcribing...")
314
+ sys.stdout.flush()
315
+ try:
316
+ from meeting_noter.transcription.engine import transcribe_file
317
+ transcribe_file(
318
+ str(filepath),
319
+ output_dir,
320
+ config.whisper_model,
321
+ config.transcripts_dir,
322
+ )
323
+ print("Transcription complete.")
324
+ except Exception as e:
325
+ print(f"Transcription error: {e}")
326
+ sys.stdout.flush()
309
327
  else:
310
328
  print("Recording discarded (too short)")
311
329
  silence_detector.reset()
@@ -313,6 +331,8 @@ def _run_capture_loop(
313
331
 
314
332
  except Exception as e:
315
333
  print(f"Error in capture loop: {e}")
334
+ import sys
335
+ sys.stdout.flush()
316
336
  finally:
317
337
  capture.stop()
318
338
 
@@ -325,6 +345,24 @@ def _run_capture_loop(
325
345
  filepath, duration = session.stop()
326
346
  if filepath:
327
347
  print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
348
+ saved_filepath = filepath
349
+ # Auto-transcribe if enabled
350
+ if config.auto_transcribe:
351
+ print("Auto-transcribing...")
352
+ import sys
353
+ sys.stdout.flush()
354
+ try:
355
+ from meeting_noter.transcription.engine import transcribe_file
356
+ transcribe_file(
357
+ str(filepath),
358
+ output_dir,
359
+ config.whisper_model,
360
+ config.transcripts_dir,
361
+ )
362
+ print("Transcription complete.")
363
+ except Exception as e:
364
+ print(f"Transcription error: {e}")
365
+ sys.stdout.flush()
328
366
 
329
367
  print("Daemon stopped.")
330
368
 
@@ -36,11 +36,21 @@ class _AudioObjectPropertyAddress(Structure):
36
36
  _core_audio = None
37
37
  _AudioObjectGetPropertyDataSize = None
38
38
  _AudioObjectGetPropertyData = None
39
+ _coreaudio_init_time = 0 # Track when CoreAudio was initialized
40
+
41
+
42
+ def _reset_coreaudio():
43
+ """Reset CoreAudio framework (for reinitializing after long idle)."""
44
+ global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData, _coreaudio_init_time
45
+ _core_audio = None
46
+ _AudioObjectGetPropertyDataSize = None
47
+ _AudioObjectGetPropertyData = None
48
+ _coreaudio_init_time = 0
39
49
 
40
50
 
41
51
  def _init_coreaudio():
42
52
  """Initialize CoreAudio framework."""
43
- global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData
53
+ global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData, _coreaudio_init_time
44
54
 
45
55
  if _core_audio is not None:
46
56
  return True
@@ -60,11 +70,26 @@ def _init_coreaudio():
60
70
  ]
61
71
  _AudioObjectGetPropertyData.restype = c_int32
62
72
 
73
+ _coreaudio_init_time = time.time()
63
74
  return True
64
75
  except Exception:
65
76
  return False
66
77
 
67
78
 
79
+ # Reinitialize CoreAudio every 30 minutes to prevent stale handles
80
+ _COREAUDIO_REFRESH_INTERVAL = 30 * 60
81
+
82
+
83
+ def _maybe_refresh_coreaudio():
84
+ """Reinitialize CoreAudio if it's been too long since last init.
85
+
86
+ This prevents stale CoreAudio handles after system sleep or long idle.
87
+ """
88
+ global _coreaudio_init_time
89
+ if _core_audio is not None and time.time() - _coreaudio_init_time > _COREAUDIO_REFRESH_INTERVAL:
90
+ _reset_coreaudio()
91
+
92
+
68
93
  def is_mic_in_use_by_another_app() -> bool:
69
94
  """Check if the microphone is being used by another application.
70
95
 
@@ -74,6 +99,9 @@ def is_mic_in_use_by_another_app() -> bool:
74
99
  Returns:
75
100
  True if another app is using the microphone
76
101
  """
102
+ # Refresh CoreAudio if it's been too long (prevents stale handles)
103
+ _maybe_refresh_coreaudio()
104
+
77
105
  if not _init_coreaudio():
78
106
  return False
79
107
 
@@ -0,0 +1,189 @@
1
+ """Favorites management for meeting transcripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+ from meeting_noter.config import get_config
11
+
12
+
13
+ def list_favorites(transcripts_dir: Path) -> None:
14
+ """List all favorite transcripts.
15
+
16
+ Args:
17
+ transcripts_dir: Directory containing transcript files
18
+ """
19
+ config = get_config()
20
+ favorites = config.favorites
21
+
22
+ if not favorites:
23
+ click.echo(click.style("No favorites yet.", fg="yellow"))
24
+ click.echo("\nAdd favorites with: meeting-noter favorites add <filename>")
25
+ click.echo("Or use: meeting-noter favorites add --latest")
26
+ return
27
+
28
+ click.echo()
29
+ click.echo(click.style("Favorite Transcripts", bold=True))
30
+ click.echo("=" * 50)
31
+ click.echo()
32
+
33
+ found_count = 0
34
+ missing_count = 0
35
+
36
+ for idx, filename in enumerate(favorites, 1):
37
+ filepath = transcripts_dir / filename
38
+ if filepath.exists():
39
+ found_count += 1
40
+ # Get file info
41
+ stat = filepath.stat()
42
+ mod_time = datetime.fromtimestamp(stat.st_mtime)
43
+ date_str = mod_time.strftime("%Y-%m-%d %H:%M")
44
+
45
+ # Display: index, filename prominently, then date
46
+ click.echo(
47
+ click.style(f" {idx}. ", fg="cyan")
48
+ + click.style(filename, fg="green", bold=True)
49
+ )
50
+ click.echo(f" ★ {date_str}")
51
+ else:
52
+ missing_count += 1
53
+ click.echo(
54
+ click.style(f" {idx}. ", fg="cyan")
55
+ + click.style(filename, fg="red", strikethrough=True)
56
+ )
57
+ click.echo(click.style(" (file not found)", fg="red"))
58
+
59
+ click.echo()
60
+ click.echo(f"Total: {found_count} favorites")
61
+ if missing_count > 0:
62
+ click.echo(
63
+ click.style(
64
+ f"Warning: {missing_count} favorite(s) no longer exist",
65
+ fg="yellow"
66
+ )
67
+ )
68
+ click.echo()
69
+
70
+
71
+ def add_favorite(
72
+ transcripts_dir: Path,
73
+ filename: Optional[str] = None,
74
+ latest: bool = False
75
+ ) -> None:
76
+ """Add a transcript to favorites.
77
+
78
+ Args:
79
+ transcripts_dir: Directory containing transcript files
80
+ filename: Name of transcript file to add
81
+ latest: If True, add the most recent transcript
82
+ """
83
+ config = get_config()
84
+
85
+ if latest:
86
+ # Find the most recent transcript
87
+ txt_files = sorted(
88
+ transcripts_dir.glob("*.txt"),
89
+ key=lambda p: p.stat().st_mtime,
90
+ reverse=True,
91
+ )
92
+ if not txt_files:
93
+ click.echo(click.style("No transcripts found.", fg="red"))
94
+ return
95
+ filename = txt_files[0].name
96
+
97
+ if not filename:
98
+ click.echo(click.style("Error: Specify a filename or use --latest", fg="red"))
99
+ return
100
+
101
+ # Check if file exists
102
+ filepath = transcripts_dir / filename
103
+ if not filepath.exists():
104
+ # Try adding .txt extension
105
+ if not filename.endswith(".txt"):
106
+ filepath = transcripts_dir / f"{filename}.txt"
107
+ if filepath.exists():
108
+ filename = f"{filename}.txt"
109
+ else:
110
+ click.echo(click.style(f"File not found: {filename}", fg="red"))
111
+ click.echo(f"Looking in: {transcripts_dir}")
112
+ return
113
+ else:
114
+ click.echo(click.style(f"File not found: {filename}", fg="red"))
115
+ click.echo(f"Looking in: {transcripts_dir}")
116
+ return
117
+
118
+ if config.add_favorite(filename):
119
+ click.echo(
120
+ click.style("★ ", fg="yellow")
121
+ + f"Added to favorites: "
122
+ + click.style(filename, fg="green")
123
+ )
124
+ else:
125
+ click.echo(click.style(f"Already a favorite: {filename}", fg="yellow"))
126
+
127
+
128
+ def remove_favorite(filename: str) -> None:
129
+ """Remove a transcript from favorites.
130
+
131
+ Args:
132
+ filename: Name of transcript file to remove
133
+ """
134
+ config = get_config()
135
+
136
+ # Handle with or without .txt extension
137
+ if not filename.endswith(".txt"):
138
+ if f"{filename}.txt" in config.favorites:
139
+ filename = f"{filename}.txt"
140
+
141
+ if config.remove_favorite(filename):
142
+ click.echo(
143
+ click.style("☆ ", fg="cyan")
144
+ + f"Removed from favorites: "
145
+ + click.style(filename, fg="green")
146
+ )
147
+ else:
148
+ click.echo(click.style(f"Not a favorite: {filename}", fg="yellow"))
149
+
150
+
151
+ def list_transcripts_with_favorites(transcripts_dir: Path, limit: int = 10) -> None:
152
+ """List transcripts with favorite status indicated.
153
+
154
+ Args:
155
+ transcripts_dir: Directory containing transcript files
156
+ limit: Maximum number of transcripts to show
157
+ """
158
+ config = get_config()
159
+
160
+ if not transcripts_dir.exists():
161
+ click.echo(click.style(f"Directory not found: {transcripts_dir}", fg="red"))
162
+ return
163
+
164
+ txt_files = sorted(
165
+ transcripts_dir.glob("*.txt"),
166
+ key=lambda p: p.stat().st_mtime,
167
+ reverse=True,
168
+ )
169
+
170
+ if not txt_files:
171
+ click.echo(click.style("No transcripts found.", fg="yellow"))
172
+ return
173
+
174
+ click.echo(f"\nTranscripts in {transcripts_dir}:\n")
175
+
176
+ for txt_file in txt_files[:limit]:
177
+ stat = txt_file.stat()
178
+ mod_time = datetime.fromtimestamp(stat.st_mtime)
179
+ date_str = mod_time.strftime("%Y-%m-%d %H:%M")
180
+
181
+ is_fav = config.is_favorite(txt_file.name)
182
+ star = click.style("★ ", fg="yellow") if is_fav else " "
183
+
184
+ click.echo(f"{star}{date_str} {txt_file.name}")
185
+
186
+ if len(txt_files) > limit:
187
+ click.echo(f"\n ... and {len(txt_files) - limit} more")
188
+
189
+ click.echo(f"\nTotal: {len(txt_files)} transcripts")