meeting-noter 1.2.0__py3-none-any.whl → 2.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,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
@@ -0,0 +1,5 @@
1
+ """Terminal User Interface for Meeting Noter."""
2
+
3
+ from meeting_noter.ui.app import MeetingNoterApp
4
+
5
+ __all__ = ["MeetingNoterApp"]
@@ -0,0 +1,68 @@
1
+ """Main Textual application for Meeting Noter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.widgets import Footer, Header
8
+
9
+ from meeting_noter.ui.screens.main import MainScreen
10
+ from meeting_noter.ui.screens.recordings import RecordingsScreen
11
+ from meeting_noter.ui.screens.search import SearchScreen
12
+ from meeting_noter.ui.screens.settings import SettingsScreen
13
+ from meeting_noter.ui.screens.logs import LogsScreen
14
+
15
+
16
+ class MeetingNoterApp(App):
17
+ """Meeting Noter Terminal UI Application."""
18
+
19
+ TITLE = "Meeting Noter"
20
+ CSS_PATH = "styles/app.tcss"
21
+
22
+ BINDINGS = [
23
+ Binding("q", "quit", "Quit"),
24
+ Binding("question_mark", "help", "Help", key_display="?"),
25
+ Binding("1", "switch_screen('main')", "Dashboard", show=True),
26
+ Binding("2", "switch_screen('recordings')", "Recordings", show=True),
27
+ Binding("3", "switch_screen('search')", "Search", show=True),
28
+ Binding("4", "switch_screen('settings')", "Settings", show=True),
29
+ Binding("5", "switch_screen('logs')", "Logs", show=True),
30
+ Binding("escape", "go_back", "Back", show=False),
31
+ ]
32
+
33
+ SCREENS = {
34
+ "main": MainScreen,
35
+ "recordings": RecordingsScreen,
36
+ "search": SearchScreen,
37
+ "settings": SettingsScreen,
38
+ "logs": LogsScreen,
39
+ }
40
+
41
+ def compose(self) -> ComposeResult:
42
+ """Compose the app layout."""
43
+ yield Header()
44
+ yield Footer()
45
+
46
+ def on_mount(self) -> None:
47
+ """Handle app mount - show the main screen."""
48
+ self.push_screen("main")
49
+
50
+ def action_switch_screen(self, screen_name: str) -> None:
51
+ """Switch to a named screen."""
52
+ # Pop all screens except the base and push the new one
53
+ while len(self.screen_stack) > 1:
54
+ self.pop_screen()
55
+ self.push_screen(screen_name)
56
+
57
+ def action_go_back(self) -> None:
58
+ """Go back to the previous screen."""
59
+ if len(self.screen_stack) > 1:
60
+ self.pop_screen()
61
+
62
+ def action_help(self) -> None:
63
+ """Show help information."""
64
+ self.notify(
65
+ "Keys: 1-5 switch screens, q quit, ? help",
66
+ title="Meeting Noter Help",
67
+ timeout=5,
68
+ )
@@ -0,0 +1,17 @@
1
+ """UI Screens for Meeting Noter."""
2
+
3
+ from meeting_noter.ui.screens.main import MainScreen
4
+ from meeting_noter.ui.screens.recordings import RecordingsScreen
5
+ from meeting_noter.ui.screens.search import SearchScreen
6
+ from meeting_noter.ui.screens.viewer import ViewerScreen
7
+ from meeting_noter.ui.screens.settings import SettingsScreen
8
+ from meeting_noter.ui.screens.logs import LogsScreen
9
+
10
+ __all__ = [
11
+ "MainScreen",
12
+ "RecordingsScreen",
13
+ "SearchScreen",
14
+ "ViewerScreen",
15
+ "SettingsScreen",
16
+ "LogsScreen",
17
+ ]
@@ -0,0 +1,166 @@
1
+ """Logs viewer screen for Meeting Noter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Container, Horizontal
9
+ from textual.screen import Screen
10
+ from textual.widgets import Button, Label, RichLog, Static, Switch
11
+
12
+
13
+ LOG_FILE = Path.home() / ".meeting-noter.log"
14
+
15
+
16
+ class LogsScreen(Screen):
17
+ """Logs viewer screen."""
18
+
19
+ BINDINGS = [
20
+ ("l", "toggle_follow", "Follow"),
21
+ ("c", "clear_display", "Clear"),
22
+ ("r", "refresh", "Refresh"),
23
+ ("escape", "go_back", "Back"),
24
+ ]
25
+
26
+ def __init__(self, *args, **kwargs):
27
+ super().__init__(*args, **kwargs)
28
+ self._follow_mode = True
29
+ self._last_size = 0
30
+
31
+ def compose(self) -> ComposeResult:
32
+ """Compose the logs screen layout."""
33
+ yield Container(
34
+ Static("[bold]Logs[/bold]", classes="title"),
35
+ Label(f"[dim]{LOG_FILE}[/dim]", id="log-path"),
36
+ Static("", classes="spacer"),
37
+ RichLog(id="log-content", highlight=True, markup=True),
38
+ Static("", classes="spacer"),
39
+ Horizontal(
40
+ Label("Follow: "),
41
+ Switch(value=True, id="follow-switch"),
42
+ Button("Refresh", id="refresh-btn"),
43
+ Button("Clear", id="clear-btn"),
44
+ Button("Back", id="back-btn"),
45
+ classes="button-row",
46
+ ),
47
+ id="logs-container",
48
+ )
49
+
50
+ def on_mount(self) -> None:
51
+ """Start log refresh on mount."""
52
+ self._load_logs()
53
+ self.set_interval(1.0, self._check_for_updates)
54
+
55
+ def _load_logs(self, lines: int = 100) -> None:
56
+ """Load the last N lines from the log file."""
57
+ log_content = self.query_one("#log-content", RichLog)
58
+
59
+ if not LOG_FILE.exists():
60
+ log_content.write("[yellow]No log file found[/yellow]")
61
+ return
62
+
63
+ try:
64
+ content = LOG_FILE.read_text()
65
+ all_lines = content.splitlines()
66
+ recent_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
67
+
68
+ log_content.clear()
69
+ for line in recent_lines:
70
+ # Color-code different log levels
71
+ if "Error" in line or "error" in line:
72
+ log_content.write(f"[red]{line}[/red]")
73
+ elif "Warning" in line or "warning" in line:
74
+ log_content.write(f"[yellow]{line}[/yellow]")
75
+ elif "Recording started" in line:
76
+ log_content.write(f"[green]{line}[/green]")
77
+ elif "Recording saved" in line:
78
+ log_content.write(f"[cyan]{line}[/cyan]")
79
+ else:
80
+ log_content.write(line)
81
+
82
+ self._last_size = LOG_FILE.stat().st_size
83
+
84
+ except Exception as e:
85
+ log_content.write(f"[red]Error reading log: {e}[/red]")
86
+
87
+ def _check_for_updates(self) -> None:
88
+ """Check for new log content if in follow mode."""
89
+ if not self._follow_mode:
90
+ return
91
+
92
+ follow_switch = self.query_one("#follow-switch", Switch)
93
+ self._follow_mode = follow_switch.value
94
+
95
+ if not self._follow_mode:
96
+ return
97
+
98
+ if not LOG_FILE.exists():
99
+ return
100
+
101
+ try:
102
+ current_size = LOG_FILE.stat().st_size
103
+ if current_size > self._last_size:
104
+ # Read new content
105
+ with open(LOG_FILE, "r") as f:
106
+ f.seek(self._last_size)
107
+ new_content = f.read()
108
+
109
+ log_content = self.query_one("#log-content", RichLog)
110
+ for line in new_content.splitlines():
111
+ if line.strip():
112
+ if "Error" in line or "error" in line:
113
+ log_content.write(f"[red]{line}[/red]")
114
+ elif "Warning" in line or "warning" in line:
115
+ log_content.write(f"[yellow]{line}[/yellow]")
116
+ elif "Recording started" in line:
117
+ log_content.write(f"[green]{line}[/green]")
118
+ elif "Recording saved" in line:
119
+ log_content.write(f"[cyan]{line}[/cyan]")
120
+ else:
121
+ log_content.write(line)
122
+
123
+ self._last_size = current_size
124
+
125
+ except Exception:
126
+ pass
127
+
128
+ def on_button_pressed(self, event: Button.Pressed) -> None:
129
+ """Handle button presses."""
130
+ button_id = event.button.id
131
+
132
+ if button_id == "refresh-btn":
133
+ self.action_refresh()
134
+ elif button_id == "clear-btn":
135
+ self.action_clear_display()
136
+ elif button_id == "back-btn":
137
+ self.action_go_back()
138
+
139
+ def on_switch_changed(self, event: Switch.Changed) -> None:
140
+ """Handle follow switch changes."""
141
+ if event.switch.id == "follow-switch":
142
+ self._follow_mode = event.value
143
+ if event.value:
144
+ self.notify("Follow mode enabled")
145
+ else:
146
+ self.notify("Follow mode disabled")
147
+
148
+ def action_toggle_follow(self) -> None:
149
+ """Toggle follow mode."""
150
+ switch = self.query_one("#follow-switch", Switch)
151
+ switch.value = not switch.value
152
+
153
+ def action_clear_display(self) -> None:
154
+ """Clear the log display."""
155
+ log_content = self.query_one("#log-content", RichLog)
156
+ log_content.clear()
157
+ self.notify("Display cleared")
158
+
159
+ def action_refresh(self) -> None:
160
+ """Refresh the log display."""
161
+ self._load_logs()
162
+ self.notify("Refreshed")
163
+
164
+ def action_go_back(self) -> None:
165
+ """Go back to the previous screen."""
166
+ self.app.pop_screen()