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.

Files changed (38) hide show
  1. meeting_noter/__init__.py +3 -0
  2. meeting_noter/__main__.py +6 -0
  3. meeting_noter/audio/__init__.py +1 -0
  4. meeting_noter/audio/capture.py +209 -0
  5. meeting_noter/audio/encoder.py +176 -0
  6. meeting_noter/audio/system_audio.py +363 -0
  7. meeting_noter/cli.py +308 -0
  8. meeting_noter/config.py +197 -0
  9. meeting_noter/daemon.py +514 -0
  10. meeting_noter/gui/__init__.py +5 -0
  11. meeting_noter/gui/__main__.py +6 -0
  12. meeting_noter/gui/app.py +53 -0
  13. meeting_noter/gui/main_window.py +50 -0
  14. meeting_noter/gui/meetings_tab.py +348 -0
  15. meeting_noter/gui/recording_tab.py +358 -0
  16. meeting_noter/gui/settings_tab.py +249 -0
  17. meeting_noter/install/__init__.py +1 -0
  18. meeting_noter/install/macos.py +102 -0
  19. meeting_noter/meeting_detector.py +296 -0
  20. meeting_noter/menubar.py +432 -0
  21. meeting_noter/output/__init__.py +1 -0
  22. meeting_noter/output/writer.py +96 -0
  23. meeting_noter/resources/__init__.py +1 -0
  24. meeting_noter/resources/icon.icns +0 -0
  25. meeting_noter/resources/icon.png +0 -0
  26. meeting_noter/resources/icon_128.png +0 -0
  27. meeting_noter/resources/icon_16.png +0 -0
  28. meeting_noter/resources/icon_256.png +0 -0
  29. meeting_noter/resources/icon_32.png +0 -0
  30. meeting_noter/resources/icon_512.png +0 -0
  31. meeting_noter/resources/icon_64.png +0 -0
  32. meeting_noter/transcription/__init__.py +1 -0
  33. meeting_noter/transcription/engine.py +208 -0
  34. meeting_noter-0.3.0.dist-info/METADATA +261 -0
  35. meeting_noter-0.3.0.dist-info/RECORD +38 -0
  36. meeting_noter-0.3.0.dist-info/WHEEL +5 -0
  37. meeting_noter-0.3.0.dist-info/entry_points.txt +2 -0
  38. meeting_noter-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """Meeting Noter - Offline meeting transcription with virtual audio devices."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m meeting_noter."""
2
+
3
+ from meeting_noter.cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -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