Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add audio player #558

Merged
merged 4 commits into from
Aug 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)