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.

@@ -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.6.1
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
- Or with pip:
67
+ **For corporate/offline environments** (bundles Whisper model, no download needed):
67
68
  ```bash
68
- pip install meeting-noter
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=w4cBrvg58inkgbApK_z90csBfb4HWBuXveLdD3J1bc0,8540
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=o7U11WmdoKG58SLs70-vzS64kkjMTY5qN0F0bs0eApk,16239
6
- meeting_noter/meeting_detector.py,sha256=I8zzSdSSmbfd3yyCOyzPL8AS-xSHttFCagrDE35qcho,9412
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=Dzt7RZT7-X5US7mT2I247UguR-uLWK0BZDnjiehLD-A,13634
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=6UgEYLFACSQEIx2nhH1Qq-cBh3qPJziMGkrm39k6Nz8,6401
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-0.6.1.dist-info/METADATA,sha256=KCJbo3BWGaFyysbFWkUDi7SMsd-65P_fNLCmiNAFOVk,6741
36
- meeting_noter-0.6.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
37
- meeting_noter-0.6.1.dist-info/entry_points.txt,sha256=rKNhzjSF5-e3bLRr8LVe22FeiwcacXabCvNpoEXfu4I,56
38
- meeting_noter-0.6.1.dist-info/top_level.txt,sha256=9Tuq04_0SXM0OXOHVbOHkHkB5tG3fqkrMrfzCMpbLpY,14
39
- meeting_noter-0.6.1.dist-info/RECORD,,
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,,
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  meeting-noter = meeting_noter.cli:cli
3
+ mn = meeting_noter.cli:cli