meeting-noter 1.1.0__py3-none-any.whl → 1.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.
- meeting_noter/__init__.py +1 -1
- meeting_noter/cli.py +131 -107
- meeting_noter/config.py +34 -0
- meeting_noter/output/favorites.py +189 -0
- meeting_noter/output/searcher.py +218 -0
- meeting_noter/transcription/live_transcription.py +17 -13
- {meeting_noter-1.1.0.dist-info → meeting_noter-1.3.0.dist-info}/METADATA +1 -3
- {meeting_noter-1.1.0.dist-info → meeting_noter-1.3.0.dist-info}/RECORD +11 -17
- meeting_noter/gui/__init__.py +0 -5
- meeting_noter/gui/__main__.py +0 -6
- meeting_noter/gui/app.py +0 -53
- meeting_noter/gui/main_window.py +0 -50
- meeting_noter/gui/meetings_tab.py +0 -348
- meeting_noter/gui/recording_tab.py +0 -358
- meeting_noter/gui/settings_tab.py +0 -249
- meeting_noter/menubar.py +0 -411
- {meeting_noter-1.1.0.dist-info → meeting_noter-1.3.0.dist-info}/WHEEL +0 -0
- {meeting_noter-1.1.0.dist-info → meeting_noter-1.3.0.dist-info}/entry_points.txt +0 -0
- {meeting_noter-1.1.0.dist-info → meeting_noter-1.3.0.dist-info}/top_level.txt +0 -0
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
"""Settings tab for configuring Meeting Noter."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import subprocess
|
|
6
|
-
import sys
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
from PyQt6.QtCore import pyqtSignal
|
|
10
|
-
from PyQt6.QtWidgets import (
|
|
11
|
-
QWidget,
|
|
12
|
-
QVBoxLayout,
|
|
13
|
-
QHBoxLayout,
|
|
14
|
-
QFormLayout,
|
|
15
|
-
QLabel,
|
|
16
|
-
QLineEdit,
|
|
17
|
-
QPushButton,
|
|
18
|
-
QComboBox,
|
|
19
|
-
QCheckBox,
|
|
20
|
-
QGroupBox,
|
|
21
|
-
QFileDialog,
|
|
22
|
-
QMessageBox,
|
|
23
|
-
QSpinBox,
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
from meeting_noter.config import get_config
|
|
27
|
-
from meeting_noter.daemon import read_pid_file, is_process_running
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class SettingsTab(QWidget):
|
|
31
|
-
"""Tab for configuring application settings."""
|
|
32
|
-
|
|
33
|
-
settings_saved = pyqtSignal()
|
|
34
|
-
|
|
35
|
-
def __init__(self):
|
|
36
|
-
super().__init__()
|
|
37
|
-
self.config = get_config()
|
|
38
|
-
self._setup_ui()
|
|
39
|
-
self._load_settings()
|
|
40
|
-
|
|
41
|
-
def _setup_ui(self):
|
|
42
|
-
"""Set up the user interface."""
|
|
43
|
-
layout = QVBoxLayout(self)
|
|
44
|
-
layout.setSpacing(16)
|
|
45
|
-
|
|
46
|
-
# Directories group
|
|
47
|
-
dirs_group = QGroupBox("Directories")
|
|
48
|
-
dirs_layout = QFormLayout(dirs_group)
|
|
49
|
-
|
|
50
|
-
# Recordings directory
|
|
51
|
-
recordings_layout = QHBoxLayout()
|
|
52
|
-
self.recordings_input = QLineEdit()
|
|
53
|
-
self.recordings_input.setReadOnly(True)
|
|
54
|
-
recordings_layout.addWidget(self.recordings_input)
|
|
55
|
-
|
|
56
|
-
recordings_browse = QPushButton("Browse...")
|
|
57
|
-
recordings_browse.clicked.connect(self._browse_recordings_dir)
|
|
58
|
-
recordings_layout.addWidget(recordings_browse)
|
|
59
|
-
|
|
60
|
-
dirs_layout.addRow("Recordings:", recordings_layout)
|
|
61
|
-
|
|
62
|
-
# Transcripts directory
|
|
63
|
-
transcripts_layout = QHBoxLayout()
|
|
64
|
-
self.transcripts_input = QLineEdit()
|
|
65
|
-
self.transcripts_input.setReadOnly(True)
|
|
66
|
-
transcripts_layout.addWidget(self.transcripts_input)
|
|
67
|
-
|
|
68
|
-
transcripts_browse = QPushButton("Browse...")
|
|
69
|
-
transcripts_browse.clicked.connect(self._browse_transcripts_dir)
|
|
70
|
-
transcripts_layout.addWidget(transcripts_browse)
|
|
71
|
-
|
|
72
|
-
dirs_layout.addRow("Transcripts:", transcripts_layout)
|
|
73
|
-
|
|
74
|
-
layout.addWidget(dirs_group)
|
|
75
|
-
|
|
76
|
-
# Transcription group
|
|
77
|
-
transcription_group = QGroupBox("Transcription")
|
|
78
|
-
transcription_layout = QFormLayout(transcription_group)
|
|
79
|
-
|
|
80
|
-
# Whisper model
|
|
81
|
-
self.model_combo = QComboBox()
|
|
82
|
-
self.model_combo.addItems([
|
|
83
|
-
"tiny.en",
|
|
84
|
-
"base.en",
|
|
85
|
-
"small.en",
|
|
86
|
-
"medium.en",
|
|
87
|
-
"large-v3",
|
|
88
|
-
])
|
|
89
|
-
transcription_layout.addRow("Whisper Model:", self.model_combo)
|
|
90
|
-
|
|
91
|
-
# Model descriptions
|
|
92
|
-
model_desc = QLabel(
|
|
93
|
-
"tiny.en: Fastest, least accurate (~1GB RAM)\n"
|
|
94
|
-
"base.en: Fast, decent accuracy (~1GB RAM)\n"
|
|
95
|
-
"small.en: Good balance (~2GB RAM)\n"
|
|
96
|
-
"medium.en: Better accuracy (~5GB RAM)\n"
|
|
97
|
-
"large-v3: Best accuracy (~10GB RAM)"
|
|
98
|
-
)
|
|
99
|
-
model_desc.setStyleSheet("color: gray; font-size: 11px;")
|
|
100
|
-
transcription_layout.addRow("", model_desc)
|
|
101
|
-
|
|
102
|
-
# Auto-transcribe
|
|
103
|
-
self.auto_transcribe_checkbox = QCheckBox("Automatically transcribe after recording stops")
|
|
104
|
-
transcription_layout.addRow("", self.auto_transcribe_checkbox)
|
|
105
|
-
|
|
106
|
-
layout.addWidget(transcription_group)
|
|
107
|
-
|
|
108
|
-
# Recording group
|
|
109
|
-
recording_group = QGroupBox("Recording")
|
|
110
|
-
recording_layout = QFormLayout(recording_group)
|
|
111
|
-
|
|
112
|
-
# Silence timeout
|
|
113
|
-
silence_layout = QHBoxLayout()
|
|
114
|
-
self.silence_timeout_spin = QSpinBox()
|
|
115
|
-
self.silence_timeout_spin.setRange(1, 60)
|
|
116
|
-
self.silence_timeout_spin.setSuffix(" minutes")
|
|
117
|
-
self.silence_timeout_spin.setToolTip("Stop recording after this many minutes of silence")
|
|
118
|
-
silence_layout.addWidget(self.silence_timeout_spin)
|
|
119
|
-
silence_layout.addStretch()
|
|
120
|
-
|
|
121
|
-
recording_layout.addRow("Silence timeout:", silence_layout)
|
|
122
|
-
|
|
123
|
-
silence_desc = QLabel("Recording stops automatically after this duration of silence")
|
|
124
|
-
silence_desc.setStyleSheet("color: gray; font-size: 11px;")
|
|
125
|
-
recording_layout.addRow("", silence_desc)
|
|
126
|
-
|
|
127
|
-
layout.addWidget(recording_group)
|
|
128
|
-
|
|
129
|
-
# Appearance group
|
|
130
|
-
appearance_group = QGroupBox("Appearance")
|
|
131
|
-
appearance_layout = QFormLayout(appearance_group)
|
|
132
|
-
|
|
133
|
-
self.show_menubar_checkbox = QCheckBox("Show menu bar icon (MN)")
|
|
134
|
-
appearance_layout.addRow("", self.show_menubar_checkbox)
|
|
135
|
-
|
|
136
|
-
layout.addWidget(appearance_group)
|
|
137
|
-
|
|
138
|
-
# Save button
|
|
139
|
-
button_layout = QHBoxLayout()
|
|
140
|
-
button_layout.addStretch()
|
|
141
|
-
|
|
142
|
-
self.save_button = QPushButton("Save Settings")
|
|
143
|
-
self.save_button.setMinimumWidth(120)
|
|
144
|
-
self.save_button.clicked.connect(self._save_settings)
|
|
145
|
-
button_layout.addWidget(self.save_button)
|
|
146
|
-
|
|
147
|
-
layout.addLayout(button_layout)
|
|
148
|
-
|
|
149
|
-
# Stretch to push everything up
|
|
150
|
-
layout.addStretch()
|
|
151
|
-
|
|
152
|
-
def _load_settings(self):
|
|
153
|
-
"""Load current settings into the UI."""
|
|
154
|
-
self.recordings_input.setText(str(self.config.recordings_dir))
|
|
155
|
-
self.transcripts_input.setText(str(self.config.transcripts_dir))
|
|
156
|
-
|
|
157
|
-
# Set combo box to current model
|
|
158
|
-
model = self.config.whisper_model
|
|
159
|
-
index = self.model_combo.findText(model)
|
|
160
|
-
if index >= 0:
|
|
161
|
-
self.model_combo.setCurrentIndex(index)
|
|
162
|
-
|
|
163
|
-
self.auto_transcribe_checkbox.setChecked(self.config.auto_transcribe)
|
|
164
|
-
self.silence_timeout_spin.setValue(self.config.silence_timeout)
|
|
165
|
-
self.show_menubar_checkbox.setChecked(self.config.show_menubar)
|
|
166
|
-
|
|
167
|
-
def _browse_recordings_dir(self):
|
|
168
|
-
"""Browse for recordings directory."""
|
|
169
|
-
current = self.recordings_input.text()
|
|
170
|
-
directory = QFileDialog.getExistingDirectory(
|
|
171
|
-
self,
|
|
172
|
-
"Select Recordings Directory",
|
|
173
|
-
current,
|
|
174
|
-
)
|
|
175
|
-
if directory:
|
|
176
|
-
self.recordings_input.setText(directory)
|
|
177
|
-
|
|
178
|
-
def _browse_transcripts_dir(self):
|
|
179
|
-
"""Browse for transcripts directory."""
|
|
180
|
-
current = self.transcripts_input.text()
|
|
181
|
-
directory = QFileDialog.getExistingDirectory(
|
|
182
|
-
self,
|
|
183
|
-
"Select Transcripts Directory",
|
|
184
|
-
current,
|
|
185
|
-
)
|
|
186
|
-
if directory:
|
|
187
|
-
self.transcripts_input.setText(directory)
|
|
188
|
-
|
|
189
|
-
def _save_settings(self):
|
|
190
|
-
"""Save settings to config file."""
|
|
191
|
-
recordings_dir = Path(self.recordings_input.text())
|
|
192
|
-
transcripts_dir = Path(self.transcripts_input.text())
|
|
193
|
-
|
|
194
|
-
# Validate directories
|
|
195
|
-
try:
|
|
196
|
-
recordings_dir.mkdir(parents=True, exist_ok=True)
|
|
197
|
-
transcripts_dir.mkdir(parents=True, exist_ok=True)
|
|
198
|
-
except Exception as e:
|
|
199
|
-
QMessageBox.warning(self, "Error", f"Could not create directories: {e}")
|
|
200
|
-
return
|
|
201
|
-
|
|
202
|
-
# Check if menubar setting changed
|
|
203
|
-
old_show_menubar = self.config.show_menubar
|
|
204
|
-
new_show_menubar = self.show_menubar_checkbox.isChecked()
|
|
205
|
-
|
|
206
|
-
# Update config
|
|
207
|
-
self.config.recordings_dir = recordings_dir
|
|
208
|
-
self.config.transcripts_dir = transcripts_dir
|
|
209
|
-
self.config.whisper_model = self.model_combo.currentText()
|
|
210
|
-
self.config.auto_transcribe = self.auto_transcribe_checkbox.isChecked()
|
|
211
|
-
self.config.silence_timeout = self.silence_timeout_spin.value()
|
|
212
|
-
self.config.show_menubar = new_show_menubar
|
|
213
|
-
|
|
214
|
-
try:
|
|
215
|
-
self.config.save()
|
|
216
|
-
|
|
217
|
-
# Handle menubar start/stop
|
|
218
|
-
if new_show_menubar and not old_show_menubar:
|
|
219
|
-
self._start_menubar()
|
|
220
|
-
elif not new_show_menubar and old_show_menubar:
|
|
221
|
-
self._stop_menubar()
|
|
222
|
-
|
|
223
|
-
self.settings_saved.emit()
|
|
224
|
-
QMessageBox.information(self, "Success", "Settings saved successfully.")
|
|
225
|
-
except Exception as e:
|
|
226
|
-
QMessageBox.warning(self, "Error", f"Could not save settings: {e}")
|
|
227
|
-
|
|
228
|
-
def _start_menubar(self):
|
|
229
|
-
"""Start the menu bar app in background."""
|
|
230
|
-
subprocess.Popen(
|
|
231
|
-
[sys.executable, "-m", "meeting_noter.menubar"],
|
|
232
|
-
stdout=subprocess.DEVNULL,
|
|
233
|
-
stderr=subprocess.DEVNULL,
|
|
234
|
-
start_new_session=True,
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
def _stop_menubar(self):
|
|
238
|
-
"""Stop the menu bar app if running."""
|
|
239
|
-
import os
|
|
240
|
-
import signal
|
|
241
|
-
|
|
242
|
-
menubar_pid_file = Path.home() / ".meeting-noter-menubar.pid"
|
|
243
|
-
|
|
244
|
-
pid = read_pid_file(menubar_pid_file)
|
|
245
|
-
if pid and is_process_running(pid):
|
|
246
|
-
try:
|
|
247
|
-
os.kill(pid, signal.SIGTERM)
|
|
248
|
-
except (OSError, ProcessLookupError):
|
|
249
|
-
pass
|
meeting_noter/menubar.py
DELETED
|
@@ -1,411 +0,0 @@
|
|
|
1
|
-
"""Menu bar app for Meeting Noter daemon control."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import atexit
|
|
6
|
-
import os
|
|
7
|
-
import subprocess
|
|
8
|
-
import threading
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from typing import Optional
|
|
11
|
-
|
|
12
|
-
import rumps
|
|
13
|
-
|
|
14
|
-
from meeting_noter.daemon import read_pid_file, is_process_running, stop_daemon
|
|
15
|
-
from meeting_noter.config import get_config, generate_meeting_name
|
|
16
|
-
from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
|
|
20
|
-
MENUBAR_PID_FILE = Path.home() / ".meeting-noter-menubar.pid"
|
|
21
|
-
RECORDING_STATE_FILE = Path.home() / ".meeting-noter-recording.json"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _write_menubar_pid():
|
|
25
|
-
"""Write the menubar PID file."""
|
|
26
|
-
with open(MENUBAR_PID_FILE, "w") as f:
|
|
27
|
-
f.write(str(os.getpid()))
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _remove_menubar_pid():
|
|
31
|
-
"""Remove the menubar PID file."""
|
|
32
|
-
try:
|
|
33
|
-
MENUBAR_PID_FILE.unlink()
|
|
34
|
-
except FileNotFoundError:
|
|
35
|
-
pass
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
class MeetingNoterApp(rumps.App):
|
|
39
|
-
"""Menu bar app for controlling the Meeting Noter daemon."""
|
|
40
|
-
|
|
41
|
-
def __init__(self):
|
|
42
|
-
super().__init__("Meeting Noter", title="▷")
|
|
43
|
-
self.pid_file = DEFAULT_PID_FILE
|
|
44
|
-
self.config = get_config()
|
|
45
|
-
self.mic_monitor = MicrophoneMonitor()
|
|
46
|
-
self.current_meeting_name: Optional[str] = None
|
|
47
|
-
self.pending_notification = False # Avoid duplicate notifications
|
|
48
|
-
self._pending_app_name: Optional[str] = None
|
|
49
|
-
self.menu = [
|
|
50
|
-
"Start Recording",
|
|
51
|
-
"Stop Recording",
|
|
52
|
-
None, # Separator
|
|
53
|
-
"Open Recordings",
|
|
54
|
-
"Open UI",
|
|
55
|
-
]
|
|
56
|
-
self._update_title()
|
|
57
|
-
|
|
58
|
-
def _is_running(self) -> bool:
|
|
59
|
-
"""Check if daemon is currently running."""
|
|
60
|
-
pid = read_pid_file(self.pid_file)
|
|
61
|
-
return pid is not None and is_process_running(pid)
|
|
62
|
-
|
|
63
|
-
def _get_current_recording_name(self) -> Optional[str]:
|
|
64
|
-
"""Get the name of the current recording from the log file.
|
|
65
|
-
|
|
66
|
-
Parses the daemon log to find the most recent 'Recording started:' entry.
|
|
67
|
-
Returns the filename (without extension) or None if not recording.
|
|
68
|
-
"""
|
|
69
|
-
if not self._is_running():
|
|
70
|
-
return None
|
|
71
|
-
|
|
72
|
-
log_path = Path.home() / ".meeting-noter.log"
|
|
73
|
-
if not log_path.exists():
|
|
74
|
-
return None
|
|
75
|
-
|
|
76
|
-
try:
|
|
77
|
-
with open(log_path, "r") as f:
|
|
78
|
-
lines = f.readlines()
|
|
79
|
-
|
|
80
|
-
# Find the most recent recording start/save
|
|
81
|
-
recording_name = None
|
|
82
|
-
for line in reversed(lines[-50:]): # Check last 50 lines
|
|
83
|
-
if "Recording started:" in line:
|
|
84
|
-
# Extract filename from "Recording started: filename.mp3"
|
|
85
|
-
parts = line.split("Recording started:")
|
|
86
|
-
if len(parts) > 1:
|
|
87
|
-
filename = parts[1].strip()
|
|
88
|
-
# Remove extension and timestamp prefix
|
|
89
|
-
name = filename.replace(".mp3", "")
|
|
90
|
-
# If format is timestamp_name, extract just the name
|
|
91
|
-
parts = name.split("_", 2) # Split max 2 times
|
|
92
|
-
if len(parts) >= 3:
|
|
93
|
-
# Format: YYYY-MM-DD_HHMMSS_MeetingName
|
|
94
|
-
recording_name = parts[2]
|
|
95
|
-
else:
|
|
96
|
-
recording_name = name
|
|
97
|
-
break
|
|
98
|
-
elif "Recording saved:" in line or "Recording discarded" in line:
|
|
99
|
-
# Recording ended, no active recording
|
|
100
|
-
break
|
|
101
|
-
|
|
102
|
-
return recording_name
|
|
103
|
-
except Exception:
|
|
104
|
-
return None
|
|
105
|
-
|
|
106
|
-
def _truncate_name(self, name: str, max_length: int = 15) -> str:
|
|
107
|
-
"""Truncate a name to fit in the menu bar."""
|
|
108
|
-
if len(name) <= max_length:
|
|
109
|
-
return name
|
|
110
|
-
return name[:max_length - 1] + "..."
|
|
111
|
-
|
|
112
|
-
def _update_title(self):
|
|
113
|
-
"""Update menu bar title based on daemon status."""
|
|
114
|
-
if self._is_running():
|
|
115
|
-
self.title = "▶" # Filled triangle = recording
|
|
116
|
-
else:
|
|
117
|
-
self.title = "▷" # Outline triangle = idle
|
|
118
|
-
|
|
119
|
-
def _save_recording_state(self, meeting_name: str, file_path: Optional[str] = None):
|
|
120
|
-
"""Save current recording state to file."""
|
|
121
|
-
import json
|
|
122
|
-
state = {
|
|
123
|
-
"recording": True,
|
|
124
|
-
"meeting_name": meeting_name,
|
|
125
|
-
"file_path": file_path,
|
|
126
|
-
}
|
|
127
|
-
try:
|
|
128
|
-
with open(RECORDING_STATE_FILE, "w") as f:
|
|
129
|
-
json.dump(state, f)
|
|
130
|
-
except Exception:
|
|
131
|
-
pass
|
|
132
|
-
|
|
133
|
-
def _clear_recording_state(self):
|
|
134
|
-
"""Clear recording state file."""
|
|
135
|
-
try:
|
|
136
|
-
RECORDING_STATE_FILE.unlink()
|
|
137
|
-
except FileNotFoundError:
|
|
138
|
-
pass
|
|
139
|
-
|
|
140
|
-
def _start_recording_with_name(self, meeting_name: str, app_name: Optional[str] = None):
|
|
141
|
-
"""Start recording with a specific meeting name."""
|
|
142
|
-
import sys
|
|
143
|
-
|
|
144
|
-
if self._is_running():
|
|
145
|
-
return
|
|
146
|
-
|
|
147
|
-
self.current_meeting_name = meeting_name
|
|
148
|
-
self._save_recording_state(meeting_name)
|
|
149
|
-
self.mic_monitor.set_recording(True, app_name)
|
|
150
|
-
|
|
151
|
-
# Start daemon with the meeting name using current Python
|
|
152
|
-
subprocess.Popen(
|
|
153
|
-
[sys.executable, "-m", "meeting_noter.cli", "daemon", "--name", meeting_name],
|
|
154
|
-
stdout=subprocess.DEVNULL,
|
|
155
|
-
stderr=subprocess.DEVNULL,
|
|
156
|
-
)
|
|
157
|
-
self._update_title()
|
|
158
|
-
|
|
159
|
-
@rumps.clicked("Start Recording")
|
|
160
|
-
def start_recording(self, _):
|
|
161
|
-
"""Start the daemon via subprocess."""
|
|
162
|
-
if self._is_running():
|
|
163
|
-
rumps.notification(
|
|
164
|
-
title="Meeting Noter",
|
|
165
|
-
subtitle="Already Running",
|
|
166
|
-
message="The daemon is already recording.",
|
|
167
|
-
)
|
|
168
|
-
return
|
|
169
|
-
|
|
170
|
-
meeting_name = generate_meeting_name()
|
|
171
|
-
self._start_recording_with_name(meeting_name)
|
|
172
|
-
|
|
173
|
-
rumps.notification(
|
|
174
|
-
title="Meeting Noter",
|
|
175
|
-
subtitle="Recording Started",
|
|
176
|
-
message=meeting_name,
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
def _get_latest_recording(self) -> Optional[Path]:
|
|
180
|
-
"""Get the most recent recording file."""
|
|
181
|
-
recordings_dir = self.config.recordings_dir
|
|
182
|
-
if not recordings_dir.exists():
|
|
183
|
-
return None
|
|
184
|
-
mp3_files = sorted(recordings_dir.glob("*.mp3"), key=lambda p: p.stat().st_mtime)
|
|
185
|
-
return mp3_files[-1] if mp3_files else None
|
|
186
|
-
|
|
187
|
-
def _get_transcript_path(self, audio_path: Path) -> Path:
|
|
188
|
-
"""Get the transcript path for an audio file."""
|
|
189
|
-
return self.config.transcripts_dir / audio_path.with_suffix(".txt").name
|
|
190
|
-
|
|
191
|
-
def _transcribe_in_background(self, audio_path: Path):
|
|
192
|
-
"""Transcribe a recording in background subprocess."""
|
|
193
|
-
import sys
|
|
194
|
-
# Use subprocess to run transcription - more reliable than threading with rumps
|
|
195
|
-
subprocess.Popen(
|
|
196
|
-
[sys.executable, "-m", "meeting_noter.cli", "transcribe", str(audio_path)],
|
|
197
|
-
stdout=subprocess.DEVNULL,
|
|
198
|
-
stderr=subprocess.DEVNULL,
|
|
199
|
-
)
|
|
200
|
-
|
|
201
|
-
@rumps.clicked("Stop Recording")
|
|
202
|
-
def stop_recording(self, _):
|
|
203
|
-
"""Stop the running daemon and optionally transcribe."""
|
|
204
|
-
if not self._is_running():
|
|
205
|
-
rumps.notification(
|
|
206
|
-
title="Meeting Noter",
|
|
207
|
-
subtitle="Not Running",
|
|
208
|
-
message="The daemon is not running.",
|
|
209
|
-
)
|
|
210
|
-
return
|
|
211
|
-
|
|
212
|
-
# Get latest recording before stopping (to compare after)
|
|
213
|
-
latest_before = self._get_latest_recording()
|
|
214
|
-
before_mtime = latest_before.stat().st_mtime if latest_before else 0
|
|
215
|
-
|
|
216
|
-
stop_daemon(self.pid_file)
|
|
217
|
-
self._update_title()
|
|
218
|
-
|
|
219
|
-
# Check for new recording after stopping
|
|
220
|
-
import time
|
|
221
|
-
time.sleep(2) # Give daemon time to save file
|
|
222
|
-
|
|
223
|
-
# Reload config to get current settings
|
|
224
|
-
self.config.load()
|
|
225
|
-
|
|
226
|
-
latest_after = self._get_latest_recording()
|
|
227
|
-
|
|
228
|
-
# Check if there's a new or updated recording
|
|
229
|
-
is_new_recording = False
|
|
230
|
-
if latest_after:
|
|
231
|
-
after_mtime = latest_after.stat().st_mtime
|
|
232
|
-
if latest_before is None or str(latest_after) != str(latest_before) or after_mtime > before_mtime:
|
|
233
|
-
is_new_recording = True
|
|
234
|
-
|
|
235
|
-
# Clear recording state
|
|
236
|
-
self._clear_recording_state()
|
|
237
|
-
self.current_meeting_name = None
|
|
238
|
-
|
|
239
|
-
if is_new_recording:
|
|
240
|
-
# New recording was saved
|
|
241
|
-
if self.config.auto_transcribe:
|
|
242
|
-
rumps.notification(
|
|
243
|
-
title="Meeting Noter",
|
|
244
|
-
subtitle="Recording Saved",
|
|
245
|
-
message=f"Transcribing {latest_after.name}...",
|
|
246
|
-
)
|
|
247
|
-
self._transcribe_in_background(latest_after)
|
|
248
|
-
else:
|
|
249
|
-
rumps.notification(
|
|
250
|
-
title="Meeting Noter",
|
|
251
|
-
subtitle="Recording Saved",
|
|
252
|
-
message=latest_after.name,
|
|
253
|
-
)
|
|
254
|
-
else:
|
|
255
|
-
rumps.notification(
|
|
256
|
-
title="Meeting Noter",
|
|
257
|
-
subtitle="Stopped",
|
|
258
|
-
message="Recording daemon stopped.",
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
@rumps.clicked("Open Recordings")
|
|
262
|
-
def open_recordings(self, _):
|
|
263
|
-
"""Open the recordings folder in Finder."""
|
|
264
|
-
recordings_dir = self.config.recordings_dir
|
|
265
|
-
recordings_dir.mkdir(parents=True, exist_ok=True)
|
|
266
|
-
subprocess.run(["open", str(recordings_dir)])
|
|
267
|
-
|
|
268
|
-
@rumps.clicked("Open UI")
|
|
269
|
-
def open_ui(self, _):
|
|
270
|
-
"""Open the desktop GUI application."""
|
|
271
|
-
import sys
|
|
272
|
-
subprocess.Popen(
|
|
273
|
-
[sys.executable, "-m", "meeting_noter.cli", "gui"],
|
|
274
|
-
stdout=subprocess.DEVNULL,
|
|
275
|
-
stderr=subprocess.DEVNULL,
|
|
276
|
-
)
|
|
277
|
-
|
|
278
|
-
@rumps.timer(2)
|
|
279
|
-
def poll_status(self, _):
|
|
280
|
-
"""Periodically update the menu bar title and check for mic usage."""
|
|
281
|
-
self._update_title()
|
|
282
|
-
|
|
283
|
-
is_recording = self._is_running()
|
|
284
|
-
|
|
285
|
-
# Tell mic monitor our recording state
|
|
286
|
-
self.mic_monitor.set_recording(is_recording)
|
|
287
|
-
|
|
288
|
-
# Check for mic usage changes
|
|
289
|
-
mic_started, mic_stopped, app_name = self.mic_monitor.check()
|
|
290
|
-
|
|
291
|
-
# Auto-stop recording when mic stops being used
|
|
292
|
-
if mic_stopped and is_recording:
|
|
293
|
-
rumps.notification(
|
|
294
|
-
title="Meeting Noter",
|
|
295
|
-
subtitle="Call Ended",
|
|
296
|
-
message="Stopping recording...",
|
|
297
|
-
)
|
|
298
|
-
self._auto_stop_recording()
|
|
299
|
-
return
|
|
300
|
-
|
|
301
|
-
# Prompt to record when mic starts being used
|
|
302
|
-
if mic_started and not is_recording and not self.pending_notification:
|
|
303
|
-
self.pending_notification = True
|
|
304
|
-
self._pending_app_name = app_name or "Unknown App"
|
|
305
|
-
# Try to get meeting name from window title, fall back to timestamp
|
|
306
|
-
window_title = get_meeting_window_title()
|
|
307
|
-
self._pending_meeting_name = window_title or generate_meeting_name()
|
|
308
|
-
|
|
309
|
-
def _auto_stop_recording(self):
|
|
310
|
-
"""Stop recording automatically when meeting ends."""
|
|
311
|
-
if not self._is_running():
|
|
312
|
-
return
|
|
313
|
-
|
|
314
|
-
# Get latest recording before stopping
|
|
315
|
-
latest_before = self._get_latest_recording()
|
|
316
|
-
before_mtime = latest_before.stat().st_mtime if latest_before else 0
|
|
317
|
-
|
|
318
|
-
stop_daemon(self.pid_file)
|
|
319
|
-
self._update_title()
|
|
320
|
-
|
|
321
|
-
# Wait for file to be saved
|
|
322
|
-
import time
|
|
323
|
-
time.sleep(2)
|
|
324
|
-
|
|
325
|
-
# Reload config
|
|
326
|
-
self.config.load()
|
|
327
|
-
|
|
328
|
-
latest_after = self._get_latest_recording()
|
|
329
|
-
|
|
330
|
-
# Check for new recording
|
|
331
|
-
is_new_recording = False
|
|
332
|
-
if latest_after:
|
|
333
|
-
after_mtime = latest_after.stat().st_mtime
|
|
334
|
-
if latest_before is None or str(latest_after) != str(latest_before) or after_mtime > before_mtime:
|
|
335
|
-
is_new_recording = True
|
|
336
|
-
|
|
337
|
-
# Clear state
|
|
338
|
-
self._clear_recording_state()
|
|
339
|
-
self.current_meeting_name = None
|
|
340
|
-
|
|
341
|
-
if is_new_recording:
|
|
342
|
-
if self.config.auto_transcribe:
|
|
343
|
-
rumps.notification(
|
|
344
|
-
title="Meeting Noter",
|
|
345
|
-
subtitle="Recording Saved",
|
|
346
|
-
message=f"Transcribing {latest_after.name}...",
|
|
347
|
-
)
|
|
348
|
-
self._transcribe_in_background(latest_after)
|
|
349
|
-
else:
|
|
350
|
-
rumps.notification(
|
|
351
|
-
title="Meeting Noter",
|
|
352
|
-
subtitle="Recording Saved",
|
|
353
|
-
message=latest_after.name,
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
@rumps.timer(1)
|
|
357
|
-
def check_pending_prompt(self, _):
|
|
358
|
-
"""Check if we need to show a recording prompt (runs on main thread)."""
|
|
359
|
-
if self.pending_notification and hasattr(self, '_pending_meeting_name'):
|
|
360
|
-
app_name = self._pending_app_name or "App"
|
|
361
|
-
meeting_name = self._pending_meeting_name
|
|
362
|
-
|
|
363
|
-
# Clear first to prevent re-triggering
|
|
364
|
-
self._pending_app_name = None
|
|
365
|
-
self.pending_notification = False
|
|
366
|
-
|
|
367
|
-
# Build message with meeting name if available
|
|
368
|
-
if meeting_name and not meeting_name[0].isdigit():
|
|
369
|
-
# Has a real meeting name (not timestamp-based)
|
|
370
|
-
message = f"Meeting: {meeting_name}\n\nDo you want to record?"
|
|
371
|
-
else:
|
|
372
|
-
message = "Do you want to record this call?"
|
|
373
|
-
|
|
374
|
-
# Show alert on main thread
|
|
375
|
-
response = rumps.alert(
|
|
376
|
-
title=f"Microphone in use: {app_name}",
|
|
377
|
-
message=message,
|
|
378
|
-
ok="Record",
|
|
379
|
-
cancel="Skip",
|
|
380
|
-
)
|
|
381
|
-
|
|
382
|
-
if response == 1: # Record clicked
|
|
383
|
-
self._start_recording_with_name(meeting_name, app_name)
|
|
384
|
-
rumps.notification(
|
|
385
|
-
title="Meeting Noter",
|
|
386
|
-
subtitle="Recording Started",
|
|
387
|
-
message=meeting_name,
|
|
388
|
-
)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
def _hide_dock_icon():
|
|
392
|
-
"""Hide the dock icon on macOS (make it a background agent app)."""
|
|
393
|
-
try:
|
|
394
|
-
from AppKit import NSApplication, NSApplicationActivationPolicyAccessory
|
|
395
|
-
NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
|
396
|
-
except ImportError:
|
|
397
|
-
pass
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
def run_menubar():
|
|
401
|
-
"""Run the menu bar app."""
|
|
402
|
-
_hide_dock_icon()
|
|
403
|
-
_write_menubar_pid()
|
|
404
|
-
atexit.register(_remove_menubar_pid)
|
|
405
|
-
|
|
406
|
-
app = MeetingNoterApp()
|
|
407
|
-
app.run()
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
if __name__ == "__main__":
|
|
411
|
-
run_menubar()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|