meeting-noter 0.6.1__py3-none-any.whl → 1.0.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/audio/encoder.py +8 -3
- meeting_noter/cli.py +773 -101
- meeting_noter/daemon.py +100 -16
- meeting_noter/meeting_detector.py +97 -60
- meeting_noter/mic_monitor.py +77 -22
- meeting_noter/transcription/live_transcription.py +250 -0
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/METADATA +14 -3
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/RECORD +11 -10
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/entry_points.txt +1 -0
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/WHEEL +0 -0
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Live transcription during recording.
|
|
2
|
+
|
|
3
|
+
Buffers audio chunks and transcribes them in a background thread,
|
|
4
|
+
writing segments to a .live.txt file that can be tailed by the CLI.
|
|
5
|
+
|
|
6
|
+
Uses overlapping windows for lower latency: keeps a 5-second context window
|
|
7
|
+
but transcribes every 2 seconds, only outputting new content.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
import numpy as np
|
|
14
|
+
from collections import deque
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from queue import Queue, Empty
|
|
17
|
+
from threading import Thread, Event
|
|
18
|
+
from typing import Optional
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LiveTranscriber:
|
|
23
|
+
"""Transcribes audio in real-time during recording.
|
|
24
|
+
|
|
25
|
+
Uses overlapping windows approach:
|
|
26
|
+
- Maintains a rolling window of audio (default 5 seconds)
|
|
27
|
+
- Transcribes every `slide_seconds` (default 2 seconds)
|
|
28
|
+
- Only outputs new segments to avoid duplicates
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
output_path: Path,
|
|
34
|
+
sample_rate: int = 48000,
|
|
35
|
+
channels: int = 2,
|
|
36
|
+
window_seconds: float = 5.0,
|
|
37
|
+
slide_seconds: float = 2.0,
|
|
38
|
+
model_size: str = "tiny.en",
|
|
39
|
+
):
|
|
40
|
+
"""Initialize the live transcriber.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
output_path: Path to write live transcript (will use .live.txt suffix in live/ subfolder)
|
|
44
|
+
sample_rate: Audio sample rate
|
|
45
|
+
channels: Number of audio channels
|
|
46
|
+
window_seconds: Size of the context window for transcription
|
|
47
|
+
slide_seconds: How often to transcribe (lower = more responsive, higher CPU)
|
|
48
|
+
model_size: Whisper model to use (tiny.en recommended for speed)
|
|
49
|
+
"""
|
|
50
|
+
# Put live transcripts in a 'live/' subfolder to keep recordings folder clean
|
|
51
|
+
live_dir = output_path.parent / "live"
|
|
52
|
+
live_dir.mkdir(exist_ok=True)
|
|
53
|
+
self.output_path = live_dir / (output_path.stem + ".live.txt")
|
|
54
|
+
self.sample_rate = sample_rate
|
|
55
|
+
self.channels = channels
|
|
56
|
+
self.window_seconds = window_seconds
|
|
57
|
+
self.slide_seconds = slide_seconds
|
|
58
|
+
self.model_size = model_size
|
|
59
|
+
|
|
60
|
+
self._audio_queue: Queue[np.ndarray] = Queue()
|
|
61
|
+
self._stop_event = Event()
|
|
62
|
+
self._thread: Optional[Thread] = None
|
|
63
|
+
self._model = None
|
|
64
|
+
self._start_time: Optional[datetime] = None
|
|
65
|
+
self._recording_offset = 0.0 # Current position in recording (seconds)
|
|
66
|
+
self._last_output_end = 0.0 # End time of last outputted segment
|
|
67
|
+
|
|
68
|
+
def start(self):
|
|
69
|
+
"""Start the live transcription thread."""
|
|
70
|
+
self._stop_event.clear()
|
|
71
|
+
self._start_time = datetime.now()
|
|
72
|
+
self._recording_offset = 0.0
|
|
73
|
+
self._last_output_end = 0.0
|
|
74
|
+
|
|
75
|
+
# Create/clear the output file
|
|
76
|
+
with open(self.output_path, "w") as f:
|
|
77
|
+
f.write(f"Live Transcription - {self._start_time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
|
78
|
+
f.write("-" * 40 + "\n\n")
|
|
79
|
+
|
|
80
|
+
self._thread = Thread(target=self._transcribe_loop, daemon=True)
|
|
81
|
+
self._thread.start()
|
|
82
|
+
|
|
83
|
+
def write(self, audio: np.ndarray):
|
|
84
|
+
"""Add audio chunk to the transcription queue.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
audio: Audio data (float32, -1 to 1)
|
|
88
|
+
"""
|
|
89
|
+
if not self._stop_event.is_set():
|
|
90
|
+
self._audio_queue.put(audio.copy())
|
|
91
|
+
|
|
92
|
+
def stop(self):
|
|
93
|
+
"""Stop the live transcription thread."""
|
|
94
|
+
self._stop_event.set()
|
|
95
|
+
if self._thread is not None:
|
|
96
|
+
self._thread.join(timeout=10.0)
|
|
97
|
+
self._thread = None
|
|
98
|
+
self._model = None
|
|
99
|
+
|
|
100
|
+
def _load_model(self):
|
|
101
|
+
"""Load the Whisper model (lazy loading)."""
|
|
102
|
+
if self._model is not None:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
from faster_whisper import WhisperModel
|
|
107
|
+
|
|
108
|
+
# Check for bundled model
|
|
109
|
+
bundled_path = None
|
|
110
|
+
try:
|
|
111
|
+
from meeting_noter_models import get_model_path
|
|
112
|
+
bundled_path = get_model_path()
|
|
113
|
+
if not (bundled_path.exists() and (bundled_path / "model.bin").exists()):
|
|
114
|
+
bundled_path = None
|
|
115
|
+
except ImportError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
if bundled_path and self.model_size == "tiny.en":
|
|
119
|
+
self._model = WhisperModel(
|
|
120
|
+
str(bundled_path),
|
|
121
|
+
device="cpu",
|
|
122
|
+
compute_type="int8",
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
self._model = WhisperModel(
|
|
126
|
+
self.model_size,
|
|
127
|
+
device="cpu",
|
|
128
|
+
compute_type="int8",
|
|
129
|
+
)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
print(f"Failed to load Whisper model: {e}", file=sys.stderr)
|
|
132
|
+
self._model = None
|
|
133
|
+
|
|
134
|
+
def _transcribe_loop(self):
|
|
135
|
+
"""Main transcription loop with overlapping windows."""
|
|
136
|
+
# Rolling buffer using deque for efficient sliding
|
|
137
|
+
window_samples = int(self.window_seconds * self.sample_rate)
|
|
138
|
+
slide_samples = int(self.slide_seconds * self.sample_rate)
|
|
139
|
+
|
|
140
|
+
# Buffer holds raw audio samples
|
|
141
|
+
rolling_buffer: deque[float] = deque(maxlen=window_samples)
|
|
142
|
+
samples_since_last_transcribe = 0
|
|
143
|
+
|
|
144
|
+
# Load model on first use
|
|
145
|
+
self._load_model()
|
|
146
|
+
if self._model is None:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
while not self._stop_event.is_set():
|
|
150
|
+
try:
|
|
151
|
+
# Collect audio chunks
|
|
152
|
+
try:
|
|
153
|
+
chunk = self._audio_queue.get(timeout=0.5)
|
|
154
|
+
|
|
155
|
+
# Add samples to rolling buffer
|
|
156
|
+
for sample in chunk:
|
|
157
|
+
rolling_buffer.append(sample)
|
|
158
|
+
|
|
159
|
+
samples_since_last_transcribe += len(chunk)
|
|
160
|
+
self._recording_offset += len(chunk) / self.sample_rate
|
|
161
|
+
|
|
162
|
+
except Empty:
|
|
163
|
+
if self._stop_event.is_set():
|
|
164
|
+
break
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Transcribe every slide_seconds
|
|
168
|
+
if samples_since_last_transcribe >= slide_samples and len(rolling_buffer) >= slide_samples:
|
|
169
|
+
self._transcribe_window(rolling_buffer)
|
|
170
|
+
samples_since_last_transcribe = 0
|
|
171
|
+
|
|
172
|
+
except Exception as e:
|
|
173
|
+
print(f"Live transcription error: {e}", file=sys.stderr)
|
|
174
|
+
|
|
175
|
+
# Final transcription on stop
|
|
176
|
+
if len(rolling_buffer) > 0:
|
|
177
|
+
self._transcribe_window(rolling_buffer)
|
|
178
|
+
|
|
179
|
+
def _transcribe_window(self, rolling_buffer: deque):
|
|
180
|
+
"""Transcribe the current window and output new segments."""
|
|
181
|
+
if not rolling_buffer or self._model is None:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
# Convert deque to numpy array (ensure float32 for Whisper)
|
|
186
|
+
audio = np.array(list(rolling_buffer), dtype=np.float32)
|
|
187
|
+
|
|
188
|
+
# Convert stereo to mono if needed
|
|
189
|
+
if self.channels == 2 and len(audio) % 2 == 0:
|
|
190
|
+
audio = audio.reshape(-1, 2).mean(axis=1).astype(np.float32)
|
|
191
|
+
|
|
192
|
+
# Resample to 16kHz if needed (Whisper expects 16kHz)
|
|
193
|
+
if self.sample_rate != 16000:
|
|
194
|
+
audio = self._resample(audio, self.sample_rate, 16000).astype(np.float32)
|
|
195
|
+
|
|
196
|
+
# Calculate window timing
|
|
197
|
+
window_duration = len(rolling_buffer) / self.sample_rate
|
|
198
|
+
window_start = self._recording_offset - window_duration
|
|
199
|
+
|
|
200
|
+
# Transcribe using faster-whisper
|
|
201
|
+
segments, _ = self._model.transcribe(
|
|
202
|
+
audio,
|
|
203
|
+
beam_size=1, # Fastest
|
|
204
|
+
vad_filter=True,
|
|
205
|
+
vad_parameters=dict(min_silence_duration_ms=200),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Write only NEW segments to file
|
|
209
|
+
with open(self.output_path, "a") as f:
|
|
210
|
+
for segment in segments:
|
|
211
|
+
# Calculate absolute timestamp
|
|
212
|
+
abs_start = window_start + segment.start
|
|
213
|
+
abs_end = window_start + segment.end
|
|
214
|
+
|
|
215
|
+
# Only output if this segment is new (starts after last output)
|
|
216
|
+
if abs_start >= self._last_output_end - 0.5: # 0.5s tolerance for overlap
|
|
217
|
+
text = segment.text.strip()
|
|
218
|
+
if text:
|
|
219
|
+
timestamp = self._format_timestamp(abs_start)
|
|
220
|
+
f.write(f"{timestamp} {text}\n")
|
|
221
|
+
f.flush()
|
|
222
|
+
self._last_output_end = abs_end
|
|
223
|
+
|
|
224
|
+
except Exception as e:
|
|
225
|
+
print(f"Transcription error: {e}", file=sys.stderr)
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _resample(audio: np.ndarray, orig_sr: int, target_sr: int) -> np.ndarray:
|
|
229
|
+
"""Simple resampling using linear interpolation."""
|
|
230
|
+
if orig_sr == target_sr:
|
|
231
|
+
return audio
|
|
232
|
+
|
|
233
|
+
duration = len(audio) / orig_sr
|
|
234
|
+
target_length = int(duration * target_sr)
|
|
235
|
+
|
|
236
|
+
# Use numpy interpolation (returns float64, so cast back)
|
|
237
|
+
indices = np.linspace(0, len(audio) - 1, target_length)
|
|
238
|
+
return np.interp(indices, np.arange(len(audio)), audio).astype(np.float32)
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _format_timestamp(seconds: float) -> str:
|
|
242
|
+
"""Format seconds as [MM:SS]."""
|
|
243
|
+
minutes = int(seconds // 60)
|
|
244
|
+
secs = int(seconds % 60)
|
|
245
|
+
return f"[{minutes:02d}:{secs:02d}]"
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def live_file_path(self) -> Path:
|
|
249
|
+
"""Get the path to the live transcript file."""
|
|
250
|
+
return self.output_path
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meeting-noter
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Offline meeting transcription for macOS with automatic meeting detection
|
|
5
5
|
Author: Victor
|
|
6
6
|
License: MIT
|
|
@@ -40,6 +40,7 @@ Requires-Dist: meeting-noter-models>=0.1.0; extra == "offline"
|
|
|
40
40
|
Provides-Extra: dev
|
|
41
41
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
42
42
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
43
|
+
Requires-Dist: pytest-mock; extra == "dev"
|
|
43
44
|
Requires-Dist: black; extra == "dev"
|
|
44
45
|
Requires-Dist: ruff; extra == "dev"
|
|
45
46
|
Requires-Dist: mypy; extra == "dev"
|
|
@@ -63,9 +64,19 @@ Offline meeting transcription tool for macOS. Captures both your voice and meeti
|
|
|
63
64
|
pipx install meeting-noter
|
|
64
65
|
```
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
**For corporate/offline environments** (bundles Whisper model, no download needed):
|
|
67
68
|
```bash
|
|
68
|
-
|
|
69
|
+
pipx install "meeting-noter[offline]"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Upgrading
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Standard
|
|
76
|
+
pipx upgrade meeting-noter
|
|
77
|
+
|
|
78
|
+
# With offline model
|
|
79
|
+
pipx reinstall "meeting-noter[offline]"
|
|
69
80
|
```
|
|
70
81
|
|
|
71
82
|
No system dependencies required - ffmpeg is bundled automatically.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
meeting_noter/__init__.py,sha256=bLOErRC3sfnQ4a4RyZUzUljZEikXy7zOiYYUz5GytPg,103
|
|
2
2
|
meeting_noter/__main__.py,sha256=6sSOqH1o3jvgvkVzsVKmF6-xVGcUAbNVQkRl2CrygdE,120
|
|
3
|
-
meeting_noter/cli.py,sha256=
|
|
3
|
+
meeting_noter/cli.py,sha256=brLJ_2kuqEi5wq868hFaMGtKPSkyBpD-OS1u27rJb1k,32859
|
|
4
4
|
meeting_noter/config.py,sha256=41LFBNp5o0IojYS5Hf0FJVIr7GNn7B5O1TJDE8SQkkk,5977
|
|
5
|
-
meeting_noter/daemon.py,sha256=
|
|
6
|
-
meeting_noter/meeting_detector.py,sha256=
|
|
5
|
+
meeting_noter/daemon.py,sha256=u9VrYe94o3lxabuIS9MDVPHSH7MqKqzTqGTuA7TNAIc,19767
|
|
6
|
+
meeting_noter/meeting_detector.py,sha256=St0qoMkvUERP4BaxnXO1M6fZDJpWqBf9In7z2SgWcWg,10564
|
|
7
7
|
meeting_noter/menubar.py,sha256=Gn6p8y5jA_HCWf1T3ademxH-vndpONHkf9vUlKs6XEo,14379
|
|
8
|
-
meeting_noter/mic_monitor.py,sha256=
|
|
8
|
+
meeting_noter/mic_monitor.py,sha256=P8vF4qaZcGrEzzJyVos78Vuf38NXHGNRREDsD-HyBHc,16211
|
|
9
9
|
meeting_noter/audio/__init__.py,sha256=O7PU8CxHSHxMeHbc9Jdwt9kePLQzsPh81GQU7VHCtBY,44
|
|
10
10
|
meeting_noter/audio/capture.py,sha256=fDrT5oXfva8vdFlht9cv60NviKbksw2QeJ8eOtI19uE,6469
|
|
11
|
-
meeting_noter/audio/encoder.py,sha256=
|
|
11
|
+
meeting_noter/audio/encoder.py,sha256=OBsgUmlZPz-YZQZ7Rp8MAlMRaQxTsccjuTgCtvRebmc,6573
|
|
12
12
|
meeting_noter/audio/system_audio.py,sha256=jbHGjNCerI19weXap0a90Ik17lVTCT1hCEgRKYke-p8,13016
|
|
13
13
|
meeting_noter/gui/__init__.py,sha256=z5GxxaeXyjqyEa9ox0dQxuL5u_BART0bi7cI6rfntEI,103
|
|
14
14
|
meeting_noter/gui/__main__.py,sha256=A2HWdYod0bTgjQQIi21O7XpmgxLH36e_X0aygEUZLls,146
|
|
@@ -32,8 +32,9 @@ meeting_noter/resources/icon_512.png,sha256=o7X3ngYcppcIAAk9AcfPx94MUmrsPRp0qBTp
|
|
|
32
32
|
meeting_noter/resources/icon_64.png,sha256=TqG7Awx3kK8YdiX1e_z1odZonosZyQI2trlkNZCzUoI,607
|
|
33
33
|
meeting_noter/transcription/__init__.py,sha256=7GY9diP06DzFyoli41wddbrPv5bVDzH35bmnWlIJev4,29
|
|
34
34
|
meeting_noter/transcription/engine.py,sha256=G9NcSS6Q-UhW7PlQ0E85hQXn6BWao64nIvyw4NR2yxI,7208
|
|
35
|
-
meeting_noter
|
|
36
|
-
meeting_noter-0.
|
|
37
|
-
meeting_noter-0.
|
|
38
|
-
meeting_noter-0.
|
|
39
|
-
meeting_noter-0.
|
|
35
|
+
meeting_noter/transcription/live_transcription.py,sha256=AslB1T1_gxu7eSp7xc79_2SdfGrNJq7L_8bA1t6YoU4,9277
|
|
36
|
+
meeting_noter-1.0.0.dist-info/METADATA,sha256=m7Pi8_-haGOHX0DbA7YXTc1KMpMWAxLHHgbE3kB3-FM,6995
|
|
37
|
+
meeting_noter-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
38
|
+
meeting_noter-1.0.0.dist-info/entry_points.txt,sha256=osZoOmm-UBPCJ4b6DGH6JOAm7mofM2fK06eK6blplmg,83
|
|
39
|
+
meeting_noter-1.0.0.dist-info/top_level.txt,sha256=9Tuq04_0SXM0OXOHVbOHkHkB5tG3fqkrMrfzCMpbLpY,14
|
|
40
|
+
meeting_noter-1.0.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|