2024-07-20

QSoundEffect を利用した Wav Player ~ PySide6

PySide (Qt for Python) は、Qt(キュート)の Python バインディングで、GUI などを構築するためのクロスプラットフォームなライブラリです。Linux/X11, macOS および Microsoft Windows をサポートしています。配布ライセンスは LGPL で公開されています。

Wikipedia によると、wav を拡張子に持つファイルは、Microsoft とIBM により開発された RIFF waveform Audio Format (WAV) という音声ファイルフォーマットです。クリックやチャイムなど、効果音に利用されている比較的小さなサイズのファイルです。

PySide6 の QSoundEffect クラスは、マニュアル [1] によると、WAV ファイルのような低レイテンシな非圧縮のオーディオファイルを再生することができ、ユーザーのアクションに応答する「フィードバック 」タイプ(クリック音)のサウンド、例えば、仮想キーボードのクリック音、ポップアップダイアログの Yes / No の応答、あるいはゲームの効果音用途に適しているとあります。

さらに、低レイテンシが重要でない場合には、代わりに QMediaPlayer クラスの使用を検討してくださいともあります。

まずは QSoundEffect クラスの使い方を覚えようと、シンプルな Wav Player なるものを作ってみました。

下記の OS 環境で動作確認をしています。

RHEL 9.4 x86_64
Python 3.12.1
PySide6 6.7.2

なお、QSoundEffect クラスが含まれる QtMultimedia パッケージを利用するために、本 RHEL では追加で libva をインストールする必要がありました。

[bitwalk@rhel9 ~]$ rpm -qa | grep libva
libva-2.20.0-1.el9.x86_64
libva-devel-2.20.0-1.el9.x86_64
[bitwalk@rhel9 ~]$

サンプルを以下に示しました。ひとつにまとめたところ、200 行を越える長めのサンプルになってしまいました。

