meeting-noter 0.3.5__tar.gz → 0.6.0__tar.gz
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-0.3.5 → meeting_noter-0.6.0}/PKG-INFO +6 -12
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/README.md +4 -9
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/pyproject.toml +2 -7
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/audio/encoder.py +58 -51
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/menubar.py +36 -57
- meeting_noter-0.6.0/src/meeting_noter/mic_monitor.py +404 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter.egg-info/PKG-INFO +6 -12
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter.egg-info/SOURCES.txt +1 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter.egg-info/requires.txt +1 -3
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/setup.cfg +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/__init__.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/__main__.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/audio/__init__.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/audio/capture.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/audio/system_audio.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/cli.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/config.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/daemon.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/gui/__init__.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/gui/__main__.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/gui/app.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/gui/main_window.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/gui/meetings_tab.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/gui/recording_tab.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/gui/settings_tab.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/install/__init__.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/install/macos.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/meeting_detector.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/output/__init__.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/output/writer.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/resources/__init__.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/resources/icon.icns +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/resources/icon.png +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/resources/icon_128.png +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/resources/icon_16.png +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/resources/icon_256.png +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/resources/icon_32.png +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/resources/icon_512.png +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/resources/icon_64.png +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/transcription/__init__.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter/transcription/engine.py +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter.egg-info/dependency_links.txt +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter.egg-info/entry_points.txt +0 -0
- {meeting_noter-0.3.5 → meeting_noter-0.6.0}/src/meeting_noter.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meeting-noter
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Offline meeting transcription for macOS with automatic meeting detection
|
|
5
5
|
Author: Victor
|
|
6
6
|
License: MIT
|
|
@@ -28,14 +28,13 @@ Requires-Dist: numpy>=1.24
|
|
|
28
28
|
Requires-Dist: faster-whisper>=1.0.0
|
|
29
29
|
Requires-Dist: rumps>=0.4.0
|
|
30
30
|
Requires-Dist: PyQt6>=6.5.0
|
|
31
|
+
Requires-Dist: imageio-ffmpeg>=0.4.9
|
|
31
32
|
Requires-Dist: pyobjc-framework-Cocoa>=9.0; sys_platform == "darwin"
|
|
32
33
|
Requires-Dist: pyobjc-framework-Quartz>=9.0; sys_platform == "darwin"
|
|
33
34
|
Requires-Dist: pyobjc-framework-ScreenCaptureKit>=9.0; sys_platform == "darwin"
|
|
34
35
|
Requires-Dist: pyobjc-framework-AVFoundation>=9.0; sys_platform == "darwin"
|
|
35
36
|
Requires-Dist: pyobjc-framework-CoreMedia>=9.0; sys_platform == "darwin"
|
|
36
37
|
Requires-Dist: pyobjc-framework-libdispatch>=9.0; sys_platform == "darwin"
|
|
37
|
-
Provides-Extra: mp3
|
|
38
|
-
Requires-Dist: lameenc>=1.5.0; extra == "mp3"
|
|
39
38
|
Provides-Extra: dev
|
|
40
39
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
41
40
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -59,11 +58,7 @@ Offline meeting transcription tool for macOS. Captures both your voice and meeti
|
|
|
59
58
|
## Installation
|
|
60
59
|
|
|
61
60
|
```bash
|
|
62
|
-
|
|
63
|
-
brew install ffmpeg lame pkg-config python@3.12
|
|
64
|
-
|
|
65
|
-
# Install meeting-noter (use Python 3.12 for best compatibility)
|
|
66
|
-
pipx install meeting-noter --python /opt/homebrew/bin/python3.12
|
|
61
|
+
pipx install meeting-noter
|
|
67
62
|
```
|
|
68
63
|
|
|
69
64
|
Or with pip:
|
|
@@ -71,6 +66,8 @@ Or with pip:
|
|
|
71
66
|
pip install meeting-noter
|
|
72
67
|
```
|
|
73
68
|
|
|
69
|
+
No system dependencies required - ffmpeg is bundled automatically.
|
|
70
|
+
|
|
74
71
|
## Quick Start
|
|
75
72
|
|
|
76
73
|
**Menu Bar App** (recommended):
|
|
@@ -208,10 +205,7 @@ Config file: `~/.config/meeting-noter/config.json`
|
|
|
208
205
|
## Requirements
|
|
209
206
|
|
|
210
207
|
- macOS 12.3+ (for ScreenCaptureKit)
|
|
211
|
-
- Python 3.9+
|
|
212
|
-
- FFmpeg (`brew install ffmpeg`) - required for audio processing
|
|
213
|
-
- LAME (`brew install lame`) - required for MP3 encoding
|
|
214
|
-
- pkg-config (`brew install pkg-config`) - required for building dependencies
|
|
208
|
+
- Python 3.9+
|
|
215
209
|
|
|
216
210
|
## License
|
|
217
211
|
|
|
@@ -14,11 +14,7 @@ Offline meeting transcription tool for macOS. Captures both your voice and meeti
|
|
|
14
14
|
## Installation
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
|
|
18
|
-
brew install ffmpeg lame pkg-config python@3.12
|
|
19
|
-
|
|
20
|
-
# Install meeting-noter (use Python 3.12 for best compatibility)
|
|
21
|
-
pipx install meeting-noter --python /opt/homebrew/bin/python3.12
|
|
17
|
+
pipx install meeting-noter
|
|
22
18
|
```
|
|
23
19
|
|
|
24
20
|
Or with pip:
|
|
@@ -26,6 +22,8 @@ Or with pip:
|
|
|
26
22
|
pip install meeting-noter
|
|
27
23
|
```
|
|
28
24
|
|
|
25
|
+
No system dependencies required - ffmpeg is bundled automatically.
|
|
26
|
+
|
|
29
27
|
## Quick Start
|
|
30
28
|
|
|
31
29
|
**Menu Bar App** (recommended):
|
|
@@ -163,10 +161,7 @@ Config file: `~/.config/meeting-noter/config.json`
|
|
|
163
161
|
## Requirements
|
|
164
162
|
|
|
165
163
|
- macOS 12.3+ (for ScreenCaptureKit)
|
|
166
|
-
- Python 3.9+
|
|
167
|
-
- FFmpeg (`brew install ffmpeg`) - required for audio processing
|
|
168
|
-
- LAME (`brew install lame`) - required for MP3 encoding
|
|
169
|
-
- pkg-config (`brew install pkg-config`) - required for building dependencies
|
|
164
|
+
- Python 3.9+
|
|
170
165
|
|
|
171
166
|
## License
|
|
172
167
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "meeting-noter"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Offline meeting transcription for macOS with automatic meeting detection"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -35,6 +35,7 @@ dependencies = [
|
|
|
35
35
|
"faster-whisper>=1.0.0",
|
|
36
36
|
"rumps>=0.4.0",
|
|
37
37
|
"PyQt6>=6.5.0",
|
|
38
|
+
"imageio-ffmpeg>=0.4.9", # Bundles ffmpeg binary for MP3 encoding
|
|
38
39
|
"pyobjc-framework-Cocoa>=9.0; sys_platform == 'darwin'",
|
|
39
40
|
"pyobjc-framework-Quartz>=9.0; sys_platform == 'darwin'",
|
|
40
41
|
"pyobjc-framework-ScreenCaptureKit>=9.0; sys_platform == 'darwin'",
|
|
@@ -43,13 +44,7 @@ dependencies = [
|
|
|
43
44
|
"pyobjc-framework-libdispatch>=9.0; sys_platform == 'darwin'",
|
|
44
45
|
]
|
|
45
46
|
|
|
46
|
-
# lameenc requires LAME to be installed (brew install lame)
|
|
47
|
-
# It will be installed automatically if LAME is present
|
|
48
|
-
|
|
49
47
|
[project.optional-dependencies]
|
|
50
|
-
mp3 = [
|
|
51
|
-
"lameenc>=1.5.0",
|
|
52
|
-
]
|
|
53
48
|
dev = [
|
|
54
49
|
"pytest>=7.0",
|
|
55
50
|
"pytest-cov",
|
|
@@ -1,37 +1,21 @@
|
|
|
1
|
-
"""MP3 encoding for audio recordings."""
|
|
1
|
+
"""MP3 encoding for audio recordings using ffmpeg."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
-
import
|
|
6
|
+
import subprocess
|
|
7
7
|
import numpy as np
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from datetime import datetime
|
|
10
10
|
from typing import Optional, Tuple
|
|
11
11
|
|
|
12
|
-
#
|
|
12
|
+
# Get bundled ffmpeg binary path
|
|
13
13
|
try:
|
|
14
|
-
import
|
|
15
|
-
|
|
14
|
+
import imageio_ffmpeg
|
|
15
|
+
FFMPEG_PATH = imageio_ffmpeg.get_ffmpeg_exe()
|
|
16
16
|
except ImportError:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _check_lameenc():
|
|
22
|
-
"""Check if lameenc is available, raise helpful error if not."""
|
|
23
|
-
if not LAMEENC_AVAILABLE:
|
|
24
|
-
print("\n" + "=" * 60, file=sys.stderr)
|
|
25
|
-
print("ERROR: MP3 encoding requires the 'lame' library", file=sys.stderr)
|
|
26
|
-
print("=" * 60, file=sys.stderr)
|
|
27
|
-
print("\nTo fix this, run:", file=sys.stderr)
|
|
28
|
-
print("\n brew install lame", file=sys.stderr)
|
|
29
|
-
print("\nThen reinstall meeting-noter:", file=sys.stderr)
|
|
30
|
-
print("\n pip install --force-reinstall meeting-noter", file=sys.stderr)
|
|
31
|
-
print("\n" + "=" * 60 + "\n", file=sys.stderr)
|
|
32
|
-
raise ImportError(
|
|
33
|
-
"lameenc not available. Install LAME first: brew install lame"
|
|
34
|
-
)
|
|
17
|
+
# Fallback to system ffmpeg
|
|
18
|
+
FFMPEG_PATH = "ffmpeg"
|
|
35
19
|
|
|
36
20
|
|
|
37
21
|
def _sanitize_filename(name: str, max_length: int = 50) -> str:
|
|
@@ -62,24 +46,42 @@ def _is_timestamp_name(name: str) -> bool:
|
|
|
62
46
|
|
|
63
47
|
|
|
64
48
|
class MP3Encoder:
|
|
65
|
-
"""Encodes audio data to MP3 format."""
|
|
49
|
+
"""Encodes audio data to MP3 format using ffmpeg."""
|
|
66
50
|
|
|
67
51
|
def __init__(
|
|
68
52
|
self,
|
|
53
|
+
output_path: Path,
|
|
69
54
|
sample_rate: int = 16000,
|
|
70
55
|
channels: int = 1,
|
|
71
56
|
bitrate: int = 128,
|
|
72
|
-
quality: int = 2,
|
|
73
57
|
):
|
|
74
|
-
|
|
58
|
+
self.output_path = output_path
|
|
75
59
|
self.sample_rate = sample_rate
|
|
76
60
|
self.channels = channels
|
|
77
|
-
self.
|
|
78
|
-
self.
|
|
79
|
-
self.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
61
|
+
self.bitrate = bitrate
|
|
62
|
+
self._process: Optional[subprocess.Popen] = None
|
|
63
|
+
self._start_ffmpeg()
|
|
64
|
+
|
|
65
|
+
def _start_ffmpeg(self):
|
|
66
|
+
"""Start ffmpeg process for encoding."""
|
|
67
|
+
cmd = [
|
|
68
|
+
FFMPEG_PATH,
|
|
69
|
+
"-y", # Overwrite output
|
|
70
|
+
"-f", "s16le", # Input format: signed 16-bit little-endian PCM
|
|
71
|
+
"-ar", str(self.sample_rate), # Sample rate
|
|
72
|
+
"-ac", str(self.channels), # Channels
|
|
73
|
+
"-i", "pipe:0", # Read from stdin
|
|
74
|
+
"-codec:a", "libmp3lame", # MP3 encoder
|
|
75
|
+
"-b:a", f"{self.bitrate}k", # Bitrate
|
|
76
|
+
"-f", "mp3", # Output format
|
|
77
|
+
str(self.output_path),
|
|
78
|
+
]
|
|
79
|
+
self._process = subprocess.Popen(
|
|
80
|
+
cmd,
|
|
81
|
+
stdin=subprocess.PIPE,
|
|
82
|
+
stdout=subprocess.DEVNULL,
|
|
83
|
+
stderr=subprocess.DEVNULL,
|
|
84
|
+
)
|
|
83
85
|
|
|
84
86
|
def encode_chunk(self, audio: np.ndarray) -> bytes:
|
|
85
87
|
"""Encode a chunk of audio data.
|
|
@@ -88,16 +90,29 @@ class MP3Encoder:
|
|
|
88
90
|
audio: Float32 audio data, values between -1 and 1
|
|
89
91
|
|
|
90
92
|
Returns:
|
|
91
|
-
|
|
93
|
+
Empty bytes (ffmpeg writes directly to file)
|
|
92
94
|
"""
|
|
95
|
+
if self._process is None or self._process.stdin is None:
|
|
96
|
+
return b""
|
|
97
|
+
|
|
93
98
|
# Convert float32 to int16
|
|
94
99
|
int_data = (audio * 32767).astype(np.int16)
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
try:
|
|
101
|
+
self._process.stdin.write(int_data.tobytes())
|
|
102
|
+
except BrokenPipeError:
|
|
103
|
+
pass
|
|
104
|
+
return b"" # ffmpeg writes to file, not returning data
|
|
97
105
|
|
|
98
106
|
def finalize(self) -> bytes:
|
|
99
|
-
"""Finalize encoding
|
|
100
|
-
|
|
107
|
+
"""Finalize encoding."""
|
|
108
|
+
if self._process is not None and self._process.stdin is not None:
|
|
109
|
+
try:
|
|
110
|
+
self._process.stdin.close()
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
self._process.wait()
|
|
114
|
+
self._process = None
|
|
115
|
+
return b""
|
|
101
116
|
|
|
102
117
|
|
|
103
118
|
class RecordingSession:
|
|
@@ -115,7 +130,6 @@ class RecordingSession:
|
|
|
115
130
|
self.channels = channels
|
|
116
131
|
self.meeting_name = meeting_name
|
|
117
132
|
self.encoder: Optional[MP3Encoder] = None
|
|
118
|
-
self.file_handle = None
|
|
119
133
|
self.filepath: Optional[Path] = None
|
|
120
134
|
self.start_time: Optional[datetime] = None
|
|
121
135
|
self.total_samples = 0
|
|
@@ -139,22 +153,20 @@ class RecordingSession:
|
|
|
139
153
|
self.filepath = self.output_dir / filename
|
|
140
154
|
|
|
141
155
|
self.encoder = MP3Encoder(
|
|
156
|
+
output_path=self.filepath,
|
|
142
157
|
sample_rate=self.sample_rate,
|
|
143
158
|
channels=self.channels,
|
|
144
159
|
)
|
|
145
|
-
self.file_handle = open(self.filepath, "wb")
|
|
146
160
|
self.total_samples = 0
|
|
147
161
|
|
|
148
162
|
return self.filepath
|
|
149
163
|
|
|
150
164
|
def write(self, audio: np.ndarray):
|
|
151
165
|
"""Write audio data to the recording."""
|
|
152
|
-
if self.encoder is None
|
|
166
|
+
if self.encoder is None:
|
|
153
167
|
raise RuntimeError("Recording session not started")
|
|
154
168
|
|
|
155
|
-
|
|
156
|
-
if mp3_data:
|
|
157
|
-
self.file_handle.write(mp3_data)
|
|
169
|
+
self.encoder.encode_chunk(audio)
|
|
158
170
|
self.total_samples += len(audio)
|
|
159
171
|
|
|
160
172
|
def stop(self) -> Tuple[Optional[Path], float]:
|
|
@@ -166,13 +178,9 @@ class RecordingSession:
|
|
|
166
178
|
duration = 0.0
|
|
167
179
|
filepath = self.filepath
|
|
168
180
|
|
|
169
|
-
if self.encoder
|
|
170
|
-
#
|
|
171
|
-
|
|
172
|
-
if final_data:
|
|
173
|
-
self.file_handle.write(final_data)
|
|
174
|
-
self.file_handle.close()
|
|
175
|
-
|
|
181
|
+
if self.encoder:
|
|
182
|
+
# Finalize encoding
|
|
183
|
+
self.encoder.finalize()
|
|
176
184
|
duration = self.total_samples / self.sample_rate
|
|
177
185
|
|
|
178
186
|
# Delete if too short (less than 5 seconds)
|
|
@@ -181,7 +189,6 @@ class RecordingSession:
|
|
|
181
189
|
filepath = None
|
|
182
190
|
|
|
183
191
|
self.encoder = None
|
|
184
|
-
self.file_handle = None
|
|
185
192
|
self.filepath = None
|
|
186
193
|
self.start_time = None
|
|
187
194
|
self.total_samples = 0
|
|
@@ -13,7 +13,7 @@ import rumps
|
|
|
13
13
|
|
|
14
14
|
from meeting_noter.daemon import read_pid_file, is_process_running, stop_daemon
|
|
15
15
|
from meeting_noter.config import get_config, generate_meeting_name
|
|
16
|
-
from meeting_noter.
|
|
16
|
+
from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
|
|
@@ -42,11 +42,10 @@ class MeetingNoterApp(rumps.App):
|
|
|
42
42
|
super().__init__("Meeting Noter", title="▷")
|
|
43
43
|
self.pid_file = DEFAULT_PID_FILE
|
|
44
44
|
self.config = get_config()
|
|
45
|
-
self.
|
|
45
|
+
self.mic_monitor = MicrophoneMonitor()
|
|
46
46
|
self.current_meeting_name: Optional[str] = None
|
|
47
47
|
self.pending_notification = False # Avoid duplicate notifications
|
|
48
|
-
self.
|
|
49
|
-
self._initial_check_done = False # Flag for initial meeting check
|
|
48
|
+
self._pending_app_name: Optional[str] = None
|
|
50
49
|
self.menu = [
|
|
51
50
|
"Start Recording",
|
|
52
51
|
"Stop Recording",
|
|
@@ -138,7 +137,7 @@ class MeetingNoterApp(rumps.App):
|
|
|
138
137
|
except FileNotFoundError:
|
|
139
138
|
pass
|
|
140
139
|
|
|
141
|
-
def _start_recording_with_name(self, meeting_name: str):
|
|
140
|
+
def _start_recording_with_name(self, meeting_name: str, app_name: Optional[str] = None):
|
|
142
141
|
"""Start recording with a specific meeting name."""
|
|
143
142
|
import sys
|
|
144
143
|
|
|
@@ -147,6 +146,7 @@ class MeetingNoterApp(rumps.App):
|
|
|
147
146
|
|
|
148
147
|
self.current_meeting_name = meeting_name
|
|
149
148
|
self._save_recording_state(meeting_name)
|
|
149
|
+
self.mic_monitor.set_recording(True, app_name)
|
|
150
150
|
|
|
151
151
|
# Start daemon with the meeting name using current Python
|
|
152
152
|
subprocess.Popen(
|
|
@@ -167,13 +167,7 @@ class MeetingNoterApp(rumps.App):
|
|
|
167
167
|
)
|
|
168
168
|
return
|
|
169
169
|
|
|
170
|
-
|
|
171
|
-
meeting_info = self.meeting_monitor.last_meeting
|
|
172
|
-
if meeting_info and meeting_info.meeting_name:
|
|
173
|
-
meeting_name = meeting_info.meeting_name
|
|
174
|
-
else:
|
|
175
|
-
meeting_name = generate_meeting_name()
|
|
176
|
-
|
|
170
|
+
meeting_name = generate_meeting_name()
|
|
177
171
|
self._start_recording_with_name(meeting_name)
|
|
178
172
|
|
|
179
173
|
rumps.notification(
|
|
@@ -281,58 +275,36 @@ class MeetingNoterApp(rumps.App):
|
|
|
281
275
|
stderr=subprocess.DEVNULL,
|
|
282
276
|
)
|
|
283
277
|
|
|
284
|
-
@rumps.timer(
|
|
278
|
+
@rumps.timer(2)
|
|
285
279
|
def poll_status(self, _):
|
|
286
|
-
"""Periodically update the menu bar title and check for
|
|
280
|
+
"""Periodically update the menu bar title and check for mic usage."""
|
|
287
281
|
self._update_title()
|
|
288
282
|
|
|
289
283
|
is_recording = self._is_running()
|
|
290
284
|
|
|
291
|
-
#
|
|
292
|
-
|
|
285
|
+
# Tell mic monitor our recording state
|
|
286
|
+
self.mic_monitor.set_recording(is_recording)
|
|
293
287
|
|
|
294
|
-
#
|
|
295
|
-
|
|
296
|
-
self._initial_check_done = True
|
|
297
|
-
if meeting_info and not is_recording:
|
|
298
|
-
meeting_started = True
|
|
288
|
+
# Check for mic usage changes
|
|
289
|
+
mic_started, mic_stopped, app_name = self.mic_monitor.check()
|
|
299
290
|
|
|
300
|
-
# Auto-stop recording when
|
|
301
|
-
if
|
|
291
|
+
# Auto-stop recording when mic stops being used
|
|
292
|
+
if mic_stopped and is_recording:
|
|
302
293
|
rumps.notification(
|
|
303
294
|
title="Meeting Noter",
|
|
304
|
-
subtitle="
|
|
295
|
+
subtitle="Call Ended",
|
|
305
296
|
message="Stopping recording...",
|
|
306
297
|
)
|
|
307
|
-
# Trigger stop recording (will auto-transcribe)
|
|
308
298
|
self._auto_stop_recording()
|
|
309
|
-
return
|
|
310
|
-
|
|
311
|
-
# Also check: if we're recording but no meeting detected for a while
|
|
312
|
-
# This catches cases where meeting window changes unexpectedly
|
|
313
|
-
if is_recording and not meeting_info and not self.meeting_monitor.is_in_meeting():
|
|
314
|
-
# Double-check by waiting one more cycle
|
|
315
|
-
if hasattr(self, '_no_meeting_count'):
|
|
316
|
-
self._no_meeting_count += 1
|
|
317
|
-
if self._no_meeting_count >= 2: # ~6 seconds of no meeting
|
|
318
|
-
rumps.notification(
|
|
319
|
-
title="Meeting Noter",
|
|
320
|
-
subtitle="Meeting Ended",
|
|
321
|
-
message="Stopping recording...",
|
|
322
|
-
)
|
|
323
|
-
self._auto_stop_recording()
|
|
324
|
-
self._no_meeting_count = 0
|
|
325
|
-
else:
|
|
326
|
-
self._no_meeting_count = 1
|
|
327
|
-
else:
|
|
328
|
-
self._no_meeting_count = 0
|
|
299
|
+
return
|
|
329
300
|
|
|
330
|
-
#
|
|
331
|
-
if not is_recording and not self.pending_notification:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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()
|
|
336
308
|
|
|
337
309
|
def _auto_stop_recording(self):
|
|
338
310
|
"""Stop recording automatically when meeting ends."""
|
|
@@ -384,24 +356,31 @@ class MeetingNoterApp(rumps.App):
|
|
|
384
356
|
@rumps.timer(1)
|
|
385
357
|
def check_pending_prompt(self, _):
|
|
386
358
|
"""Check if we need to show a recording prompt (runs on main thread)."""
|
|
387
|
-
if self.pending_notification and hasattr(self, '
|
|
388
|
-
|
|
359
|
+
if self.pending_notification and hasattr(self, '_pending_meeting_name'):
|
|
360
|
+
app_name = self._pending_app_name or "App"
|
|
389
361
|
meeting_name = self._pending_meeting_name
|
|
390
362
|
|
|
391
363
|
# Clear first to prevent re-triggering
|
|
392
|
-
self.
|
|
364
|
+
self._pending_app_name = None
|
|
393
365
|
self.pending_notification = False
|
|
394
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
|
+
|
|
395
374
|
# Show alert on main thread
|
|
396
375
|
response = rumps.alert(
|
|
397
|
-
title=f"
|
|
398
|
-
message=
|
|
376
|
+
title=f"Microphone in use: {app_name}",
|
|
377
|
+
message=message,
|
|
399
378
|
ok="Record",
|
|
400
379
|
cancel="Skip",
|
|
401
380
|
)
|
|
402
381
|
|
|
403
382
|
if response == 1: # Record clicked
|
|
404
|
-
self._start_recording_with_name(meeting_name)
|
|
383
|
+
self._start_recording_with_name(meeting_name, app_name)
|
|
405
384
|
rumps.notification(
|
|
406
385
|
title="Meeting Noter",
|
|
407
386
|
subtitle="Recording Started",
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Monitor microphone usage to detect when meetings start/end."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
from ctypes import c_uint32, c_int32, byref, POINTER, Structure
|
|
7
|
+
import time
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class MicStatus:
|
|
14
|
+
"""Status of microphone usage."""
|
|
15
|
+
is_in_use: bool
|
|
16
|
+
app_name: Optional[str] = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# CoreAudio constants
|
|
20
|
+
_kAudioObjectSystemObject = 1
|
|
21
|
+
_kAudioHardwarePropertyDefaultInputDevice = 1682533920 # 'dIn '
|
|
22
|
+
_kAudioDevicePropertyDeviceIsRunningSomewhere = 1735356005 # 'gone'
|
|
23
|
+
_kAudioObjectPropertyScopeGlobal = 1735159650 # 'glob'
|
|
24
|
+
_kAudioObjectPropertyElementMain = 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _AudioObjectPropertyAddress(Structure):
|
|
28
|
+
_fields_ = [
|
|
29
|
+
('mSelector', c_uint32),
|
|
30
|
+
('mScope', c_uint32),
|
|
31
|
+
('mElement', c_uint32),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Load CoreAudio framework lazily
|
|
36
|
+
_core_audio = None
|
|
37
|
+
_AudioObjectGetPropertyDataSize = None
|
|
38
|
+
_AudioObjectGetPropertyData = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _init_coreaudio():
|
|
42
|
+
"""Initialize CoreAudio framework."""
|
|
43
|
+
global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData
|
|
44
|
+
|
|
45
|
+
if _core_audio is not None:
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
_core_audio = ctypes.CDLL('/System/Library/Frameworks/CoreAudio.framework/CoreAudio')
|
|
50
|
+
|
|
51
|
+
_AudioObjectGetPropertyDataSize = _core_audio.AudioObjectGetPropertyDataSize
|
|
52
|
+
_AudioObjectGetPropertyDataSize.argtypes = [
|
|
53
|
+
c_uint32, POINTER(_AudioObjectPropertyAddress), c_uint32, ctypes.c_void_p, POINTER(c_uint32)
|
|
54
|
+
]
|
|
55
|
+
_AudioObjectGetPropertyDataSize.restype = c_int32
|
|
56
|
+
|
|
57
|
+
_AudioObjectGetPropertyData = _core_audio.AudioObjectGetPropertyData
|
|
58
|
+
_AudioObjectGetPropertyData.argtypes = [
|
|
59
|
+
c_uint32, POINTER(_AudioObjectPropertyAddress), c_uint32, ctypes.c_void_p, POINTER(c_uint32), ctypes.c_void_p
|
|
60
|
+
]
|
|
61
|
+
_AudioObjectGetPropertyData.restype = c_int32
|
|
62
|
+
|
|
63
|
+
return True
|
|
64
|
+
except Exception:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_mic_in_use_by_another_app() -> bool:
|
|
69
|
+
"""Check if the microphone is being used by another application.
|
|
70
|
+
|
|
71
|
+
Uses CoreAudio's kAudioDevicePropertyDeviceIsRunningSomewhere property
|
|
72
|
+
to detect if any app has an active audio input session.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
True if another app is using the microphone
|
|
76
|
+
"""
|
|
77
|
+
if not _init_coreaudio():
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
# Get default input device
|
|
82
|
+
addr = _AudioObjectPropertyAddress(
|
|
83
|
+
_kAudioHardwarePropertyDefaultInputDevice,
|
|
84
|
+
_kAudioObjectPropertyScopeGlobal,
|
|
85
|
+
_kAudioObjectPropertyElementMain
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
size = c_uint32(4)
|
|
89
|
+
device_id = c_uint32(0)
|
|
90
|
+
|
|
91
|
+
err = _AudioObjectGetPropertyData(
|
|
92
|
+
_kAudioObjectSystemObject, byref(addr), 0, None, byref(size), byref(device_id)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if err != 0 or device_id.value == 0:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
# Check if device is running somewhere (another app using it)
|
|
99
|
+
addr_running = _AudioObjectPropertyAddress(
|
|
100
|
+
_kAudioDevicePropertyDeviceIsRunningSomewhere,
|
|
101
|
+
_kAudioObjectPropertyScopeGlobal,
|
|
102
|
+
_kAudioObjectPropertyElementMain
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
is_running = c_uint32(0)
|
|
106
|
+
size = c_uint32(4)
|
|
107
|
+
|
|
108
|
+
err = _AudioObjectGetPropertyData(
|
|
109
|
+
device_id.value, byref(addr_running), 0, None, byref(size), byref(is_running)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return err == 0 and is_running.value != 0
|
|
113
|
+
|
|
114
|
+
except Exception:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_meeting_app_active() -> Optional[str]:
|
|
119
|
+
"""Check if a meeting app has an ACTIVE MEETING window.
|
|
120
|
+
|
|
121
|
+
Returns the app name only if there's an active call/meeting,
|
|
122
|
+
not just because the app is open.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
import Quartz
|
|
126
|
+
|
|
127
|
+
# Get all on-screen windows
|
|
128
|
+
windows = Quartz.CGWindowListCopyWindowInfo(
|
|
129
|
+
Quartz.kCGWindowListOptionOnScreenOnly,
|
|
130
|
+
Quartz.kCGNullWindowID
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
for win in windows:
|
|
134
|
+
owner = win.get(Quartz.kCGWindowOwnerName, "")
|
|
135
|
+
title = win.get(Quartz.kCGWindowName, "") or ""
|
|
136
|
+
|
|
137
|
+
if not owner:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
owner_lower = owner.lower()
|
|
141
|
+
title_lower = title.lower()
|
|
142
|
+
|
|
143
|
+
# Zoom: detect meeting windows (not just "Zoom Workplace" or "Zoom")
|
|
144
|
+
if "zoom.us" in owner_lower:
|
|
145
|
+
# Skip non-meeting windows
|
|
146
|
+
if title_lower in ["", "zoom", "zoom workplace", "zoom.us"]:
|
|
147
|
+
continue
|
|
148
|
+
# This is likely a meeting window
|
|
149
|
+
return "Zoom"
|
|
150
|
+
|
|
151
|
+
# Microsoft Teams: detect call windows
|
|
152
|
+
if "microsoft teams" in owner_lower:
|
|
153
|
+
# Skip main app windows
|
|
154
|
+
if title_lower in ["", "microsoft teams"]:
|
|
155
|
+
continue
|
|
156
|
+
# Check for call indicators
|
|
157
|
+
if any(x in title_lower for x in ["call", "meeting", "chat"]):
|
|
158
|
+
return "Teams"
|
|
159
|
+
return "Teams"
|
|
160
|
+
|
|
161
|
+
# Slack: detect huddle/call windows
|
|
162
|
+
if "slack" in owner_lower:
|
|
163
|
+
if any(x in title_lower for x in ["huddle", "call"]):
|
|
164
|
+
return "Slack"
|
|
165
|
+
|
|
166
|
+
# FaceTime
|
|
167
|
+
if "facetime" in owner_lower:
|
|
168
|
+
if title and title_lower != "facetime":
|
|
169
|
+
return "FaceTime"
|
|
170
|
+
|
|
171
|
+
# Webex
|
|
172
|
+
if "webex" in owner_lower:
|
|
173
|
+
if title and "meeting" in title_lower:
|
|
174
|
+
return "Webex"
|
|
175
|
+
|
|
176
|
+
# Discord: detect voice channel
|
|
177
|
+
if "discord" in owner_lower:
|
|
178
|
+
# Discord shows voice channel name when in call
|
|
179
|
+
if title and title_lower != "discord":
|
|
180
|
+
return "Discord"
|
|
181
|
+
|
|
182
|
+
# Browser-based meetings (Google Meet, etc.)
|
|
183
|
+
if any(browser in owner_lower for browser in ["chrome", "safari", "firefox", "edge", "brave", "arc"]):
|
|
184
|
+
# Google Meet: title starts with "Meet -" or contains "meet.google.com"
|
|
185
|
+
if title_lower.startswith("meet -") or "meet.google.com" in title_lower:
|
|
186
|
+
return "Google Meet"
|
|
187
|
+
# Generic "Meeting" in browser title
|
|
188
|
+
if " meeting" in title_lower and any(x in title_lower for x in ["zoom", "teams", "webex"]):
|
|
189
|
+
return "Browser Meeting"
|
|
190
|
+
|
|
191
|
+
return None
|
|
192
|
+
except Exception:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_meeting_window_title() -> Optional[str]:
|
|
197
|
+
"""Get the window title of the active meeting app.
|
|
198
|
+
|
|
199
|
+
Returns the window title (meeting name) if found, None otherwise.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
import Quartz
|
|
203
|
+
|
|
204
|
+
# Known meeting apps
|
|
205
|
+
meeting_apps = ["zoom.us", "Microsoft Teams", "Slack", "Discord", "FaceTime", "Webex"]
|
|
206
|
+
browsers = ["chrome", "safari", "firefox", "edge", "brave", "arc"]
|
|
207
|
+
|
|
208
|
+
# Get all on-screen windows
|
|
209
|
+
windows = Quartz.CGWindowListCopyWindowInfo(
|
|
210
|
+
Quartz.kCGWindowListOptionOnScreenOnly,
|
|
211
|
+
Quartz.kCGNullWindowID
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
for win in windows:
|
|
215
|
+
owner = win.get(Quartz.kCGWindowOwnerName, "")
|
|
216
|
+
title = win.get(Quartz.kCGWindowName, "") or ""
|
|
217
|
+
|
|
218
|
+
if not owner:
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
owner_lower = owner.lower()
|
|
222
|
+
title_lower = title.lower()
|
|
223
|
+
|
|
224
|
+
# Check native meeting apps
|
|
225
|
+
for app_id in meeting_apps:
|
|
226
|
+
if app_id.lower() in owner_lower:
|
|
227
|
+
if title and _is_meaningful_title(title, owner):
|
|
228
|
+
return _clean_meeting_title(title)
|
|
229
|
+
|
|
230
|
+
# Check browser-based meetings (Google Meet)
|
|
231
|
+
if any(browser in owner_lower for browser in browsers):
|
|
232
|
+
if title_lower.startswith("meet -"):
|
|
233
|
+
# Extract meeting code/name from "Meet - xyz-abc-123 🔊"
|
|
234
|
+
return _clean_meeting_title(title)
|
|
235
|
+
|
|
236
|
+
return None
|
|
237
|
+
except Exception:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_meaningful_title(title: str, app_name: str) -> bool:
|
|
242
|
+
"""Check if a window title is meaningful (not just the app name)."""
|
|
243
|
+
# Skip generic titles
|
|
244
|
+
generic_titles = [
|
|
245
|
+
"zoom", "zoom.us", "zoom meeting", "zoom workplace",
|
|
246
|
+
"microsoft teams", "teams",
|
|
247
|
+
"slack", "discord", "facetime", "webex",
|
|
248
|
+
"", " "
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
title_lower = title.lower().strip()
|
|
252
|
+
|
|
253
|
+
# Skip if it's just the app name or a generic title
|
|
254
|
+
if title_lower in generic_titles:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
# Skip Zoom's generic windows
|
|
258
|
+
if "zoom.us" in app_name.lower():
|
|
259
|
+
if title_lower in ["zoom", "zoom meeting", "zoom workplace"]:
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _clean_meeting_title(title: str) -> str:
|
|
266
|
+
"""Clean up a meeting title for use as a filename."""
|
|
267
|
+
import re
|
|
268
|
+
|
|
269
|
+
# Remove common prefixes/suffixes
|
|
270
|
+
title = title.strip()
|
|
271
|
+
|
|
272
|
+
# Google Meet: "Meet - xyz-abc-123 🔊" -> "Meet_xyz-abc-123"
|
|
273
|
+
if title.lower().startswith("meet - "):
|
|
274
|
+
title = "Meet_" + title[7:]
|
|
275
|
+
# Remove speaker emoji and other indicators
|
|
276
|
+
title = re.sub(r'[🔊🔇📹]', '', title).strip()
|
|
277
|
+
|
|
278
|
+
# Remove "Zoom Meeting - " prefix
|
|
279
|
+
if title.lower().startswith("zoom meeting - "):
|
|
280
|
+
title = title[15:]
|
|
281
|
+
|
|
282
|
+
# Remove " - Zoom" suffix
|
|
283
|
+
if title.lower().endswith(" - zoom"):
|
|
284
|
+
title = title[:-7]
|
|
285
|
+
|
|
286
|
+
# Remove " | Microsoft Teams" suffix
|
|
287
|
+
if " | Microsoft Teams" in title:
|
|
288
|
+
title = title.split(" | Microsoft Teams")[0]
|
|
289
|
+
|
|
290
|
+
# Replace invalid filename characters
|
|
291
|
+
title = re.sub(r'[<>:"/\\|?*]', '_', title)
|
|
292
|
+
|
|
293
|
+
# Replace multiple spaces/underscores with single underscore
|
|
294
|
+
title = re.sub(r'[\s_]+', '_', title)
|
|
295
|
+
|
|
296
|
+
# Limit length
|
|
297
|
+
if len(title) > 50:
|
|
298
|
+
title = title[:50]
|
|
299
|
+
|
|
300
|
+
return title.strip('_')
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def get_app_using_mic() -> Optional[str]:
|
|
304
|
+
"""Get the name of the meeting app that might be using the microphone.
|
|
305
|
+
|
|
306
|
+
Note: This returns any active meeting app, but doesn't guarantee
|
|
307
|
+
that specific app is the one using the mic.
|
|
308
|
+
"""
|
|
309
|
+
return is_meeting_app_active()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class MicrophoneMonitor:
|
|
313
|
+
"""Monitor microphone usage to detect meeting start/end.
|
|
314
|
+
|
|
315
|
+
Start detection: Another app starts using the microphone
|
|
316
|
+
Stop detection: The meeting app window is no longer visible
|
|
317
|
+
|
|
318
|
+
This approach works because:
|
|
319
|
+
- Start: CoreAudio tells us when mic is activated (before we start recording)
|
|
320
|
+
- Stop: We can't rely on mic state (our recording uses it), so we check if meeting app is gone
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
def __init__(self):
|
|
324
|
+
self._was_mic_in_use = False
|
|
325
|
+
self._is_recording = False
|
|
326
|
+
self._recording_app: Optional[str] = None # Which app triggered recording
|
|
327
|
+
self._last_check_time = 0
|
|
328
|
+
self._on_count = 0 # Count consecutive "on" readings
|
|
329
|
+
self._off_count = 0 # Count consecutive "off" readings
|
|
330
|
+
self._on_threshold = 2 # Require 2 consecutive "on" readings to trigger start
|
|
331
|
+
self._off_threshold = 3 # Require 3 consecutive "off" readings to trigger stop
|
|
332
|
+
|
|
333
|
+
def set_recording(self, is_recording: bool, app_name: Optional[str] = None):
|
|
334
|
+
"""Tell the monitor whether we're currently recording."""
|
|
335
|
+
self._is_recording = is_recording
|
|
336
|
+
|
|
337
|
+
if is_recording:
|
|
338
|
+
self._was_mic_in_use = True
|
|
339
|
+
self._on_count = 0
|
|
340
|
+
# Only set app_name if provided (don't overwrite on status updates)
|
|
341
|
+
if app_name:
|
|
342
|
+
self._recording_app = app_name
|
|
343
|
+
else:
|
|
344
|
+
self._recording_app = None
|
|
345
|
+
|
|
346
|
+
def check(self) -> tuple[bool, bool, Optional[str]]:
|
|
347
|
+
"""Check for microphone usage changes.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Tuple of (mic_started, mic_stopped, app_name)
|
|
351
|
+
- mic_started: True if another app started using mic (should prompt to record)
|
|
352
|
+
- mic_stopped: True if meeting app closed (should stop recording)
|
|
353
|
+
- app_name: Name of meeting app (if detected)
|
|
354
|
+
"""
|
|
355
|
+
now = time.time()
|
|
356
|
+
|
|
357
|
+
# Debounce - check every 2 seconds
|
|
358
|
+
if now - self._last_check_time < 2.0:
|
|
359
|
+
return False, False, None
|
|
360
|
+
self._last_check_time = now
|
|
361
|
+
|
|
362
|
+
mic_started = False
|
|
363
|
+
mic_stopped = False
|
|
364
|
+
app_name = None
|
|
365
|
+
|
|
366
|
+
if self._is_recording:
|
|
367
|
+
# While recording: check if meeting app is still visible
|
|
368
|
+
# (Can't rely on mic state since our app is using it)
|
|
369
|
+
current_app = is_meeting_app_active()
|
|
370
|
+
|
|
371
|
+
if current_app is None:
|
|
372
|
+
# Meeting app window is gone
|
|
373
|
+
self._off_count += 1
|
|
374
|
+
if self._off_count >= self._off_threshold:
|
|
375
|
+
mic_stopped = True
|
|
376
|
+
self._was_mic_in_use = False
|
|
377
|
+
self._off_count = 0
|
|
378
|
+
else:
|
|
379
|
+
self._off_count = 0
|
|
380
|
+
else:
|
|
381
|
+
# Not recording: check if mic is being used by another app
|
|
382
|
+
mic_in_use = is_mic_in_use_by_another_app()
|
|
383
|
+
|
|
384
|
+
if mic_in_use:
|
|
385
|
+
self._off_count = 0
|
|
386
|
+
# Get app name for display (optional, doesn't affect start decision)
|
|
387
|
+
app_name = is_meeting_app_active()
|
|
388
|
+
|
|
389
|
+
if not self._was_mic_in_use:
|
|
390
|
+
self._on_count += 1
|
|
391
|
+
# Require consecutive readings to avoid false positives
|
|
392
|
+
if self._on_count >= self._on_threshold:
|
|
393
|
+
mic_started = True
|
|
394
|
+
self._was_mic_in_use = True
|
|
395
|
+
self._on_count = 0
|
|
396
|
+
else:
|
|
397
|
+
self._on_count = 0
|
|
398
|
+
self._was_mic_in_use = False
|
|
399
|
+
|
|
400
|
+
return mic_started, mic_stopped, app_name
|
|
401
|
+
|
|
402
|
+
def is_mic_active(self) -> bool:
|
|
403
|
+
"""Check if microphone is currently in use by another app."""
|
|
404
|
+
return is_mic_in_use_by_another_app()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meeting-noter
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Offline meeting transcription for macOS with automatic meeting detection
|
|
5
5
|
Author: Victor
|
|
6
6
|
License: MIT
|
|
@@ -28,14 +28,13 @@ Requires-Dist: numpy>=1.24
|
|
|
28
28
|
Requires-Dist: faster-whisper>=1.0.0
|
|
29
29
|
Requires-Dist: rumps>=0.4.0
|
|
30
30
|
Requires-Dist: PyQt6>=6.5.0
|
|
31
|
+
Requires-Dist: imageio-ffmpeg>=0.4.9
|
|
31
32
|
Requires-Dist: pyobjc-framework-Cocoa>=9.0; sys_platform == "darwin"
|
|
32
33
|
Requires-Dist: pyobjc-framework-Quartz>=9.0; sys_platform == "darwin"
|
|
33
34
|
Requires-Dist: pyobjc-framework-ScreenCaptureKit>=9.0; sys_platform == "darwin"
|
|
34
35
|
Requires-Dist: pyobjc-framework-AVFoundation>=9.0; sys_platform == "darwin"
|
|
35
36
|
Requires-Dist: pyobjc-framework-CoreMedia>=9.0; sys_platform == "darwin"
|
|
36
37
|
Requires-Dist: pyobjc-framework-libdispatch>=9.0; sys_platform == "darwin"
|
|
37
|
-
Provides-Extra: mp3
|
|
38
|
-
Requires-Dist: lameenc>=1.5.0; extra == "mp3"
|
|
39
38
|
Provides-Extra: dev
|
|
40
39
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
41
40
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -59,11 +58,7 @@ Offline meeting transcription tool for macOS. Captures both your voice and meeti
|
|
|
59
58
|
## Installation
|
|
60
59
|
|
|
61
60
|
```bash
|
|
62
|
-
|
|
63
|
-
brew install ffmpeg lame pkg-config python@3.12
|
|
64
|
-
|
|
65
|
-
# Install meeting-noter (use Python 3.12 for best compatibility)
|
|
66
|
-
pipx install meeting-noter --python /opt/homebrew/bin/python3.12
|
|
61
|
+
pipx install meeting-noter
|
|
67
62
|
```
|
|
68
63
|
|
|
69
64
|
Or with pip:
|
|
@@ -71,6 +66,8 @@ Or with pip:
|
|
|
71
66
|
pip install meeting-noter
|
|
72
67
|
```
|
|
73
68
|
|
|
69
|
+
No system dependencies required - ffmpeg is bundled automatically.
|
|
70
|
+
|
|
74
71
|
## Quick Start
|
|
75
72
|
|
|
76
73
|
**Menu Bar App** (recommended):
|
|
@@ -208,10 +205,7 @@ Config file: `~/.config/meeting-noter/config.json`
|
|
|
208
205
|
## Requirements
|
|
209
206
|
|
|
210
207
|
- macOS 12.3+ (for ScreenCaptureKit)
|
|
211
|
-
- Python 3.9+
|
|
212
|
-
- FFmpeg (`brew install ffmpeg`) - required for audio processing
|
|
213
|
-
- LAME (`brew install lame`) - required for MP3 encoding
|
|
214
|
-
- pkg-config (`brew install pkg-config`) - required for building dependencies
|
|
208
|
+
- Python 3.9+
|
|
215
209
|
|
|
216
210
|
## License
|
|
217
211
|
|
|
@@ -7,6 +7,7 @@ src/meeting_noter/config.py
|
|
|
7
7
|
src/meeting_noter/daemon.py
|
|
8
8
|
src/meeting_noter/meeting_detector.py
|
|
9
9
|
src/meeting_noter/menubar.py
|
|
10
|
+
src/meeting_noter/mic_monitor.py
|
|
10
11
|
src/meeting_noter.egg-info/PKG-INFO
|
|
11
12
|
src/meeting_noter.egg-info/SOURCES.txt
|
|
12
13
|
src/meeting_noter.egg-info/dependency_links.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|