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 +1 -1
- meeting_noter/cli.py +3 -97
- {meeting_noter-1.1.0.dist-info → meeting_noter-1.2.0.dist-info}/METADATA +1 -3
- {meeting_noter-1.1.0.dist-info → meeting_noter-1.2.0.dist-info}/RECORD +7 -15
- meeting_noter/gui/__init__.py +0 -5
- meeting_noter/gui/__main__.py +0 -6
- meeting_noter/gui/app.py +0 -53
- meeting_noter/gui/main_window.py +0 -50
- meeting_noter/gui/meetings_tab.py +0 -348
- meeting_noter/gui/recording_tab.py +0 -358
- meeting_noter/gui/settings_tab.py +0 -249
- meeting_noter/menubar.py +0 -411
- {meeting_noter-1.1.0.dist-info → meeting_noter-1.2.0.dist-info}/WHEEL +0 -0
- {meeting_noter-1.1.0.dist-info → meeting_noter-1.2.0.dist-info}/entry_points.txt +0 -0
- {meeting_noter-1.1.0.dist-info → meeting_noter-1.2.0.dist-info}/top_level.txt +0 -0
meeting_noter/__init__.py
CHANGED
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
|
|
317
|
-
|
|
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
|
|
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.
|
|
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=
|
|
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=
|
|
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.
|
|
38
|
-
meeting_noter-1.
|
|
39
|
-
meeting_noter-1.
|
|
40
|
-
meeting_noter-1.
|
|
41
|
-
meeting_noter-1.
|
|
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,,
|
meeting_noter/gui/__init__.py
DELETED
meeting_noter/gui/__main__.py
DELETED
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()
|
meeting_noter/gui/main_window.py
DELETED
|
@@ -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)])
|