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
|
@@ -0,0 +1,456 @@
|
|
|
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
|
+
Prioritizes dedicated meeting apps over chat apps.
|
|
122
|
+
Returns the app name only if there's an active call/meeting,
|
|
123
|
+
not just because the app is open.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
import Quartz
|
|
127
|
+
|
|
128
|
+
# Get all on-screen windows
|
|
129
|
+
windows = Quartz.CGWindowListCopyWindowInfo(
|
|
130
|
+
Quartz.kCGWindowListOptionOnScreenOnly,
|
|
131
|
+
Quartz.kCGNullWindowID
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# First pass: check for primary meeting apps (dedicated video conferencing)
|
|
135
|
+
for win in windows:
|
|
136
|
+
owner = win.get(Quartz.kCGWindowOwnerName, "")
|
|
137
|
+
title = win.get(Quartz.kCGWindowName, "") or ""
|
|
138
|
+
|
|
139
|
+
if not owner:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
owner_lower = owner.lower()
|
|
143
|
+
title_lower = title.lower()
|
|
144
|
+
|
|
145
|
+
# Zoom: detect meeting windows (not just "Zoom Workplace" or "Zoom")
|
|
146
|
+
if "zoom.us" in owner_lower:
|
|
147
|
+
# Skip non-meeting windows
|
|
148
|
+
if title_lower in ["", "zoom", "zoom workplace", "zoom.us"]:
|
|
149
|
+
continue
|
|
150
|
+
# This is likely a meeting window
|
|
151
|
+
return "Zoom"
|
|
152
|
+
|
|
153
|
+
# Microsoft Teams: detect call windows
|
|
154
|
+
if "microsoft teams" in owner_lower:
|
|
155
|
+
# Skip main app windows
|
|
156
|
+
if title_lower in ["", "microsoft teams"]:
|
|
157
|
+
continue
|
|
158
|
+
return "Teams"
|
|
159
|
+
|
|
160
|
+
# FaceTime
|
|
161
|
+
if "facetime" in owner_lower:
|
|
162
|
+
if title and title_lower != "facetime":
|
|
163
|
+
return "FaceTime"
|
|
164
|
+
|
|
165
|
+
# Webex
|
|
166
|
+
if "webex" in owner_lower:
|
|
167
|
+
if title and "meeting" in title_lower:
|
|
168
|
+
return "Webex"
|
|
169
|
+
|
|
170
|
+
# Browser-based meetings (Google Meet, etc.)
|
|
171
|
+
if any(browser in owner_lower for browser in ["chrome", "safari", "firefox", "edge", "brave", "arc"]):
|
|
172
|
+
if title_lower.startswith("meet -") or "meet.google.com" in title_lower:
|
|
173
|
+
return "Google Meet"
|
|
174
|
+
if " meeting" in title_lower and any(x in title_lower for x in ["zoom", "teams", "webex"]):
|
|
175
|
+
return "Browser Meeting"
|
|
176
|
+
|
|
177
|
+
# Second pass: check for secondary apps (chat apps with call features)
|
|
178
|
+
for win in windows:
|
|
179
|
+
owner = win.get(Quartz.kCGWindowOwnerName, "")
|
|
180
|
+
title = win.get(Quartz.kCGWindowName, "") or ""
|
|
181
|
+
|
|
182
|
+
if not owner:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
owner_lower = owner.lower()
|
|
186
|
+
title_lower = title.lower()
|
|
187
|
+
|
|
188
|
+
# Slack: detect huddle/call windows only
|
|
189
|
+
if "slack" in owner_lower:
|
|
190
|
+
if any(x in title_lower for x in ["huddle", "call"]):
|
|
191
|
+
return "Slack"
|
|
192
|
+
|
|
193
|
+
# Discord: detect voice channel
|
|
194
|
+
if "discord" in owner_lower:
|
|
195
|
+
if "voice connected" in title_lower:
|
|
196
|
+
return "Discord"
|
|
197
|
+
|
|
198
|
+
return None
|
|
199
|
+
except Exception:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_meeting_window_title() -> Optional[str]:
|
|
204
|
+
"""Get the window title of the active meeting app.
|
|
205
|
+
|
|
206
|
+
Prioritizes dedicated meeting apps (Zoom, Teams, Meet) over chat apps (Slack, Discord).
|
|
207
|
+
Returns the window title (meeting name) if found, or app name as fallback.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
import Quartz
|
|
211
|
+
|
|
212
|
+
# Primary meeting apps (checked first) - dedicated video conferencing
|
|
213
|
+
primary_apps = {
|
|
214
|
+
"zoom.us": "Zoom",
|
|
215
|
+
"microsoft teams": "Teams",
|
|
216
|
+
"facetime": "FaceTime",
|
|
217
|
+
"webex": "Webex",
|
|
218
|
+
}
|
|
219
|
+
# Secondary apps (chat apps with call features) - only used if no primary found
|
|
220
|
+
secondary_apps = {"slack": "Slack_Huddle", "discord": "Discord"}
|
|
221
|
+
browsers = ["chrome", "safari", "firefox", "edge", "brave", "arc"]
|
|
222
|
+
|
|
223
|
+
# Get all on-screen windows
|
|
224
|
+
windows = Quartz.CGWindowListCopyWindowInfo(
|
|
225
|
+
Quartz.kCGWindowListOptionOnScreenOnly,
|
|
226
|
+
Quartz.kCGNullWindowID
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# First pass: check for primary meeting apps
|
|
230
|
+
found_primary_app = None # Track if we found a primary app (for fallback)
|
|
231
|
+
|
|
232
|
+
for win in windows:
|
|
233
|
+
owner = win.get(Quartz.kCGWindowOwnerName, "")
|
|
234
|
+
title = win.get(Quartz.kCGWindowName, "") or ""
|
|
235
|
+
|
|
236
|
+
if not owner:
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
owner_lower = owner.lower()
|
|
240
|
+
title_lower = title.lower()
|
|
241
|
+
|
|
242
|
+
# Check primary meeting apps
|
|
243
|
+
for app_id, app_name in primary_apps.items():
|
|
244
|
+
if app_id.lower() in owner_lower:
|
|
245
|
+
# Remember we found this app (for fallback)
|
|
246
|
+
if found_primary_app is None:
|
|
247
|
+
# Check if it looks like an active meeting window
|
|
248
|
+
if "zoom.us" in owner_lower and title_lower not in ["", "zoom workplace"]:
|
|
249
|
+
found_primary_app = app_name
|
|
250
|
+
elif "microsoft teams" in owner_lower and title_lower not in ["", "microsoft teams"]:
|
|
251
|
+
found_primary_app = app_name
|
|
252
|
+
elif title and title_lower not in ["", app_name.lower()]:
|
|
253
|
+
found_primary_app = app_name
|
|
254
|
+
|
|
255
|
+
# Return specific title if meaningful
|
|
256
|
+
if title and _is_meaningful_title(title, owner):
|
|
257
|
+
return _clean_meeting_title(title)
|
|
258
|
+
|
|
259
|
+
# Check browser-based meetings (Google Meet)
|
|
260
|
+
if any(browser in owner_lower for browser in browsers):
|
|
261
|
+
if title_lower.startswith("meet -"):
|
|
262
|
+
return _clean_meeting_title(title)
|
|
263
|
+
|
|
264
|
+
# If we found a primary meeting app but no specific title, use app name
|
|
265
|
+
if found_primary_app:
|
|
266
|
+
return found_primary_app
|
|
267
|
+
|
|
268
|
+
# Second pass: only check secondary apps if no primary meeting found
|
|
269
|
+
for win in windows:
|
|
270
|
+
owner = win.get(Quartz.kCGWindowOwnerName, "")
|
|
271
|
+
title = win.get(Quartz.kCGWindowName, "") or ""
|
|
272
|
+
|
|
273
|
+
if not owner:
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
owner_lower = owner.lower()
|
|
277
|
+
|
|
278
|
+
for app_id, app_name in secondary_apps.items():
|
|
279
|
+
if app_id.lower() in owner_lower:
|
|
280
|
+
# For Slack/Discord, only match if it looks like a call window
|
|
281
|
+
if "slack" in owner_lower:
|
|
282
|
+
if "huddle" in title.lower() or "call" in title.lower():
|
|
283
|
+
return _clean_meeting_title(title) if title else app_name
|
|
284
|
+
elif "discord" in owner_lower:
|
|
285
|
+
if "voice connected" in title.lower():
|
|
286
|
+
return _clean_meeting_title(title) if title else app_name
|
|
287
|
+
|
|
288
|
+
return None
|
|
289
|
+
except Exception:
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _is_meaningful_title(title: str, app_name: str) -> bool:
|
|
294
|
+
"""Check if a window title is meaningful (not just the app name)."""
|
|
295
|
+
# Skip generic titles
|
|
296
|
+
generic_titles = [
|
|
297
|
+
"zoom", "zoom.us", "zoom meeting", "zoom workplace",
|
|
298
|
+
"microsoft teams", "teams",
|
|
299
|
+
"slack", "discord", "facetime", "webex",
|
|
300
|
+
"", " "
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
title_lower = title.lower().strip()
|
|
304
|
+
|
|
305
|
+
# Skip if it's just the app name or a generic title
|
|
306
|
+
if title_lower in generic_titles:
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
# Skip Zoom's generic windows
|
|
310
|
+
if "zoom.us" in app_name.lower():
|
|
311
|
+
if title_lower in ["zoom", "zoom meeting", "zoom workplace"]:
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _clean_meeting_title(title: str) -> str:
|
|
318
|
+
"""Clean up a meeting title for use as a filename."""
|
|
319
|
+
import re
|
|
320
|
+
|
|
321
|
+
# Remove common prefixes/suffixes
|
|
322
|
+
title = title.strip()
|
|
323
|
+
|
|
324
|
+
# Google Meet: "Meet - xyz-abc-123 🔊" -> "Meet_xyz-abc-123"
|
|
325
|
+
if title.lower().startswith("meet - "):
|
|
326
|
+
title = "Meet_" + title[7:]
|
|
327
|
+
# Remove speaker emoji and other indicators
|
|
328
|
+
title = re.sub(r'[🔊🔇📹]', '', title).strip()
|
|
329
|
+
|
|
330
|
+
# Remove "Zoom Meeting - " prefix
|
|
331
|
+
if title.lower().startswith("zoom meeting - "):
|
|
332
|
+
title = title[15:]
|
|
333
|
+
|
|
334
|
+
# Remove " - Zoom" suffix
|
|
335
|
+
if title.lower().endswith(" - zoom"):
|
|
336
|
+
title = title[:-7]
|
|
337
|
+
|
|
338
|
+
# Remove " | Microsoft Teams" suffix
|
|
339
|
+
if " | Microsoft Teams" in title:
|
|
340
|
+
title = title.split(" | Microsoft Teams")[0]
|
|
341
|
+
|
|
342
|
+
# Replace invalid filename characters
|
|
343
|
+
title = re.sub(r'[<>:"/\\|?*]', '_', title)
|
|
344
|
+
|
|
345
|
+
# Replace multiple spaces/underscores with single underscore
|
|
346
|
+
title = re.sub(r'[\s_]+', '_', title)
|
|
347
|
+
|
|
348
|
+
# Limit length
|
|
349
|
+
if len(title) > 50:
|
|
350
|
+
title = title[:50]
|
|
351
|
+
|
|
352
|
+
return title.strip('_')
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def get_app_using_mic() -> Optional[str]:
|
|
356
|
+
"""Get the name of the meeting app that might be using the microphone.
|
|
357
|
+
|
|
358
|
+
Note: This returns any active meeting app, but doesn't guarantee
|
|
359
|
+
that specific app is the one using the mic.
|
|
360
|
+
"""
|
|
361
|
+
return is_meeting_app_active()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class MicrophoneMonitor:
|
|
365
|
+
"""Monitor microphone usage to detect meeting start/end.
|
|
366
|
+
|
|
367
|
+
Start detection: Another app starts using the microphone
|
|
368
|
+
Stop detection: The meeting app window is no longer visible
|
|
369
|
+
|
|
370
|
+
This approach works because:
|
|
371
|
+
- Start: CoreAudio tells us when mic is activated (before we start recording)
|
|
372
|
+
- Stop: We can't rely on mic state (our recording uses it), so we check if meeting app is gone
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
def __init__(self):
|
|
376
|
+
self._was_mic_in_use = False
|
|
377
|
+
self._is_recording = False
|
|
378
|
+
self._recording_app: Optional[str] = None # Which app triggered recording
|
|
379
|
+
self._last_check_time = 0
|
|
380
|
+
self._on_count = 0 # Count consecutive "on" readings
|
|
381
|
+
self._off_count = 0 # Count consecutive "off" readings
|
|
382
|
+
self._on_threshold = 2 # Require 2 consecutive "on" readings to trigger start
|
|
383
|
+
self._off_threshold = 3 # Require 3 consecutive "off" readings to trigger stop
|
|
384
|
+
|
|
385
|
+
def set_recording(self, is_recording: bool, app_name: Optional[str] = None):
|
|
386
|
+
"""Tell the monitor whether we're currently recording."""
|
|
387
|
+
self._is_recording = is_recording
|
|
388
|
+
|
|
389
|
+
if is_recording:
|
|
390
|
+
self._was_mic_in_use = True
|
|
391
|
+
self._on_count = 0
|
|
392
|
+
# Only set app_name if provided (don't overwrite on status updates)
|
|
393
|
+
if app_name:
|
|
394
|
+
self._recording_app = app_name
|
|
395
|
+
else:
|
|
396
|
+
self._recording_app = None
|
|
397
|
+
|
|
398
|
+
def check(self) -> tuple[bool, bool, Optional[str]]:
|
|
399
|
+
"""Check for microphone usage changes.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
Tuple of (mic_started, mic_stopped, app_name)
|
|
403
|
+
- mic_started: True if another app started using mic (should prompt to record)
|
|
404
|
+
- mic_stopped: True if meeting app closed (should stop recording)
|
|
405
|
+
- app_name: Name of meeting app (if detected)
|
|
406
|
+
"""
|
|
407
|
+
now = time.time()
|
|
408
|
+
|
|
409
|
+
# Debounce - check every 2 seconds
|
|
410
|
+
if now - self._last_check_time < 2.0:
|
|
411
|
+
return False, False, None
|
|
412
|
+
self._last_check_time = now
|
|
413
|
+
|
|
414
|
+
mic_started = False
|
|
415
|
+
mic_stopped = False
|
|
416
|
+
app_name = None
|
|
417
|
+
|
|
418
|
+
if self._is_recording:
|
|
419
|
+
# While recording: check if meeting app is still visible
|
|
420
|
+
# (Can't rely on mic state since our app is using it)
|
|
421
|
+
current_app = is_meeting_app_active()
|
|
422
|
+
|
|
423
|
+
if current_app is None:
|
|
424
|
+
# Meeting app window is gone
|
|
425
|
+
self._off_count += 1
|
|
426
|
+
if self._off_count >= self._off_threshold:
|
|
427
|
+
mic_stopped = True
|
|
428
|
+
self._was_mic_in_use = False
|
|
429
|
+
self._off_count = 0
|
|
430
|
+
else:
|
|
431
|
+
self._off_count = 0
|
|
432
|
+
else:
|
|
433
|
+
# Not recording: check if mic is being used by another app
|
|
434
|
+
mic_in_use = is_mic_in_use_by_another_app()
|
|
435
|
+
|
|
436
|
+
if mic_in_use:
|
|
437
|
+
self._off_count = 0
|
|
438
|
+
# Get app name for display (optional, doesn't affect start decision)
|
|
439
|
+
app_name = is_meeting_app_active()
|
|
440
|
+
|
|
441
|
+
if not self._was_mic_in_use:
|
|
442
|
+
self._on_count += 1
|
|
443
|
+
# Require consecutive readings to avoid false positives
|
|
444
|
+
if self._on_count >= self._on_threshold:
|
|
445
|
+
mic_started = True
|
|
446
|
+
self._was_mic_in_use = True
|
|
447
|
+
self._on_count = 0
|
|
448
|
+
else:
|
|
449
|
+
self._on_count = 0
|
|
450
|
+
self._was_mic_in_use = False
|
|
451
|
+
|
|
452
|
+
return mic_started, mic_stopped, app_name
|
|
453
|
+
|
|
454
|
+
def is_mic_active(self) -> bool:
|
|
455
|
+
"""Check if microphone is currently in use by another app."""
|
|
456
|
+
return is_mic_in_use_by_another_app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Output handling modules."""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Output handling for recordings and transcripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_duration(seconds: float) -> str:
|
|
12
|
+
"""Format duration as human-readable string."""
|
|
13
|
+
if seconds < 60:
|
|
14
|
+
return f"{seconds:.0f}s"
|
|
15
|
+
elif seconds < 3600:
|
|
16
|
+
minutes = int(seconds // 60)
|
|
17
|
+
secs = int(seconds % 60)
|
|
18
|
+
return f"{minutes}m {secs}s"
|
|
19
|
+
else:
|
|
20
|
+
hours = int(seconds // 3600)
|
|
21
|
+
minutes = int((seconds % 3600) // 60)
|
|
22
|
+
return f"{hours}h {minutes}m"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_size(bytes: int) -> str:
|
|
26
|
+
"""Format file size as human-readable string."""
|
|
27
|
+
if bytes < 1024:
|
|
28
|
+
return f"{bytes} B"
|
|
29
|
+
elif bytes < 1024 * 1024:
|
|
30
|
+
return f"{bytes / 1024:.1f} KB"
|
|
31
|
+
else:
|
|
32
|
+
return f"{bytes / (1024 * 1024):.1f} MB"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_audio_duration(filepath: Path) -> Optional[float]:
|
|
36
|
+
"""Get duration of an audio file in seconds.
|
|
37
|
+
|
|
38
|
+
Uses a simple estimation based on file size and bitrate.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
# Rough estimation: 128kbps MP3 = 16KB per second
|
|
42
|
+
size = filepath.stat().st_size
|
|
43
|
+
return size / (128 * 1000 / 8) # 128kbps = 16000 bytes/sec
|
|
44
|
+
except:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def list_recordings(output_dir: Path, limit: int = 10):
|
|
49
|
+
"""List recent meeting recordings."""
|
|
50
|
+
if not output_dir.exists():
|
|
51
|
+
click.echo(click.style(f"Directory not found: {output_dir}", fg="red"))
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# Find all MP3 files
|
|
55
|
+
mp3_files = sorted(
|
|
56
|
+
output_dir.glob("*.mp3"),
|
|
57
|
+
key=lambda p: p.stat().st_mtime,
|
|
58
|
+
reverse=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if not mp3_files:
|
|
62
|
+
click.echo(click.style("No recordings found.", fg="yellow"))
|
|
63
|
+
click.echo(f"\nRecordings directory: {output_dir}")
|
|
64
|
+
click.echo("Run 'meeting-noter daemon' to start capturing audio.")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
click.echo(f"\nRecent recordings in {output_dir}:\n")
|
|
68
|
+
|
|
69
|
+
# Show header
|
|
70
|
+
click.echo(f" {'Date':<20} {'Duration':<12} {'Size':<10} {'Transcript':<12} File")
|
|
71
|
+
click.echo(" " + "-" * 75)
|
|
72
|
+
|
|
73
|
+
for mp3 in mp3_files[:limit]:
|
|
74
|
+
# Get file info
|
|
75
|
+
stat = mp3.stat()
|
|
76
|
+
mod_time = datetime.fromtimestamp(stat.st_mtime)
|
|
77
|
+
date_str = mod_time.strftime("%Y-%m-%d %H:%M")
|
|
78
|
+
|
|
79
|
+
# Duration estimate
|
|
80
|
+
duration = get_audio_duration(mp3)
|
|
81
|
+
duration_str = format_duration(duration) if duration else "?"
|
|
82
|
+
|
|
83
|
+
# File size
|
|
84
|
+
size_str = format_size(stat.st_size)
|
|
85
|
+
|
|
86
|
+
# Check for transcript
|
|
87
|
+
transcript = mp3.with_suffix(".txt")
|
|
88
|
+
has_transcript = click.style("Yes", fg="green") if transcript.exists() else click.style("No", fg="yellow")
|
|
89
|
+
|
|
90
|
+
click.echo(f" {date_str:<20} {duration_str:<12} {size_str:<10} {has_transcript:<12} {mp3.name}")
|
|
91
|
+
|
|
92
|
+
if len(mp3_files) > limit:
|
|
93
|
+
click.echo(f"\n ... and {len(mp3_files) - limit} more recordings")
|
|
94
|
+
|
|
95
|
+
click.echo(f"\nTotal: {len(mp3_files)} recordings")
|
|
96
|
+
click.echo("\nTo transcribe: meeting-noter transcribe [filename]")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Resources package for Meeting Noter."""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Transcription modules."""
|