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.

Files changed (39) 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 +208 -0
  6. meeting_noter/audio/system_audio.py +363 -0
  7. meeting_noter/cli.py +837 -0
  8. meeting_noter/config.py +197 -0
  9. meeting_noter/daemon.py +519 -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 +333 -0
  20. meeting_noter/menubar.py +411 -0
  21. meeting_noter/mic_monitor.py +456 -0
  22. meeting_noter/output/__init__.py +1 -0
  23. meeting_noter/output/writer.py +96 -0
  24. meeting_noter/resources/__init__.py +1 -0
  25. meeting_noter/resources/icon.icns +0 -0
  26. meeting_noter/resources/icon.png +0 -0
  27. meeting_noter/resources/icon_128.png +0 -0
  28. meeting_noter/resources/icon_16.png +0 -0
  29. meeting_noter/resources/icon_256.png +0 -0
  30. meeting_noter/resources/icon_32.png +0 -0
  31. meeting_noter/resources/icon_512.png +0 -0
  32. meeting_noter/resources/icon_64.png +0 -0
  33. meeting_noter/transcription/__init__.py +1 -0
  34. meeting_noter/transcription/engine.py +234 -0
  35. meeting_noter-0.7.0.dist-info/METADATA +224 -0
  36. meeting_noter-0.7.0.dist-info/RECORD +39 -0
  37. meeting_noter-0.7.0.dist-info/WHEEL +5 -0
  38. meeting_noter-0.7.0.dist-info/entry_points.txt +2 -0
  39. meeting_noter-0.7.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,208 @@
