meeting-noter 0.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 +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 +176 -0
- meeting_noter/audio/system_audio.py +363 -0
- meeting_noter/cli.py +308 -0
- meeting_noter/config.py +197 -0
- meeting_noter/daemon.py +514 -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 +296 -0
- meeting_noter/menubar.py +432 -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 +208 -0
- meeting_noter-0.3.0.dist-info/METADATA +261 -0
- meeting_noter-0.3.0.dist-info/RECORD +38 -0
- meeting_noter-0.3.0.dist-info/WHEEL +5 -0
- meeting_noter-0.3.0.dist-info/entry_points.txt +2 -0
- meeting_noter-0.3.0.dist-info/top_level.txt +1 -0
meeting_noter/config.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Configuration management for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_meeting_name() -> str:
|
|
16
|
+
"""Generate a default meeting name with current timestamp."""
|
|
17
|
+
now = datetime.now()
|
|
18
|
+
return now.strftime("%d_%b_%Y_%H%M") # e.g., "29_Jan_2026_1430"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_default_meeting_name(name: str) -> bool:
|
|
22
|
+
"""Check if a name matches the default timestamp pattern."""
|
|
23
|
+
# Pattern: DD_Mon_YYYY_HHMM (e.g., 29_Jan_2026_1430)
|
|
24
|
+
return bool(re.match(r"^\d{2}_[A-Z][a-z]{2}_\d{4}_\d{4}$", name))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "meeting-noter"
|
|
28
|
+
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.json"
|
|
29
|
+
|
|
30
|
+
DEFAULT_CONFIG = {
|
|
31
|
+
"recordings_dir": str(Path.home() / "meetings"),
|
|
32
|
+
"transcripts_dir": str(Path.home() / "meetings"),
|
|
33
|
+
"whisper_model": "tiny.en",
|
|
34
|
+
"auto_transcribe": True,
|
|
35
|
+
"silence_timeout": 5, # Minutes of silence before stopping recording
|
|
36
|
+
"capture_system_audio": True, # Capture other participants via ScreenCaptureKit
|
|
37
|
+
"show_menubar": False,
|
|
38
|
+
"setup_complete": False,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Config:
|
|
43
|
+
"""Configuration manager for Meeting Noter."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, config_path: Path = DEFAULT_CONFIG_FILE):
|
|
46
|
+
self.config_path = config_path
|
|
47
|
+
self._data: dict[str, Any] = {}
|
|
48
|
+
self.load()
|
|
49
|
+
|
|
50
|
+
def load(self) -> None:
|
|
51
|
+
"""Load configuration from disk."""
|
|
52
|
+
if self.config_path.exists():
|
|
53
|
+
try:
|
|
54
|
+
with open(self.config_path, "r") as f:
|
|
55
|
+
self._data = json.load(f)
|
|
56
|
+
except (json.JSONDecodeError, IOError):
|
|
57
|
+
self._data = {}
|
|
58
|
+
else:
|
|
59
|
+
self._data = {}
|
|
60
|
+
|
|
61
|
+
# Fill in missing defaults
|
|
62
|
+
for key, value in DEFAULT_CONFIG.items():
|
|
63
|
+
if key not in self._data:
|
|
64
|
+
self._data[key] = value
|
|
65
|
+
|
|
66
|
+
def save(self) -> None:
|
|
67
|
+
"""Save configuration to disk."""
|
|
68
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
with open(self.config_path, "w") as f:
|
|
70
|
+
json.dump(self._data, f, indent=4)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def recordings_dir(self) -> Path:
|
|
74
|
+
"""Get recordings directory."""
|
|
75
|
+
return Path(self._data["recordings_dir"]).expanduser()
|
|
76
|
+
|
|
77
|
+
@recordings_dir.setter
|
|
78
|
+
def recordings_dir(self, value: Path | str) -> None:
|
|
79
|
+
"""Set recordings directory."""
|
|
80
|
+
self._data["recordings_dir"] = str(value)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def transcripts_dir(self) -> Path:
|
|
84
|
+
"""Get transcripts directory."""
|
|
85
|
+
return Path(self._data["transcripts_dir"]).expanduser()
|
|
86
|
+
|
|
87
|
+
@transcripts_dir.setter
|
|
88
|
+
def transcripts_dir(self, value: Path | str) -> None:
|
|
89
|
+
"""Set transcripts directory."""
|
|
90
|
+
self._data["transcripts_dir"] = str(value)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def whisper_model(self) -> str:
|
|
94
|
+
"""Get Whisper model name."""
|
|
95
|
+
return self._data["whisper_model"]
|
|
96
|
+
|
|
97
|
+
@whisper_model.setter
|
|
98
|
+
def whisper_model(self, value: str) -> None:
|
|
99
|
+
"""Set Whisper model name."""
|
|
100
|
+
self._data["whisper_model"] = value
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def auto_transcribe(self) -> bool:
|
|
104
|
+
"""Get auto-transcribe setting."""
|
|
105
|
+
return self._data["auto_transcribe"]
|
|
106
|
+
|
|
107
|
+
@auto_transcribe.setter
|
|
108
|
+
def auto_transcribe(self, value: bool) -> None:
|
|
109
|
+
"""Set auto-transcribe setting."""
|
|
110
|
+
self._data["auto_transcribe"] = value
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def silence_timeout(self) -> int:
|
|
114
|
+
"""Get silence timeout in minutes."""
|
|
115
|
+
return self._data.get("silence_timeout", 5)
|
|
116
|
+
|
|
117
|
+
@silence_timeout.setter
|
|
118
|
+
def silence_timeout(self, value: int) -> None:
|
|
119
|
+
"""Set silence timeout in minutes."""
|
|
120
|
+
self._data["silence_timeout"] = value
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def capture_system_audio(self) -> bool:
|
|
124
|
+
"""Get capture system audio setting."""
|
|
125
|
+
return self._data.get("capture_system_audio", True)
|
|
126
|
+
|
|
127
|
+
@capture_system_audio.setter
|
|
128
|
+
def capture_system_audio(self, value: bool) -> None:
|
|
129
|
+
"""Set capture system audio setting."""
|
|
130
|
+
self._data["capture_system_audio"] = value
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def show_menubar(self) -> bool:
|
|
134
|
+
"""Get show menubar setting."""
|
|
135
|
+
return self._data.get("show_menubar", False)
|
|
136
|
+
|
|
137
|
+
@show_menubar.setter
|
|
138
|
+
def show_menubar(self, value: bool) -> None:
|
|
139
|
+
"""Set show menubar setting."""
|
|
140
|
+
self._data["show_menubar"] = value
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def setup_complete(self) -> bool:
|
|
144
|
+
"""Check if setup has been completed."""
|
|
145
|
+
return self._data.get("setup_complete", False)
|
|
146
|
+
|
|
147
|
+
@setup_complete.setter
|
|
148
|
+
def setup_complete(self, value: bool) -> None:
|
|
149
|
+
"""Set setup completion status."""
|
|
150
|
+
self._data["setup_complete"] = value
|
|
151
|
+
|
|
152
|
+
def __getitem__(self, key: str) -> Any:
|
|
153
|
+
"""Get config value by key."""
|
|
154
|
+
return self._data.get(key, DEFAULT_CONFIG.get(key))
|
|
155
|
+
|
|
156
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
157
|
+
"""Set config value by key."""
|
|
158
|
+
self._data[key] = value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Global config instance (lazy loaded)
|
|
162
|
+
_config: Config | None = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_config() -> Config:
|
|
166
|
+
"""Get the global config instance."""
|
|
167
|
+
global _config
|
|
168
|
+
if _config is None:
|
|
169
|
+
_config = Config()
|
|
170
|
+
return _config
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def is_setup_complete() -> bool:
|
|
174
|
+
"""Check if setup has been completed."""
|
|
175
|
+
return get_config().setup_complete
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def require_setup(f):
|
|
179
|
+
"""Decorator that ensures basic config exists.
|
|
180
|
+
|
|
181
|
+
Now more lenient - just ensures config directories exist.
|
|
182
|
+
Setup is optional since we can use any microphone.
|
|
183
|
+
|
|
184
|
+
Usage:
|
|
185
|
+
@cli.command()
|
|
186
|
+
@require_setup
|
|
187
|
+
def my_command():
|
|
188
|
+
...
|
|
189
|
+
"""
|
|
190
|
+
@functools.wraps(f)
|
|
191
|
+
def wrapper(*args, **kwargs):
|
|
192
|
+
config = get_config()
|
|
193
|
+
# Ensure directories exist
|
|
194
|
+
config.recordings_dir.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
config.transcripts_dir.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
return f(*args, **kwargs)
|
|
197
|
+
return wrapper
|
meeting_noter/daemon.py
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""Background daemon for capturing meeting audio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import signal
|
|
8
|
+
import time
|
|
9
|
+
import click
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from threading import Event
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
# IMPORTANT: Do NOT import audio modules at top level!
|
|
15
|
+
# CoreAudio crashes when Python forks after loading audio libraries.
|
|
16
|
+
# Audio imports are deferred until after daemonize() is called.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Global stop event for signal handling
|
|
20
|
+
_stop_event = Event()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _signal_handler(signum, frame):
|
|
24
|
+
"""Handle termination signals."""
|
|
25
|
+
_stop_event.set()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def daemonize():
|
|
29
|
+
"""Fork the process to run as a daemon."""
|
|
30
|
+
# First fork
|
|
31
|
+
try:
|
|
32
|
+
pid = os.fork()
|
|
33
|
+
if pid > 0:
|
|
34
|
+
# Parent exits
|
|
35
|
+
sys.exit(0)
|
|
36
|
+
except OSError as e:
|
|
37
|
+
sys.stderr.write(f"Fork #1 failed: {e}\n")
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
# Decouple from parent environment
|
|
41
|
+
os.chdir("/")
|
|
42
|
+
os.setsid()
|
|
43
|
+
os.umask(0)
|
|
44
|
+
|
|
45
|
+
# Second fork
|
|
46
|
+
try:
|
|
47
|
+
pid = os.fork()
|
|
48
|
+
if pid > 0:
|
|
49
|
+
sys.exit(0)
|
|
50
|
+
except OSError as e:
|
|
51
|
+
sys.stderr.write(f"Fork #2 failed: {e}\n")
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
# Redirect standard file descriptors
|
|
55
|
+
sys.stdout.flush()
|
|
56
|
+
sys.stderr.flush()
|
|
57
|
+
|
|
58
|
+
with open("/dev/null", "r") as devnull:
|
|
59
|
+
os.dup2(devnull.fileno(), sys.stdin.fileno())
|
|
60
|
+
|
|
61
|
+
# Keep stdout/stderr for logging to a file
|
|
62
|
+
log_path = Path.home() / ".meeting-noter.log"
|
|
63
|
+
log_file = open(log_path, "a")
|
|
64
|
+
os.dup2(log_file.fileno(), sys.stdout.fileno())
|
|
65
|
+
os.dup2(log_file.fileno(), sys.stderr.fileno())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_pid_file(pid_file: Path):
|
|
69
|
+
"""Write the current PID to file."""
|
|
70
|
+
with open(pid_file, "w") as f:
|
|
71
|
+
f.write(str(os.getpid()))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def remove_pid_file(pid_file: Path):
|
|
75
|
+
"""Remove the PID file."""
|
|
76
|
+
try:
|
|
77
|
+
pid_file.unlink()
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_pid_file(pid_file: Path) -> Optional[int]:
|
|
83
|
+
"""Read PID from file."""
|
|
84
|
+
try:
|
|
85
|
+
with open(pid_file, "r") as f:
|
|
86
|
+
return int(f.read().strip())
|
|
87
|
+
except (FileNotFoundError, ValueError):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_process_running(pid: int) -> bool:
|
|
92
|
+
"""Check if a process with given PID is running."""
|
|
93
|
+
try:
|
|
94
|
+
os.kill(pid, 0)
|
|
95
|
+
return True
|
|
96
|
+
except (OSError, ProcessLookupError):
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def check_audio_available() -> bool:
|
|
101
|
+
"""Check if any audio input device is available."""
|
|
102
|
+
import sounddevice as sd
|
|
103
|
+
try:
|
|
104
|
+
devices = sd.query_devices()
|
|
105
|
+
for device in devices:
|
|
106
|
+
if device["max_input_channels"] > 0:
|
|
107
|
+
return True
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run_daemon(
|
|
114
|
+
output_dir: Path,
|
|
115
|
+
foreground: bool = False,
|
|
116
|
+
pid_file: Optional[Path] = None,
|
|
117
|
+
meeting_name: Optional[str] = None,
|
|
118
|
+
):
|
|
119
|
+
"""Run the audio capture daemon.
|
|
120
|
+
|
|
121
|
+
IMPORTANT: We must NOT import any audio libraries (sounddevice, etc.) before
|
|
122
|
+
forking, because CoreAudio crashes when Python forks after loading audio libs.
|
|
123
|
+
All audio-related imports happen in _run_capture_loop() AFTER daemonize().
|
|
124
|
+
"""
|
|
125
|
+
# Check if already running (no audio imports needed)
|
|
126
|
+
if pid_file:
|
|
127
|
+
existing_pid = read_pid_file(pid_file)
|
|
128
|
+
if existing_pid and is_process_running(existing_pid):
|
|
129
|
+
click.echo(click.style(
|
|
130
|
+
f"Daemon already running (PID {existing_pid})",
|
|
131
|
+
fg="yellow"
|
|
132
|
+
))
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
if not foreground:
|
|
136
|
+
click.echo("Starting daemon in background...")
|
|
137
|
+
daemonize()
|
|
138
|
+
|
|
139
|
+
# NOW it's safe to check audio (after fork)
|
|
140
|
+
if not check_audio_available():
|
|
141
|
+
print("Error: No audio input device found.")
|
|
142
|
+
print("Please check your microphone settings.")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# Set up signal handlers
|
|
146
|
+
signal.signal(signal.SIGTERM, _signal_handler)
|
|
147
|
+
signal.signal(signal.SIGINT, _signal_handler)
|
|
148
|
+
|
|
149
|
+
# Write PID file
|
|
150
|
+
if pid_file:
|
|
151
|
+
write_pid_file(pid_file)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
_run_capture_loop(output_dir, meeting_name=meeting_name)
|
|
155
|
+
finally:
|
|
156
|
+
if pid_file:
|
|
157
|
+
remove_pid_file(pid_file)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _run_capture_loop(output_dir: Path, meeting_name: Optional[str] = None):
|
|
161
|
+
"""Main capture loop.
|
|
162
|
+
|
|
163
|
+
Audio imports happen HERE, safely AFTER the fork.
|
|
164
|
+
"""
|
|
165
|
+
import sys
|
|
166
|
+
from meeting_noter.config import get_config
|
|
167
|
+
|
|
168
|
+
# Import audio modules AFTER fork to avoid CoreAudio crash
|
|
169
|
+
from meeting_noter.audio.capture import AudioCapture, SilenceDetector
|
|
170
|
+
from meeting_noter.audio.encoder import RecordingSession
|
|
171
|
+
|
|
172
|
+
config = get_config()
|
|
173
|
+
|
|
174
|
+
print(f"Meeting Noter daemon started. Saving to {output_dir}")
|
|
175
|
+
sys.stdout.flush()
|
|
176
|
+
if meeting_name:
|
|
177
|
+
print(f"Meeting: {meeting_name}")
|
|
178
|
+
sys.stdout.flush()
|
|
179
|
+
|
|
180
|
+
# Use combined capture (mic + system audio) if enabled
|
|
181
|
+
use_combined = False
|
|
182
|
+
if config.capture_system_audio:
|
|
183
|
+
try:
|
|
184
|
+
from meeting_noter.audio.system_audio import CombinedAudioCapture
|
|
185
|
+
capture = CombinedAudioCapture()
|
|
186
|
+
use_combined = True
|
|
187
|
+
except Exception as e:
|
|
188
|
+
print(f"Combined capture not available: {e}")
|
|
189
|
+
sys.stdout.flush()
|
|
190
|
+
|
|
191
|
+
if not use_combined:
|
|
192
|
+
print("Listening for audio...")
|
|
193
|
+
sys.stdout.flush()
|
|
194
|
+
try:
|
|
195
|
+
capture = AudioCapture()
|
|
196
|
+
except RuntimeError as e:
|
|
197
|
+
print(f"Error creating AudioCapture: {e}")
|
|
198
|
+
sys.stdout.flush()
|
|
199
|
+
return
|
|
200
|
+
except Exception as e:
|
|
201
|
+
print(f"Unexpected error creating AudioCapture: {type(e).__name__}: {e}")
|
|
202
|
+
sys.stdout.flush()
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
# Start capture first (CombinedAudioCapture updates channels during start)
|
|
206
|
+
try:
|
|
207
|
+
capture.start()
|
|
208
|
+
print("Capture started. Waiting for audio...")
|
|
209
|
+
sys.stdout.flush()
|
|
210
|
+
except Exception as e:
|
|
211
|
+
print(f"Error starting capture: {type(e).__name__}: {e}")
|
|
212
|
+
sys.stdout.flush()
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
# Get sample rate and channels from capture device AFTER start
|
|
217
|
+
sample_rate = capture.sample_rate
|
|
218
|
+
channels = capture.channels
|
|
219
|
+
|
|
220
|
+
silence_detector = SilenceDetector(
|
|
221
|
+
threshold=0.01, # Higher threshold to ignore background noise
|
|
222
|
+
silence_duration=30.0, # 30 seconds of silence = meeting ended
|
|
223
|
+
sample_rate=sample_rate,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Use same sample rate and channels as capture device
|
|
227
|
+
session = RecordingSession(
|
|
228
|
+
output_dir,
|
|
229
|
+
sample_rate=sample_rate,
|
|
230
|
+
channels=channels,
|
|
231
|
+
meeting_name=meeting_name,
|
|
232
|
+
)
|
|
233
|
+
recording_started = False
|
|
234
|
+
audio_detected = False
|
|
235
|
+
|
|
236
|
+
while not _stop_event.is_set():
|
|
237
|
+
audio = capture.get_audio(timeout=0.5)
|
|
238
|
+
if audio is None:
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
# Flatten if needed
|
|
242
|
+
if audio.ndim > 1:
|
|
243
|
+
audio = audio.flatten()
|
|
244
|
+
|
|
245
|
+
has_audio = silence_detector.is_audio_present(audio)
|
|
246
|
+
is_silence = silence_detector.update(audio)
|
|
247
|
+
|
|
248
|
+
# State machine for recording
|
|
249
|
+
if not session.is_active:
|
|
250
|
+
# Not recording - wait for audio to start
|
|
251
|
+
if has_audio:
|
|
252
|
+
filepath = session.start()
|
|
253
|
+
print(f"Recording started: {filepath.name}")
|
|
254
|
+
recording_started = True
|
|
255
|
+
audio_detected = True
|
|
256
|
+
else:
|
|
257
|
+
# Currently recording
|
|
258
|
+
session.write(audio)
|
|
259
|
+
|
|
260
|
+
if has_audio:
|
|
261
|
+
audio_detected = True
|
|
262
|
+
|
|
263
|
+
# Check for extended silence (meeting ended)
|
|
264
|
+
if is_silence and audio_detected:
|
|
265
|
+
filepath, duration = session.stop()
|
|
266
|
+
if filepath:
|
|
267
|
+
print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
|
|
268
|
+
else:
|
|
269
|
+
print("Recording discarded (too short)")
|
|
270
|
+
silence_detector.reset()
|
|
271
|
+
audio_detected = False
|
|
272
|
+
|
|
273
|
+
except Exception as e:
|
|
274
|
+
print(f"Error in capture loop: {e}")
|
|
275
|
+
finally:
|
|
276
|
+
capture.stop()
|
|
277
|
+
|
|
278
|
+
# Save any ongoing recording
|
|
279
|
+
if 'session' in locals() and session.is_active:
|
|
280
|
+
filepath, duration = session.stop()
|
|
281
|
+
if filepath:
|
|
282
|
+
print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
|
|
283
|
+
|
|
284
|
+
print("Daemon stopped.")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def check_screencapturekit_available() -> bool:
|
|
288
|
+
"""Check if ScreenCaptureKit is available and has permission."""
|
|
289
|
+
try:
|
|
290
|
+
from Quartz import CGPreflightScreenCaptureAccess
|
|
291
|
+
return CGPreflightScreenCaptureAccess()
|
|
292
|
+
except Exception:
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def check_status(pid_file: Path):
|
|
297
|
+
"""Check daemon status."""
|
|
298
|
+
# Check audio devices
|
|
299
|
+
audio_ok = check_audio_available()
|
|
300
|
+
screencapture_ok = check_screencapturekit_available()
|
|
301
|
+
|
|
302
|
+
if audio_ok:
|
|
303
|
+
click.echo("Microphone: " + click.style("available", fg="green"))
|
|
304
|
+
else:
|
|
305
|
+
click.echo("Microphone: " + click.style("not found", fg="red"))
|
|
306
|
+
click.echo(" Please check your microphone settings")
|
|
307
|
+
|
|
308
|
+
# System audio capture
|
|
309
|
+
if screencapture_ok:
|
|
310
|
+
click.echo("System audio: " + click.style("enabled", fg="green") + " (Screen Recording permission granted)")
|
|
311
|
+
else:
|
|
312
|
+
click.echo("System audio: " + click.style("not available", fg="yellow"))
|
|
313
|
+
click.echo(" Grant Screen Recording permission to capture other participants")
|
|
314
|
+
|
|
315
|
+
# Check daemon
|
|
316
|
+
pid = read_pid_file(pid_file)
|
|
317
|
+
|
|
318
|
+
if pid is None:
|
|
319
|
+
click.echo("Daemon: " + click.style("not running", fg="red"))
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
if is_process_running(pid):
|
|
323
|
+
click.echo("Daemon: " + click.style("running", fg="green") + f" (PID {pid})")
|
|
324
|
+
|
|
325
|
+
# Show log tail
|
|
326
|
+
log_path = Path.home() / ".meeting-noter.log"
|
|
327
|
+
if log_path.exists():
|
|
328
|
+
click.echo("\nRecent log entries:")
|
|
329
|
+
with open(log_path, "r") as f:
|
|
330
|
+
lines = f.readlines()
|
|
331
|
+
for line in lines[-5:]:
|
|
332
|
+
click.echo(f" {line.rstrip()}")
|
|
333
|
+
else:
|
|
334
|
+
click.echo("Daemon: " + click.style("not running", fg="red") + " (stale PID file)")
|
|
335
|
+
remove_pid_file(pid_file)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def stop_daemon(pid_file: Path):
|
|
339
|
+
"""Stop the running daemon."""
|
|
340
|
+
pid = read_pid_file(pid_file)
|
|
341
|
+
|
|
342
|
+
if pid is None:
|
|
343
|
+
click.echo("Daemon is not running")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
if not is_process_running(pid):
|
|
347
|
+
click.echo("Daemon is not running (cleaning up stale PID file)")
|
|
348
|
+
remove_pid_file(pid_file)
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
click.echo(f"Stopping daemon (PID {pid})...")
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
os.kill(pid, signal.SIGTERM)
|
|
355
|
+
|
|
356
|
+
# Wait for process to stop
|
|
357
|
+
for _ in range(10):
|
|
358
|
+
time.sleep(0.5)
|
|
359
|
+
if not is_process_running(pid):
|
|
360
|
+
break
|
|
361
|
+
|
|
362
|
+
if is_process_running(pid):
|
|
363
|
+
click.echo("Daemon did not stop gracefully, forcing...")
|
|
364
|
+
os.kill(pid, signal.SIGKILL)
|
|
365
|
+
|
|
366
|
+
click.echo(click.style("Daemon stopped", fg="green"))
|
|
367
|
+
except ProcessLookupError:
|
|
368
|
+
click.echo("Daemon already stopped")
|
|
369
|
+
finally:
|
|
370
|
+
remove_pid_file(pid_file)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def run_foreground_capture(
|
|
374
|
+
output_dir: Path,
|
|
375
|
+
meeting_name: str,
|
|
376
|
+
auto_transcribe: bool = True,
|
|
377
|
+
whisper_model: str = "tiny.en",
|
|
378
|
+
transcripts_dir: Optional[Path] = None,
|
|
379
|
+
silence_timeout_minutes: int = 5,
|
|
380
|
+
) -> Optional[Path]:
|
|
381
|
+
"""Run audio capture in foreground with a named meeting.
|
|
382
|
+
|
|
383
|
+
This function is used by the 'start' command for interactive recording.
|
|
384
|
+
Records until Ctrl+C is pressed or silence timeout, then optionally transcribes.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
output_dir: Directory to save recordings
|
|
388
|
+
meeting_name: Name of the meeting (used in filename)
|
|
389
|
+
auto_transcribe: Whether to transcribe after recording stops
|
|
390
|
+
whisper_model: Whisper model to use for transcription
|
|
391
|
+
transcripts_dir: Directory for transcripts
|
|
392
|
+
silence_timeout_minutes: Stop after this many minutes of silence
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Path to the saved recording, or None if recording was too short
|
|
396
|
+
"""
|
|
397
|
+
# Import audio modules (safe since no fork)
|
|
398
|
+
from meeting_noter.audio.capture import AudioCapture, SilenceDetector
|
|
399
|
+
from meeting_noter.audio.encoder import RecordingSession
|
|
400
|
+
from meeting_noter.config import get_config
|
|
401
|
+
|
|
402
|
+
config = get_config()
|
|
403
|
+
|
|
404
|
+
# Check audio device
|
|
405
|
+
if not check_audio_available():
|
|
406
|
+
click.echo(click.style("Error: ", fg="red") + "No audio input device found.")
|
|
407
|
+
click.echo("Please check your microphone settings.")
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
# Set up signal handlers
|
|
411
|
+
signal.signal(signal.SIGTERM, _signal_handler)
|
|
412
|
+
signal.signal(signal.SIGINT, _signal_handler)
|
|
413
|
+
|
|
414
|
+
click.echo(f"Meeting: {click.style(meeting_name, fg='cyan', bold=True)}")
|
|
415
|
+
click.echo(f"Output: {output_dir}")
|
|
416
|
+
click.echo(f"Silence timeout: {silence_timeout_minutes} minutes")
|
|
417
|
+
click.echo("Press Ctrl+C to stop recording.\n")
|
|
418
|
+
|
|
419
|
+
# Use combined capture (mic + system audio) if enabled
|
|
420
|
+
capture = None
|
|
421
|
+
if config.capture_system_audio:
|
|
422
|
+
try:
|
|
423
|
+
from meeting_noter.audio.system_audio import CombinedAudioCapture
|
|
424
|
+
capture = CombinedAudioCapture()
|
|
425
|
+
except Exception as e:
|
|
426
|
+
click.echo(click.style(f"Combined capture not available: {e}", fg="yellow"))
|
|
427
|
+
|
|
428
|
+
if capture is None:
|
|
429
|
+
try:
|
|
430
|
+
capture = AudioCapture()
|
|
431
|
+
except RuntimeError as e:
|
|
432
|
+
click.echo(click.style(f"Error: {e}", fg="red"))
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
session = RecordingSession(
|
|
436
|
+
output_dir,
|
|
437
|
+
sample_rate=capture.sample_rate,
|
|
438
|
+
channels=capture.channels,
|
|
439
|
+
meeting_name=meeting_name,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Silence detection
|
|
443
|
+
silence_detector = SilenceDetector(
|
|
444
|
+
threshold=0.01,
|
|
445
|
+
silence_duration=silence_timeout_minutes * 60.0, # Convert to seconds
|
|
446
|
+
sample_rate=capture.sample_rate,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
saved_filepath = None
|
|
450
|
+
stopped_by_silence = False
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
capture.start()
|
|
454
|
+
|
|
455
|
+
# Start recording immediately
|
|
456
|
+
filepath = session.start()
|
|
457
|
+
click.echo(click.style("Recording: ", fg="green") + filepath.name)
|
|
458
|
+
|
|
459
|
+
while not _stop_event.is_set():
|
|
460
|
+
audio = capture.get_audio(timeout=0.5)
|
|
461
|
+
if audio is None:
|
|
462
|
+
continue
|
|
463
|
+
|
|
464
|
+
# Flatten if needed
|
|
465
|
+
if audio.ndim > 1:
|
|
466
|
+
audio = audio.flatten()
|
|
467
|
+
|
|
468
|
+
session.write(audio)
|
|
469
|
+
|
|
470
|
+
# Check for extended silence
|
|
471
|
+
if silence_detector.update(audio):
|
|
472
|
+
click.echo("\n" + click.style("Stopped: ", fg="yellow") + "silence timeout reached")
|
|
473
|
+
stopped_by_silence = True
|
|
474
|
+
break
|
|
475
|
+
|
|
476
|
+
# Show live duration every few seconds
|
|
477
|
+
duration = session.duration
|
|
478
|
+
if int(duration) % 5 == 0 and duration > 0:
|
|
479
|
+
mins, secs = divmod(int(duration), 60)
|
|
480
|
+
click.echo(f"\r Duration: {mins:02d}:{secs:02d}", nl=False)
|
|
481
|
+
|
|
482
|
+
except Exception as e:
|
|
483
|
+
click.echo(click.style(f"\nError: {e}", fg="red"))
|
|
484
|
+
finally:
|
|
485
|
+
capture.stop()
|
|
486
|
+
|
|
487
|
+
# Save recording
|
|
488
|
+
if session.is_active:
|
|
489
|
+
saved_filepath, duration = session.stop()
|
|
490
|
+
if not stopped_by_silence:
|
|
491
|
+
click.echo() # New line after duration display
|
|
492
|
+
|
|
493
|
+
if saved_filepath:
|
|
494
|
+
mins, secs = divmod(int(duration), 60)
|
|
495
|
+
click.echo(
|
|
496
|
+
click.style("\nSaved: ", fg="green") +
|
|
497
|
+
f"{saved_filepath.name} ({mins:02d}:{secs:02d})"
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# Auto-transcribe if enabled
|
|
501
|
+
if auto_transcribe:
|
|
502
|
+
click.echo(click.style("\nTranscribing...", fg="cyan"))
|
|
503
|
+
try:
|
|
504
|
+
from meeting_noter.transcription.engine import transcribe_file
|
|
505
|
+
transcribe_file(str(saved_filepath), output_dir, whisper_model, transcripts_dir)
|
|
506
|
+
except Exception as e:
|
|
507
|
+
click.echo(click.style(f"Transcription error: {e}", fg="red"))
|
|
508
|
+
else:
|
|
509
|
+
click.echo(click.style("\nRecording discarded", fg="yellow") + " (too short)")
|
|
510
|
+
|
|
511
|
+
# Reset stop event for potential future use
|
|
512
|
+
_stop_event.clear()
|
|
513
|
+
|
|
514
|
+
return saved_filepath
|