meeting-noter 1.1.0__py3-none-any.whl → 1.2.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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """Meeting Noter - Offline meeting transcription with virtual audio devices."""
2
2
 
3
- __version__ = "1.1.0"
3
+ __version__ = "1.2.0"
meeting_noter/cli.py CHANGED
@@ -90,20 +90,6 @@ class SuggestCommand(click.Command):
90
90
  SuggestGroup.command_class = SuggestCommand
91
91
 
92
92
 
93
- def _launch_gui_background():
94
- """Launch the GUI in background and return immediately."""
95
- import subprocess
96
- import sys
97
-
98
- subprocess.Popen(
99
- [sys.executable, "-m", "meeting_noter.gui"],
100
- stdout=subprocess.DEVNULL,
101
- stderr=subprocess.DEVNULL,
102
- start_new_session=True,
103
- )
104
- click.echo("Meeting Noter GUI launched.")
105
-
106
-
107
93
  def _handle_version():
108
94
  """Handle --version: show version, check for updates, auto-update if enabled."""
109
95
  import subprocess
@@ -290,17 +276,6 @@ def status():
290
276
  except (ProcessLookupError, ValueError, FileNotFoundError):
291
277
  pass
292
278
 
293
- # Check menubar
294
- menubar_running = False
295
- menubar_pid_file = Path.home() / ".meeting-noter-menubar.pid"
296
- if menubar_pid_file.exists():
297
- try:
298
- pid = int(menubar_pid_file.read_text().strip())
299
- os.kill(pid, 0)
300
- menubar_running = True
301
- except (ProcessLookupError, ValueError, FileNotFoundError):
302
- pass
303
-
304
279
  # Check daemon (recording)
305
280
  daemon_running = False
306
281
  daemon_pid = read_pid_file(DEFAULT_PID_FILE)
@@ -313,9 +288,8 @@ def status():
313
288
  # Get current recording name from log
314
289
  recording_name = _get_current_recording_name()
315
290
  click.echo(f"🔴 Recording: {recording_name or 'In progress'}")
316
- elif watcher_running or menubar_running:
317
- mode = "watcher" if watcher_running else "menubar"
318
- click.echo(f"👀 Ready to record ({mode} active)")
291
+ elif watcher_running:
292
+ click.echo("👀 Ready to record (watcher active)")
319
293
  else:
320
294
  click.echo("⏹️ Stopped (run 'meeting-noter' to start)")
321
295
 
@@ -324,7 +298,6 @@ def status():
324
298
  # Show details
325
299
  click.echo("Components:")
326
300
  click.echo(f" Watcher: {'running' if watcher_running else 'stopped'}")
327
- click.echo(f" Menubar: {'running' if menubar_running else 'stopped'}")
328
301
  click.echo(f" Recorder: {'recording' if daemon_running else 'idle'}")
329
302
  click.echo()
330
303
 
@@ -358,7 +331,7 @@ def _get_current_recording_name() -> str | None:
358
331
 
359
332
  @cli.command()
360
333
  def shutdown():
361
- """Stop all Meeting Noter processes (daemon, watcher, menubar, GUI)."""
334
+ """Stop all Meeting Noter processes (daemon, watcher)."""
362
335
  import subprocess
363
336
  import os
364
337
  import signal
@@ -381,17 +354,6 @@ def shutdown():
381
354
  except (ProcessLookupError, ValueError):
382
355
  WATCHER_PID_FILE.unlink(missing_ok=True)
383
356
 
384
- # Stop menubar
385
- menubar_pid_file = Path.home() / ".meeting-noter-menubar.pid"
386
- if menubar_pid_file.exists():
387
- try:
388
- pid = int(menubar_pid_file.read_text().strip())
389
- os.kill(pid, signal.SIGTERM)
390
- menubar_pid_file.unlink()
391
- stopped.append("menubar")
392
- except (ProcessLookupError, ValueError):
393
- menubar_pid_file.unlink(missing_ok=True)
394
-
395
357
  # Kill any remaining meeting-noter processes
