diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5874e571..a6fda42cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,7 @@ jobs: - name: Install apt dependencies run: | sudo apt-get update - sudo apt-get install --no-install-recommends libyaml-dev libegl1-mesa libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-shape0 libxcb-cursor0 libportaudio2 gettext + sudo apt-get install --no-install-recommends libyaml-dev libegl1-mesa libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-shape0 libxcb-cursor0 libportaudio2 gettext libpulse0 if: "startsWith(matrix.os, 'ubuntu-')" - name: Test @@ -133,7 +133,7 @@ jobs: - name: Install apt dependencies run: | sudo apt-get update - sudo apt-get install --no-install-recommends libyaml-dev libegl1-mesa libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-shape0 libxcb-cursor0 libportaudio2 gettext + sudo apt-get install --no-install-recommends libyaml-dev libegl1-mesa libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-shape0 libxcb-cursor0 libportaudio2 gettext libpulse0 if: "startsWith(matrix.os, 'ubuntu-')" - name: Install FPM diff --git a/assets/pause_black_24dp.svg b/assets/pause_black_24dp.svg new file mode 100644 index 000000000..4104cb00b --- /dev/null +++ b/assets/pause_black_24dp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/play_arrow_black_24dp.svg b/assets/play_arrow_black_24dp.svg new file mode 100644 index 000000000..178bd3a4d --- /dev/null +++ b/assets/play_arrow_black_24dp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/buzz/widgets/audio_player.py b/buzz/widgets/audio_player.py new file mode 100644 index 000000000..4dfa683fc --- /dev/null +++ b/buzz/widgets/audio_player.py @@ -0,0 +1,125 @@ +from typing import Tuple, Optional + +from PyQt6 import QtGui +from PyQt6.QtCore import QTime, QUrl, Qt +from PyQt6.QtMultimedia import QAudioOutput, QMediaPlayer +from PyQt6.QtWidgets import QWidget, QSlider, QPushButton, QLabel, QHBoxLayout + +from buzz.widgets.icon import PlayIcon, PauseIcon + + +class AudioPlayer(QWidget): + def __init__(self, file_path: str): + super().__init__() + + self.range_ms: Optional[Tuple[int, int]] = None + self.position = 0 + self.duration = 0 + self.invalid_media = None + + self.audio_output = QAudioOutput() + self.audio_output.setVolume(100) + + self.media_player = QMediaPlayer() + self.media_player.setSource(QUrl.fromLocalFile(file_path)) + self.media_player.setAudioOutput(self.audio_output) + + self.scrubber = QSlider(Qt.Orientation.Horizontal) + self.scrubber.setRange(0, 0) + self.scrubber.sliderMoved.connect(self.on_slider_moved) + + self.play_icon = PlayIcon(self) + self.pause_icon = PauseIcon(self) + + self.play_button = QPushButton("") + self.play_button.setIcon(self.play_icon) + self.play_button.clicked.connect(self.toggle_play) + + self.time_label = QLabel() + self.time_label.setAlignment(Qt.AlignmentFlag.AlignRight) + + layout = QHBoxLayout() + layout.addWidget(self.scrubber, alignment=Qt.AlignmentFlag.AlignVCenter) + layout.addWidget(self.time_label, alignment=Qt.AlignmentFlag.AlignVCenter) + layout.addWidget(self.play_button, alignment=Qt.AlignmentFlag.AlignVCenter) + layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) + + self.setLayout(layout) + + # Connect media player signals to the corresponding slots + self.media_player.durationChanged.connect(self.on_duration_changed) + self.media_player.positionChanged.connect(self.on_position_changed) + self.media_player.playbackStateChanged.connect(self.on_playback_state_changed) + self.media_player.mediaStatusChanged.connect(self.on_media_status_changed) + + self.update_time_label() + + def on_duration_changed(self, duration_ms: int): + self.scrubber.setRange(0, duration_ms) + self.duration = duration_ms + self.update_time_label() + + def on_position_changed(self, position_ms: int): + self.scrubber.setValue(position_ms) + self.position = position_ms + self.update_time_label() + + if self.range_ms is not None: + start_range_ms, end_range_ms = self.range_ms + if position_ms > end_range_ms: + self.set_position(start_range_ms) + + def on_playback_state_changed(self, state: QMediaPlayer.PlaybackState): + if state == QMediaPlayer.PlaybackState.PlayingState: + self.play_button.setIcon(self.pause_icon) + else: + self.play_button.setIcon(self.play_icon) + + def on_media_status_changed(self, status: QMediaPlayer.MediaStatus): + match status: + case QMediaPlayer.MediaStatus.InvalidMedia: + self.set_invalid_media(True) + case QMediaPlayer.MediaStatus.LoadedMedia: + self.set_invalid_media(False) + + def set_invalid_media(self, invalid_media: bool): + self.invalid_media = invalid_media + if self.invalid_media: + self.play_button.setDisabled(True) + self.scrubber.setRange(0, 1) + self.scrubber.setDisabled(True) + self.time_label.setDisabled(True) + + def toggle_play(self): + if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.media_player.pause() + else: + self.media_player.play() + + def set_range(self, range_ms: Tuple[int, int]): + self.range_ms = range_ms + self.set_position(range_ms[0]) + + def on_slider_moved(self, position_ms: int): + self.set_position(position_ms) + # Reset range if slider is scrubbed manually + self.range_ms = None + + def set_position(self, position_ms: int): + self.media_player.setPosition(position_ms) + + def update_time_label(self): + position_time = QTime(0, 0).addMSecs(self.position).toString() + duration_time = QTime(0, 0).addMSecs(self.duration).toString() + self.time_label.setText(f'{position_time} / {duration_time}') + + def stop(self): + self.media_player.stop() + + def closeEvent(self, a0: QtGui.QCloseEvent) -> None: + self.stop() + super().closeEvent(a0) + + def hideEvent(self, a0: QtGui.QHideEvent) -> None: + self.stop() + super().hideEvent(a0) diff --git a/buzz/widgets/icon.py b/buzz/widgets/icon.py index a5acfeeb6..f6e925846 100644 --- a/buzz/widgets/icon.py +++ b/buzz/widgets/icon.py @@ -7,12 +7,12 @@ # TODO: move icons to Qt resources: https://stackoverflow.com/a/52341917/9830227 class Icon(QIcon): LIGHT_THEME_BACKGROUND = '#555' - DARK_THEME_BACKGROUND = '#AAA' + DARK_THEME_BACKGROUND = '#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.DARK_THEME_BACKGROUND if is_dark_theme else self.LIGHT_THEME_BACKGROUND + color = self.get_color(is_dark_theme) pixmap = QPixmap(path) painter = QPainter(pixmap) @@ -22,6 +22,19 @@ def __init__(self, path: str, parent: QWidget): super().__init__(pixmap) + def get_color(self, is_dark_theme): + return self.DARK_THEME_BACKGROUND if is_dark_theme else self.LIGHT_THEME_BACKGROUND + + +class PlayIcon(Icon): + def __init__(self, parent: QWidget): + super().__init__(get_asset_path('assets/play_arrow_black_24dp.svg'), parent) + + +class PauseIcon(Icon): + def __init__(self, parent: QWidget): + super().__init__(get_asset_path('assets/pause_black_24dp.svg'), parent) + BUZZ_ICON_PATH = get_asset_path('assets/buzz.ico') BUZZ_LARGE_ICON_PATH = get_asset_path('assets/buzz-icon-1024.png') diff --git a/buzz/widgets/transcription_segments_editor_widget.py b/buzz/widgets/transcription_segments_editor_widget.py index e85d06cfb..0796231d8 100644 --- a/buzz/widgets/transcription_segments_editor_widget.py +++ b/buzz/widgets/transcription_segments_editor_widget.py @@ -10,6 +10,7 @@ class TranscriptionSegmentsEditorWidget(QTableWidget): segment_text_changed = pyqtSignal(tuple) + segment_index_selected = pyqtSignal(int) class Column(enum.Enum): START = 0 @@ -49,6 +50,7 @@ def __init__(self, segments: List[Segment], parent: Optional[QWidget]): self.setItem(row_index, self.Column.TEXT.value, text_item) self.itemChanged.connect(self.on_item_changed) + self.itemSelectionChanged.connect(self.on_item_selection_changed) def on_item_changed(self, item: QTableWidgetItem): if item.column() == self.Column.TEXT.value: @@ -56,3 +58,8 @@ def on_item_changed(self, item: QTableWidgetItem): def set_segment_text(self, index: int, text: str): self.item(index, self.Column.TEXT.value).setText(text) + + def on_item_selection_changed(self): + ranges = self.selectedRanges() + self.segment_index_selected.emit( + ranges[0].topRow() if len(ranges) > 0 else -1) diff --git a/buzz/widgets/transcription_viewer_widget.py b/buzz/widgets/transcription_viewer_widget.py index 337e6a94b..42f46887e 100644 --- a/buzz/widgets/transcription_viewer_widget.py +++ b/buzz/widgets/transcription_viewer_widget.py @@ -1,3 +1,4 @@ +import platform from typing import List, Optional from PyQt6.QtCore import Qt, pyqtSignal @@ -11,6 +12,7 @@ from buzz.paths import file_path_as_title from buzz.transcriber import FileTranscriptionTask, Segment, OutputFormat, \ get_default_output_file_path, write_output +from buzz.widgets.audio_player import AudioPlayer from buzz.widgets.icon import Icon from buzz.widgets.toolbar import ToolBar from buzz.widgets.transcription_segments_editor_widget import \ @@ -22,7 +24,8 @@ class TranscriptionViewerWidget(QWidget): task_changed = pyqtSignal() class ChangeSegmentTextCommand(QUndoCommand): - def __init__(self, table_widget: TranscriptionSegmentsEditorWidget, segments: List[Segment], + def __init__(self, table_widget: TranscriptionSegmentsEditorWidget, + segments: List[Segment], segment_index: int, segment_text: str, task_changed: pyqtSignal): super().__init__() @@ -67,19 +70,27 @@ def __init__( undo_action = self.undo_stack.createUndoAction(self, _("Undo")) undo_action.setShortcuts(QKeySequence.StandardKey.Undo) - undo_action.setIcon(Icon(get_asset_path('assets/undo_FILL0_wght700_GRAD0_opsz48.svg'), self)) + undo_action.setIcon( + Icon(get_asset_path('assets/undo_FILL0_wght700_GRAD0_opsz48.svg'), self)) undo_action.setToolTip(Action.get_tooltip(undo_action)) redo_action = self.undo_stack.createRedoAction(self, _("Redo")) redo_action.setShortcuts(QKeySequence.StandardKey.Redo) - redo_action.setIcon(Icon(get_asset_path('assets/redo_FILL0_wght700_GRAD0_opsz48.svg'), self)) + redo_action.setIcon( + Icon(get_asset_path('assets/redo_FILL0_wght700_GRAD0_opsz48.svg'), self)) redo_action.setToolTip(Action.get_tooltip(redo_action)) toolbar = ToolBar() toolbar.addActions([undo_action, redo_action]) - self.table_widget = TranscriptionSegmentsEditorWidget(segments=transcription_task.segments, parent=self) + self.table_widget = TranscriptionSegmentsEditorWidget( + segments=transcription_task.segments, parent=self) self.table_widget.segment_text_changed.connect(self.on_segment_text_changed) + self.table_widget.segment_index_selected.connect(self.on_segment_index_selected) + + self.audio_player: Optional[AudioPlayer] = None + if platform.system() != "Linux": + self.audio_player = AudioPlayer(file_path=transcription_task.file_path) buttons_layout = QHBoxLayout() buttons_layout.addStretch() @@ -100,6 +111,8 @@ def __init__( layout = QVBoxLayout(self) layout.setMenuBar(toolbar) layout.addWidget(self.table_widget) + if self.audio_player is not None: + layout.addWidget(self.audio_player) layout.addLayout(buttons_layout) self.setLayout(layout) @@ -107,10 +120,17 @@ def __init__( def on_segment_text_changed(self, event: tuple): segment_index, segment_text = event self.undo_stack.push( - self.ChangeSegmentTextCommand(table_widget=self.table_widget, segments=self.transcription_task.segments, - segment_index=segment_index, segment_text=segment_text, + self.ChangeSegmentTextCommand(table_widget=self.table_widget, + segments=self.transcription_task.segments, + segment_index=segment_index, + segment_text=segment_text, task_changed=self.task_changed)) + def on_segment_index_selected(self, index: int): + selected_segment = self.transcription_task.segments[index] + if self.audio_player is not None: + self.audio_player.set_range((selected_segment.start, selected_segment.end)) + def on_menu_triggered(self, action: QAction): output_format = OutputFormat[action.text()] @@ -119,10 +139,12 @@ def on_menu_triggered(self, action: QAction): input_file_path=self.transcription_task.file_path, output_format=output_format) - (output_file_path, nil) = QFileDialog.getSaveFileName(self, _('Save File'), default_path, + (output_file_path, nil) = QFileDialog.getSaveFileName(self, _('Save File'), + default_path, _('Text files') + f' (*.{output_format.value})') if output_file_path == '': return - write_output(path=output_file_path, segments=self.transcription_task.segments, output_format=output_format) + write_output(path=output_file_path, segments=self.transcription_task.segments, + output_format=output_format)