Skip to content

Commit

Permalink
feat: add folder watch
Browse files Browse the repository at this point in the history
  • Loading branch information
chidiwilliams committed Dec 26, 2023
1 parent 1120723 commit a1958b3
Show file tree
Hide file tree
Showing 18 changed files with 653 additions and 183 deletions.
4 changes: 2 additions & 2 deletions buzz/settings/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ def value(
def clear(self):
self.settings.clear()

def begin_group(self, group: Key):
def begin_group(self, group: Key) -> None:
self.settings.beginGroup(group.value)

def end_group(self):
def end_group(self) -> None:
self.settings.endGroup()

def sync(self):
Expand Down
34 changes: 25 additions & 9 deletions buzz/transcriber.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ class Status(enum.Enum):
FAILED = "failed"
CANCELED = "canceled"

class Source(enum.Enum):
FILE_IMPORT = "file_import"
FOLDER_WATCH = "folder_watch"

file_path: str
transcription_options: TranscriptionOptions
file_transcription_options: FileTranscriptionOptions
Expand All @@ -108,6 +112,8 @@ class Status(enum.Enum):
queued_at: Optional[datetime.datetime] = None
started_at: Optional[datetime.datetime] = None
completed_at: Optional[datetime.datetime] = None
output_directory: Optional[str] = None
source: Source = Source.FILE_IMPORT

def status_text(self) -> str:
if self.status == FileTranscriptionTask.Status.IN_PROGRESS:
Expand Down Expand Up @@ -169,14 +175,23 @@ def run(self):
for (
output_format
) in self.transcription_task.file_transcription_options.output_formats:
default_path = get_default_output_file_path(
default_path = get_output_file_path(
task=self.transcription_task, output_format=output_format
)

write_output(
path=default_path, segments=segments, output_format=output_format
)

if self.transcription_task.source == FileTranscriptionTask.Source.FOLDER_WATCH:
os.rename(
self.transcription_task.file_path,
os.path.join(
self.transcription_task.output_directory,
os.path.basename(self.transcription_task.file_path),
),
)

@abstractmethod
def transcribe(self) -> List[Segment]:
...
Expand Down Expand Up @@ -644,24 +659,22 @@ def segments_to_text(segments: List[Segment]) -> str:

def to_timestamp(ms: float, ms_separator=".") -> str:
hr = int(ms / (1000 * 60 * 60))
ms = ms - hr * (1000 * 60 * 60)
ms -= hr * (1000 * 60 * 60)
min = int(ms / (1000 * 60))
ms = ms - min * (1000 * 60)
ms -= min * (1000 * 60)
sec = int(ms / 1000)
ms = int(ms - sec * 1000)
return f"{hr:02d}:{min:02d}:{sec:02d}{ms_separator}{ms:03d}"


SUPPORTED_OUTPUT_FORMATS = "Audio files (*.mp3 *.wav *.m4a *.ogg);;\
SUPPORTED_AUDIO_FORMATS = "Audio files (*.mp3 *.wav *.m4a *.ogg);;\
Video files (*.mp4 *.webm *.ogm *.mov);;All files (*.*)"


def get_default_output_file_path(
task: FileTranscriptionTask, output_format: OutputFormat
):
input_file_name = os.path.splitext(task.file_path)[0]
def get_output_file_path(task: FileTranscriptionTask, output_format: OutputFormat):
input_file_name = os.path.splitext(os.path.basename(task.file_path))[0]
date_time_now = datetime.datetime.now().strftime("%d-%b-%Y %H-%M-%S")
return (
output_file_name = (
task.file_transcription_options.default_output_file_name.replace(
"{{ input_file_name }}", input_file_name
)
Expand All @@ -678,6 +691,9 @@ def get_default_output_file_path(
+ f".{output_format.value}"
)

output_directory = task.output_directory or os.path.dirname(task.file_path)
return os.path.join(output_directory, output_file_name)


def whisper_cpp_params(
language: str,
Expand Down
2 changes: 1 addition & 1 deletion buzz/widgets/audio_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def __init__(self, file_path: str):
self.media_player.playbackStateChanged.connect(self.on_playback_state_changed)
self.media_player.mediaStatusChanged.connect(self.on_media_status_changed)

self.update_time_label()
self.on_duration_changed(self.media_player.duration())

def on_duration_changed(self, duration_ms: int):
self.scrubber.setRange(0, duration_ms)
Expand Down
45 changes: 33 additions & 12 deletions buzz/widgets/icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,48 @@
from buzz.assets import get_asset_path


# TODO: move icons to Qt resources: https://stackoverflow.com/a/52341917/9830227
class Icon(QIcon):
LIGHT_THEME_BACKGROUND = "#555"
DARK_THEME_BACKGROUND = "#EEE"
LIGHT_THEME_COLOR = "#555"
DARK_THEME_COLOR = "#EEE"

def __init__(self, path: str, parent: QWidget):
# Adapted from https://stackoverflow.com/questions/15123544/change-the-color-of-an-svg-in-qt
is_dark_theme = parent.palette().window().color().black() > 127
color = self.get_color(is_dark_theme)

super().__init__()
self.path = path
self.parent = parent

self.color = self.get_color()
normal_pixmap = self.create_default_pixmap(self.path, self.color)
disabled_pixmap = self.create_disabled_pixmap(normal_pixmap, self.color)
self.addPixmap(normal_pixmap, QIcon.Mode.Normal)
self.addPixmap(disabled_pixmap, QIcon.Mode.Disabled)

# https://stackoverflow.com/questions/15123544/change-the-color-of-an-svg-in-qt
def create_default_pixmap(self, path, color):
pixmap = QPixmap(path)
painter = QPainter(pixmap)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
painter.fillRect(pixmap.rect(), QColor(color))
painter.fillRect(pixmap.rect(), color)
painter.end()
return pixmap

def create_disabled_pixmap(self, pixmap, color):
disabled_pixmap = QPixmap(pixmap.size())
disabled_pixmap.fill(QColor(0, 0, 0, 0))

super().__init__(pixmap)
painter = QPainter(disabled_pixmap)
painter.setOpacity(0.4)
painter.drawPixmap(0, 0, pixmap)
painter.setCompositionMode(
QPainter.CompositionMode.CompositionMode_DestinationIn
)
painter.fillRect(disabled_pixmap.rect(), color)
painter.end()
return disabled_pixmap

def get_color(self, is_dark_theme):
return (
self.DARK_THEME_BACKGROUND if is_dark_theme else self.LIGHT_THEME_BACKGROUND
def get_color(self) -> QColor:
is_dark_theme = self.parent.palette().window().color().black() > 127
return QColor(
self.DARK_THEME_COLOR if is_dark_theme else self.LIGHT_THEME_COLOR
)


Expand Down
111 changes: 96 additions & 15 deletions buzz/widgets/main_window.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from typing import Dict, Optional, Tuple, List
import logging
import os
from typing import Dict, Tuple, List

from PyQt6 import QtGui
from PyQt6.QtCore import pyqtSignal, Qt, QThread, QModelIndex
from PyQt6.QtCore import Qt, QThread, QModelIndex, QFileSystemWatcher
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QMainWindow, QMessageBox, QFileDialog

Expand All @@ -15,11 +17,12 @@
FileTranscriptionTask,
TranscriptionOptions,
FileTranscriptionOptions,
SUPPORTED_OUTPUT_FORMATS,
SUPPORTED_AUDIO_FORMATS,
)
from buzz.widgets.icon import BUZZ_ICON_PATH
from buzz.widgets.main_window_toolbar import MainWindowToolbar
from buzz.widgets.menu_bar import MenuBar
from buzz.widgets.preferences_dialog.models.preferences import Preferences
from buzz.widgets.transcriber.file_transcriber_widget import FileTranscriberWidget
from buzz.widgets.transcription_tasks_table_widget import TranscriptionTasksTableWidget
from buzz.widgets.transcription_viewer.transcription_viewer_widget import (
Expand All @@ -30,8 +33,6 @@
class MainWindow(QMainWindow):
table_widget: TranscriptionTasksTableWidget
tasks: Dict[int, "FileTranscriptionTask"]
tasks_changed = pyqtSignal()
openai_access_token: Optional[str]

def __init__(self, tasks_cache=TasksCache()):
super().__init__(flags=Qt.WindowType.Window)
Expand All @@ -54,7 +55,6 @@ def __init__(self, tasks_cache=TasksCache()):
)

self.tasks = {}
self.tasks_changed.connect(self.on_tasks_changed)

self.toolbar = MainWindowToolbar(shortcuts=self.shortcuts, parent=self)
self.toolbar.new_transcription_action_triggered.connect(
Expand All @@ -72,9 +72,11 @@ def __init__(self, tasks_cache=TasksCache()):
self.addToolBar(self.toolbar)
self.setUnifiedTitleAndToolBarOnMac(True)

self.preferences = self.load_preferences(settings=self.settings)
self.menu_bar = MenuBar(
shortcuts=self.shortcuts,
default_export_file_name=self.default_export_file_name,
preferences=self.preferences,
parent=self,
)
self.menu_bar.import_action_triggered.connect(
Expand All @@ -87,6 +89,7 @@ def __init__(self, tasks_cache=TasksCache()):
self.menu_bar.default_export_file_name_changed.connect(
self.default_export_file_name_changed
)
self.menu_bar.preferences_changed.connect(self.on_preferences_changed)
self.setMenuBar(self.menu_bar)

self.table_widget = TranscriptionTasksTableWidget(self)
Expand All @@ -113,6 +116,83 @@ def __init__(self, tasks_cache=TasksCache()):

self.load_geometry()

self.watcher = QFileSystemWatcher(self)
self.watcher.directoryChanged.connect(self.on_watch_directory_changed)
self.reset_file_system_watcher()
self.sync_watch_folder()

def reset_file_system_watcher(self):
if len(self.watcher.directories()) > 0:
self.watcher.removePaths(self.watcher.directories())

if (
self.preferences.folder_watch.enabled
and len(self.preferences.folder_watch.input_folder) > 0
):
self.watcher.addPath(self.preferences.folder_watch.input_folder)
logging.debug(
'Watching for media files in "%s"',
self.preferences.folder_watch.input_folder,
)

def on_preferences_changed(self, preferences: Preferences):
self.preferences = preferences
self.save_preferences(preferences)
self.reset_file_system_watcher()
self.sync_watch_folder()

def save_preferences(self, preferences: Preferences):
self.settings.settings.beginGroup("preferences")
preferences.save(self.settings.settings)
self.settings.settings.endGroup()

def load_preferences(self, settings: Settings):
settings.settings.beginGroup("preferences")
preferences = Preferences.load(settings.settings)
settings.settings.endGroup()
return preferences

def on_watch_directory_changed(self):
self.sync_watch_folder()

def sync_watch_folder(self):
path = self.preferences.folder_watch.input_folder
tasks = {task.file_path: task for task in self.tasks.values()}
for dirpath, dirnames, filenames in os.walk(path):
for filename in filenames:
file_path = os.path.join(dirpath, filename)
if (
filename.startswith(".") # hidden files
or file_path in tasks # file already in tasks
):
continue

openai_access_token = KeyringStore().get_password(
KeyringStore.Key.OPENAI_API_KEY
)
(
transcription_options,
file_transcription_options,
) = self.preferences.folder_watch.file_transcription_options.to_transcription_options(
openai_access_token=openai_access_token,
default_output_file_name=self.default_export_file_name,
file_paths=[file_path],
)
model_path = transcription_options.model.get_local_model_path()
self.add_task(
task=FileTranscriptionTask(
file_path=file_path,
transcription_options=transcription_options,
file_transcription_options=file_transcription_options,
model_path=model_path,
output_directory=self.preferences.folder_watch.output_directory,
source=FileTranscriptionTask.Source.FOLDER_WATCH,
)
)

# Don't traverse into subdirectories
break

def dragEnterEvent(self, event):
# Accept file drag events
if event.mimeData().hasUrls():
Expand All @@ -134,13 +214,13 @@ def on_file_transcriber_triggered(
)
self.add_task(task)

def load_task(self, task: FileTranscriptionTask):
def upsert_task_in_table(self, task: FileTranscriptionTask):
self.table_widget.upsert_task(task)
self.tasks[task.id] = task

def update_task_table_row(self, task: FileTranscriptionTask):
self.load_task(task=task)
self.tasks_changed.emit()
self.upsert_task_in_table(task=task)
self.on_tasks_changed()

@staticmethod
def task_completed_or_errored(task: FileTranscriptionTask):
Expand All @@ -158,7 +238,8 @@ def on_clear_history_action_triggered(self):
self,
_("Clear History"),
_(
"Are you sure you want to delete the selected transcription(s)? This action cannot be undone."
"Are you sure you want to delete the selected transcription(s)? "
"This action cannot be undone."
),
)
if reply == QMessageBox.StandardButton.Yes:
Expand All @@ -169,7 +250,7 @@ def on_clear_history_action_triggered(self):
for task_id in task_ids:
self.table_widget.clear_task(task_id)
self.tasks.pop(task_id)
self.tasks_changed.emit()
self.on_tasks_changed()

def on_stop_transcription_action_triggered(self):
selected_rows = self.table_widget.selectionModel().selectedRows()
Expand All @@ -178,13 +259,13 @@ def on_stop_transcription_action_triggered(self):
task = self.tasks[task_id]

task.status = FileTranscriptionTask.Status.CANCELED
self.tasks_changed.emit()
self.on_tasks_changed()
self.transcriber_worker.cancel_task(task_id)
self.table_widget.upsert_task(task)

def on_new_transcription_action_triggered(self):
(file_paths, __) = QFileDialog.getOpenFileNames(
self, _("Select audio file"), "", SUPPORTED_OUTPUT_FORMATS
self, _("Select audio file"), "", SUPPORTED_AUDIO_FORMATS
)
if len(file_paths) == 0:
return
Expand Down Expand Up @@ -291,9 +372,9 @@ def load_tasks_from_cache(self):
or task.status == FileTranscriptionTask.Status.IN_PROGRESS
):
task.status = None
self.transcriber_worker.add_task(task)
self.add_task(task)
else:
self.load_task(task=task)
self.upsert_task_in_table(task=task)

def save_tasks_to_cache(self):
self.tasks_cache.save(list(self.tasks.values()))
Expand Down
Loading

0 comments on commit a1958b3

Please sign in to comment.