396
358
  result = subprocess.run(
397
359
  ["pkill", "-f", "meeting_noter"],
@@ -584,62 +546,6 @@ def live():
584
546
  click.echo("\n" + click.style("Stopped watching.", fg="cyan"))
585
547
 
586
548
 
587
- @cli.command()
588
- @click.option(
589
- "--foreground", "-f",
590
- is_flag=True,
591
- help="Run in foreground instead of background",
592
- )
593
- @require_setup
594
- def menubar(foreground: bool):
595
- """Launch menu bar app for daemon control.
596
-
597
- Adds a menu bar icon for one-click start/stop of the recording daemon.
598
- The icon shows "MN" when idle and "MN [filename]" when recording.
599
-
600
- By default, runs in background. Use -f for foreground (debugging).
601
- """
602
- import subprocess
603
- import sys
604
-
605
- if foreground:
606
- from meeting_noter.menubar import run_menubar
607
- run_menubar()
608
- else:
609
- # Spawn as background process
610
- subprocess.Popen(
611
- [sys.executable, "-m", "meeting_noter.menubar"],
612
- stdout=subprocess.DEVNULL,
613
- stderr=subprocess.DEVNULL,
614
- start_new_session=True,
615
- )
616
- click.echo("Menu bar app started in background.")
617
-
618
-
619
- @cli.command()
620
- @click.option(
621
- "--foreground", "-f",
622
- is_flag=True,
623
- help="Run in foreground instead of background",
624
- )
625
- @require_setup
626
- def gui(foreground: bool):
627
- """Launch the desktop GUI application.
628
-
629
- Opens a window with tabs for:
630
- - Recording: Start/stop recordings with meeting names
631
- - Meetings: Browse, play, and manage recordings
632
- - Settings: Configure directories, models, and preferences
633
-
634
- By default runs in background. Use -f for foreground.
635
- """
636
- if foreground:
637
- from meeting_noter.gui import run_gui
638
- run_gui()
639
- else:
640
- _launch_gui_background()
641
-
642
-
643
549
  # Config key mappings (CLI name -> config attribute)
644
550
  CONFIG_KEYS = {
645
551
  "recordings-dir": ("recordings_dir", "path", "Directory for audio recordings"),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meeting-noter
3
- Version: 1.1.0
3
+ Version: 1.2.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,23 +1,15 @@
1
- meeting_noter/__init__.py,sha256=OuitWUFUeSb7eg41mnnDD6QiJrcGVpXpdhDzyxtCmmU,103
1
+ meeting_noter/__init__.py,sha256=AFUfCIr09uvvLIrY-GT15IaqogAMm4zSwZCpcZWzk9s,103
2
2
  meeting_noter/__main__.py,sha256=6sSOqH1o3jvgvkVzsVKmF6-xVGcUAbNVQkRl2CrygdE,120
3
- meeting_noter/cli.py,sha256=BAoUnQ-FPgwTp6-ON6WQkO-7Biwk1mYhwFaGdchHGHQ,34267
3
+ meeting_noter/cli.py,sha256=jEdiOBEG0BP43Z05HSRv5lI9kuj30p-XFCFWZWlxLjc,31439
4
4
  meeting_noter/config.py,sha256=_Jy-gZDTCN3TwH5fiLEWk-qFhoSGISO2xDQmACV_zRc,6334
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
@@ -34,8 +26,8 @@ meeting_noter/resources/icon_64.png,sha256=TqG7Awx3kK8YdiX1e_z1odZonosZyQI2trlkN
34
26
  meeting_noter/transcription/__init__.py,sha256=7GY9diP06DzFyoli41wddbrPv5bVDzH35bmnWlIJev4,29
35
27
  meeting_noter/transcription/engine.py,sha256=G9NcSS6Q-UhW7PlQ0E85hQXn6BWao64nIvyw4NR2yxI,7208
36
28
  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,,
29
+ meeting_noter-1.2.0.dist-info/METADATA,sha256=ieb-BY1DCzl4joYd7wDc3JOPqxAxdww7Rb-goDknKcs,6939
30
+ meeting_noter-1.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
31
+ meeting_noter-1.2.0.dist-info/entry_points.txt,sha256=osZoOmm-UBPCJ4b6DGH6JOAm7mofM2fK06eK6blplmg,83
32
+ meeting_noter-1.2.0.dist-info/top_level.txt,sha256=9Tuq04_0SXM0OXOHVbOHkHkB5tG3fqkrMrfzCMpbLpY,14
33
+ meeting_noter-1.2.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()
@@ -1,348 +0,0 @@
1
- """Meetings list tab for browsing and managing recordings."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import subprocess
7
- from datetime import datetime
8
- from pathlib import Path
9
- from typing import Optional
10
-
11
- from PyQt6.QtCore import Qt, QUrl
12
- from PyQt6.QtWidgets import (
13
- QWidget,
14
- QVBoxLayout,
15
- QHBoxLayout,
16
- QTableWidget,
17
- QTableWidgetItem,
18
- QPushButton,
19
- QHeaderView,
20
- QDialog,
21
- QTextEdit,
22
- QLineEdit,
23
- QLabel,
24
- QMessageBox,
25
- QDialogButtonBox,
26
- )
27
- from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
28
-
29
- from meeting_noter.config import get_config
30
-
31
-
32
- class TranscriptDialog(QDialog):
33
- """Dialog for viewing a transcript."""
34
-
35
- def __init__(self, title: str, content: str, parent=None):
36
- super().__init__(parent)
37
- self.setWindowTitle(f"Transcript: {title}")
38
- self.setMinimumSize(600, 400)
39
-
40
- layout = QVBoxLayout(self)
41
-
42
- self.text_edit = QTextEdit()
43
- self.text_edit.setReadOnly(True)
44
- self.text_edit.setPlainText(content)
45
- layout.addWidget(self.text_edit)
46
-
47
- button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
48
- button_box.rejected.connect(self.close)
49
- layout.addWidget(button_box)
50
-
51
-
52
- class RenameDialog(QDialog):
53
- """Dialog for renaming a meeting."""
54
-
55
- def __init__(self, current_name: str, parent=None):
56
- super().__init__(parent)
57
- self.setWindowTitle("Rename Meeting")
58
- self.setMinimumWidth(400)
59
-
60
- layout = QVBoxLayout(self)
61
-
62
- layout.addWidget(QLabel("New name:"))
63
-
64
- self.name_input = QLineEdit()
65
- self.name_input.setText(current_name)
66
- self.name_input.selectAll()
67
- layout.addWidget(self.name_input)
68
-
69
- button_box = QDialogButtonBox(
70
- QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
71
- )
72
- button_box.accepted.connect(self.accept)
73
- button_box.rejected.connect(self.reject)
74
- layout.addWidget(button_box)
75
-
76
- def get_name(self) -> str:
77
- """Get the entered name."""
78
- return self.name_input.text().strip()
79
-
80
-
81
- class MeetingsTab(QWidget):
82
- """Tab for browsing and managing meeting recordings."""
83
-
84
- def __init__(self):
85
- super().__init__()
86
- self.config = get_config()
87
- self.player: Optional[QMediaPlayer] = None
88
- self.audio_output: Optional[QAudioOutput] = None
89
- self.current_playing: Optional[Path] = None
90
-
91
- self._setup_ui()
92
- self.refresh()
93
-
94
- def _setup_ui(self):
95
- """Set up the user interface."""
96
- layout = QVBoxLayout(self)
97
-
98
- # Table for meetings
99
- self.table = QTableWidget()
100
- self.table.setColumnCount(5)
101
- self.table.setHorizontalHeaderLabels(["Date", "Name", "Duration", "Transcript", "Actions"])
102
- self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
103
- self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
104
- self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
105
- self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
106
- self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
107
- self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
108
- self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
109
- layout.addWidget(self.table)
110
-
111
- # Bottom buttons
112
- button_layout = QHBoxLayout()
113
-
114
- self.refresh_button = QPushButton("Refresh")
115
- self.refresh_button.clicked.connect(self.refresh)
116
- button_layout.addWidget(self.refresh_button)
117
-
118
- self.open_folder_button = QPushButton("Open Folder")
119
- self.open_folder_button.clicked.connect(self._open_folder)
120
- button_layout.addWidget(self.open_folder_button)
121
-
122
- button_layout.addStretch()
123
-
124
- layout.addLayout(button_layout)
125
-
126
- def refresh(self):
127
- """Refresh the meetings list."""
128
- self.table.setRowCount(0)
129
-
130
- recordings_dir = self.config.recordings_dir
131
- if not recordings_dir.exists():
132
- return
133
-
134
- # Find all MP3 files
135
- mp3_files = sorted(recordings_dir.glob("*.mp3"), reverse=True)
136
-
137
- for mp3_path in mp3_files:
138
- self._add_meeting_row(mp3_path)
139
-
140
- def _get_transcript_path(self, mp3_path: Path) -> Path:
141
- """Get the transcript path for an audio file."""
142
- transcripts_dir = self.config.transcripts_dir
143
- return transcripts_dir / mp3_path.with_suffix(".txt").name
144
-
145
- def _add_meeting_row(self, mp3_path: Path):
146
- """Add a row for a meeting recording."""
147
- row = self.table.rowCount()
148
- self.table.insertRow(row)
149
-
150
- # Parse filename
151
- name = mp3_path.stem
152
- parts = name.split("_", 2)
153
-
154
- if len(parts) >= 2:
155
- # Format: YYYY-MM-DD_HHMMSS or YYYY-MM-DD_HHMMSS_name
156
- try:
157
- date_str = parts[0]
158
- time_str = parts[1]
159
- date_obj = datetime.strptime(f"{date_str}_{time_str}", "%Y-%m-%d_%H%M%S")
160
- display_date = date_obj.strftime("%Y-%m-%d %H:%M")
161
- except ValueError:
162
- display_date = name
163
- meeting_name = parts[2] if len(parts) > 2 else ""
164
- else:
165
- display_date = name
166
- meeting_name = ""
167
-
168
- # Date column
169
- date_item = QTableWidgetItem(display_date)
170
- date_item.setData(Qt.ItemDataRole.UserRole, str(mp3_path))
171
- self.table.setItem(row, 0, date_item)
172
-
173
- # Name column
174
- name_item = QTableWidgetItem(meeting_name)
175
- self.table.setItem(row, 1, name_item)
176
-
177
- # Duration column
178
- duration = self._get_duration(mp3_path)
179
- duration_item = QTableWidgetItem(duration)
180
- self.table.setItem(row, 2, duration_item)
181
-
182
- # Transcript column
183
- transcript_path = self._get_transcript_path(mp3_path)
184
- has_transcript = transcript_path.exists()
185
- transcript_item = QTableWidgetItem("Yes" if has_transcript else "No")
186
- self.table.setItem(row, 3, transcript_item)
187
-
188
- # Actions column - widget with buttons
189
- actions_widget = QWidget()
190
- actions_layout = QHBoxLayout(actions_widget)
191
- actions_layout.setContentsMargins(4, 2, 4, 2)
192
- actions_layout.setSpacing(4)
193
-
194
- play_btn = QPushButton("Play")
195
- play_btn.setFixedWidth(50)
196
- play_btn.clicked.connect(lambda checked, p=mp3_path: self._play_audio(p))
197
- actions_layout.addWidget(play_btn)
198
-
199
- if has_transcript:
200
- view_btn = QPushButton("View")
201
- view_btn.setFixedWidth(50)
202
- tp = transcript_path # Capture for lambda
203
- view_btn.clicked.connect(lambda checked, p=tp: self._view_transcript(p))
204
- actions_layout.addWidget(view_btn)
205
- else:
206
- transcribe_btn = QPushButton("Transcribe")
207
- transcribe_btn.setFixedWidth(70)
208
- transcribe_btn.clicked.connect(lambda checked, p=mp3_path: self._transcribe_recording(p))
209
- actions_layout.addWidget(transcribe_btn)
210
-
211
- rename_btn = QPushButton("Rename")
212
- rename_btn.setFixedWidth(60)
213
- rename_btn.clicked.connect(lambda checked, p=mp3_path, r=row: self._rename_meeting(p, r))
214
- actions_layout.addWidget(rename_btn)
215
-
216
- self.table.setCellWidget(row, 4, actions_widget)
217
-
218
- def _get_duration(self, mp3_path: Path) -> str:
219
- """Get the duration of an MP3 file."""
220
- try:
221
- # Use file size as rough estimate (128kbps = 16KB/s)
222
- size_bytes = mp3_path.stat().st_size
223
- duration_secs = size_bytes / (128 * 1000 / 8)
224
- mins, secs = divmod(int(duration_secs), 60)
225
- return f"{mins:02d}:{secs:02d}"
226
- except Exception:
227
- return "--:--"
228
-
229
- def _play_audio(self, mp3_path: Path):
230
- """Play or stop audio playback."""
231
- # Stop current playback if any
232
- if self.player and self.current_playing == mp3_path:
233
- self.player.stop()
234
- self.player = None
235
- self.audio_output = None
236
- self.current_playing = None
237
- return
238
-
239
- # Stop any existing playback
240
- if self.player:
241
- self.player.stop()
242
-
243
- # Create new player
244
- self.audio_output = QAudioOutput()
245
- self.player = QMediaPlayer()
246
- self.player.setAudioOutput(self.audio_output)
247
- self.player.setSource(QUrl.fromLocalFile(str(mp3_path)))
248
- self.player.play()
249
- self.current_playing = mp3_path
250
-
251
- def _view_transcript(self, transcript_path: Path):
252
- """View a transcript in a dialog."""
253
- try:
254
- content = transcript_path.read_text()
255
- dialog = TranscriptDialog(transcript_path.stem, content, self)
256
- dialog.exec()
257
- except Exception as e:
258
- QMessageBox.warning(self, "Error", f"Could not read transcript: {e}")
259
-
260
- def _rename_meeting(self, mp3_path: Path, row: int):
261
- """Rename a meeting (both MP3 and transcript files)."""
262
- # Extract current name
263
- name = mp3_path.stem
264
- parts = name.split("_", 2)
265
- current_name = parts[2] if len(parts) > 2 else ""
266
-
267
- dialog = RenameDialog(current_name, self)
268
- if dialog.exec() != QDialog.DialogCode.Accepted:
269
- return
270
-
271
- new_name = dialog.get_name()
272
- if not new_name:
273
- return
274
-
275
- # Sanitize new name
276
- import re
277
- sanitized = new_name.replace(" ", "_")
278
- sanitized = re.sub(r"[^\w\-]", "", sanitized)
279
- if len(sanitized) > 50:
280
- sanitized = sanitized[:50].rstrip("_-")
281
-
282
- # Build new filename
283
- if len(parts) >= 2:
284
- new_stem = f"{parts[0]}_{parts[1]}_{sanitized}"
285
- else:
286
- new_stem = f"{name}_{sanitized}"
287
-
288
- new_mp3_path = mp3_path.with_stem(new_stem)
289
- transcript_path = self._get_transcript_path(mp3_path)
290
- new_transcript_path = self._get_transcript_path(new_mp3_path)
291
-
292
- try:
293
- # Rename MP3
294
- mp3_path.rename(new_mp3_path)
295
-
296
- # Rename transcript if exists
297
- if transcript_path.exists():
298
- transcript_path.rename(new_transcript_path)
299
-
300
- self.refresh()
301
- except Exception as e:
302
- QMessageBox.warning(self, "Error", f"Could not rename files: {e}")
303
-
304
- def _transcribe_recording(self, mp3_path: Path):
305
- """Transcribe a recording that doesn't have a transcript."""
306
- from PyQt6.QtCore import QThread, pyqtSignal
307
-
308
- class TranscribeWorker(QThread):
309
- finished = pyqtSignal(bool, str)
310
-
311
- def __init__(self, audio_path, config):
312
- super().__init__()
313
- self.audio_path = audio_path
314
- self.config = config
315
-
316
- def run(self):
317
- try:
318
- from meeting_noter.transcription.engine import transcribe_file
319
- transcribe_file(
320
- str(self.audio_path),
321
- self.config.recordings_dir,
322
- self.config.whisper_model,
323
- self.config.transcripts_dir,
324
- )
325
- self.finished.emit(True, "")
326
- except Exception as e:
327
- self.finished.emit(False, str(e))
328
-
329
- def on_finished(success, error):
330
- self._transcribe_worker = None
331
- if success:
332
- QMessageBox.information(self, "Success", f"Transcription complete: {mp3_path.name}")
333
- self.refresh()
334
- else:
335
- QMessageBox.warning(self, "Error", f"Transcription failed: {error}")
336
-
337
- # Show progress
338
- QMessageBox.information(self, "Transcribing", f"Transcribing {mp3_path.name}...\nThis may take a while.")
339
-
340
- self._transcribe_worker = TranscribeWorker(mp3_path, self.config)
341
- self._transcribe_worker.finished.connect(on_finished)
342
- self._transcribe_worker.start()
343
-
344
- def _open_folder(self):
345
- """Open the recordings folder in Finder."""
346
- recordings_dir = self.config.recordings_dir
347
- recordings_dir.mkdir(parents=True, exist_ok=True)
348
- subprocess.run(["open", str(recordings_dir)])