meeting-noter 1.3.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.

Potentially problematic release.


This version of meeting-noter might be problematic. Click here for more details.

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.3.0"
3
+ __version__ = "2.0.0"
meeting_noter/cli.py CHANGED
@@ -804,6 +804,25 @@ def watcher(foreground: bool):
804
804
  click.echo("Use 'meeting-noter shutdown' to stop.")
805
805
 
806
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
+
807
826
  def _run_watcher_loop():
808
827
  """Run the watcher loop (foreground)."""
809
828
  import time
@@ -813,6 +832,9 @@ def _run_watcher_loop():
813
832
  from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title, is_meeting_app_active
814
833
  from meeting_noter.daemon import is_process_running, read_pid_file, stop_daemon
815
834
 
835
+ # Disable App Nap to prevent macOS from throttling this process
836
+ _disable_app_nap()
837
+
816
838
  # Write PID file
817
839
  WATCHER_PID_FILE.write_text(str(os.getpid()))
818
840
  atexit.register(lambda: WATCHER_PID_FILE.unlink(missing_ok=True))
@@ -824,8 +846,23 @@ def _run_watcher_loop():
824
846
  mic_monitor = MicrophoneMonitor()
825
847
  current_meeting_name = None
826
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
+
827
853
  try:
828
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
+
829
866
  mic_started, mic_stopped, app_name = mic_monitor.check()
830
867
 
831
868
  is_recording = read_pid_file(DEFAULT_PID_FILE) is not None and \
@@ -990,6 +1027,30 @@ def open_folder(what: str):
990
1027
  click.echo(f"Opened: {path}")
991
1028
 
992
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
+
993
1054
  @cli.command()
994
1055
  @click.option("--shell", type=click.Choice(["zsh", "bash", "fish"]), default="zsh")