1
+ """MP3 encoding for audio recordings using ffmpeg."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ import numpy as np
8
+ from pathlib import Path
9
+ from datetime import datetime
10
+ from typing import Optional, Tuple
11
+
12
+ # Get bundled ffmpeg binary path
13
+ try:
14
+ import imageio_ffmpeg
15
+ FFMPEG_PATH = imageio_ffmpeg.get_ffmpeg_exe()
16
+ except ImportError:
17
+ # Fallback to system ffmpeg
18
+ FFMPEG_PATH = "ffmpeg"
19
+
20
+
21
+ def _sanitize_filename(name: str, max_length: int = 50) -> str:
22
+ """Sanitize a string for use as a filename.
23
+
24
+ Args:
25
+ name: The name to sanitize
26
+ max_length: Maximum length for the sanitized name
27
+
28
+ Returns:
29
+ A safe filename string with spaces replaced by underscores
30
+ """
31
+ # Replace spaces with underscores
32
+ name = name.replace(" ", "_")
33
+ # Remove any character that isn't alphanumeric, underscore, or hyphen
34
+ name = re.sub(r"[^\w\-]", "", name)
35
+ # Truncate to max length
36
+ if len(name) > max_length:
37
+ name = name[:max_length]
38
+ # Remove trailing underscores/hyphens
39
+ name = name.rstrip("_-")
40
+ return name
41
+
42
+
43
+ def _is_timestamp_name(name: str) -> bool:
44
+ """Check if name is a default timestamp pattern (DD_Mon_YYYY_HHMM)."""
45
+ return bool(re.match(r"^\d{2}_[A-Z][a-z]{2}_\d{4}_\d{4}$", name))
46
+
47
+
48
+ class MP3Encoder:
49
+ """Encodes audio data to MP3 format using ffmpeg."""
50
+
51
+ def __init__(
52
+ self,
53
+ output_path: Path,
54
+ sample_rate: int = 16000,
55
+ channels: int = 1,
56
+ bitrate: int = 128,
57
+ ):
58
+ self.output_path = output_path
59
+ self.sample_rate = sample_rate
60
+ self.channels = channels
61
+ self.bitrate = bitrate
62
+ self._process: Optional[subprocess.Popen] = None
63
+ self._start_ffmpeg()
64
+
65
+ def _start_ffmpeg(self):
66
+ """Start ffmpeg process for encoding."""
67
+ cmd = [
68
+ FFMPEG_PATH,
69
+ "-y", # Overwrite output
70
+ "-f", "s16le", # Input format: signed 16-bit little-endian PCM
71
+ "-ar", str(self.sample_rate), # Sample rate
72
+ "-ac", str(self.channels), # Channels
73
+ "-i", "pipe:0", # Read from stdin
74
+ "-codec:a", "libmp3lame", # MP3 encoder
75
+ "-b:a", f"{self.bitrate}k", # Bitrate
76
+ "-f", "mp3", # Output format
77
+ str(self.output_path),
78
+ ]
79
+ self._process = subprocess.Popen(
80
+ cmd,
81
+ stdin=subprocess.PIPE,
82
+ stdout=subprocess.DEVNULL,
83
+ stderr=subprocess.DEVNULL,
84
+ )
85
+
86
+ def encode_chunk(self, audio: np.ndarray) -> bytes:
87
+ """Encode a chunk of audio data.
88
+
89
+ Args:
90
+ audio: Float32 audio data, values between -1 and 1
91
+
92
+ Returns:
93
+ Empty bytes (ffmpeg writes directly to file)
94
+ """
95
+ if self._process is None or self._process.stdin is None:
96
+ return b""
97
+
98
+ # Convert float32 to int16
99
+ int_data = (audio * 32767).astype(np.int16)
100
+ try:
101
+ self._process.stdin.write(int_data.tobytes())
102
+ except BrokenPipeError:
103
+ pass
104
+ return b"" # ffmpeg writes to file, not returning data
105
+
106
+ def finalize(self) -> bytes:
107
+ """Finalize encoding."""
108
+ if self._process is not None and self._process.stdin is not None:
109
+ try:
110
+ self._process.stdin.close()
111
+ except Exception:
112
+ pass
113
+ self._process.wait()
114
+ self._process = None
115
+ return b""
116
+
117
+
118
+ class RecordingSession:
119
+ """Manages a single recording session (one meeting)."""
120
+
121
+ def __init__(
122
+ self,
123
+ output_dir: Path,
124
+ sample_rate: int = 16000,
125
+ channels: int = 1,
126
+ meeting_name: Optional[str] = None,
127
+ ):
128
+ self.output_dir = output_dir
129
+ self.sample_rate = sample_rate
130
+ self.channels = channels
131
+ self.meeting_name = meeting_name
132
+ self.encoder: Optional[MP3Encoder] = None
133
+ self.filepath: Optional[Path] = None
134
+ self.start_time: Optional[datetime] = None
135
+ self.total_samples = 0
136
+
137
+ def start(self) -> Path:
138
+ """Start a new recording session."""
139
+ self.start_time = datetime.now()
140
+
141
+ if self.meeting_name:
142
+ sanitized = _sanitize_filename(self.meeting_name)
143
+ if _is_timestamp_name(self.meeting_name):
144
+ # Default timestamp name - use as-is without extra prefix
145
+ filename = f"{sanitized}.mp3"
146
+ else:
147
+ # Custom name - add timestamp prefix for uniqueness
148
+ timestamp = self.start_time.strftime("%Y-%m-%d_%H%M%S")
149
+ filename = f"{timestamp}_{sanitized}.mp3"
150
+ else:
151
+ timestamp = self.start_time.strftime("%Y-%m-%d_%H%M%S")
152
+ filename = f"{timestamp}.mp3"
153
+ self.filepath = self.output_dir / filename
154
+
155
+ self.encoder = MP3Encoder(
156
+ output_path=self.filepath,
157
+ sample_rate=self.sample_rate,
158
+ channels=self.channels,
159
+ )
160
+ self.total_samples = 0
161
+
162
+ return self.filepath
163
+
164
+ def write(self, audio: np.ndarray):
165
+ """Write audio data to the recording."""
166
+ if self.encoder is None:
167
+ raise RuntimeError("Recording session not started")
168
+
169
+ self.encoder.encode_chunk(audio)
170
+ self.total_samples += len(audio)
171
+
172
+ def stop(self) -> Tuple[Optional[Path], float]:
173
+ """Stop the recording session.
174
+
175
+ Returns:
176
+ Tuple of (filepath, duration_seconds)
177
+ """
178
+ duration = 0.0
179
+ filepath = self.filepath
180
+
181
+ if self.encoder:
182
+ # Finalize encoding
183
+ self.encoder.finalize()
184
+ duration = self.total_samples / self.sample_rate
185
+
186
+ # Delete if too short (less than 5 seconds)
187
+ if duration < 5.0 and filepath and filepath.exists():
188
+ filepath.unlink()
189
+ filepath = None
190
+
191
+ self.encoder = None
192
+ self.filepath = None
193
+ self.start_time = None
194
+ self.total_samples = 0
195
+
196
+ return filepath, duration
197
+
198
+ @property
199
+ def is_active(self) -> bool:
200
+ """Check if a recording is in progress."""
201
+ return self.encoder is not None
202
+
203
+ @property
204
+ def duration(self) -> float:
205
+ """Get current recording duration in seconds."""
206
+ if self.sample_rate > 0:
207
+ return self.total_samples / self.sample_rate
208
+ return 0.0