meeting-noter 1.2.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.
Potentially problematic release.
This version of meeting-noter might be problematic. Click here for more details.
- meeting_noter/__init__.py +1 -1
- meeting_noter/cli.py +129 -11
- meeting_noter/config.py +34 -0
- meeting_noter/output/favorites.py +189 -0
- meeting_noter/output/searcher.py +218 -0
- meeting_noter/transcription/live_transcription.py +17 -13
- {meeting_noter-1.2.0.dist-info → meeting_noter-1.3.0.dist-info}/METADATA +1 -1
- {meeting_noter-1.2.0.dist-info → meeting_noter-1.3.0.dist-info}/RECORD +11 -9
- {meeting_noter-1.2.0.dist-info → meeting_noter-1.3.0.dist-info}/WHEEL +0 -0
- {meeting_noter-1.2.0.dist-info → meeting_noter-1.3.0.dist-info}/entry_points.txt +0 -0
- {meeting_noter-1.2.0.dist-info → meeting_noter-1.3.0.dist-info}/top_level.txt +0 -0
meeting_noter/__init__.py
CHANGED
meeting_noter/cli.py
CHANGED
|
@@ -262,7 +262,12 @@ def daemon(output_dir: Optional[str], foreground: bool, name: Optional[str]):
|
|
|
262
262
|
|
|
263
263
|
@cli.command()
|
|
264
264
|
def status():
|
|
265
|
-
"""Show Meeting Noter status.
|
|
265
|
+
"""Show Meeting Noter status.
|
|
266
|
+
|
|
267
|
+
\b
|
|
268
|
+
Examples:
|
|
269
|
+
meeting-noter status # Check if recording or watching
|
|
270
|
+
"""
|
|
266
271
|
import os
|
|
267
272
|
from meeting_noter.daemon import read_pid_file, is_process_running
|
|
268
273
|
|
|
@@ -331,7 +336,12 @@ def _get_current_recording_name() -> str | None:
|
|
|
331
336
|
|
|
332
337
|
@cli.command()
|
|
333
338
|
def shutdown():
|
|
334
|
-
"""Stop all Meeting Noter processes (daemon, watcher).
|
|
339
|
+
"""Stop all Meeting Noter processes (daemon, watcher).
|
|
340
|
+
|
|
341
|
+
\b
|
|
342
|
+
Examples:
|
|
343
|
+
meeting-noter shutdown # Stop recording and watcher
|
|
344
|
+
"""
|
|
335
345
|
import subprocess
|
|
336
346
|
import os
|
|
337
347
|
import signal
|
|
@@ -372,7 +382,14 @@ def shutdown():
|
|
|
372
382
|
@click.option("--follow", "-f", is_flag=True, help="Follow log output (like tail -f)")
|
|
373
383
|
@click.option("--lines", "-n", default=50, help="Number of lines to show")
|
|
374
384
|
def logs(follow: bool, lines: int):
|
|
375
|
-
"""View Meeting Noter logs.
|
|
385
|
+
"""View Meeting Noter logs.
|
|
386
|
+
|
|
387
|
+
\b
|
|
388
|
+
Examples:
|
|
389
|
+
meeting-noter logs # Show last 50 lines
|
|
390
|
+
meeting-noter logs -n 100 # Show last 100 lines
|
|
391
|
+
meeting-noter logs -f # Follow log output (Ctrl+C to stop)
|
|
392
|
+
"""
|
|
376
393
|
import subprocess
|
|
377
394
|
|
|
378
395
|
log_file = Path.home() / ".meeting-noter.log"
|
|
@@ -392,6 +409,11 @@ def logs(follow: bool, lines: int):
|
|
|
392
409
|
|
|
393
410
|
|
|
394
411
|
@cli.command("list")
|
|
412
|
+
@click.option(
|
|
413
|
+
"--transcripts", "-t",
|
|
414
|
+
is_flag=True,
|
|
415
|
+
help="List transcripts instead of recordings",
|
|
416
|
+
)
|
|
395
417
|
@click.option(
|
|
396
418
|
"--output-dir", "-o",
|
|
397
419
|
type=click.Path(exists=True),
|
|
@@ -402,22 +424,118 @@ def logs(follow: bool, lines: int):
|
|
|
402
424
|
"--limit", "-n",
|
|
403
425
|
type=int,
|
|
404
426
|
default=10,
|
|
405
|
-
help="Number of
|
|
427
|
+
help="Number of items to show",
|
|
428
|
+
)
|
|
429
|
+
@require_setup
|
|
430
|
+
def list_recordings(transcripts: bool, output_dir: Optional[str], limit: int):
|
|
431
|
+
"""List recent meeting recordings or transcripts.
|
|
432
|
+
|
|
433
|
+
\b
|
|
434
|
+
Examples:
|
|
435
|
+
meeting-noter list # Show last 10 recordings
|
|
436
|
+
meeting-noter list -t # Show last 10 transcripts
|
|
437
|
+
meeting-noter list -t -n 20 # Show last 20 transcripts
|
|
438
|
+
"""
|
|
439
|
+
config = get_config()
|
|
440
|
+
|
|
441
|
+
if transcripts:
|
|
442
|
+
from meeting_noter.output.favorites import list_transcripts_with_favorites
|
|
443
|
+
path = Path(output_dir) if output_dir else config.transcripts_dir
|
|
444
|
+
list_transcripts_with_favorites(path, limit)
|
|
445
|
+
else:
|
|
446
|
+
from meeting_noter.output.writer import list_recordings as _list_recordings
|
|
447
|
+
path = Path(output_dir) if output_dir else config.recordings_dir
|
|
448
|
+
_list_recordings(path, limit)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@cli.command("search")
|
|
452
|
+
@click.argument("query")
|
|
453
|
+
@click.option(
|
|
454
|
+
"--case-sensitive", "-c",
|
|
455
|
+
is_flag=True,
|
|
456
|
+
help="Case-sensitive search",
|
|
457
|
+
)
|
|
458
|
+
@click.option(
|
|
459
|
+
"--limit", "-n",
|
|
460
|
+
type=int,
|
|
461
|
+
default=20,
|
|
462
|
+
help="Max results to show",
|
|
463
|
+
)
|
|
464
|
+
@click.option(
|
|
465
|
+
"--transcripts-dir", "-d",
|
|
466
|
+
type=click.Path(),
|
|
467
|
+
default=None,
|
|
468
|
+
help="Override transcripts directory",
|
|
406
469
|
)
|
|
407
470
|
@require_setup
|
|
408
|
-
def
|
|
409
|
-
"""
|
|
471
|
+
def search(query: str, case_sensitive: bool, limit: int, transcripts_dir: Optional[str]):
|
|
472
|
+
"""Search across all meeting transcripts.
|
|
410
473
|
|
|
411
474
|
\b
|
|
412
475
|
Examples:
|
|
413
|
-
meeting-noter
|
|
414
|
-
meeting-noter
|
|
476
|
+
meeting-noter search "action items"
|
|
477
|
+
meeting-noter search "API" --case-sensitive
|
|
478
|
+
meeting-noter search "standup" -n 5
|
|
415
479
|
"""
|
|
416
|
-
from meeting_noter.output.
|
|
480
|
+
from meeting_noter.output.searcher import search_transcripts
|
|
417
481
|
|
|
418
482
|
config = get_config()
|
|
419
|
-
path = Path(
|
|
420
|
-
|
|
483
|
+
path = Path(transcripts_dir) if transcripts_dir else config.transcripts_dir
|
|
484
|
+
search_transcripts(path, query, case_sensitive, limit)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@cli.group("favorites", invoke_without_command=True)
|
|
488
|
+
@click.pass_context
|
|
489
|
+
@require_setup
|
|
490
|
+
def favorites(ctx):
|
|
491
|
+
"""Manage favorite transcripts.
|
|
492
|
+
|
|
493
|
+
\b
|
|
494
|
+
Examples:
|
|
495
|
+
meeting-noter favorites # List all favorites
|
|
496
|
+
meeting-noter favorites add file.txt # Add to favorites
|
|
497
|
+
meeting-noter favorites add --latest # Add most recent transcript
|
|
498
|
+
meeting-noter favorites remove file # Remove from favorites
|
|
499
|
+
"""
|
|
500
|
+
if ctx.invoked_subcommand is None:
|
|
501
|
+
# Default: list favorites
|
|
502
|
+
from meeting_noter.output.favorites import list_favorites
|
|
503
|
+
|
|
504
|
+
config = get_config()
|
|
505
|
+
list_favorites(config.transcripts_dir)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@favorites.command("add")
|
|
509
|
+
@click.argument("filename", required=False)
|
|
510
|
+
@click.option("--latest", "-l", is_flag=True, help="Add the most recent transcript")
|
|
511
|
+
@require_setup
|
|
512
|
+
def favorites_add(filename: Optional[str], latest: bool):
|
|
513
|
+
"""Add a transcript to favorites.
|
|
514
|
+
|
|
515
|
+
\b
|
|
516
|
+
Examples:
|
|
517
|
+
meeting-noter favorites add meeting.txt
|
|
518
|
+
meeting-noter favorites add --latest
|
|
519
|
+
"""
|
|
520
|
+
from meeting_noter.output.favorites import add_favorite
|
|
521
|
+
|
|
522
|
+
config = get_config()
|
|
523
|
+
add_favorite(config.transcripts_dir, filename, latest)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@favorites.command("remove")
|
|
527
|
+
@click.argument("filename")
|
|
528
|
+
@require_setup
|
|
529
|
+
def favorites_remove(filename: str):
|
|
530
|
+
"""Remove a transcript from favorites.
|
|
531
|
+
|
|
532
|
+
\b
|
|
533
|
+
Examples:
|
|
534
|
+
meeting-noter favorites remove meeting.txt
|
|
535
|
+
"""
|
|
536
|
+
from meeting_noter.output.favorites import remove_favorite
|
|
537
|
+
|
|
538
|
+
remove_favorite(filename)
|
|
421
539
|
|
|
422
540
|
|
|
423
541
|
@cli.command()
|
meeting_noter/config.py
CHANGED
|
@@ -160,6 +160,40 @@ class Config:
|
|
|
160
160
|
"""Set setup completion status."""
|
|
161
161
|
self._data["setup_complete"] = value
|
|
162
162
|
|
|
163
|
+
@property
|
|
164
|
+
def favorites(self) -> list[str]:
|
|
165
|
+
"""Get list of favorite transcript filenames."""
|
|
166
|
+
return self._data.get("favorites", [])
|
|
167
|
+
|
|
168
|
+
@favorites.setter
|
|
169
|
+
def favorites(self, value: list[str]) -> None:
|
|
170
|
+
"""Set list of favorite transcript filenames."""
|
|
171
|
+
self._data["favorites"] = value
|
|
172
|
+
|
|
173
|
+
def add_favorite(self, filename: str) -> bool:
|
|
174
|
+
"""Add a transcript to favorites. Returns True if added, False if already exists."""
|
|
175
|
+
favorites = self.favorites
|
|
176
|
+
if filename not in favorites:
|
|
177
|
+
favorites.append(filename)
|
|
178
|
+
self.favorites = favorites
|
|
179
|
+
self.save()
|
|
180
|
+
return True
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
def remove_favorite(self, filename: str) -> bool:
|
|
184
|
+
"""Remove a transcript from favorites. Returns True if removed, False if not found."""
|
|
185
|
+
favorites = self.favorites
|
|
186
|
+
if filename in favorites:
|
|
187
|
+
favorites.remove(filename)
|
|
188
|
+
self.favorites = favorites
|
|
189
|
+
self.save()
|
|
190
|
+
return True
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
def is_favorite(self, filename: str) -> bool:
|
|
194
|
+
"""Check if a transcript is a favorite."""
|
|
195
|
+
return filename in self.favorites
|
|
196
|
+
|
|
163
197
|
def __getitem__(self, key: str) -> Any:
|
|
164
198
|
"""Get config value by key."""
|
|
165
199
|
return self._data.get(key, DEFAULT_CONFIG.get(key))
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Favorites management for meeting transcripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from meeting_noter.config import get_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def list_favorites(transcripts_dir: Path) -> None:
|
|
14
|
+
"""List all favorite transcripts.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
transcripts_dir: Directory containing transcript files
|
|
18
|
+
"""
|
|
19
|
+
config = get_config()
|
|
20
|
+
favorites = config.favorites
|
|
21
|
+
|
|
22
|
+
if not favorites:
|
|
23
|
+
click.echo(click.style("No favorites yet.", fg="yellow"))
|
|
24
|
+
click.echo("\nAdd favorites with: meeting-noter favorites add <filename>")
|
|
25
|
+
click.echo("Or use: meeting-noter favorites add --latest")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
click.echo()
|
|
29
|
+
click.echo(click.style("Favorite Transcripts", bold=True))
|
|
30
|
+
click.echo("=" * 50)
|
|
31
|
+
click.echo()
|
|
32
|
+
|
|
33
|
+
found_count = 0
|
|
34
|
+
missing_count = 0
|
|
35
|
+
|
|
36
|
+
for idx, filename in enumerate(favorites, 1):
|
|
37
|
+
filepath = transcripts_dir / filename
|
|
38
|
+
if filepath.exists():
|
|
39
|
+
found_count += 1
|
|
40
|
+
# Get file info
|
|
41
|
+
stat = filepath.stat()
|
|
42
|
+
mod_time = datetime.fromtimestamp(stat.st_mtime)
|
|
43
|
+
date_str = mod_time.strftime("%Y-%m-%d %H:%M")
|
|
44
|
+
|
|
45
|
+
# Display: index, filename prominently, then date
|
|
46
|
+
click.echo(
|
|
47
|
+
click.style(f" {idx}. ", fg="cyan")
|
|
48
|
+
+ click.style(filename, fg="green", bold=True)
|
|
49
|
+
)
|
|
50
|
+
click.echo(f" ★ {date_str}")
|
|
51
|
+
else:
|
|
52
|
+
missing_count += 1
|
|
53
|
+
click.echo(
|
|
54
|
+
click.style(f" {idx}. ", fg="cyan")
|
|
55
|
+
+ click.style(filename, fg="red", strikethrough=True)
|
|
56
|
+
)
|
|
57
|
+
click.echo(click.style(" (file not found)", fg="red"))
|
|
58
|
+
|
|
59
|
+
click.echo()
|
|
60
|
+
click.echo(f"Total: {found_count} favorites")
|
|
61
|
+
if missing_count > 0:
|
|
62
|
+
click.echo(
|
|
63
|
+
click.style(
|
|
64
|
+
f"Warning: {missing_count} favorite(s) no longer exist",
|
|
65
|
+
fg="yellow"
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
click.echo()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def add_favorite(
|
|
72
|
+
transcripts_dir: Path,
|
|
73
|
+
filename: Optional[str] = None,
|
|
74
|
+
latest: bool = False
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Add a transcript to favorites.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
transcripts_dir: Directory containing transcript files
|
|
80
|
+
filename: Name of transcript file to add
|
|
81
|
+
latest: If True, add the most recent transcript
|
|
82
|
+
"""
|
|
83
|
+
config = get_config()
|
|
84
|
+
|
|
85
|
+
if latest:
|
|
86
|
+
# Find the most recent transcript
|
|
87
|
+
txt_files = sorted(
|
|
88
|
+
transcripts_dir.glob("*.txt"),
|
|
89
|
+
key=lambda p: p.stat().st_mtime,
|
|
90
|
+
reverse=True,
|
|
91
|
+
)
|
|
92
|
+
if not txt_files:
|
|
93
|
+
click.echo(click.style("No transcripts found.", fg="red"))
|
|
94
|
+
return
|
|
95
|
+
filename = txt_files[0].name
|
|
96
|
+
|
|
97
|
+
if not filename:
|
|
98
|
+
click.echo(click.style("Error: Specify a filename or use --latest", fg="red"))
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# Check if file exists
|
|
102
|
+
filepath = transcripts_dir / filename
|
|
103
|
+
if not filepath.exists():
|
|
104
|
+
# Try adding .txt extension
|
|
105
|
+
if not filename.endswith(".txt"):
|
|
106
|
+
filepath = transcripts_dir / f"{filename}.txt"
|
|
107
|
+
if filepath.exists():
|
|
108
|
+
filename = f"{filename}.txt"
|
|
109
|
+
else:
|
|
110
|
+
click.echo(click.style(f"File not found: {filename}", fg="red"))
|
|
111
|
+
click.echo(f"Looking in: {transcripts_dir}")
|
|
112
|
+
return
|
|
113
|
+
else:
|
|
114
|
+
click.echo(click.style(f"File not found: {filename}", fg="red"))
|
|
115
|
+
click.echo(f"Looking in: {transcripts_dir}")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
if config.add_favorite(filename):
|
|
119
|
+
click.echo(
|
|
120
|
+
click.style("★ ", fg="yellow")
|
|
121
|
+
+ f"Added to favorites: "
|
|
122
|
+
+ click.style(filename, fg="green")
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
click.echo(click.style(f"Already a favorite: {filename}", fg="yellow"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def remove_favorite(filename: str) -> None:
|
|
129
|
+
"""Remove a transcript from favorites.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
filename: Name of transcript file to remove
|
|
133
|
+
"""
|
|
134
|
+
config = get_config()
|
|
135
|
+
|
|
136
|
+
# Handle with or without .txt extension
|
|
137
|
+
if not filename.endswith(".txt"):
|
|
138
|
+
if f"{filename}.txt" in config.favorites:
|
|
139
|
+
filename = f"{filename}.txt"
|
|
140
|
+
|
|
141
|
+
if config.remove_favorite(filename):
|
|
142
|
+
click.echo(
|
|
143
|
+
click.style("☆ ", fg="cyan")
|
|
144
|
+
+ f"Removed from favorites: "
|
|
145
|
+
+ click.style(filename, fg="green")
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
click.echo(click.style(f"Not a favorite: {filename}", fg="yellow"))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def list_transcripts_with_favorites(transcripts_dir: Path, limit: int = 10) -> None:
|
|
152
|
+
"""List transcripts with favorite status indicated.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
transcripts_dir: Directory containing transcript files
|
|
156
|
+
limit: Maximum number of transcripts to show
|
|
157
|
+
"""
|
|
158
|
+
config = get_config()
|
|
159
|
+
|
|
160
|
+
if not transcripts_dir.exists():
|
|
161
|
+
click.echo(click.style(f"Directory not found: {transcripts_dir}", fg="red"))
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
txt_files = sorted(
|
|
165
|
+
transcripts_dir.glob("*.txt"),
|
|
166
|
+
key=lambda p: p.stat().st_mtime,
|
|
167
|
+
reverse=True,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if not txt_files:
|
|
171
|
+
click.echo(click.style("No transcripts found.", fg="yellow"))
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
click.echo(f"\nTranscripts in {transcripts_dir}:\n")
|
|
175
|
+
|
|
176
|
+
for txt_file in txt_files[:limit]:
|
|
177
|
+
stat = txt_file.stat()
|
|
178
|
+
mod_time = datetime.fromtimestamp(stat.st_mtime)
|
|
179
|
+
date_str = mod_time.strftime("%Y-%m-%d %H:%M")
|
|
180
|
+
|
|
181
|
+
is_fav = config.is_favorite(txt_file.name)
|
|
182
|
+
star = click.style("★ ", fg="yellow") if is_fav else " "
|
|
183
|
+
|
|
184
|
+
click.echo(f"{star}{date_str} {txt_file.name}")
|
|
185
|
+
|
|
186
|
+
if len(txt_files) > limit:
|
|
187
|
+
click.echo(f"\n ... and {len(txt_files) - limit} more")
|
|
188
|
+
|
|
189
|
+
click.echo(f"\nTotal: {len(txt_files)} transcripts")
|
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
device="
|
|
122
|
-
compute_type="
|
|
124
|
+
model_path,
|
|
125
|
+
device="cuda",
|
|
126
|
+
compute_type="float16",
|
|
123
127
|
)
|
|
124
|
-
|
|
128
|
+
except Exception:
|
|
129
|
+
# Fall back to CPU with int8 (fastest CPU option)
|
|
125
130
|
self._model = WhisperModel(
|
|
126
|
-
|
|
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.
|
|
158
|
+
chunk = self._audio_queue.get(timeout=0.1)
|
|
154
159
|
|
|
155
|
-
# Add samples to rolling buffer
|
|
156
|
-
|
|
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,7 +1,7 @@
|
|
|
1
|
-
meeting_noter/__init__.py,sha256=
|
|
1
|
+
meeting_noter/__init__.py,sha256=6WLmaMLX2bfnCWqF2vzOnwjKo728hFUrDHXgUjykajQ,103
|
|
2
2
|
meeting_noter/__main__.py,sha256=6sSOqH1o3jvgvkVzsVKmF6-xVGcUAbNVQkRl2CrygdE,120
|
|
3
|
-
meeting_noter/cli.py,sha256=
|
|
4
|
-
meeting_noter/config.py,sha256=
|
|
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
7
|
meeting_noter/mic_monitor.py,sha256=P8vF4qaZcGrEzzJyVos78Vuf38NXHGNRREDsD-HyBHc,16211
|
|
@@ -13,6 +13,8 @@ meeting_noter/audio/system_audio.py,sha256=jbHGjNCerI19weXap0a90Ik17lVTCT1hCEgRK
|
|
|
13
13
|
meeting_noter/install/__init__.py,sha256=SX5vLFMrV8aBDEGW18jhaqBqJqnRXaeo0Ct7QVGDgvE,38
|
|
14
14
|
meeting_noter/install/macos.py,sha256=dO-86zbNKRtt0l4D8naVn7kFWjzI8TufWLWE3FRLHQ8,3400
|
|
15
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
|
|
16
18
|
meeting_noter/output/writer.py,sha256=zO8y6FAFUAp0EEtALY-M5e2Ja5P-hgV38JjcKW7c-bA,3017
|
|
17
19
|
meeting_noter/resources/__init__.py,sha256=yzHNxgypkuVDFZWv6xZjUygOVB_Equ9NNX_HGRvN7VM,43
|
|
18
20
|
meeting_noter/resources/icon.icns,sha256=zMWqXCq7pI5acS0tbekFgFDvLt66EKUBP5-5IgztwPM,35146
|
|
@@ -25,9 +27,9 @@ meeting_noter/resources/icon_512.png,sha256=o7X3ngYcppcIAAk9AcfPx94MUmrsPRp0qBTp
|
|
|
25
27
|
meeting_noter/resources/icon_64.png,sha256=TqG7Awx3kK8YdiX1e_z1odZonosZyQI2trlkNZCzUoI,607
|
|
26
28
|
meeting_noter/transcription/__init__.py,sha256=7GY9diP06DzFyoli41wddbrPv5bVDzH35bmnWlIJev4,29
|
|
27
29
|
meeting_noter/transcription/engine.py,sha256=G9NcSS6Q-UhW7PlQ0E85hQXn6BWao64nIvyw4NR2yxI,7208
|
|
28
|
-
meeting_noter/transcription/live_transcription.py,sha256=
|
|
29
|
-
meeting_noter-1.
|
|
30
|
-
meeting_noter-1.
|
|
31
|
-
meeting_noter-1.
|
|
32
|
-
meeting_noter-1.
|
|
33
|
-
meeting_noter-1.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|