995
1056
  def completion(shell: str):
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,5 @@
1
+ """Terminal User Interface for Meeting Noter."""
2
+
3
+ from meeting_noter.ui.app import MeetingNoterApp
4
+
5
+ __all__ = ["MeetingNoterApp"]
@@ -0,0 +1,68 @@
1
+ """Main Textual application for Meeting Noter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.widgets import Footer, Header
8
+
9
+ from meeting_noter.ui.screens.main import MainScreen
10
+ from meeting_noter.ui.screens.recordings import RecordingsScreen
11
+ from meeting_noter.ui.screens.search import SearchScreen
12
+ from meeting_noter.ui.screens.settings import SettingsScreen
13
+ from meeting_noter.ui.screens.logs import LogsScreen
14
+
15
+
16
+ class MeetingNoterApp(App):
17
+ """Meeting Noter Terminal UI Application."""
18
+
19
+ TITLE = "Meeting Noter"
20
+ CSS_PATH = "styles/app.tcss"
21
+
22
+ BINDINGS = [
23
+ Binding("q", "quit", "Quit"),
24
+ Binding("question_mark", "help", "Help", key_display="?"),
25
+ Binding("1", "switch_screen('main')", "Dashboard", show=True),
26
+ Binding("2", "switch_screen('recordings')", "Recordings", show=True),
27
+ Binding("3", "switch_screen('search')", "Search", show=True),
28
+ Binding("4", "switch_screen('settings')", "Settings", show=True),
29
+ Binding("5", "switch_screen('logs')", "Logs", show=True),
30
+ Binding("escape", "go_back", "Back", show=False),
31
+ ]
32
+
33
+ SCREENS = {
34
+ "main": MainScreen,
35
+ "recordings": RecordingsScreen,
36
+ "search": SearchScreen,
37
+ "settings": SettingsScreen,
38
+ "logs": LogsScreen,
39
+ }
40
+
41
+ def compose(self) -> ComposeResult:
42
+ """Compose the app layout."""
43
+ yield Header()
44
+ yield Footer()
45
+
46
+ def on_mount(self) -> None:
47
+ """Handle app mount - show the main screen."""
48
+ self.push_screen("main")
49
+
50
+ def action_switch_screen(self, screen_name: str) -> None:
51
+ """Switch to a named screen."""
52
+ # Pop all screens except the base and push the new one
53
+ while len(self.screen_stack) > 1:
54
+ self.pop_screen()
55
+ self.push_screen(screen_name)
56
+
57
+ def action_go_back(self) -> None:
58
+ """Go back to the previous screen."""
59
+ if len(self.screen_stack) > 1:
60
+ self.pop_screen()
61
+
62
+ def action_help(self) -> None:
63
+ """Show help information."""
64
+ self.notify(
65
+ "Keys: 1-5 switch screens, q quit, ? help",
66
+ title="Meeting Noter Help",
67
+ timeout=5,
68
+ )
@@ -0,0 +1,17 @@
1
+ """UI Screens for Meeting Noter."""
2
+
3
+ from meeting_noter.ui.screens.main import MainScreen
4
+ from meeting_noter.ui.screens.recordings import RecordingsScreen
5
+ from meeting_noter.ui.screens.search import SearchScreen
6
+ from meeting_noter.ui.screens.viewer import ViewerScreen
7
+ from meeting_noter.ui.screens.settings import SettingsScreen
8
+ from meeting_noter.ui.screens.logs import LogsScreen
9
+
10
+ __all__ = [
11
+ "MainScreen",
12
+ "RecordingsScreen",
13
+ "SearchScreen",
14
+ "ViewerScreen",
15
+ "SettingsScreen",
16
+ "LogsScreen",
17
+ ]
@@ -0,0 +1,166 @@
1
+ """Logs viewer screen for Meeting Noter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Container, Horizontal
9
+ from textual.screen import Screen
10
+ from textual.widgets import Button, Label, RichLog, Static, Switch
11
+
12
+
13
+ LOG_FILE = Path.home() / ".meeting-noter.log"
14
+
15
+
16
+ class LogsScreen(Screen):
17
+ """Logs viewer screen."""
18
+
19
+ BINDINGS = [
20
+ ("l", "toggle_follow", "Follow"),
21
+ ("c", "clear_display", "Clear"),
22
+ ("r", "refresh", "Refresh"),
23
+ ("escape", "go_back", "Back"),
24
+ ]
25
+
26
+ def __init__(self, *args, **kwargs):
27
+ super().__init__(*args, **kwargs)
28
+ self._follow_mode = True
29
+ self._last_size = 0
30
+
31
+ def compose(self) -> ComposeResult:
32
+ """Compose the logs screen layout."""
33
+ yield Container(
34
+ Static("[bold]Logs[/bold]", classes="title"),
35
+ Label(f"[dim]{LOG_FILE}[/dim]", id="log-path"),
36
+ Static("", classes="spacer"),
37
+ RichLog(id="log-content", highlight=True, markup=True),
38
+ Static("", classes="spacer"),
39
+ Horizontal(
40
+ Label("Follow: "),
41
+ Switch(value=True, id="follow-switch"),
42
+ Button("Refresh", id="refresh-btn"),
43
+ Button("Clear", id="clear-btn"),
44
+ Button("Back", id="back-btn"),
45
+ classes="button-row",
46
+ ),
47
+ id="logs-container",
48
+ )
49
+
50
+ def on_mount(self) -> None:
51
+ """Start log refresh on mount."""
52
+ self._load_logs()
53
+ self.set_interval(1.0, self._check_for_updates)
54
+
55
+ def _load_logs(self, lines: int = 100) -> None:
56
+ """Load the last N lines from the log file."""
57
+ log_content = self.query_one("#log-content", RichLog)
58
+
59
+ if not LOG_FILE.exists():
60
+ log_content.write("[yellow]No log file found[/yellow]")
61
+ return
62
+
63
+ try:
64
+ content = LOG_FILE.read_text()
65
+ all_lines = content.splitlines()
66
+ recent_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
67
+
68
+ log_content.clear()
69
+ for line in recent_lines:
70
+ # Color-code different log levels
71
+ if "Error" in line or "error" in line:
72
+ log_content.write(f"[red]{line}[/red]")
73
+ elif "Warning" in line or "warning" in line:
74
+ log_content.write(f"[yellow]{line}[/yellow]")
75
+ elif "Recording started" in line:
76
+ log_content.write(f"[green]{line}[/green]")
77
+ elif "Recording saved" in line:
78
+ log_content.write(f"[cyan]{line}[/cyan]")
79
+ else:
80
+ log_content.write(line)
81
+
82
+ self._last_size = LOG_FILE.stat().st_size
83
+
84
+ except Exception as e:
85
+ log_content.write(f"[red]Error reading log: {e}[/red]")
86
+
87
+ def _check_for_updates(self) -> None:
88
+ """Check for new log content if in follow mode."""
89
+ if not self._follow_mode:
90
+ return
91
+
92
+ follow_switch = self.query_one("#follow-switch", Switch)
93
+ self._follow_mode = follow_switch.value
94
+
95
+ if not self._follow_mode:
96
+ return
97
+
98
+ if not LOG_FILE.exists():
99
+ return
100
+
101
+ try:
102
+ current_size = LOG_FILE.stat().st_size
103
+ if current_size > self._last_size:
104
+ # Read new content
105
+ with open(LOG_FILE, "r") as f:
106
+ f.seek(self._last_size)
107
+ new_content = f.read()
108
+
109
+ log_content = self.query_one("#log-content", RichLog)
110
+ for line in new_content.splitlines():
111
+ if line.strip():
112
+ if "Error" in line or "error" in line:
113
+ log_content.write(f"[red]{line}[/red]")
114
+ elif "Warning" in line or "warning" in line:
115
+ log_content.write(f"[yellow]{line}[/yellow]")
116
+ elif "Recording started" in line:
117
+ log_content.write(f"[green]{line}[/green]")
118
+ elif "Recording saved" in line:
119
+ log_content.write(f"[cyan]{line}[/cyan]")
120
+ else:
121
+ log_content.write(line)
122
+
123
+ self._last_size = current_size
124
+
125
+ except Exception:
126
+ pass
127
+
128
+ def on_button_pressed(self, event: Button.Pressed) -> None:
129
+ """Handle button presses."""
130
+ button_id = event.button.id
131
+
132
+ if button_id == "refresh-btn":
133
+ self.action_refresh()
134
+ elif button_id == "clear-btn":
135
+ self.action_clear_display()
136
+ elif button_id == "back-btn":
137
+ self.action_go_back()
138
+
139
+ def on_switch_changed(self, event: Switch.Changed) -> None:
140
+ """Handle follow switch changes."""
141
+ if event.switch.id == "follow-switch":
142
+ self._follow_mode = event.value
143
+ if event.value:
144
+ self.notify("Follow mode enabled")
145
+ else:
146
+ self.notify("Follow mode disabled")
147
+
148
+ def action_toggle_follow(self) -> None:
149
+ """Toggle follow mode."""
150
+ switch = self.query_one("#follow-switch", Switch)
151
+ switch.value = not switch.value
152
+
153
+ def action_clear_display(self) -> None:
154
+ """Clear the log display."""
155
+ log_content = self.query_one("#log-content", RichLog)
156
+ log_content.clear()
157
+ self.notify("Display cleared")
158
+
159
+ def action_refresh(self) -> None:
160
+ """Refresh the log display."""
161
+ self._load_logs()
162
+ self.notify("Refreshed")
163
+
164
+ def action_go_back(self) -> None:
165
+ """Go back to the previous screen."""
166
+ self.app.pop_screen()