Skip to content

Commit

Permalink
Add audio player (#558)
Browse files Browse the repository at this point in the history
  • Loading branch information
chidiwilliams authored Aug 4, 2023
1 parent af4d57b commit 8b253ff
Show file tree
Hide file tree
Showing 7 changed files with 181 additions and 12 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions assets/pause_black_24dp.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/play_arrow_black_24dp.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
125 changes: 125 additions & 0 deletions buzz/widgets/audio_player.py
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 15 additions & 2 deletions buzz/widgets/icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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')
7 changes: 7 additions & 0 deletions buzz/widgets/transcription_segments_editor_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

class TranscriptionSegmentsEditorWidget(QTableWidget):
segment_text_changed = pyqtSignal(tuple)
segment_index_selected = pyqtSignal(int)

class Column(enum.Enum):
START = 0
Expand Down Expand Up @@ -49,10 +50,16 @@ 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:
self.segment_text_changed.emit((item.row(), item.text()))

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)
38 changes: 30 additions & 8 deletions buzz/widgets/transcription_viewer_widget.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import platform
from typing import List, Optional

from PyQt6.QtCore import Qt, pyqtSignal
Expand All @@ -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 \
Expand All @@ -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__()

Expand Down Expand Up @@ -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()
Expand All @@ -100,17 +111,26 @@ 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)

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()]

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

0 comments on commit 8b253ff

Please sign in to comment.