meeting-noter 1.3.0__py3-none-any.whl → 3.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.
Files changed (40) hide show
  1. meeting_noter/__init__.py +1 -1
  2. meeting_noter/cli.py +103 -0
  3. meeting_noter/daemon.py +38 -0
  4. meeting_noter/gui/__init__.py +51 -0
  5. meeting_noter/gui/main_window.py +219 -0
  6. meeting_noter/gui/menubar.py +248 -0
  7. meeting_noter/gui/screens/__init__.py +17 -0
  8. meeting_noter/gui/screens/dashboard.py +262 -0
  9. meeting_noter/gui/screens/logs.py +184 -0
  10. meeting_noter/gui/screens/recordings.py +279 -0
  11. meeting_noter/gui/screens/search.py +229 -0
  12. meeting_noter/gui/screens/settings.py +232 -0
  13. meeting_noter/gui/screens/viewer.py +140 -0
  14. meeting_noter/gui/theme/__init__.py +5 -0
  15. meeting_noter/gui/theme/dark_theme.py +53 -0
  16. meeting_noter/gui/theme/styles.qss +504 -0
  17. meeting_noter/gui/utils/__init__.py +15 -0
  18. meeting_noter/gui/utils/signals.py +82 -0
  19. meeting_noter/gui/utils/workers.py +258 -0
  20. meeting_noter/gui/widgets/__init__.py +6 -0
  21. meeting_noter/gui/widgets/sidebar.py +210 -0
  22. meeting_noter/gui/widgets/status_indicator.py +108 -0
  23. meeting_noter/mic_monitor.py +29 -1
  24. meeting_noter/ui/__init__.py +5 -0
  25. meeting_noter/ui/app.py +68 -0
  26. meeting_noter/ui/screens/__init__.py +17 -0
  27. meeting_noter/ui/screens/logs.py +166 -0
  28. meeting_noter/ui/screens/main.py +346 -0
  29. meeting_noter/ui/screens/recordings.py +241 -0
  30. meeting_noter/ui/screens/search.py +191 -0
  31. meeting_noter/ui/screens/settings.py +184 -0
  32. meeting_noter/ui/screens/viewer.py +116 -0
  33. meeting_noter/ui/styles/app.tcss +257 -0
  34. meeting_noter/ui/widgets/__init__.py +1 -0
  35. {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/METADATA +4 -1
  36. meeting_noter-3.0.0.dist-info/RECORD +65 -0
  37. meeting_noter-1.3.0.dist-info/RECORD +0 -35
  38. {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/WHEEL +0 -0
  39. {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/entry_points.txt +0 -0
  40. {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,258 @@
1
+ """Background worker threads for Meeting Noter GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from PySide6.QtCore import QThread, Signal
11
+
12
+ from meeting_noter.config import get_config
13
+ from meeting_noter.daemon import is_process_running, read_pid_file
14
+
15
+
16
+ DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
17
+ WATCHER_PID_FILE = Path.home() / ".meeting-noter-watcher.pid"
18
+ LOG_FILE = Path.home() / ".meeting-noter.log"
19
+
20
+
21
+ class StatusPollingWorker(QThread):
22
+ """Polls daemon/watcher status periodically."""
23
+
24
+ status_updated = Signal(bool, bool, str) # watcher_running, daemon_running, meeting_name
25
+
26
+ def __init__(self, interval_ms: int = 2000):
27
+ super().__init__()
28
+ self._stop_flag = False
29
+ self._interval = interval_ms
30
+
31
+ def run(self):
32
+ """Poll status until stopped."""
33
+ while not self._stop_flag:
34
+ watcher = self._check_watcher()
35
+ daemon, name = self._check_daemon()
36
+ self.status_updated.emit(watcher, daemon, name or "")
37
+ self.msleep(self._interval)
38
+
39
+ def stop(self):
40
+ """Stop the worker."""
41
+ self._stop_flag = True
42
+ self.wait()
43
+
44
+ def _check_watcher(self) -> bool:
45
+ """Check if watcher is running."""
46
+ if WATCHER_PID_FILE.exists():
47
+ try:
48
+ pid = int(WATCHER_PID_FILE.read_text().strip())
49
+ os.kill(pid, 0)
50
+ return True
51
+ except (ProcessLookupError, ValueError, FileNotFoundError, PermissionError):
52
+ pass
53
+ return False
54
+
55
+ def _check_daemon(self) -> tuple[bool, Optional[str]]:
56
+ """Check if daemon is running and get recording name."""
57
+ daemon_pid = read_pid_file(DEFAULT_PID_FILE)
58
+ if daemon_pid and is_process_running(daemon_pid):
59
+ recording_name = self._get_current_recording_name()
60
+ return True, recording_name
61
+ return False, None
62
+
63
+ def _get_current_recording_name(self) -> Optional[str]:
64
+ """Get the name of the current recording from the log file."""
65
+ if not LOG_FILE.exists():
66
+ return None
67
+
68
+ try:
69
+ with open(LOG_FILE, "r") as f:
70
+ lines = f.readlines()
71
+
72
+ for line in reversed(lines[-50:]):
73
+ if "Recording started:" in line:
74
+ parts = line.split("Recording started:")
75
+ if len(parts) > 1:
76
+ filename = parts[1].strip().replace(".mp3", "")
77
+ name_parts = filename.split("_", 2)
78
+ if len(name_parts) >= 3:
79
+ return name_parts[2]
80
+ return filename
81
+ elif "Recording saved:" in line or "Recording discarded" in line:
82
+ break
83
+ return None
84
+ except Exception:
85
+ return None
86
+
87
+
88
+ class LiveTranscriptWatcher(QThread):
89
+ """Watches live transcript file for changes."""
90
+
91
+ new_content = Signal(str) # new text content
92
+ file_changed = Signal(str) # new file name
93
+ recording_ended = Signal()
94
+
95
+ def __init__(self, interval_ms: int = 500):
96
+ super().__init__()
97
+ self._stop_flag = False
98
+ self._interval = interval_ms
99
+ self._current_file: Optional[Path] = None
100
+ self._last_content = ""
101
+ self._last_mtime = 0.0
102
+
103
+ def run(self):
104
+ """Watch for live transcript updates."""
105
+ while not self._stop_flag:
106
+ self._check_live_transcript()
107
+ self.msleep(self._interval)
108
+
109
+ def stop(self):
110
+ """Stop the worker."""
111
+ self._stop_flag = True
112
+ self.wait()
113
+
114
+ def _check_live_transcript(self) -> None:
115
+ """Check for live transcript updates."""
116
+ config = get_config()
117
+ live_dir = config.recordings_dir / "live"
118
+
119
+ if not live_dir.exists():
120
+ return
121
+
122
+ # Find the most recent live file
123
+ try:
124
+ live_files = sorted(
125
+ live_dir.glob("*.live.txt"),
126
+ key=lambda p: p.stat().st_mtime,
127
+ reverse=True,
128
+ )
129
+ except Exception:
130
+ return
131
+
132
+ if not live_files:
133
+ if self._current_file is not None:
134
+ self.recording_ended.emit()
135
+ self._current_file = None
136
+ self._last_content = ""
137
+ return
138
+
139
+ live_file = live_files[0]
140
+
141
+ # Check if file is recent (active within last 30 seconds)
142
+ try:
143
+ file_mtime = live_file.stat().st_mtime
144
+ if time.time() - file_mtime > 30:
145
+ if self._current_file is not None:
146
+ self.recording_ended.emit()
147
+ self._current_file = None
148
+ self._last_content = ""
149
+ return
150
+ except Exception:
151
+ return
152
+
153
+ # Detect new file (new recording started)
154
+ if self._current_file != live_file:
155
+ self._current_file = live_file
156
+ self._last_content = ""
157
+ self._last_mtime = file_mtime
158
+ meeting_name = live_file.stem.replace(".live", "")
159
+ self.file_changed.emit(meeting_name)
160
+
161
+ # Read and emit new content
162
+ try:
163
+ content = live_file.read_text()
164
+ if len(content) > len(self._last_content):
165
+ new_content = content[len(self._last_content) :]
166
+ self.new_content.emit(new_content)
167
+ self._last_content = content
168
+ except Exception:
169
+ pass
170
+
171
+
172
+ class TranscriptionWorker(QThread):
173
+ """Runs transcription in background."""
174
+
175
+ progress = Signal(str) # status message
176
+ finished = Signal(str) # transcript path
177
+ error = Signal(str) # error message
178
+
179
+ def __init__(self, audio_path: Path, model: str = ""):
180
+ super().__init__()
181
+ self._audio_path = audio_path
182
+ self._model = model
183
+
184
+ def run(self):
185
+ """Run transcription."""
186
+ import subprocess
187
+ import sys
188
+
189
+ try:
190
+ self.progress.emit("Starting transcription...")
191
+
192
+ cmd = [sys.executable, "-m", "meeting_noter.cli", "transcribe", str(self._audio_path)]
193
+ if self._model:
194
+ cmd.extend(["--model", self._model])
195
+
196
+ result = subprocess.run(cmd, capture_output=True, text=True)
197
+
198
+ if result.returncode == 0:
199
+ # Extract transcript path from output
200
+ output = result.stdout
201
+ for line in output.splitlines():
202
+ if "Transcript saved:" in line:
203
+ path = line.split("Transcript saved:")[-1].strip()
204
+ self.finished.emit(path)
205
+ return
206
+ self.finished.emit("")
207
+ else:
208
+ self.error.emit(result.stderr or "Transcription failed")
209
+
210
+ except Exception as e:
211
+ self.error.emit(str(e))
212
+
213
+
214
+ class LogWatcher(QThread):
215
+ """Watches log file for new content."""
216
+
217
+ new_content = Signal(str) # new log lines
218
+
219
+ def __init__(self, interval_ms: int = 1000):
220
+ super().__init__()
221
+ self._stop_flag = False
222
+ self._interval = interval_ms
223
+ self._last_size = 0
224
+
225
+ def run(self):
226
+ """Watch log file for changes."""
227
+ while not self._stop_flag:
228
+ self._check_log_file()
229
+ self.msleep(self._interval)
230
+
231
+ def stop(self):
232
+ """Stop the worker."""
233
+ self._stop_flag = True
234
+ self.wait()
235
+
236
+ def _check_log_file(self) -> None:
237
+ """Check for new log content."""
238
+ if not LOG_FILE.exists():
239
+ return
240
+
241
+ try:
242
+ current_size = LOG_FILE.stat().st_size
243
+ if current_size > self._last_size:
244
+ with open(LOG_FILE, "r") as f:
245
+ f.seek(self._last_size)
246
+ new_content = f.read()
247
+ if new_content:
248
+ self.new_content.emit(new_content)
249
+ self._last_size = current_size
250
+ elif current_size < self._last_size:
251
+ # File was truncated, reset
252
+ self._last_size = 0
253
+ except Exception:
254
+ pass
255
+
256
+ def reset(self):
257
+ """Reset the file position."""
258
+ self._last_size = 0
@@ -0,0 +1,6 @@
1
+ """Reusable widgets for Meeting Noter GUI."""
2
+
3
+ from meeting_noter.gui.widgets.sidebar import Sidebar
4
+ from meeting_noter.gui.widgets.status_indicator import StatusIndicator
5
+
6
+ __all__ = ["Sidebar", "StatusIndicator"]
@@ -0,0 +1,210 @@
1
+ """Navigation sidebar widget for Meeting Noter GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import Qt, Signal
6
+ from PySide6.QtGui import QFont
7
+ from PySide6.QtWidgets import (
8
+ QButtonGroup,
9
+ QFrame,
10
+ QLabel,
11
+ QPushButton,
12
+ QSizePolicy,
13
+ QSpacerItem,
14
+ QVBoxLayout,
15
+ QWidget,
16
+ )
17
+
18
+
19
+ class SidebarButton(QPushButton):
20
+ """A navigation button for the sidebar."""
21
+
22
+ def __init__(self, text: str, icon: str = "", parent=None):
23
+ super().__init__(parent)
24
+ display_text = f" {icon} {text}" if icon else f" {text}"
25
+ self.setText(display_text)
26
+ self.setCheckable(True)
27
+ self.setMinimumHeight(50)
28
+ self.setCursor(Qt.PointingHandCursor)
29
+
30
+
31
+ class Sidebar(QWidget):
32
+ """Navigation sidebar with icon buttons."""
33
+
34
+ screen_selected = Signal(str) # Emits screen name when clicked
35
+ quit_requested = Signal() # Emits when quit button clicked
36
+
37
+ # Navigation items with better Unicode icons
38
+ NAV_ITEMS = [
39
+ ("dashboard", "Dashboard", "⌂"), # Home
40
+ ("recordings", "Recordings", "◉"), # Record
41
+ ("search", "Search", "⌕"), # Search
42
+ ("settings", "Settings", "⚙"), # Gear
43
+ ("logs", "Logs", "☰"), # Menu/logs
44
+ ]
45
+
46
+ def __init__(self, parent=None):
47
+ super().__init__(parent)
48
+ self.setObjectName("sidebar")
49
+ self.setMinimumWidth(220)
50
+ self.setMaximumWidth(220)
51
+
52
+ self._setup_ui()
53
+ self._connect_signals()
54
+
55
+ def _setup_ui(self):
56
+ """Set up the sidebar UI."""
57
+ layout = QVBoxLayout(self)
58
+ layout.setContentsMargins(0, 0, 0, 0)
59
+ layout.setSpacing(0)
60
+
61
+ # Title/Logo area
62
+ title_frame = QFrame()
63
+ title_frame.setObjectName("sidebar-title-frame")
64
+ title_layout = QVBoxLayout(title_frame)
65
+ title_layout.setContentsMargins(20, 20, 20, 20)
66
+
67
+ title = QLabel("Meeting Noter")
68
+ title.setObjectName("sidebar-title")
69
+ title_font = QFont("Menlo, Monaco, Courier New, monospace", 18)
70
+ title_font.setBold(True)
71
+ title.setFont(title_font)
72
+ title.setAlignment(Qt.AlignCenter)
73
+ title_layout.addWidget(title)
74
+
75
+ subtitle = QLabel("v2.0")
76
+ subtitle.setObjectName("sidebar-subtitle")
77
+ subtitle.setAlignment(Qt.AlignCenter)
78
+ subtitle.setStyleSheet("color: #5a5a5a; font-size: 12px;")
79
+ title_layout.addWidget(subtitle)
80
+
81
+ layout.addWidget(title_frame)
82
+
83
+ # Separator
84
+ separator = QFrame()
85
+ separator.setFrameShape(QFrame.HLine)
86
+ separator.setStyleSheet("background-color: #3c3c3c; max-height: 1px;")
87
+ layout.addWidget(separator)
88
+
89
+ # Navigation section label
90
+ nav_label = QLabel(" NAVIGATION")
91
+ nav_label.setObjectName("sidebar-section-label")
92
+ nav_label.setStyleSheet(
93
+ "color: #5a5a5a; font-size: 11px; font-weight: 600; "
94
+ "letter-spacing: 1px; padding: 15px 0 5px 15px;"
95
+ )
96
+ layout.addWidget(nav_label)
97
+
98
+ # Navigation buttons
99
+ self._button_group = QButtonGroup(self)
100
+ self._button_group.setExclusive(True)
101
+ self._buttons: dict[str, SidebarButton] = {}
102
+
103
+ button_font = QFont("Menlo, Monaco, Courier New, monospace", 14)
104
+
105
+ for screen_id, label, icon in self.NAV_ITEMS:
106
+ btn = SidebarButton(label, icon)
107
+ btn.setFont(button_font)
108
+ btn.setProperty("screen_id", screen_id)
109
+ self._buttons[screen_id] = btn
110
+ self._button_group.addButton(btn)
111
+ layout.addWidget(btn)
112
+
113
+ # Spacer
114
+ layout.addItem(QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding))
115
+
116
+ # Status area at bottom
117
+ separator2 = QFrame()
118
+ separator2.setFrameShape(QFrame.HLine)
119
+ separator2.setStyleSheet("background-color: #3c3c3c; max-height: 1px;")
120
+ layout.addWidget(separator2)
121
+
122
+ self._status_frame = QFrame()
123
+ self._status_frame.setObjectName("sidebar-status")
124
+ status_layout = QVBoxLayout(self._status_frame)
125
+ status_layout.setContentsMargins(15, 15, 15, 15)
126
+
127
+ status_label = QLabel("STATUS")
128
+ status_label.setStyleSheet(
129
+ "color: #5a5a5a; font-size: 11px; font-weight: 600; letter-spacing: 1px;"
130
+ )
131
+ status_layout.addWidget(status_label)
132
+
133
+ self._status_text = QLabel("● Idle")
134
+ self._status_text.setObjectName("sidebar-status-text")
135
+ status_font = QFont("Menlo, Monaco, Courier New, monospace", 13)
136
+ self._status_text.setFont(status_font)
137
+ self._status_text.setStyleSheet("color: #5a5a5a; padding-top: 5px;")
138
+ status_layout.addWidget(self._status_text)
139
+
140
+ layout.addWidget(self._status_frame)
141
+
142
+ # Quit button at bottom
143
+ separator3 = QFrame()
144
+ separator3.setFrameShape(QFrame.HLine)
145
+ separator3.setStyleSheet("background-color: #3c3c3c; max-height: 1px;")
146
+ layout.addWidget(separator3)
147
+
148
+ self._quit_btn = QPushButton(" ⏻ Quit")
149
+ self._quit_btn.setFont(button_font)
150
+ self._quit_btn.setMinimumHeight(50)
151
+ self._quit_btn.setCursor(Qt.PointingHandCursor)
152
+ self._quit_btn.setStyleSheet("""
153
+ QPushButton {
154
+ background-color: transparent;
155
+ border: none;
156
+ border-radius: 6px;
157
+ padding: 12px 15px;
158
+ text-align: left;
159
+ color: #808080;
160
+ font-size: 14px;
161
+ margin: 3px 10px;
162
+ }
163
+ QPushButton:hover {
164
+ background-color: #2a1a1a;
165
+ color: #f14c4c;
166
+ }
167
+ """)
168
+ layout.addWidget(self._quit_btn)
169
+
170
+ # Bottom padding
171
+ layout.addSpacing(10)
172
+
173
+ # Select dashboard by default
174
+ self._buttons["dashboard"].setChecked(True)
175
+
176
+ def _connect_signals(self):
177
+ """Connect button signals."""
178
+ for btn in self._buttons.values():
179
+ btn.clicked.connect(self._on_button_clicked)
180
+ self._quit_btn.clicked.connect(self._on_quit_clicked)
181
+
182
+ def _on_button_clicked(self):
183
+ """Handle navigation button click."""
184
+ btn = self.sender()
185
+ if btn:
186
+ screen_id = btn.property("screen_id")
187
+ self.screen_selected.emit(screen_id)
188
+
189
+ def _on_quit_clicked(self):
190
+ """Handle quit button click."""
191
+ self.quit_requested.emit()
192
+
193
+ def select_screen(self, screen_id: str):
194
+ """Programmatically select a screen."""
195
+ if screen_id in self._buttons:
196
+ self._buttons[screen_id].setChecked(True)
197
+
198
+ def update_status(self, is_recording: bool, meeting_name: str = ""):
199
+ """Update the status display."""
200
+ if is_recording:
201
+ if meeting_name:
202
+ display_name = meeting_name[:18] + "..." if len(meeting_name) > 18 else meeting_name
203
+ status = f"● {display_name}"
204
+ else:
205
+ status = "● Recording..."
206
+ self._status_text.setStyleSheet("color: #f14c4c; padding-top: 5px;")
207
+ else:
208
+ status = "● Idle"
209
+ self._status_text.setStyleSheet("color: #5a5a5a; padding-top: 5px;")
210
+ self._status_text.setText(status)
@@ -0,0 +1,108 @@
1
+ """Status indicator widget for Meeting Noter GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import Qt
6
+ from PySide6.QtGui import QFont
7
+ from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
8
+
9
+ from meeting_noter.gui.theme.dark_theme import COLORS
10
+
11
+
12
+ class StatusIndicator(QWidget):
13
+ """Widget to display current recording status."""
14
+
15
+ def __init__(self, parent=None):
16
+ super().__init__(parent)
17
+ self.setObjectName("status-indicator")
18
+ self._setup_ui()
19
+ self._set_idle_state()
20
+
21
+ def _setup_ui(self):
22
+ """Set up the status indicator UI."""
23
+ self.setStyleSheet(
24
+ "background-color: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 8px;"
25
+ )
26
+
27
+ layout = QHBoxLayout(self)
28
+ layout.setContentsMargins(20, 20, 20, 20)
29
+ layout.setSpacing(20)
30
+
31
+ # Status dot
32
+ self._dot = QLabel("●")
33
+ self._dot.setObjectName("status-dot")
34
+ self._dot.setAlignment(Qt.AlignCenter)
35
+ dot_font = QFont("Menlo, Monaco, Courier New, monospace", 32)
36
+ self._dot.setFont(dot_font)
37
+ layout.addWidget(self._dot)
38
+
39
+ # Status text container
40
+ text_container = QWidget()
41
+ text_container.setStyleSheet("background-color: transparent; border: none;")
42
+ text_layout = QVBoxLayout(text_container)
43
+ text_layout.setContentsMargins(0, 0, 0, 0)
44
+ text_layout.setSpacing(4)
45
+
46
+ self._status_text = QLabel("Stopped")
47
+ self._status_text.setObjectName("status-text")
48
+ status_font = QFont("Menlo, Monaco, Courier New, monospace", 18)
49
+ status_font.setBold(True)
50
+ self._status_text.setFont(status_font)
51
+ text_layout.addWidget(self._status_text)
52
+
53
+ self._details_text = QLabel("Watcher: stopped | Recorder: idle")
54
+ self._details_text.setObjectName("status-details")
55
+ details_font = QFont("Menlo, Monaco, Courier New, monospace", 12)
56
+ self._details_text.setFont(details_font)
57
+ self._details_text.setStyleSheet("color: #808080; background-color: transparent;")
58
+ text_layout.addWidget(self._details_text)
59
+
60
+ layout.addWidget(text_container)
61
+ layout.addStretch()
62
+
63
+ def _set_idle_state(self):
64
+ """Set the idle/stopped state."""
65
+ self._dot.setStyleSheet(f"color: #5a5a5a; background-color: transparent;")
66
+ self._status_text.setText("Stopped")
67
+ self._status_text.setStyleSheet("color: #808080; background-color: transparent;")
68
+ self._details_text.setText("Watcher: stopped | Recorder: idle")
69
+
70
+ def update_status(
71
+ self, watcher_running: bool, daemon_running: bool, meeting_name: str = ""
72
+ ):
73
+ """Update the status display."""
74
+ if daemon_running:
75
+ # Recording state - red
76
+ self._dot.setStyleSheet("color: #f14c4c; background-color: transparent;")
77
+ self._status_text.setStyleSheet("color: #f14c4c; background-color: transparent;")
78
+ if meeting_name:
79
+ self._status_text.setText(f"Recording: {meeting_name}")
80
+ else:
81
+ self._status_text.setText("Recording...")
82
+ elif watcher_running:
83
+ # Ready state - green
84
+ self._dot.setStyleSheet("color: #4ec9b0; background-color: transparent;")
85
+ self._status_text.setStyleSheet("color: #4ec9b0; background-color: transparent;")
86
+ self._status_text.setText("Ready (watcher active)")
87
+ else:
88
+ # Stopped state - gray
89
+ self._dot.setStyleSheet("color: #5a5a5a; background-color: transparent;")
90
+ self._status_text.setStyleSheet("color: #808080; background-color: transparent;")
91
+ self._status_text.setText("Stopped")
92
+
93
+ # Update details
94
+ watcher_status = "running" if watcher_running else "stopped"
95
+ recorder_status = "recording" if daemon_running else "idle"
96
+ self._details_text.setText(f"Watcher: {watcher_status} | Recorder: {recorder_status}")
97
+
98
+ def set_recording(self, meeting_name: str = ""):
99
+ """Set recording state."""
100
+ self.update_status(False, True, meeting_name)
101
+
102
+ def set_ready(self):
103
+ """Set ready (watcher active) state."""
104
+ self.update_status(True, False)
105
+
106
+ def set_stopped(self):
107
+ """Set stopped state."""
108
+ self.update_status(False, False)
@@ -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"]