mirror of
https://github.com/chidiwilliams/buzz.git
synced 2026-03-14 22:55:46 +01:00
Add audio player (#558)
This commit is contained in:
parent
af4d57b881
commit
8b253ffc1c
7 changed files with 181 additions and 12 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
assets/pause_black_24dp.svg
Normal file
1
assets/pause_black_24dp.svg
Normal 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 |
1
assets/play_arrow_black_24dp.svg
Normal file
1
assets/play_arrow_black_24dp.svg
Normal 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 |
125
buzz/widgets/audio_player.py
Normal file
125
buzz/widgets/audio_player.py
Normal 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)
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue