meeting-noter 1.1.0__py3-none-any.whl → 1.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.
@@ -0,0 +1,218 @@
1
+ """Search functionality for meeting transcripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import click
7
+ from pathlib import Path
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class SearchMatch:
14
+ """A single match within a transcript file."""
15
+
16
+ line_number: int
17
+ line: str
18
+ timestamp: Optional[str] = None
19
+
20
+
21
+ @dataclass
22
+ class FileSearchResult:
23
+ """Search results for a single transcript file."""
24
+
25
+ filepath: Path
26
+ matches: list[SearchMatch]
27
+
28
+ @property
29
+ def match_count(self) -> int:
30
+ return len(self.matches)
31
+
32
+
33
+ def _extract_timestamp(line: str) -> Optional[str]:
34
+ """Extract timestamp from line if present (e.g., [05:32] or [01:23:45])."""
35
+ match = re.match(r"^\[(\d{1,2}:\d{2}(?::\d{2})?)\]", line.strip())
36
+ if match:
37
+ return match.group(1)
38
+ return None
39
+
40
+
41
+ def _truncate_line(line: str, max_length: int = 80) -> str:
42
+ """Truncate line to max length with ellipsis."""
43
+ line = line.strip()
44
+ if len(line) <= max_length:
45
+ return line
46
+ return line[: max_length - 3] + "..."
47
+
48
+
49
+ def _highlight_match(line: str, query: str, case_sensitive: bool) -> str:
50
+ """Highlight matching text in the line."""
51
+ if case_sensitive:
52
+ pattern = re.escape(query)
53
+ else:
54
+ pattern = re.compile(re.escape(query), re.IGNORECASE)
55
+
56
+ def replace_with_highlight(match):
57
+ return click.style(match.group(0), bold=True, fg="yellow")
58
+
59
+ if case_sensitive:
60
+ return re.sub(pattern, replace_with_highlight, line)
61
+ else:
62
+ return pattern.sub(replace_with_highlight, line)
63
+
64
+
65
+ def _search_file(
66
+ filepath: Path,
67
+ query: str,
68
+ case_sensitive: bool,
69
+ context_lines: int = 1,
70
+ ) -> Optional[FileSearchResult]:
71
+ """Search a single file for the query.
72
+
73
+ Returns FileSearchResult if matches found, None otherwise.
74
+ """
75
+ try:
76
+ content = filepath.read_text(encoding="utf-8", errors="ignore")
77
+ except Exception:
78
+ return None
79
+
80
+ lines = content.splitlines()
81
+ matches: list[SearchMatch] = []
82
+
83
+ search_query = query if case_sensitive else query.lower()
84
+
85
+ for i, line in enumerate(lines):
86
+ search_line = line if case_sensitive else line.lower()
87
+
88
+ if search_query in search_line:
89
+ timestamp = _extract_timestamp(line)
90
+ matches.append(
91
+ SearchMatch(
92
+ line_number=i + 1,
93
+ line=line,
94
+ timestamp=timestamp,
95
+ )
96
+ )
97
+
98
+ if matches:
99
+ return FileSearchResult(filepath=filepath, matches=matches)
100
+ return None
101
+
102
+
103
+ def search_transcripts(
104
+ transcripts_dir: Path,
105
+ query: str,
106
+ case_sensitive: bool = False,
107
+ limit: int = 20,
108
+ context_lines: int = 1,
109
+ ) -> None:
110
+ """Search across all meeting transcripts.
111
+
112
+ Args:
113
+ transcripts_dir: Directory containing transcript files
114
+ query: Search query string
115
+ case_sensitive: Whether to perform case-sensitive search
116
+ limit: Maximum number of matches to display
117
+ context_lines: Number of context lines around matches (not yet implemented)
118
+ """
119
+ if not query.strip():
120
+ click.echo(click.style("Error: Search query cannot be empty.", fg="red"))
121
+ return
122
+
123
+ if not transcripts_dir.exists():
124
+ click.echo(click.style(f"Directory not found: {transcripts_dir}", fg="red"))
125
+ return
126
+
127
+ # Find all transcript files
128
+ txt_files = sorted(
129
+ transcripts_dir.glob("*.txt"),
130
+ key=lambda p: p.stat().st_mtime,
131
+ reverse=True,
132
+ )
133
+
134
+ if not txt_files:
135
+ click.echo(click.style("No transcripts found.", fg="yellow"))
136
+ click.echo(f"\nTranscripts directory: {transcripts_dir}")
137
+ click.echo("Record and transcribe meetings to search them.")
138
+ return
139
+
140
+ # Search all files
141
+ results: list[FileSearchResult] = []
142
+ for txt_file in txt_files:
143
+ result = _search_file(txt_file, query, case_sensitive, context_lines)
144
+ if result:
145
+ results.append(result)
146
+
147
+ if not results:
148
+ click.echo(click.style(f'No results found for "{query}"', fg="yellow"))
149
+ click.echo(f"\nSearched {len(txt_files)} transcripts in {transcripts_dir}")
150
+ if not case_sensitive:
151
+ click.echo("Tip: Use --case-sensitive for exact matching.")
152
+ return
153
+
154
+ # Sort by match count (most matches first)
155
+ results.sort(key=lambda r: r.match_count, reverse=True)
156
+
157
+ # Count total matches
158
+ total_matches = sum(r.match_count for r in results)
159
+ total_files = len(results)
160
+
161
+ # Display header
162
+ click.echo()
163
+ matches_word = "match" if total_matches == 1 else "matches"
164
+ files_word = "transcript" if total_files == 1 else "transcripts"
165
+ click.echo(
166
+ click.style(
167
+ f"Found {total_matches} {matches_word} in {total_files} {files_word}:",
168
+ bold=True,
169
+ )
170
+ )
171
+ click.echo()
172
+
173
+ # Display results
174
+ matches_shown = 0
175
+ limit_reached = False
176
+ for result in results:
177
+ if matches_shown >= limit:
178
+ limit_reached = True
179
+ break
180
+
181
+ # File header
182
+ match_word = "match" if result.match_count == 1 else "matches"
183
+ click.echo(
184
+ click.style(f"{result.filepath.name}", fg="green", bold=True)
185
+ + f" ({result.match_count} {match_word})"
186
+ )
187
+
188
+ # Show matches (limited)
189
+ for match in result.matches:
190
+ if matches_shown >= limit:
191
+ limit_reached = True
192
+ break
193
+
194
+ # Format the line
195
+ prefix = f" [{match.timestamp}] " if match.timestamp else " "
196
+ line_text = match.line
197
+ if match.timestamp:
198
+ # Remove timestamp from line since we're showing it in prefix
199
+ line_text = re.sub(r"^\[\d{1,2}:\d{2}(?::\d{2})?\]\s*", "", line_text)
200
+
201
+ truncated = _truncate_line(line_text, 70)
202
+ highlighted = _highlight_match(truncated, query, case_sensitive)
203
+
204
+ click.echo(f"{prefix}...{highlighted}...")
205
+ matches_shown += 1
206
+
207
+ click.echo()
208
+
209
+ # Show remaining count if limit was reached
210
+ if limit_reached and matches_shown < total_matches:
211
+ remaining = total_matches - matches_shown
212
+ click.echo(
213
+ click.style(f"... and {remaining} more matches", fg="cyan")
214
+ )
215
+ click.echo()
216
+
217
+ # Footer
218
+ click.echo(f"Searched {len(txt_files)} transcripts in {transcripts_dir}")
@@ -4,7 +4,7 @@ Buffers audio chunks and transcribes them in a background thread,
4
4
  writing segments to a .live.txt file that can be tailed by the CLI.
5
5
 
6
6
  Uses overlapping windows for lower latency: keeps a 5-second context window
7
- but transcribes every 2 seconds, only outputting new content.
7
+ but transcribes every 1 second, only outputting new content.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -24,7 +24,7 @@ class LiveTranscriber:
24
24
 
25
25
  Uses overlapping windows approach:
26
26
  - Maintains a rolling window of audio (default 5 seconds)
27
- - Transcribes every `slide_seconds` (default 2 seconds)
27
+ - Transcribes every `slide_seconds` (default 1 second)
28
28
  - Only outputs new segments to avoid duplicates
29
29
  """
30
30
 
@@ -34,7 +34,7 @@ class LiveTranscriber:
34
34
  sample_rate: int = 48000,
35
35
  channels: int = 2,
36
36
  window_seconds: float = 5.0,
37
- slide_seconds: float = 2.0,
37
+ slide_seconds: float = 1.0,
38
38
  model_size: str = "tiny.en",
39
39
  ):
40
40
  """Initialize the live transcriber.
@@ -115,15 +115,20 @@ class LiveTranscriber:
115
115
  except ImportError:
116
116
  pass
117
117
 
118
- if bundled_path and self.model_size == "tiny.en":
118
+ # Try GPU acceleration first, fall back to CPU if not supported
119
+ model_path = str(bundled_path) if (bundled_path and self.model_size == "tiny.en") else self.model_size
120
+
121
+ try:
122
+ # Try GPU with float16 first
119
123
  self._model = WhisperModel(
120
- str(bundled_path),
121
- device="cpu",
122
- compute_type="int8",
124
+ model_path,
125
+ device="cuda",
126
+ compute_type="float16",
123
127
  )
124
- else:
128
+ except Exception:
129
+ # Fall back to CPU with int8 (fastest CPU option)
125
130
  self._model = WhisperModel(
126
- self.model_size,
131
+ model_path,
127
132
  device="cpu",
128
133
  compute_type="int8",
129
134
  )
@@ -150,11 +155,10 @@ class LiveTranscriber:
150
155
  try:
151
156
  # Collect audio chunks
152
157
  try:
153
- chunk = self._audio_queue.get(timeout=0.5)
158
+ chunk = self._audio_queue.get(timeout=0.1)
154
159
 
155
- # Add samples to rolling buffer
156
- for sample in chunk:
157
- rolling_buffer.append(sample)
160
+ # Add samples to rolling buffer (batch extend is faster than per-sample append)
161
+ rolling_buffer.extend(chunk)
158
162
 
159
163
  samples_since_last_transcribe += len(chunk)
160
164
  self._recording_offset += len(chunk) / self.sample_rate
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meeting-noter
3
- Version: 1.1.0
3
+ Version: 1.3.0
4
4
  Summary: Offline meeting transcription for macOS with automatic meeting detection
5
5
  Author: Victor
6
6
  License: MIT
@@ -26,8 +26,6 @@ Requires-Dist: click>=8.0
26
26
  Requires-Dist: sounddevice>=0.4.6
27
27
  Requires-Dist: numpy>=1.24
28
28
  Requires-Dist: faster-whisper>=1.0.0
29
- Requires-Dist: rumps>=0.4.0
30
- Requires-Dist: PyQt6>=6.5.0
31
29
  Requires-Dist: imageio-ffmpeg>=0.4.9
32
30
  Requires-Dist: pyobjc-framework-Cocoa>=9.0; sys_platform == "darwin"
33
31
  Requires-Dist: pyobjc-framework-Quartz>=9.0; sys_platform == "darwin"
@@ -1,26 +1,20 @@
1
- meeting_noter/__init__.py,sha256=OuitWUFUeSb7eg41mnnDD6QiJrcGVpXpdhDzyxtCmmU,103
1
+ meeting_noter/__init__.py,sha256=6WLmaMLX2bfnCWqF2vzOnwjKo728hFUrDHXgUjykajQ,103
2
2
  meeting_noter/__main__.py,sha256=6sSOqH1o3jvgvkVzsVKmF6-xVGcUAbNVQkRl2CrygdE,120
3
- meeting_noter/cli.py,sha256=BAoUnQ-FPgwTp6-ON6WQkO-7Biwk1mYhwFaGdchHGHQ,34267
4
- meeting_noter/config.py,sha256=_Jy-gZDTCN3TwH5fiLEWk-qFhoSGISO2xDQmACV_zRc,6334
3
+ meeting_noter/cli.py,sha256=XMTDUpXpmfvy8E0KqXAtS3GD382xKJSzHBpZdvPfbwE,34771
4
+ meeting_noter/config.py,sha256=vdnTh6W6-DUPJCj0ekFu1Q1m87O7gx0QD3E5DrRGpXk,7537
5
5
  meeting_noter/daemon.py,sha256=u9VrYe94o3lxabuIS9MDVPHSH7MqKqzTqGTuA7TNAIc,19767
6
6
  meeting_noter/meeting_detector.py,sha256=St0qoMkvUERP4BaxnXO1M6fZDJpWqBf9In7z2SgWcWg,10564
7
- meeting_noter/menubar.py,sha256=Gn6p8y5jA_HCWf1T3ademxH-vndpONHkf9vUlKs6XEo,14379
8
7
  meeting_noter/mic_monitor.py,sha256=P8vF4qaZcGrEzzJyVos78Vuf38NXHGNRREDsD-HyBHc,16211
9
8
  meeting_noter/update_checker.py,sha256=sMmIiiZJL6K7wqLWE64Aj4hS8uspjUOirr6BG_IlL1I,1701
10
9
  meeting_noter/audio/__init__.py,sha256=O7PU8CxHSHxMeHbc9Jdwt9kePLQzsPh81GQU7VHCtBY,44
11
10
  meeting_noter/audio/capture.py,sha256=fDrT5oXfva8vdFlht9cv60NviKbksw2QeJ8eOtI19uE,6469
12
11
  meeting_noter/audio/encoder.py,sha256=OBsgUmlZPz-YZQZ7Rp8MAlMRaQxTsccjuTgCtvRebmc,6573
13
12
  meeting_noter/audio/system_audio.py,sha256=jbHGjNCerI19weXap0a90Ik17lVTCT1hCEgRKYke-p8,13016
14
- meeting_noter/gui/__init__.py,sha256=z5GxxaeXyjqyEa9ox0dQxuL5u_BART0bi7cI6rfntEI,103
15
- meeting_noter/gui/__main__.py,sha256=A2HWdYod0bTgjQQIi21O7XpmgxLH36e_X0aygEUZLls,146
16
- meeting_noter/gui/app.py,sha256=COUAWu_dR5HriNYxbE86CVGS1eGYqyteH2oUFN_YtYQ,1370
17
- meeting_noter/gui/main_window.py,sha256=vSvNO86CHMgJf9Pem8AOdrqKyTV9ITp3W4nCoqIuAmI,1667
18
- meeting_noter/gui/meetings_tab.py,sha256=pqXqMv5YvCj8H6yR_TF3SMzsIDMAxyLe2otbENbM2SY,12315
19
- meeting_noter/gui/recording_tab.py,sha256=UlrPkUiOmkGgOKqVrfAVp4EyiY5sq86W49Y-YAhYexY,12521
20
- meeting_noter/gui/settings_tab.py,sha256=NUQVKDdSpyNp_MVxPLw2dB93wxaD5VeBKiDtGS4CyoU,8446
21
13
  meeting_noter/install/__init__.py,sha256=SX5vLFMrV8aBDEGW18jhaqBqJqnRXaeo0Ct7QVGDgvE,38
22
14
  meeting_noter/install/macos.py,sha256=dO-86zbNKRtt0l4D8naVn7kFWjzI8TufWLWE3FRLHQ8,3400
23
15
  meeting_noter/output/__init__.py,sha256=F7xPlOrqweZcPbZtDrhved1stBI59vnWnLYfGwdu6oY,31
16
+ meeting_noter/output/favorites.py,sha256=kYtEshq5E5xxaqjG36JMY5a-r6C2w98iqmKsGsxpgag,5764
17
+ meeting_noter/output/searcher.py,sha256=ZGbEewodNuqq5mM4XvVtxELv_6UQByjs-LmEwodc-Ug,6448
24
18
  meeting_noter/output/writer.py,sha256=zO8y6FAFUAp0EEtALY-M5e2Ja5P-hgV38JjcKW7c-bA,3017
25
19
  meeting_noter/resources/__init__.py,sha256=yzHNxgypkuVDFZWv6xZjUygOVB_Equ9NNX_HGRvN7VM,43
26
20
  meeting_noter/resources/icon.icns,sha256=zMWqXCq7pI5acS0tbekFgFDvLt66EKUBP5-5IgztwPM,35146
@@ -33,9 +27,9 @@ meeting_noter/resources/icon_512.png,sha256=o7X3ngYcppcIAAk9AcfPx94MUmrsPRp0qBTp
33
27
  meeting_noter/resources/icon_64.png,sha256=TqG7Awx3kK8YdiX1e_z1odZonosZyQI2trlkNZCzUoI,607
34
28
  meeting_noter/transcription/__init__.py,sha256=7GY9diP06DzFyoli41wddbrPv5bVDzH35bmnWlIJev4,29
35
29
  meeting_noter/transcription/engine.py,sha256=G9NcSS6Q-UhW7PlQ0E85hQXn6BWao64nIvyw4NR2yxI,7208
36
- meeting_noter/transcription/live_transcription.py,sha256=AslB1T1_gxu7eSp7xc79_2SdfGrNJq7L_8bA1t6YoU4,9277
37
- meeting_noter-1.1.0.dist-info/METADATA,sha256=bGwwBq9AeFcxYKf0vpD3WtENmibBNLl4mzf1fLDyiVs,6995
38
- meeting_noter-1.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
39
- meeting_noter-1.1.0.dist-info/entry_points.txt,sha256=osZoOmm-UBPCJ4b6DGH6JOAm7mofM2fK06eK6blplmg,83
40
- meeting_noter-1.1.0.dist-info/top_level.txt,sha256=9Tuq04_0SXM0OXOHVbOHkHkB5tG3fqkrMrfzCMpbLpY,14
41
- meeting_noter-1.1.0.dist-info/RECORD,,
30
+ meeting_noter/transcription/live_transcription.py,sha256=YfojFWv4h3Lp-pK5tjIauvimCWAmDhj6pj5gUmyBxr4,9539
31
+ meeting_noter-1.3.0.dist-info/METADATA,sha256=Y8UXDVyKjcA_F4Ck8czm9GlNpsAjUcbFnLJLAAAdEdw,6939
32
+ meeting_noter-1.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
33
+ meeting_noter-1.3.0.dist-info/entry_points.txt,sha256=osZoOmm-UBPCJ4b6DGH6JOAm7mofM2fK06eK6blplmg,83
34
+ meeting_noter-1.3.0.dist-info/top_level.txt,sha256=9Tuq04_0SXM0OXOHVbOHkHkB5tG3fqkrMrfzCMpbLpY,14
35
+ meeting_noter-1.3.0.dist-info/RECORD,,
@@ -1,5 +0,0 @@
1
- """Desktop GUI for Meeting Noter."""
2
-
3
- from meeting_noter.gui.app import run_gui
4
-
5
- __all__ = ["run_gui"]
@@ -1,6 +0,0 @@
1
- """Allow running gui as a module: python -m meeting_noter.gui"""
2
-
3
- from meeting_noter.gui import run_gui
4
-
5
- if __name__ == "__main__":
6
- run_gui()
meeting_noter/gui/app.py DELETED
@@ -1,53 +0,0 @@
1
- """PyQt6 application entry point."""
2
-
3
- from __future__ import annotations
4
-
5
- import sys
6
- from pathlib import Path
7
-
8
- from PyQt6.QtGui import QIcon
9
- from PyQt6.QtWidgets import QApplication
10
-
11
- from meeting_noter.gui.main_window import MainWindow
12
-
13
-
14
- def _set_macos_dock_icon(icon_path: Path):
15
- """Set the macOS dock icon using AppKit."""
16
- try:
17
- from AppKit import NSApplication, NSImage
18
- ns_app = NSApplication.sharedApplication()
19
- icon = NSImage.alloc().initWithContentsOfFile_(str(icon_path))
20
- if icon:
21
- ns_app.setApplicationIconImage_(icon)
22
- except ImportError:
23
- pass # pyobjc not installed
24
-
25
-
26
- def run_gui():
27
- """Launch the Meeting Noter GUI application."""
28
- resources = Path(__file__).parent.parent / "resources"
29
-
30
- app = QApplication(sys.argv)
31
- app.setApplicationName("Meeting Noter")
32
- app.setOrganizationName("Meeting Noter")
33
-
34
- # Set window icon
35
- icon_path = resources / "icon.png"
36
- if icon_path.exists():
37
- app.setWindowIcon(QIcon(str(icon_path)))
38
-
39
- window = MainWindow()
40
- window.show()
41
-
42
- # Set macOS dock icon AFTER window is shown
43
- if sys.platform == "darwin":
44
- icns_path = resources / "icon.icns"
45
- if icns_path.exists():
46
- _set_macos_dock_icon(icns_path)
47
- app.processEvents()
48
-
49
- sys.exit(app.exec())
50
-
51
-
52
- if __name__ == "__main__":
53
- run_gui()
@@ -1,50 +0,0 @@
1
- """Main window with tab interface."""
2
-
3
- from __future__ import annotations
4
-
5
- from PyQt6.QtWidgets import QMainWindow, QTabWidget, QWidget, QVBoxLayout
6
-
7
- from meeting_noter.gui.recording_tab import RecordingTab
8
- from meeting_noter.gui.meetings_tab import MeetingsTab
9
- from meeting_noter.gui.settings_tab import SettingsTab
10
-
11
-
12
- class MainWindow(QMainWindow):
13
- """Main application window with tabbed interface."""
14
-
15
- def __init__(self):
16
- super().__init__()
17
- self.setWindowTitle("Meeting Noter")
18
- self.setMinimumSize(800, 600)
19
-
20
- # Create central widget with tabs
21
- central_widget = QWidget()
22
- self.setCentralWidget(central_widget)
23
-
24
- layout = QVBoxLayout(central_widget)
25
- layout.setContentsMargins(0, 0, 0, 0)
26
-
27
- # Create tab widget
28
- self.tabs = QTabWidget()
29
- layout.addWidget(self.tabs)
30
-
31
- # Create tabs
32
- self.recording_tab = RecordingTab()
33
- self.meetings_tab = MeetingsTab()
34
- self.settings_tab = SettingsTab()
35
-
36
- self.tabs.addTab(self.recording_tab, "Record")
37
- self.tabs.addTab(self.meetings_tab, "Meetings")
38
- self.tabs.addTab(self.settings_tab, "Settings")
39
-
40
- # Connect settings changes to refresh meetings list
41
- self.settings_tab.settings_saved.connect(self.meetings_tab.refresh)
42
-
43
- # Connect recording completion to refresh meetings list
44
- self.recording_tab.recording_saved.connect(self.meetings_tab.refresh)
45
-
46
- def closeEvent(self, event):
47
- """Handle window close - stop any active recording."""
48
- if self.recording_tab.is_recording:
49
- self.recording_tab.stop_recording()
50
- event.accept()