qt_soundeffect.py
import sys
from PySide6.QtCore import Signal, QUrl
from PySide6.QtGui import QIcon
from PySide6.QtMultimedia import QSoundEffect
from PySide6.QtWidgets import (
QApplication,
QDial,
QFileDialog,
QLineEdit,
QMainWindow,
QPlainTextEdit,
QStyle,
QToolBar,
QToolButton,
)
def get_icon(parent, name: str) -> QIcon:
pixmap = getattr(QStyle.StandardPixmap, name)
icon = parent.style().standardIcon(pixmap)
return icon
class MyToolBar(QToolBar):
wavSelected = Signal(str)
wavPlay = Signal()
wavStop = Signal()
wavVolume = Signal(float)
def __init__(self):
super().__init__()
but_folder = QToolButton()
but_folder.setToolTip('Choose wav file.')
ico_folder = get_icon(self, 'SP_DirIcon')
but_folder.setIcon(ico_folder)
but_folder.clicked.connect(self.file_dialog)
self.addWidget(but_folder)
self.but_play = but_play = QToolButton()
but_play.setToolTip('Start playing wav file.')
ico_play = get_icon(self, 'SP_MediaPlay')
but_play.setIcon(ico_play)
but_play.setEnabled(False)
but_play.clicked.connect(self.wav_play)
self.addWidget(but_play)
self.but_stop = but_stop = QToolButton()
but_stop.setToolTip('Stop playing wav file.')
ico_stop = get_icon(self, 'SP_MediaStop')
but_stop.setIcon(ico_stop)
but_stop.setEnabled(False)
but_stop.clicked.connect(self.wav_stop)
self.addWidget(but_stop)
self.addSeparator()
self.entry = entry = QLineEdit()
entry.setStyleSheet("""
QLineEdit {margin-left: 5; padding: 0 5 0 5;}
QLineEdit:disabled {background-color: white;}
""")
entry.setEnabled(False)
self.addWidget(entry)
self.dial = dial = QDial()
dial.setToolTip('Adjust sound volume.')
dial.setFixedSize(32, 32)
dial.setMinimum(0)
dial.setMaximum(100)
dial.setValue(25)
dial.valueChanged.connect(self.change_dial)
self.addWidget(dial)
def change_dial(self, value: int):
self.wavVolume.emit(value / 100.)
def file_dialog(self):
dialog = QFileDialog()
dialog.setNameFilter('wav file (*.wav)')
if dialog.exec():
filename = dialog.selectedFiles()[0]
self.but_play.setEnabled(True)
self.entry.setText(filename)
self.wavSelected.emit(filename)
def getVolume(self) -> float:
return self.dial.value() / 100.
def wav_play(self):
self.playStart()
self.wavPlay.emit()
def wav_stop(self):
self.playEnd()
self.wavStop.emit()
def playStart(self):
self.but_play.setEnabled(False)
self.but_stop.setEnabled(True)
def playEnd(self):
self.but_play.setEnabled(True)
self.but_stop.setEnabled(False)
class MyWavPlayer(QMainWindow):
def __init__(self):
super().__init__()
icon_win = get_icon(self, 'SP_TitleBarMenuButton')
self.setWindowIcon(icon_win)
self.setWindowTitle('Wav Player')
self.effect = None
self.toolbar = toolbar = MyToolBar()
toolbar.wavSelected.connect(self.source_selected)
toolbar.wavPlay.connect(self.sound_play)
toolbar.wavStop.connect(self.sound_stop)
toolbar.wavVolume.connect(self.set_volume)
self.addToolBar(toolbar)
self.pte = pte = QPlainTextEdit()
pte.setReadOnly(True)
pte.setStyleSheet('QPlainTextEdit {background-color: white;}')
self.setCentralWidget(pte)
def add_msg(self, msg: str):
# Reference:
# https://stackoverflow.com/questions/14550146/qtextedit-scroll-down-automatically-only-if-the-scrollbar-is-at-the-bottom
scr = self.pte.verticalScrollBar()
scr_at_bottom = (scr.value() >= (scr.maximum() - 4))
scr_prev_value = scr.value()
self.pte.insertPlainText('%s\n' % msg)
if scr_at_bottom:
self.pte.ensureCursorVisible()
else:
self.pte.verticalScrollBar().setValue(scr_prev_value)
def create_sound_effect(self, wav_file: str):
self.effect = QSoundEffect()
self.effect.loopsRemainingChanged.connect(self.remaining_changed)
self.effect.sourceChanged.connect(self.source_changed)
self.effect.statusChanged.connect(self.status_changed)
self.effect.volumeChanged.connect(self.volume_changed)
self.effect.setSource(QUrl.fromLocalFile(wav_file))
self.effect.setVolume(self.toolbar.getVolume())
def remaining_changed(self):
if self.effect.loopsRemaining() == 0:
qurl: QUrl = self.effect.source()
msg = 'End playing "%s".' % qurl.fileName()
self.add_msg(msg)
self.toolbar.playEnd()
def set_volume(self, volume: float):
if self.effect is not None:
self.effect.setVolume(volume)
def source_changed(self):
qurl: QUrl = self.effect.source()
self.add_msg('Wav file: %s' % qurl.fileName())
def source_selected(self, wav_file: str):
self.create_sound_effect(wav_file)
def status_changed(self):
if self.effect.status() == QSoundEffect.Status.Loading:
msg = 'Loading'
elif self.effect.status() == QSoundEffect.Status.Ready:
msg = 'Ready'
elif self.effect.status() == QSoundEffect.Status.Error:
msg = 'Error'
elif self.effect.status() == QSoundEffect.Status.Null:
msg = 'Null'
else:
msg = 'Unknown'
self.add_msg(msg)
def sound_play(self):
qurl: QUrl = self.effect.source()
msg = 'Start playing "%s".' % qurl.fileName()
self.add_msg(msg)
self.effect.play()
def sound_stop(self):
qurl: QUrl = self.effect.source()
msg = 'Stop playing "%s".' % qurl.fileName()
self.add_msg(msg)
self.effect.stop()
def volume_changed(self):
msg = 'Volume = %.2f' % self.effect.volume()
self.add_msg(msg)
def main():
app = QApplication(sys.argv)
ex = MyWavPlayer()
ex.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

サンプルの実行例を下記に示しました。

qt_soundeffect.py の実行例

