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)