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.
- meeting_noter/__init__.py +3 -0
- meeting_noter/__main__.py +6 -0
- meeting_noter/audio/__init__.py +1 -0
- meeting_noter/audio/capture.py +209 -0
- meeting_noter/audio/encoder.py +208 -0
- meeting_noter/audio/system_audio.py +363 -0
- meeting_noter/cli.py +837 -0
- meeting_noter/config.py +197 -0
- meeting_noter/daemon.py +519 -0
- meeting_noter/gui/__init__.py +5 -0
- meeting_noter/gui/__main__.py +6 -0
- meeting_noter/gui/app.py +53 -0
- meeting_noter/gui/main_window.py +50 -0
- meeting_noter/gui/meetings_tab.py +348 -0
- meeting_noter/gui/recording_tab.py +358 -0
- meeting_noter/gui/settings_tab.py +249 -0
- meeting_noter/install/__init__.py +1 -0
- meeting_noter/install/macos.py +102 -0
- meeting_noter/meeting_detector.py +333 -0
- meeting_noter/menubar.py +411 -0
- meeting_noter/mic_monitor.py +456 -0
- meeting_noter/output/__init__.py +1 -0
- meeting_noter/output/writer.py +96 -0
- meeting_noter/resources/__init__.py +1 -0
- meeting_noter/resources/icon.icns +0 -0
- meeting_noter/resources/icon.png +0 -0
- meeting_noter/resources/icon_128.png +0 -0
- meeting_noter/resources/icon_16.png +0 -0
- meeting_noter/resources/icon_256.png +0 -0
- meeting_noter/resources/icon_32.png +0 -0
- meeting_noter/resources/icon_512.png +0 -0
- meeting_noter/resources/icon_64.png +0 -0
- meeting_noter/transcription/__init__.py +1 -0
- meeting_noter/transcription/engine.py +234 -0
- meeting_noter-0.7.0.dist-info/METADATA +224 -0
- meeting_noter-0.7.0.dist-info/RECORD +39 -0
- meeting_noter-0.7.0.dist-info/WHEEL +5 -0
- meeting_noter-0.7.0.dist-info/entry_points.txt +2 -0
- 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()
|