meeting-noter 0.7.0__tar.gz → 1.0.0__tar.gz

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 (51) hide show
  1. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/PKG-INFO +2 -1
  2. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/pyproject.toml +57 -1
  3. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/audio/encoder.py +8 -3
  4. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/cli.py +168 -25
  5. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/daemon.py +80 -1
  6. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/mic_monitor.py +3 -0
  7. meeting_noter-1.0.0/src/meeting_noter/transcription/live_transcription.py +250 -0
  8. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter.egg-info/PKG-INFO +2 -1
  9. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter.egg-info/SOURCES.txt +8 -1
  10. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter.egg-info/entry_points.txt +1 -0
  11. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter.egg-info/requires.txt +1 -0
  12. meeting_noter-1.0.0/tests/test_cli.py +804 -0
  13. meeting_noter-1.0.0/tests/test_config.py +297 -0
  14. meeting_noter-1.0.0/tests/test_daemon.py +917 -0
  15. meeting_noter-1.0.0/tests/test_meeting_detector.py +641 -0
  16. meeting_noter-1.0.0/tests/test_mic_monitor.py +596 -0
  17. meeting_noter-1.0.0/tests/test_output_writer.py +205 -0
  18. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/README.md +0 -0
  19. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/setup.cfg +0 -0
  20. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/__init__.py +0 -0
  21. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/__main__.py +0 -0
  22. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/audio/__init__.py +0 -0
  23. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/audio/capture.py +0 -0
  24. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/audio/system_audio.py +0 -0
  25. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/config.py +0 -0
  26. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/gui/__init__.py +0 -0
  27. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/gui/__main__.py +0 -0
  28. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/gui/app.py +0 -0
  29. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/gui/main_window.py +0 -0
  30. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/gui/meetings_tab.py +0 -0
  31. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/gui/recording_tab.py +0 -0
  32. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/gui/settings_tab.py +0 -0
  33. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/install/__init__.py +0 -0
  34. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/install/macos.py +0 -0
  35. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/meeting_detector.py +0 -0
  36. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/menubar.py +0 -0
  37. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/output/__init__.py +0 -0
  38. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/output/writer.py +0 -0
  39. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/resources/__init__.py +0 -0
  40. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/resources/icon.icns +0 -0
  41. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/resources/icon.png +0 -0
  42. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/resources/icon_128.png +0 -0
  43. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/resources/icon_16.png +0 -0
  44. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/resources/icon_256.png +0 -0
  45. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/resources/icon_32.png +0 -0
  46. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/resources/icon_512.png +0 -0
  47. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/resources/icon_64.png +0 -0
  48. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/transcription/__init__.py +0 -0
  49. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter/transcription/engine.py +0 -0
  50. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter.egg-info/dependency_links.txt +0 -0
  51. {meeting_noter-0.7.0 → meeting_noter-1.0.0}/src/meeting_noter.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meeting-noter
3
- Version: 0.7.0
3
+ Version: 1.0.0
4
4
  Summary: Offline meeting transcription for macOS with automatic meeting detection
5
5
  Author: Victor
6
6
  License: MIT
@@ -40,6 +40,7 @@ Requires-Dist: meeting-noter-models>=0.1.0; extra == "offline"
40
40
  Provides-Extra: dev
41
41
  Requires-Dist: pytest>=7.0; extra == "dev"
42
42
  Requires-Dist: pytest-cov; extra == "dev"
43
+ Requires-Dist: pytest-mock; extra == "dev"
43
44
  Requires-Dist: black; extra == "dev"
44
45
  Requires-Dist: ruff; extra == "dev"
45
46
  Requires-Dist: mypy; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meeting-noter"
7
- version = "0.7.0"
7
+ version = "1.0.0"
8
8
  description = "Offline meeting transcription for macOS with automatic meeting detection"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -51,6 +51,7 @@ offline = [
51
51
  dev = [
52
52
  "pytest>=7.0",
53
53
  "pytest-cov",
54
+ "pytest-mock",
54
55
  "black",
55
56
  "ruff",
56
57
  "mypy",
@@ -58,6 +59,7 @@ dev = [
58
59
 
59
60
  [project.scripts]
60
61
  meeting-noter = "meeting_noter.cli:cli"
62
+ mn = "meeting_noter.cli:cli"
61
63
 
62
64
  [project.urls]
63
65
  Homepage = "https://github.com/tech4vision/meeting-noter"
@@ -77,3 +79,57 @@ target-version = ["py39", "py310", "py311", "py312"]
77
79
  [tool.ruff]
78
80
  line-length = 100
79
81
  select = ["E", "F", "I", "N", "W"]
82
+
83
+ [tool.pytest.ini_options]
84
+ testpaths = ["tests"]
85
+ python_files = ["test_*.py"]
86
+ python_functions = ["test_*"]
87
+ addopts = [
88
+ "--cov=meeting_noter",
89
+ "--cov-report=term-missing",
90
+ "--cov-report=html",
91
+ "-v",
92
+ ]
93
+ filterwarnings = [
94
+ "ignore::DeprecationWarning",
95
+ ]
96
+
97
+ [tool.coverage.run]
98
+ source = ["src/meeting_noter"]
99
+ branch = true
100
+ omit = [
101
+ "*/gui/*",
102
+ "*/menubar.py",
103
+ "*/__main__.py",
104
+ "*/audio/system_audio.py", # ScreenCaptureKit-based, requires macOS runtime
105
+ ]
106
+
107
+ [tool.coverage.report]
108
+ exclude_lines = [
109
+ "pragma: no cover",
110
+ "def __repr__",
111
+ "raise NotImplementedError",
112
+ "if TYPE_CHECKING:",
113
+ "if __name__ == .__main__.:",
114
+ # Exclude macOS-specific framework code that can't be unit tested
115
+ "import ScreenCaptureKit",
116
+ "import CoreMedia",
117
+ "import AppKit",
118
+ "from AppKit import",
119
+ "from EventKit import",
120
+ "from Foundation import NSObject",
121
+ "from libdispatch import",
122
+ "from dispatch import",
123
+ "class StreamOutput",
124
+ "def stream_didOutputSampleBuffer_ofType_",
125
+ "def stream_didStopWithError_",
126
+ "except ImportError:",
127
+ # Exclude daemon fork code (requires OS process fork)
128
+ "def daemonize",
129
+ "os.fork()",
130
+ "os.setsid()",
131
+ "os.chdir",
132
+ "os.dup2",
133
+ "sys.exit(0)",
134
+ "sys.exit(1)",
135
+ ]
@@ -105,12 +105,17 @@ class MP3Encoder:
105
105
 
106
106
  def finalize(self) -> bytes:
107
107
  """Finalize encoding."""
108
- if self._process is not None and self._process.stdin is not None:
108
+ if self._process is not None:
109
109
  try:
110
- self._process.stdin.close()
110
+ if self._process.stdin is not None:
111
+ self._process.stdin.close()
111
112
  except Exception:
112
113
  pass
113
- self._process.wait()
114
+ try:
115
+ self._process.wait(timeout=5.0)
116
+ except subprocess.TimeoutExpired:
117
+ self._process.kill()
118
+ self._process.wait()
114
119
  self._process = None
115
120
  return b""
116
121
 
@@ -131,8 +131,9 @@ def cli(ctx):
131
131
 
132
132
  @cli.command()
133
133
  @click.argument("name", required=False)
134
+ @click.option("--live", "-l", is_flag=True, help="Show live transcription in terminal")
134
135
  @require_setup
135
- def start(name: Optional[str]):
136
+ def start(name: Optional[str], live: bool):
136
137
  """Start an interactive foreground recording session.
137
138
 
138
139
  NAME is the meeting name (optional). If not provided, uses a timestamp
@@ -141,11 +142,14 @@ def start(name: Optional[str]):
141
142
  Examples:
142
143
  meeting-noter start # Uses timestamp name
143
144
  meeting-noter start "Weekly Standup" # Uses custom name
145
+ meeting-noter start "Meeting" --live # With live transcription
144
146
 
145
147
  Press Ctrl+C to stop recording. The recording will be automatically
146
148
  transcribed if auto_transcribe is enabled in settings.
147
149
  """
148
150
  from meeting_noter.daemon import run_foreground_capture
151
+ import threading
152
+ import time
149
153
 
150
154
  config = get_config()
151
155
  output_dir = config.recordings_dir
@@ -154,14 +158,59 @@ def start(name: Optional[str]):
154
158
  # Use default timestamp name if not provided
155
159
  meeting_name = name if name else generate_meeting_name()
156
160
 
157
- run_foreground_capture(
158
- output_dir=output_dir,
159
- meeting_name=meeting_name,
160
- auto_transcribe=config.auto_transcribe,
161
- whisper_model=config.whisper_model,
162
- transcripts_dir=config.transcripts_dir,
163
- silence_timeout_minutes=config.silence_timeout,
164
- )
161
+ # Live transcription display thread
162
+ stop_live_display = threading.Event()
163
+
164
+ def display_live_transcript():
165
+ """Background thread to display live transcription."""
166
+ live_dir = output_dir / "live"
167
+ last_content = ""
168
+
169
+ # Wait for live file to appear
170
+ while not stop_live_display.is_set():
171
+ live_files = list(live_dir.glob("*.live.txt")) if live_dir.exists() else []
172
+ if live_files:
173
+ live_file = max(live_files, key=lambda p: p.stat().st_mtime)
174
+ break
175
+ time.sleep(0.5)
176
+ else:
177
+ return
178
+
179
+ # Tail the file
180
+ while not stop_live_display.is_set():
181
+ try:
182
+ content = live_file.read_text()
183
+ if len(content) > len(last_content):
184
+ new_content = content[len(last_content):]
185
+ for line in new_content.splitlines():
186
+ # Only show timestamp lines (transcriptions)
187
+ if line.strip() and line.startswith("["):
188
+ click.echo(click.style(line, fg="cyan"))
189
+ last_content = content
190
+ except Exception:
191
+ pass
192
+ time.sleep(0.5)
193
+
194
+ # Start live display thread if requested
195
+ live_thread = None
196
+ if live:
197
+ click.echo(click.style("Live transcription enabled", fg="cyan"))
198
+ live_thread = threading.Thread(target=display_live_transcript, daemon=True)
199
+ live_thread.start()
200
+
201
+ try:
202
+ run_foreground_capture(
203
+ output_dir=output_dir,
204
+ meeting_name=meeting_name,
205
+ auto_transcribe=config.auto_transcribe,
206
+ whisper_model=config.whisper_model,
207
+ transcripts_dir=config.transcripts_dir,
208
+ silence_timeout_minutes=config.silence_timeout,
209
+ )
210
+ finally:
211
+ stop_live_display.set()
212
+ if live_thread:
213
+ live_thread.join(timeout=1.0)
165
214
 
166
215
 
167
216
  @cli.command(hidden=True) # Internal command used by watcher
@@ -355,7 +404,13 @@ def logs(follow: bool, lines: int):
355
404
  )
356
405
  @require_setup
357
406
  def list_recordings(output_dir: Optional[str], limit: int):
358
- """List recent meeting recordings."""
407
+ """List recent meeting recordings.
408
+
409
+ \b
410
+ Examples:
411
+ meeting-noter list # Show last 10 recordings
412
+ meeting-noter list -n 20 # Show last 20 recordings
413
+ """
359
414
  from meeting_noter.output.writer import list_recordings as _list_recordings
360
415
 
361
416
  config = get_config()
@@ -377,28 +432,116 @@ def list_recordings(output_dir: Optional[str], limit: int):
377
432
  default=None,
378
433
  help="Whisper model size (overrides config)",
379
434
  )
380
- @click.option(
381
- "--live", "-l",
382
- is_flag=True,
383
- help="Real-time transcription of current recording",
384
- )
385
435
  @require_setup
386
- def transcribe(file: Optional[str], output_dir: Optional[str], model: Optional[str], live: bool):
436
+ def transcribe(file: Optional[str], output_dir: Optional[str], model: Optional[str]):
387
437
  """Transcribe a meeting recording.
388
438
 
389
- If no FILE is specified, transcribes the most recent recording.
390
- Use --live for real-time transcription of an ongoing meeting.
439
+ \b
440
+ Examples:
441
+ meeting-noter transcribe # Transcribe latest recording
442
+ meeting-noter transcribe recording.mp3 # Transcribe specific file
443
+ meeting-noter transcribe -m base.en # Use larger model for accuracy
391
444
  """
392
- from meeting_noter.transcription.engine import transcribe_file, transcribe_live
445
+ from meeting_noter.transcription.engine import transcribe_file
393
446
 
394
447
  config = get_config()
395
448
  output_path = Path(output_dir) if output_dir else config.recordings_dir
396
449
  whisper_model = model or config.whisper_model
397
450
 
398
- if live:
399
- transcribe_live(output_path, whisper_model)
400
- else:
401
- transcribe_file(file, output_path, whisper_model, config.transcripts_dir)
451
+ transcribe_file(file, output_path, whisper_model, config.transcripts_dir)
452
+
453
+
454
+ @cli.command()
455
+ @require_setup
456
+ def live():
457
+ """Show live transcription of an active recording.
458
+
459
+ Displays the real-time transcript as it's being generated.
460
+ Use in a separate terminal while recording with 'meeting-noter start'.
461
+
462
+ \b
463
+ Examples:
464
+ # Terminal 1: Start recording
465
+ meeting-noter start "Team Meeting"
466
+
467
+ # Terminal 2: Watch live transcript
468
+ meeting-noter live
469
+
470
+ Or use 'meeting-noter start "name" --live' to see both in one terminal.
471
+ """
472
+ import time
473
+
474
+ config = get_config()
475
+ live_dir = config.recordings_dir / "live"
476
+
477
+ # Find the most recent .live.txt file in the live/ subfolder
478
+ if not live_dir.exists():
479
+ click.echo(click.style("No live transcript found.", fg="yellow"))
480
+ click.echo("Start a recording with: meeting-noter start")
481
+ return
482
+
483
+ live_files = sorted(
484
+ live_dir.glob("*.live.txt"),
485
+ key=lambda p: p.stat().st_mtime,
486
+ reverse=True,
487
+ )
488
+
489
+ if not live_files:
490
+ click.echo(click.style("No live transcript found.", fg="yellow"))
491
+ click.echo("Start a recording with: meeting-noter start")
492
+ return
493
+
494
+ live_file = live_files[0]
495
+
496
+ # Check if file is actively being written (modified in last 30 seconds)
497
+ file_age = time.time() - live_file.stat().st_mtime
498
+ if file_age > 30:
499
+ click.echo(click.style("No active recording found.", fg="yellow"))
500
+ click.echo(f"Most recent transcript ({live_file.name}) is {int(file_age)}s old.")
501
+ click.echo("Start a recording with: meeting-noter start")
502
+ return
503
+
504
+ click.echo(click.style("Live Transcription", fg="cyan", bold=True))
505
+ click.echo(f"Source: {live_file.name.replace('.live.txt', '.mp3')}")
506
+ click.echo("Press Ctrl+C to stop watching.\n")
507
+ click.echo("-" * 40)
508
+
509
+ # Tail the file
510
+ try:
511
+ last_content = ""
512
+ no_update_count = 0
513
+
514
+ while True:
515
+ try:
516
+ with open(live_file, "r") as f:
517
+ content = f.read()
518
+
519
+ # Print only new content
520
+ if len(content) > len(last_content):
521
+ new_content = content[len(last_content):]
522
+ # Print line by line for better formatting
523
+ for line in new_content.splitlines():
524
+ if line.strip():
525
+ click.echo(line)
526
+ last_content = content
527
+ no_update_count = 0
528
+ else:
529
+ no_update_count += 1
530
+
531
+ # Check if file hasn't been updated for 30+ seconds (recording likely ended)
532
+ file_age = time.time() - live_file.stat().st_mtime
533
+ if file_age > 30 and no_update_count > 5:
534
+ click.echo("\n" + click.style("Recording ended.", fg="yellow"))
535
+ break
536
+
537
+ except FileNotFoundError:
538
+ click.echo("\n" + click.style("Live transcript file removed.", fg="yellow"))
539
+ break
540
+
541
+ time.sleep(1)
542
+
543
+ except KeyboardInterrupt:
544
+ click.echo("\n" + click.style("Stopped watching.", fg="cyan"))
402
545
 
403
546
 
404
547
  @cli.command()
@@ -553,7 +696,7 @@ def config(key: Optional[str], value: Optional[str]):
553
696
  WATCHER_PID_FILE = Path.home() / ".meeting-noter-watcher.pid"
554
697
 
555
698
 
556
- @cli.command()
699
+ @cli.command(hidden=True)
557
700
  @click.option(
558
701
  "--foreground", "-f",
559
702
  is_flag=True,
@@ -665,7 +808,7 @@ def _run_watcher_loop():
665
808
  stop_daemon(DEFAULT_PID_FILE)
666
809
 
667
810
 
668
- @cli.command()
811
+ @cli.command(hidden=True)
669
812
  @require_setup
670
813
  def watch():
671
814
  """Watch for meetings interactively (foreground with prompts).
@@ -157,7 +157,11 @@ def run_daemon(
157
157
  remove_pid_file(pid_file)
158
158
 
159
159
 
160
- def _run_capture_loop(output_dir: Path, meeting_name: Optional[str] = None):
160
+ def _run_capture_loop(
161
+ output_dir: Path,
162
+ meeting_name: Optional[str] = None,
163
+ enable_live_transcription: bool = True,
164
+ ):
161
165
  """Main capture loop.
162
166
 
163
167
  Audio imports happen HERE, safely AFTER the fork.
@@ -171,6 +175,16 @@ def _run_capture_loop(output_dir: Path, meeting_name: Optional[str] = None):
171
175
 
172
176
  config = get_config()
173
177
 
178
+ # Live transcription (imported here to avoid loading Whisper before fork)
179
+ live_transcriber = None
180
+ if enable_live_transcription:
181
+ try:
182
+ from meeting_noter.transcription.live_transcription import LiveTranscriber
183
+ LiveTranscriber # Just verify import works, create later
184
+ except ImportError as e:
185
+ print(f"Live transcription not available: {e}")
186
+ enable_live_transcription = False
187
+
174
188
  print(f"Meeting Noter daemon started. Saving to {output_dir}")
175
189
  sys.stdout.flush()
176
190
  if meeting_name:
@@ -253,15 +267,42 @@ def _run_capture_loop(output_dir: Path, meeting_name: Optional[str] = None):
253
267
  print(f"Recording started: {filepath.name}")
254
268
  recording_started = True
255
269
  audio_detected = True
270
+
271
+ # Start live transcription
272
+ if enable_live_transcription:
273
+ try:
274
+ from meeting_noter.transcription.live_transcription import LiveTranscriber
275
+ live_transcriber = LiveTranscriber(
276
+ output_path=filepath,
277
+ sample_rate=sample_rate,
278
+ channels=channels,
279
+ window_seconds=5.0,
280
+ slide_seconds=2.0,
281
+ model_size=config.whisper_model,
282
+ )
283
+ live_transcriber.start()
284
+ print(f"Live transcription: {live_transcriber.live_file_path.name}")
285
+ except Exception as e:
286
+ print(f"Failed to start live transcription: {e}")
287
+ live_transcriber = None
256
288
  else:
257
289
  # Currently recording
258
290
  session.write(audio)
259
291
 
292
+ # Feed audio to live transcriber
293
+ if live_transcriber is not None:
294
+ live_transcriber.write(audio)
295
+
260
296
  if has_audio:
261
297
  audio_detected = True
262
298
 
263
299
  # Check for extended silence (meeting ended)
264
300
  if is_silence and audio_detected:
301
+ # Stop live transcription first
302
+ if live_transcriber is not None:
303
+ live_transcriber.stop()
304
+ live_transcriber = None
305
+
265
306
  filepath, duration = session.stop()
266
307
  if filepath:
267
308
  print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
@@ -275,6 +316,10 @@ def _run_capture_loop(output_dir: Path, meeting_name: Optional[str] = None):
275
316
  finally:
276
317
  capture.stop()
277
318
 
319
+ # Stop live transcription
320
+ if live_transcriber is not None:
321
+ live_transcriber.stop()
322
+
278
323
  # Save any ongoing recording
279
324
  if 'session' in locals() and session.is_active:
280
325
  filepath, duration = session.stop()
@@ -377,6 +422,7 @@ def run_foreground_capture(
377
422
  whisper_model: str = "tiny.en",
378
423
  transcripts_dir: Optional[Path] = None,
379
424
  silence_timeout_minutes: int = 5,
425
+ enable_live_transcription: bool = True,
380
426
  ) -> Optional[Path]:
381
427
  """Run audio capture in foreground with a named meeting.
382
428
 
@@ -390,6 +436,7 @@ def run_foreground_capture(
390
436
  whisper_model: Whisper model to use for transcription
391
437
  transcripts_dir: Directory for transcripts
392
438
  silence_timeout_minutes: Stop after this many minutes of silence
439
+ enable_live_transcription: Whether to enable real-time transcription
393
440
 
394
441
  Returns:
395
442
  Path to the saved recording, or None if recording was too short
@@ -401,6 +448,9 @@ def run_foreground_capture(
401
448
 
402
449
  config = get_config()
403
450
 
451
+ # Initialize live transcriber
452
+ live_transcriber = None
453
+
404
454
  # Check audio device
405
455
  if not check_audio_available():
406
456
  click.echo(click.style("Error: ", fg="red") + "No audio input device found.")
@@ -461,6 +511,27 @@ def run_foreground_capture(
461
511
  filepath = session.start()
462
512
  click.echo(click.style("Recording: ", fg="green") + filepath.name)
463
513
 
514
+ # Start live transcription
515
+ if enable_live_transcription:
516
+ try:
517
+ from meeting_noter.transcription.live_transcription import LiveTranscriber
518
+ live_transcriber = LiveTranscriber(
519
+ output_path=filepath,
520
+ sample_rate=sample_rate,
521
+ channels=channels,
522
+ window_seconds=5.0,
523
+ slide_seconds=2.0,
524
+ model_size=whisper_model,
525
+ )
526
+ live_transcriber.start()
527
+ click.echo(
528
+ click.style("Live transcript: ", fg="cyan") +
529
+ str(live_transcriber.live_file_path)
530
+ )
531
+ except Exception as e:
532
+ click.echo(click.style(f"Live transcription not available: {e}", fg="yellow"))
533
+ live_transcriber = None
534
+
464
535
  while not _stop_event.is_set():
465
536
  audio = capture.get_audio(timeout=0.5)
466
537
  if audio is None:
@@ -472,6 +543,10 @@ def run_foreground_capture(
472
543
 
473
544
  session.write(audio)
474
545
 
546
+ # Feed audio to live transcriber
547
+ if live_transcriber is not None:
548
+ live_transcriber.write(audio)
549
+
475
550
  # Check for extended silence
476
551
  if silence_detector.update(audio):
477
552
  click.echo("\n" + click.style("Stopped: ", fg="yellow") + "silence timeout reached")
@@ -487,6 +562,10 @@ def run_foreground_capture(
487
562
  except Exception as e:
488
563
  click.echo(click.style(f"\nError: {e}", fg="red"))
489
564
  finally:
565
+ # Stop live transcription
566
+ if live_transcriber is not None:
567
+ live_transcriber.stop()
568
+
490
569
  capture.stop()
491
570
 
492
571
  # Save recording
@@ -169,6 +169,9 @@ def is_meeting_app_active() -> Optional[str]:
169
169
 
170
170
  # Browser-based meetings (Google Meet, etc.)
171
171
  if any(browser in owner_lower for browser in ["chrome", "safari", "firefox", "edge", "brave", "arc"]):
172
+ # Skip video streaming sites (YouTube, Vimeo, etc.)
173
+ if any(x in title_lower for x in ["youtube", "vimeo", "twitch", "netflix"]):
174
+ continue
172
175
  if title_lower.startswith("meet -") or "meet.google.com" in title_lower:
173
176
  return "Google Meet"
174
177
  if " meeting" in title_lower and any(x in title_lower for x in ["zoom", "teams", "webex"]):