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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Audio capture and processing modules."""
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Audio capture from default microphone."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import sounddevice as sd
|
|
7
|
+
from queue import Queue
|
|
8
|
+
from threading import Event
|
|
9
|
+
from typing import Optional, Union
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SAMPLE_RATE = 48000 # Native sample rate for macOS devices
|
|
13
|
+
CHANNELS = 2 # Capture stereo for better quality
|
|
14
|
+
BLOCK_SIZE = 1024
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def find_capture_device() -> tuple[Optional[int], bool]:
|
|
18
|
+
"""Find the default microphone for capture.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Tuple of (device_index, has_system_audio)
|
|
22
|
+
has_system_audio is always False - system audio comes from ScreenCaptureKit
|
|
23
|
+
"""
|
|
24
|
+
# Use default input device (microphone)
|
|
25
|
+
try:
|
|
26
|
+
default_input = sd.default.device[0]
|
|
27
|
+
if default_input is not None and default_input >= 0:
|
|
28
|
+
device_info = sd.query_devices(default_input)
|
|
29
|
+
if device_info["max_input_channels"] > 0:
|
|
30
|
+
return default_input, False
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
# Fallback: find any real input device (skip virtual devices)
|
|
35
|
+
devices = sd.query_devices()
|
|
36
|
+
for i, device in enumerate(devices):
|
|
37
|
+
if device["max_input_channels"] > 0:
|
|
38
|
+
name = device["name"].lower()
|
|
39
|
+
# Skip virtual audio devices
|
|
40
|
+
if "blackhole" in name or "virtual" in name or "aggregate" in name:
|
|
41
|
+
continue
|
|
42
|
+
return i, False
|
|
43
|
+
|
|
44
|
+
# Last resort: any input device
|
|
45
|
+
for i, device in enumerate(devices):
|
|
46
|
+
if device["max_input_channels"] > 0:
|
|
47
|
+
return i, False
|
|
48
|
+
|
|
49
|
+
return None, False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find_capture_device_simple() -> Optional[int]:
|
|
53
|
+
"""Simple wrapper for backward compatibility."""
|
|
54
|
+
device_idx, _ = find_capture_device()
|
|
55
|
+
return device_idx
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def find_default_microphone() -> Optional[int]:
|
|
59
|
+
"""Find the default microphone device."""
|
|
60
|
+
try:
|
|
61
|
+
default_input = sd.default.device[0]
|
|
62
|
+
if default_input is not None and default_input >= 0:
|
|
63
|
+
return default_input
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Backward compatibility alias
|
|
70
|
+
find_capture_device_legacy = find_capture_device_simple
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_device_by_name(name: str) -> Optional[int]:
|
|
74
|
+
"""Find a device by name (partial match)."""
|
|
75
|
+
devices = sd.query_devices()
|
|
76
|
+
for i, device in enumerate(devices):
|
|
77
|
+
if name.lower() in device["name"].lower():
|
|
78
|
+
return i
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AudioCapture:
|
|
83
|
+
"""Captures audio from default microphone."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
device: Union[int, str, None] = None,
|
|
88
|
+
sample_rate: int = SAMPLE_RATE,
|
|
89
|
+
channels: Optional[int] = None,
|
|
90
|
+
block_size: int = BLOCK_SIZE,
|
|
91
|
+
):
|
|
92
|
+
self.sample_rate = sample_rate
|
|
93
|
+
self.block_size = block_size
|
|
94
|
+
self.audio_queue: Queue[np.ndarray] = Queue()
|
|
95
|
+
self.stop_event = Event()
|
|
96
|
+
self.stream: Optional[sd.InputStream] = None
|
|
97
|
+
|
|
98
|
+
# Find device
|
|
99
|
+
self.has_system_audio = False # System audio comes from ScreenCaptureKit
|
|
100
|
+
if device is None:
|
|
101
|
+
self.device_index, _ = find_capture_device()
|
|
102
|
+
if self.device_index is None:
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
"No audio input device found. Please check your microphone."
|
|
105
|
+
)
|
|
106
|
+
elif isinstance(device, str):
|
|
107
|
+
self.device_index = find_device_by_name(device)
|
|
108
|
+
if self.device_index is None:
|
|
109
|
+
raise RuntimeError(f"Device '{device}' not found.")
|
|
110
|
+
else:
|
|
111
|
+
self.device_index = device
|
|
112
|
+
|
|
113
|
+
# Get actual channel count from device (use up to 2 channels)
|
|
114
|
+
device_info = sd.query_devices(self.device_index)
|
|
115
|
+
max_channels = device_info["max_input_channels"]
|
|
116
|
+
self.channels = channels if channels is not None else min(max_channels, CHANNELS)
|
|
117
|
+
|
|
118
|
+
print(f"Using microphone: {device_info['name']}")
|
|
119
|
+
|
|
120
|
+
def _audio_callback(
|
|
121
|
+
self,
|
|
122
|
+
indata: np.ndarray,
|
|
123
|
+
frames: int,
|
|
124
|
+
time_info: dict,
|
|
125
|
+
status: sd.CallbackFlags,
|
|
126
|
+
):
|
|
127
|
+
"""Callback for audio stream."""
|
|
128
|
+
if status:
|
|
129
|
+
print(f"Audio status: {status}")
|
|
130
|
+
# Copy data to queue
|
|
131
|
+
self.audio_queue.put(indata.copy())
|
|
132
|
+
|
|
133
|
+
def start(self):
|
|
134
|
+
"""Start capturing audio."""
|
|
135
|
+
self.stop_event.clear()
|
|
136
|
+
self.stream = sd.InputStream(
|
|
137
|
+
device=self.device_index,
|
|
138
|
+
channels=self.channels,
|
|
139
|
+
samplerate=self.sample_rate,
|
|
140
|
+
blocksize=self.block_size,
|
|
141
|
+
dtype=np.float32,
|
|
142
|
+
callback=self._audio_callback,
|
|
143
|
+
)
|
|
144
|
+
self.stream.start()
|
|
145
|
+
|
|
146
|
+
def stop(self):
|
|
147
|
+
"""Stop capturing audio."""
|
|
148
|
+
self.stop_event.set()
|
|
149
|
+
if self.stream:
|
|
150
|
+
self.stream.stop()
|
|
151
|
+
self.stream.close()
|
|
152
|
+
self.stream = None
|
|
153
|
+
|
|
154
|
+
def get_audio(self, timeout: float = 1.0) -> Optional[np.ndarray]:
|
|
155
|
+
"""Get audio data from queue."""
|
|
156
|
+
try:
|
|
157
|
+
return self.audio_queue.get(timeout=timeout)
|
|
158
|
+
except:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def get_all_audio(self) -> np.ndarray:
|
|
162
|
+
"""Get all accumulated audio from queue."""
|
|
163
|
+
chunks = []
|
|
164
|
+
while not self.audio_queue.empty():
|
|
165
|
+
try:
|
|
166
|
+
chunks.append(self.audio_queue.get_nowait())
|
|
167
|
+
except:
|
|
168
|
+
break
|
|
169
|
+
if chunks:
|
|
170
|
+
return np.concatenate(chunks)
|
|
171
|
+
return np.array([], dtype=np.float32)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class SilenceDetector:
|
|
175
|
+
"""Detects extended silence in audio stream."""
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
threshold: float = 0.01,
|
|
180
|
+
silence_duration: float = 60.0,
|
|
181
|
+
sample_rate: int = SAMPLE_RATE,
|
|
182
|
+
):
|
|
183
|
+
self.threshold = threshold
|
|
184
|
+
self.silence_samples = int(silence_duration * sample_rate)
|
|
185
|
+
self.current_silence = 0
|
|
186
|
+
self.sample_rate = sample_rate
|
|
187
|
+
|
|
188
|
+
def update(self, audio: np.ndarray) -> bool:
|
|
189
|
+
"""Update with new audio data.
|
|
190
|
+
|
|
191
|
+
Returns True if extended silence is detected (meeting likely ended).
|
|
192
|
+
"""
|
|
193
|
+
rms = np.sqrt(np.mean(audio ** 2))
|
|
194
|
+
|
|
195
|
+
if rms < self.threshold:
|
|
196
|
+
self.current_silence += len(audio)
|
|
197
|
+
else:
|
|
198
|
+
self.current_silence = 0
|
|
199
|
+
|
|
200
|
+
return self.current_silence >= self.silence_samples
|
|
201
|
+
|
|
202
|
+
def reset(self):
|
|
203
|
+
"""Reset silence counter."""
|
|
204
|
+
self.current_silence = 0
|
|
205
|
+
|
|
206
|
+
def is_audio_present(self, audio: np.ndarray) -> bool:
|
|
207
|
+
"""Check if there's meaningful audio (not just silence)."""
|
|
208
|
+
rms = np.sqrt(np.mean(audio ** 2))
|
|
209
|
+
return rms >= self.threshold
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""MP3 encoding for audio recordings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import numpy as np
|
|
7
|
+
import lameenc
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Optional, Tuple
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _sanitize_filename(name: str, max_length: int = 50) -> str:
|
|
14
|
+
"""Sanitize a string for use as a filename.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
name: The name to sanitize
|
|
18
|
+
max_length: Maximum length for the sanitized name
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A safe filename string with spaces replaced by underscores
|
|
22
|
+
"""
|
|
23
|
+
# Replace spaces with underscores
|
|
24
|
+
name = name.replace(" ", "_")
|
|
25
|
+
# Remove any character that isn't alphanumeric, underscore, or hyphen
|
|
26
|
+
name = re.sub(r"[^\w\-]", "", name)
|
|
27
|
+
# Truncate to max length
|
|
28
|
+
if len(name) > max_length:
|
|
29
|
+
name = name[:max_length]
|
|
30
|
+
# Remove trailing underscores/hyphens
|
|
31
|
+
name = name.rstrip("_-")
|
|
32
|
+
return name
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_timestamp_name(name: str) -> bool:
|
|
36
|
+
"""Check if name is a default timestamp pattern (DD_Mon_YYYY_HHMM)."""
|
|
37
|
+
return bool(re.match(r"^\d{2}_[A-Z][a-z]{2}_\d{4}_\d{4}$", name))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MP3Encoder:
|
|
41
|
+
"""Encodes audio data to MP3 format."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
sample_rate: int = 16000,
|
|
46
|
+
channels: int = 1,
|
|
47
|
+
bitrate: int = 128,
|
|
48
|
+
quality: int = 2,
|
|
49
|
+
):
|
|
50
|
+
self.sample_rate = sample_rate
|
|
51
|
+
self.channels = channels
|
|
52
|
+
self.encoder = lameenc.Encoder()
|
|
53
|
+
self.encoder.set_bit_rate(bitrate)
|
|
54
|
+
self.encoder.set_in_sample_rate(sample_rate)
|
|
55
|
+
self.encoder.set_channels(channels)
|
|
56
|
+
self.encoder.set_quality(quality) # 2 = high quality
|
|
57
|
+
self._buffer = bytearray()
|
|
58
|
+
|
|
59
|
+
def encode_chunk(self, audio: np.ndarray) -> bytes:
|
|
60
|
+
"""Encode a chunk of audio data.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
audio: Float32 audio data, values between -1 and 1
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
MP3 encoded bytes
|
|
67
|
+
"""
|
|
68
|
+
# Convert float32 to int16
|
|
69
|
+
int_data = (audio * 32767).astype(np.int16)
|
|
70
|
+
mp3_data = self.encoder.encode(int_data.tobytes())
|
|
71
|
+
return mp3_data
|
|
72
|
+
|
|
73
|
+
def finalize(self) -> bytes:
|
|
74
|
+
"""Finalize encoding and return remaining data."""
|
|
75
|
+
return self.encoder.flush()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RecordingSession:
|
|
79
|
+
"""Manages a single recording session (one meeting)."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
output_dir: Path,
|
|
84
|
+
sample_rate: int = 16000,
|
|
85
|
+
channels: int = 1,
|
|
86
|
+
meeting_name: Optional[str] = None,
|
|
87
|
+
):
|
|
88
|
+
self.output_dir = output_dir
|
|
89
|
+
self.sample_rate = sample_rate
|
|
90
|
+
self.channels = channels
|
|
91
|
+
self.meeting_name = meeting_name
|
|
92
|
+
self.encoder: Optional[MP3Encoder] = None
|
|
93
|
+
self.file_handle = None
|
|
94
|
+
self.filepath: Optional[Path] = None
|
|
95
|
+
self.start_time: Optional[datetime] = None
|
|
96
|
+
self.total_samples = 0
|
|
97
|
+
|
|
98
|
+
def start(self) -> Path:
|
|
99
|
+
"""Start a new recording session."""
|
|
100
|
+
self.start_time = datetime.now()
|
|
101
|
+
|
|
102
|
+
if self.meeting_name:
|
|
103
|
+
sanitized = _sanitize_filename(self.meeting_name)
|
|
104
|
+
if _is_timestamp_name(self.meeting_name):
|
|
105
|
+
# Default timestamp name - use as-is without extra prefix
|
|
106
|
+
filename = f"{sanitized}.mp3"
|
|
107
|
+
else:
|
|
108
|
+
# Custom name - add timestamp prefix for uniqueness
|
|
109
|
+
timestamp = self.start_time.strftime("%Y-%m-%d_%H%M%S")
|
|
110
|
+
filename = f"{timestamp}_{sanitized}.mp3"
|
|
111
|
+
else:
|
|
112
|
+
timestamp = self.start_time.strftime("%Y-%m-%d_%H%M%S")
|
|
113
|
+
filename = f"{timestamp}.mp3"
|
|
114
|
+
self.filepath = self.output_dir / filename
|
|
115
|
+
|
|
116
|
+
self.encoder = MP3Encoder(
|
|
117
|
+
sample_rate=self.sample_rate,
|
|
118
|
+
channels=self.channels,
|
|
119
|
+
)
|
|
120
|
+
self.file_handle = open(self.filepath, "wb")
|
|
121
|
+
self.total_samples = 0
|
|
122
|
+
|
|
123
|
+
return self.filepath
|
|
124
|
+
|
|
125
|
+
def write(self, audio: np.ndarray):
|
|
126
|
+
"""Write audio data to the recording."""
|
|
127
|
+
if self.encoder is None or self.file_handle is None:
|
|
128
|
+
raise RuntimeError("Recording session not started")
|
|
129
|
+
|
|
130
|
+
mp3_data = self.encoder.encode_chunk(audio)
|
|
131
|
+
if mp3_data:
|
|
132
|
+
self.file_handle.write(mp3_data)
|
|
133
|
+
self.total_samples += len(audio)
|
|
134
|
+
|
|
135
|
+
def stop(self) -> Tuple[Optional[Path], float]:
|
|
136
|
+
"""Stop the recording session.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Tuple of (filepath, duration_seconds)
|
|
140
|
+
"""
|
|
141
|
+
duration = 0.0
|
|
142
|
+
filepath = self.filepath
|
|
143
|
+
|
|
144
|
+
if self.encoder and self.file_handle:
|
|
145
|
+
# Write final data
|
|
146
|
+
final_data = self.encoder.finalize()
|
|
147
|
+
if final_data:
|
|
148
|
+
self.file_handle.write(final_data)
|
|
149
|
+
self.file_handle.close()
|
|
150
|
+
|
|
151
|
+
duration = self.total_samples / self.sample_rate
|
|
152
|
+
|
|
153
|
+
# Delete if too short (less than 5 seconds)
|
|
154
|
+
if duration < 5.0 and filepath and filepath.exists():
|
|
155
|
+
filepath.unlink()
|
|
156
|
+
filepath = None
|
|
157
|
+
|
|
158
|
+
self.encoder = None
|
|
159
|
+
self.file_handle = None
|
|
160
|
+
self.filepath = None
|
|
161
|
+
self.start_time = None
|
|
162
|
+
self.total_samples = 0
|
|
163
|
+
|
|
164
|
+
return filepath, duration
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def is_active(self) -> bool:
|
|
168
|
+
"""Check if a recording is in progress."""
|
|
169
|
+
return self.encoder is not None
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def duration(self) -> float:
|
|
173
|
+
"""Get current recording duration in seconds."""
|
|
174
|
+
if self.sample_rate > 0:
|
|
175
|
+
return self.total_samples / self.sample_rate
|
|
176
|
+
return 0.0
|