Add audio player (#558)

This commit is contained in:
Chidi Williams 2023-08-04 13:23:43 -07:00 committed by GitHub
commit 8b253ffc1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 181 additions and 12 deletions

View file

@ -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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>

After

Width:  |  Height:  |  Size: 188 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></svg>

After

Width:  |  Height:  |  Size: 170 B

View file

@ -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)

View file

@ -7,12 +7,12 @@ 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 = '#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 @@ class Icon(QIcon):
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')

View file

@ -10,6 +10,7 @@ from buzz.transcriber import Segment, to_timestamp
class TranscriptionSegmentsEditorWidget(QTableWidget):
segment_text_changed = pyqtSignal(tuple)
segment_index_selected = pyqtSignal(int)
class Column(enum.Enum):
START = 0
@ -49,6 +50,7 @@ class TranscriptionSegmentsEditorWidget(QTableWidget):
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 @@ class TranscriptionSegmentsEditorWidget(QTableWidget):
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)

View file

@ -1,3 +1,4 @@
import platform
from typing import List, Optional
from PyQt6.QtCore import Qt, pyqtSignal
@ -11,6 +12,7 @@ from buzz.locale import _
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 @@ class TranscriptionViewerWidget(QWidget):
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 @@ class TranscriptionViewerWidget(QWidget):
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 @@ class TranscriptionViewerWidget(QWidget):
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 @@ class TranscriptionViewerWidget(QWidget):
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)