WAV ファイルは、参考サイト [2] からダウンロードできるファイルを利用させていただきました。

サンプルの説明

WAV ファイルを読み込み、PLAY/STOP したり、ボリューム調整をする操作は、すべて QToolBar クラスを継承した MyToolBar クラスにまとめています。

class MyToolBar(QToolBar):
    wavSelected = Signal(str)
    wavPlay = Signal()
    wavStop = Signal()
    wavVolume = Signal(float)
 
    def __init__(self):
        super().__init__()
 
        but_folder = QToolButton()
        but_folder.setToolTip('Choose wav file.')
        ico_folder = get_icon(self, 'SP_DirIcon')
        but_folder.setIcon(ico_folder)
        but_folder.clicked.connect(self.file_dialog)
        self.addWidget(but_folder)
 
        self.but_play = but_play = QToolButton()
        but_play.setToolTip('Start playing wav file.')
        ico_play = get_icon(self, 'SP_MediaPlay')
        but_play.setIcon(ico_play)
        but_play.setEnabled(False)
        but_play.clicked.connect(self.wav_play)
        self.addWidget(but_play)
(以下省略)

QSoundEffect のインスタンスを扱うのは、QMainWindow クラスを継承した MyWavPlayer クラスです。ここでは QPlainTextEdit クラスを利用して、操作イベントのログ表示もしています。

class MyWavPlayer(QMainWindow):
    def __init__(self):
        super().__init__()
        icon_win = get_icon(self, 'SP_TitleBarMenuButton')
        self.setWindowIcon(icon_win)
        self.setWindowTitle('Wav Player')
 
        self.effect = None
 
        self.toolbar = toolbar = MyToolBar()
        toolbar.wavSelected.connect(self.source_selected)
        toolbar.wavPlay.connect(self.sound_play)
        toolbar.wavStop.connect(self.sound_stop)
        toolbar.wavVolume.connect(self.set_volume)
        self.addToolBar(toolbar)
 
        self.pte = pte = QPlainTextEdit()
        pte.setReadOnly(True)
        pte.setStyleSheet('QPlainTextEdit {background-color: white;}')
 
        self.setCentralWidget(pte)
(以下省略)

なお、QSoundEffect のインスタンスに読み込んだ WAV ファイルの変更ができなかったので、別の WAV ファイルを読み込む度に create_sound_effect メソッドでインスタンスを作り直しています。

def create_sound_effect(self, wav_file: str):
    self.effect = QSoundEffect()
    self.effect.loopsRemainingChanged.connect(self.remaining_changed)
    self.effect.sourceChanged.connect(self.source_changed)
    self.effect.statusChanged.connect(self.status_changed)
    self.effect.volumeChanged.connect(self.volume_changed)
 
    self.effect.setSource(QUrl.fromLocalFile(wav_file))
    self.effect.setVolume(self.toolbar.getVolume())

PySide6 に組み込まれている標準のビットマップイメージ (Pixmap) を利用するために、名前を指定してアイコンを取得する関数を冒頭に記載しています。

def get_icon(parent, name: str) -> QIcon:
    pixmap = getattr(QStyle.StandardPixmap, name)
    icon = parent.style().standardIcon(pixmap)
    return icon

気づいたこと

QSoundEffect クラスのインスタンス effect について、二点、気になることがありました。

effect = QSoundEffect()

setSource メソッドで他の WAV ファイルを読み込んでも、再生されるサウンドに反映されなかった。

effect.setSource(QUrl.fromLocalFile(file))

再生ループで下記の無限ループの enum (QSoundEffect.Loop.Infinite) が利用できなかった。

effect.setLoopCount(QSoundEffect.Loop.Infinite)

使い方が間違っているのかもしれませんが、バグがもしれないので問い合わせてみることにします。

参考サイト

  1. QSoundEffect - Qt for Python
  2. フリーのBGMと動画やゲーム用音楽素材[Wave,MP3]

 

ブログランキング・にほんブログ村へ bitWalk's - にほんブログ村 にほんブログ村 IT技術ブログ オープンソースへ
にほんブログ村

オープンソース - ブログ村ハッシュタグ
#オープンソース



0 件のコメント: