2024-07-28

GNOME デスクトップから自作アプリを起動

GNOME は、Wayland あるいは X Window System 上で動作するデスクトップ環境です。長年、Linux をメインの OS として愛用していますが、概ね Fedora LinuxRHEL など、Red Hat 系の Linux ディストロを使っているので、普段使いのデスクトップ環境はずっと GNOME です。

自分専用の GUI アプリを PySide6 (Qt for Python) で作成して使っていますが、アプリの起動にいちいち端末エミュレータを起動してディレクトリを移動して~…、なんていう操作をするのが面倒なので、ある程度アプリが出来上がったらデスクトップアプリとして GNOME のアプリ一覧の画面に登録しています。

今回は、自分専用アプリ(GUI アプリでなくと対応可能)を GNOME デスクトップへ登録する方法をまとめました。

今回のテーマ
  • 自分向けに作った GUI アプリを、ローカルアカウント内で GNOME デスクトップ・アプリとして登録して、アプリ一覧から起動できるようにする。
    • 配布するパッケージではないので、アプリの場所などをハードコーディングすることを許容するものとします。

なお、本ブログで説明する内容は、下記の Github のサイトからダウンロードできます。

スクショ用に下記の OS 環境で動作確認をしていますが、いまどきの GNOME デスクトップ環境であれば、同様に動作すると思います。

Fedora Workstation 40 x86_64
GNOME 46
Python 3.12.4

なお、KDE Plasma デスクトップ環境も GNOME 同様に FreeDesktop.org が策定する規格に準拠していますので、やはり同じように自作アプリを登録できます。

アプリの作成場所

アプリを開発する場所は人それぞれでしょうが、ここでは $HOME/MyProjects 以下に個々のアプリ用のディレクトリを作成するものとします。今回のアプリは、my_desktop_app というディレクトリを作成して、その中で作成します。

bitwalk@fedora:~$ cd MyProjects
bitwalk@fedora:~/MyProjects$ mkdir my_desktop_app
bitwalk@fedora:~/MyProjects$ cd my_desktop_app
bitwalk@fedora:~/MyProjects/my_desktop_app$ pwd
/home/bitwalk/MyProjects/my_desktop_app

最後に pwd コマンドで確認したように、GUI アプリを作成する場所は、ここでは /home/bitwalk/MyProjects/my_desktop_app になります。以降、このフルパスが随所に出現しますが、お使いの環境に応じて適宜読み替えるようにしてください。

GUI アプリは PySide6 で

ここでサンプルとして取り上げる GUI アプリは PySide6 で作成したものとします。メインプログラムかつ、今回は唯一のプログラムファイルは app.py です。デスクトップに登録するためのサンプルですので、ごく簡単に Hello World タイプのプログラムにしました。

app.py
import platform
import sys
import PySide6
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QApplication,
QLabel,
QMainWindow,
)
class Hello(QMainWindow):
def __init__(self):
super().__init__()
icon = QIcon('app.png')
self.setWindowIcon(icon)
self.setWindowTitle('Hello World!')
print('> Platform', platform.platform())
print('> Python', sys.version)
print('> PySide', PySide6.__version__)
label = QLabel('こんにちは、世界!')
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setCentralWidget(label)
def main():
app = QApplication(sys.argv)
hello = Hello()
hello.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()
view raw app.py hosted with ❤ by GitHub

今回利用する Python モジュールは PySide6 だけですが、requirements.txt に記載して pip コマンドで一括複数のモジュールをインストールできるようにしておきます。

requirements.txt
pyside6

アプリが使用するアイコンイメージは、とりあえず下記のイメージを利用しています。下記イメージを app.png という名前で /home/bitwalk/MyProjects/my_desktop_app 内に保存しておきます。

アイコンに使用する app.png(例)

Python の仮想環境 venv を利用

ここでは Python の仮想環境 venv を利用して pip コマンドで必要なモジュールをローカルディレクトリにインストールして利用することにします。pip モジュールも最新のバージョンにしておきます。

bitwalk@fedora:~/MyProjects/my_desktop_app$ python3 -m venv venv
bitwalk@fedora:~/MyProjects/my_desktop_app$ source venv/bin/activate
(venv) bitwalk@fedora:~/MyProjects/my_desktop_app$ pip install -r requirements.txt
Collecting pyside6 (from -r requirements.txt (line 1))
  Downloading PySide6-6.7.2-cp39-abi3-manylinux_2_28_x86_64.whl.metadata (5.3 kB)
...
(途中省略)
...
Downloading shiboken6-6.7.2-cp39-abi3-manylinux_2_28_x86_64.whl (188 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 189.0/189.0 kB 9.7 MB/s eta 0:00:00
Installing collected packages: shiboken6, PySide6-Essentials, PySide6-Addons, pyside6
Successfully installed PySide6-Addons-6.7.2 PySide6-Essentials-6.7.2 pyside6-6.7.2 shiboken6-6.7.2

[notice] A new release of pip is available: 23.3.2 -> 24.1.2
[notice] To update, run: pip install --upgrade pip
(venv) $ pip install --upgrade pip
Requirement already satisfied: pip in ./venv/lib64/python3.12/site-packages (23.3.2)
Collecting pip
  Downloading pip-24.1.2-py3-none-any.whl.metadata (3.6 kB)
Downloading pip-24.1.2-py3-none-any.whl (1.8 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.8/1.8 MB 11.0 MB/s eta 0:00:00
Installing collected packages: pip
  Attempting uninstall: pip
    Found existing installation: pip 23.3.2
    Uninstalling pip-23.3.2:
      Successfully uninstalled pip-23.3.2
Successfully installed pip-24.1.2
(venv) bitwalk@fedora:~/MyProjects/my_desktop_app$ deactivate
bitwalk@fedora:~/MyProjects/my_desktop_app$

最後に、deactivate コマンドで Python の仮想環境から抜けます。

シェルスクリプトと desktop ファイル

アプリを起動するシェルスクリプトを用意します。

GUI アプリがバイナリ、あるいはシステムの Python (python3) で起動できるアプリであれば、このシェルスクリプトは不要で、次に説明する desktop ファイル Exec エントリに直接記載できます。

今回は、Python の venv 仮想環境を利用することを前提にしていますので、シェルスクリプトで一旦 venv のあるディレクトリ(=シェルスクリプトが存在するディレクトリ)へ移動して、仮想環境をアクティブにして、同じディレクトリにあるメインプログラム (app.py) を実行するようにしています。

app.sh
#!/bin/bash
cd "$(dirname ${0})" || exit
source venv/bin/activate
python app.py

GNOME のアプリ一覧に登録するための desktop ファイルを用意します。Exec エントリに、先に用意したシェルスクリプト app.shIcon エントリに、アプリ一覧に表示するアイコン画像 app.png をフルパスで指定しています。お使いの環境に応じて変更してください。

フルパスで指定しなくとも、せめて $HOME などの環境変数で一部を指定できないか試しましたが、うまくいきませんでした。やりかたが間違っているだけかもしれませんので、何か判ったら追記するようにします。

app.desktop
[Desktop Entry]
Name=Hello World!
Name[ja]=こんにちは、世界!
Comment=Sample GUI
Keywords=Hello World!;
Keywords[ja]=こんにちは、世界!;
Exec=/home/bitwalk/MyProjects/my_desktop_app/app.sh
Icon=/home/bitwalk/MyProjects/my_desktop_app/app.png
Terminal=false
Type=Application
StartupNotify=true
Categories=Utility;

GUI アプリでなく、コマンドラインでやりとりする CUI アプリの場合、あるいはデバッグ目的で出力する内容を確認したい場合は Terminal エントリを true にするとアプリの実行時に端末エミュレータが表示されます。アプリの実行が終了すると同時に端末エミュレータも閉じます。

シェルスクリプトの動作確認

先ほど作成したシェルスクリプトのファイル app,sh に実行権限を付与後、ホームディレクトリにワーキングディレクトリを移して、スクリプトの動作確認をします。

bitwalk@fedora:~/MyProjects/my_desktop_app$ chmod +x app.sh
bitwalk@fedora:~/MyProjects/my_desktop_app$ cd ~/
bitwalk@fedora:~$ MyProjects/my_desktop_app/app.sh

以下のような GUI アプリのウィンドウが表示されれば正しく動作しています。

app.sh の実行例

デスクトップアプリの登録

準備した desktop ファイルを所定の場所 $HOME/.local/share/applications/ 内にコピーすれば、アプリ一覧に登録されるのですが、desktop ファイルの内容を変更する度にコピーし直すのは面倒ですので、以下のようにシンボリックリンクを貼っておきます。

$ ln -s /home/bitwalk/MyProjects/my_desktop_app/app.desktop /home/bitwalk/.local/share/applications/app.desktop

問題がなければ、GNOME のアプリ一覧画面に直ちに反映されます。

アプリ一覧に追加された「こんにちは、世界!」

今回はサンプルということで desktop ファイル名を app.desktop としましたが、現実的には、いくつも登録することを考慮して一意なファイル名にする必要があります。

参考サイト

  1. そのシェルスクリプトのあるディレクトリに移動する #ShellScript - Qiita [2013-06-28]
  2. Desktop Entry Specification

 

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

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



2024-07-21

QMediaPlayer によるサウンドプレーヤー ~ PySide6

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

本ブログ記事 [1] では、QSoundEffect クラスの使い方を覚えようと、シンプルな Wav Player なるものを作ってみました。しかし、シンプルなだけあって、再生の進捗を表示できないのが不満です。

そこで、同じようなサンプルを QMediaPlayer クラスを利用して作ってみました。今回は再生の進捗をモニターできるようにして、更に MP3 のファイルを選択できるようにしました。

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

RHEL 9.4 x86_64
Python 3.12.1
PySide6 6.7.2

サンプルを以下に示しました。今回も 200 行を越える長めのサンプルになってしまいました。

qt_mediaplayer_sound.py
import sys
from PySide6.QtCore import QUrl, Signal
from PySide6.QtGui import QIcon
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
from PySide6.QtWidgets import (
QApplication,
QDial,
QFileDialog,
QLineEdit,
QMainWindow,
QPlainTextEdit,
QProgressBar,
QStatusBar,
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):
soundSelected = Signal(str)
soundPlay = Signal()
soundStop = Signal()
soundVolume = Signal(float)
def __init__(self):
super().__init__()
but_folder = QToolButton()
but_folder.setToolTip('Choose sound 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 sound 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 sound 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.soundVolume.emit(value / 100.)
def file_dialog(self):
dialog = QFileDialog()
dialog.setNameFilter('Sound files (*.wav *.mp3)')
if dialog.exec():
filename = dialog.selectedFiles()[0]
self.but_play.setEnabled(True)
self.entry.setText(filename)
self.soundSelected.emit(filename)
def getVolume(self) -> float:
return self.dial.value() / 100.
def wav_play(self):
self.playStart()
self.soundPlay.emit()
def wav_stop(self):
self.playEnd()
self.soundStop.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 MyStatusBar(QStatusBar):
def __init__(self):
super().__init__()
self.progress = progress = QProgressBar()
self.addWidget(progress, stretch=True)
def clearProgress(self):
self.setProgress(0)
def setDuration(self, maximum: int):
self.progress.setRange(0, maximum)
def setProgress(self, progress: int):
self.progress.setValue(progress)
class MySoundPlayer(QMainWindow):
def __init__(self):
super().__init__()
icon_win = get_icon(self, 'SP_TitleBarMenuButton')
self.setWindowIcon(icon_win)
self.setWindowTitle('Sound Player')
self.toolbar = toolbar = MyToolBar()
toolbar.soundSelected.connect(self.source_selected)
toolbar.soundPlay.connect(self.sound_play)
toolbar.soundStop.connect(self.sound_stop)
toolbar.soundVolume.connect(self.set_volume)
self.addToolBar(toolbar)
self.statusbar = statusbar = MyStatusBar()
self.setStatusBar(statusbar)
self.pte = pte = QPlainTextEdit()
pte.setReadOnly(True)
pte.setStyleSheet('QPlainTextEdit {background-color: white;}')
self.setCentralWidget(pte)
self.output = output = QAudioOutput()
output.setVolume(toolbar.getVolume())
output.volumeChanged.connect(self.volume_changed)
self.player = player = QMediaPlayer()
player.setAudioOutput(output)
player.durationChanged.connect(self.duration_changed)
player.positionChanged.connect(self.position_changed)
player.playbackStateChanged.connect(self.playback_state_changed)
player.sourceChanged.connect(self.source_changed)
def add_msg(self, msg: str):
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 duration_changed(self, duration: int):
self.statusbar.setDuration(duration)
def position_changed(self, position: int):
self.statusbar.setProgress(position)
def playback_state_changed(self, status: QMediaPlayer.PlaybackState):
if status == QMediaPlayer.PlaybackState.PausedState:
self.add_msg('Paused')
elif status == QMediaPlayer.PlaybackState.PlayingState:
self.add_msg('Playing')
elif status == QMediaPlayer.PlaybackState.StoppedState:
self.statusbar.clearProgress()
self.add_msg('Stopped')
else:
self.add_msg('Unknown status')
def set_volume(self, volume: float):
self.output.setVolume(volume)
def source_changed(self):
qurl: QUrl = self.player.source()
self.add_msg('Sound file: %s' % qurl.fileName())
def source_selected(self, file: str):
self.player.setSource(QUrl.fromLocalFile(file))
def sound_play(self):
self.player.play()
def sound_stop(self):
self.player.stop()
def volume_changed(self, volume: float):
self.add_msg('Volume = %.2f' % volume)
def main():
app = QApplication(sys.argv)
ex = MySoundPlayer()
ex.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

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

qt_mediaplayer_sound.py の実行例

Amazon.co.jp から購入・ダウンロードした MP3 ファイルを再生してみました。

サンプルの説明

サウンド・ファイルを読み込み、PLAY/STOP したり、ボリューム調整をする操作は、すべて QToolBar クラスを継承した MyToolBar クラスにまとめています。QSoundEffect を利用したサンプル [1]  で使用したクラスとほぼ同じですが、WAV ファイルの他に MP3 ファイルも選択できるようにしました。

class MyToolBar(QToolBar):
    soundSelected = Signal(str)
    soundPlay = Signal()
    soundStop = Signal()
    soundVolume = Signal(float)
 
    def __init__(self):
        super().__init__()
 
        but_folder = QToolButton()
        but_folder.setToolTip('Choose sound file.')
        ico_folder = get_icon(self, 'SP_DirIcon')
        but_folder.setIcon(ico_folder)
        but_folder.clicked.connect(self.file_dialog)
        self.addWidget(but_folder)
 
        (途中省略)
 
    def file_dialog(self):
        dialog = QFileDialog()
        dialog.setNameFilter('Sound files (*.wav *.mp3)')
        if dialog.exec():
            filename = dialog.selectedFiles()[0]
            self.but_play.setEnabled(True)
            self.entry.setText(filename)
            self.soundSelected.emit(filename)
 
        (以下省略)

QStatusBar クラスを継承した MyStatusBar を用意して、QProgressBar クラスのインスタンスで再生時間の進捗を表示するようにしています。

class MyStatusBar(QStatusBar):
    def __init__(self):
        super().__init__()
        self.progress = progress = QProgressBar()
        self.addWidget(progress, stretch=True)
 
    def clearProgress(self):
        self.setProgress(0)
 
    def setDuration(self, maximum: int):
        self.progress.setRange(0, maximum)
 
    def setProgress(self, progress: int):
        self.progress.setValue(progress)

QMediaPlayer のインスタンス self.player を扱うのは、QMainWindow クラスを継承した MySoundPlayer クラスです。QSoundEffect を利用したサンプル [1]  と同様に、操作イベントのログ表示もしています。

class MySoundPlayer(QMainWindow):
    def __init__(self):
        super().__init__()
        icon_win = get_icon(self, 'SP_TitleBarMenuButton')
        self.setWindowIcon(icon_win)
        self.setWindowTitle('Sound Player')
 
        self.toolbar = toolbar = MyToolBar()
        toolbar.soundSelected.connect(self.source_selected)
        toolbar.soundPlay.connect(self.sound_play)
        toolbar.soundStop.connect(self.sound_stop)
        toolbar.soundVolume.connect(self.set_volume)
        self.addToolBar(toolbar)
 
        self.statusbar = statusbar = MyStatusBar()
        self.setStatusBar(statusbar)
 
        self.pte = pte = QPlainTextEdit()
        pte.setReadOnly(True)
        pte.setStyleSheet('QPlainTextEdit {background-color: white;}')
        self.setCentralWidget(pte)
 
        self.output = output = QAudioOutput()
        output.setVolume(toolbar.getVolume())
        output.volumeChanged.connect(self.volume_changed)
 
        self.player = player = QMediaPlayer()
        player.setAudioOutput(output)
        player.durationChanged.connect(self.duration_changed)
        player.positionChanged.connect(self.position_changed)
        player.playbackStateChanged.connect(self.playback_state_changed)
        player.sourceChanged.connect(self.source_changed)
         
        (以下省略)

QMediaPlayer クラスでは、オーディオの出力先に QAudioOutput クラスのインスタンスを指定しています。ここが QSoundEffect クラスの使い方との大きな違いです。なお、ボリューム(音量)の調節は QAudioOutput クラス側でおこないます。

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

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

今回はシンプルさを優先して、ひとつのファイルを再生するだけの仕様にしましたが、自分用にカスタマイズした音楽プレーヤーアプリを作ることができるでしょう。

参考サイト

  1. bitWalk's: QSoundEffect を利用した Wav Player ~ PySide6 [2024-07-20]
  2. QMediaPlayer - Qt for Python
  3. QAudioOutput - Qt for Python

 

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

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



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技術ブログ オープンソースへ
にほんブログ村

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



2024-07-12

SciPy を利用した数値積分

SciPy は Python のための科学的ツールのオープンソース・ライブラリとして開発されています。SciPyは、NumPy をベースにしていて、統計、最適化、積分、線形代数、フーリエ変換、信号・イメージ処理、遺伝的アルゴリズム、ODE (常微分方程式) ソルバ、特殊関数、その他のモジュールを提供しています。

Wikipedia より引用、編集

時系列のトレンドデータを解析する際に、ある区間を数値積分して、その(符号付きの)面積を評価したい場合があります。こういう場合、その面積の精度はそこそこで良いし、面積の単位とかを問題にするわけでもないので、前提条件を決めて数値積分するルーチンを自前で作成して使っていましたが、SciPy に数値積分するパッケージがあるので利用してみました。

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

RHEL 9.4 x86_64
Python 3.12.1
jupyterlab 4.2.3
matplotlib 3.9.1
numpy 2.0.0
pandas 2.2.2
scipy 1.14.0

サンプルデータ

今回使用するデモ用のサンプルは、気象庁の過去の気象データ・ダウンロード・サイト [1] からダウンロードした、東京における 2023 年の気温データです。利用しやすいように加工しました。下記に CSV ファイルをダウンロードできるようにしました。

2023_temp_data.csv

以下、JupyterLab で確認しています。

CSV データの読み込み

下記のようにして、CSV ファイルを Pandas のデータファイルとして読み込みます。

import pandas as pd
 
file = '2023_temp_data.csv'
df = pd.read_csv(file, index_col=0, parse_dates=True)
df

データフレームのインデックスに日時列を割り当てています。一時間毎の気温データです。

データの視覚化

Matplotlib でデータを視覚化します。数値積分は下記プロットのオレンジ色の領域の(符号付き)面積を求めることになります。

import matplotlib.pyplot as plt
 
plt.rcParams["figure.figsize"] = (10,3)
fig, ax = plt.subplots()
 
ax.plot(df, lw=0.5, c='C0')
ax.fill_between(df.index, df['Temperature'], color='C1', alpha=0.1)
ax.set_xlabel('Date / Hour')
ax.set_ylabel('Temperature [degC]')
ax.grid()
 
plt.savefig('temperature_002.png')
plt.show()

積分する値の見積り

一年分の気温データですので、気温の平均値に一年 356 日を乗じてどの程度の量になるか見積もります。

import numpy as np
 
area_est = np.mean(df['Temperature']) * 365
print('mean * 365 days : %.3f' % area_est)
mean * 365 days : 6459.058

この計算だと大体 6000 台の数量です。平均値を使って算出したこの数量は、案外、積分する値の妥当な推定値になっているかもしれませんが…、とにかく数値積分をしてみましょう。

データフレームのデータは一時間毎のデータですので、数値積分した数量が、平均値と日数で見積もった数量と同程度のオーダーになるようにします。具体的には、日時を UNIX タイムスタンプ(秒)に変換し、これを 24 x 60 x 60 で割って、秒から日へ大きさを調整します。この数列を x とします。

y はもちろん気温データ列になります。

x = np.array([t.timestamp() / (24 * 60 * 60) for t in df.index])
y = np.array(df['Temperature'])

SciPy による数値積分

SciPy の integrate パッケージで利用できる、台形公式 (integrate.trapezoid)、シンプソンの公式 (integrate.simpson) それぞれの方法で数値積分をしました。

from scipy import integrate
 
area1 = integrate.trapezoid(y, x=x)
print('Trapezoidal rule : %.3f' % area1)
 
area2 = integrate.simpson(y, x=x)
print('Simpson\'s rule   : %.3f' % area2)
Trapezoidal rule : 6458.052
Simpson's rule   : 6458.646

今回の1万個に満たないデータ数では、台形公式とシンプソンの公式で計算時間に差異を感じませんでした。どちらの方法でも概ね同じ結果が出ていますが、用途によっては精度が必要な場合もあるでしょう。頻繁に利用する場合には、必要な精度と計算量(リソース)を評価して、どちらかに決める必要があります。

参考サイト

  1. 気象庁|過去の気象データ・ダウンロード
  2. trapezoid — SciPy Manual
  3. simpson — SciPy Manual

 

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

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



2024-07-07

動的に Matplotlib のチャートを利用する (2) ~ PySide6

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

本ブログ記事 [1] で紹介した Matplotlib のチャートに逐次、データを追加するサンプルを、PySide6 の GUI に移したので、備忘録としてブログ記事にしました。

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

RHEL 9.4 x86_64
Python 3.12.1
matplotlib 3.9.0

サンプルを以下に示しました。

qt_matplotlib_trend_realtime.py
import math
import numpy as np
import sys
from PySide6.QtCore import QTimer, Qt
from PySide6.QtWidgets import QApplication, QMainWindow
from matplotlib.backends.backend_qtagg import (
FigureCanvasQTAgg as FigureCanvas,
NavigationToolbar2QT as NavigationToolbar,
)
from matplotlib.figure import Figure
class MyTrend(FigureCanvas):
def __init__(self, count_max: int, legend_label: str):
self.fig = Figure()
super().__init__(self.fig)
self.ax = ax = self.fig.add_subplot(111)
self.ax.set(title='Sample')
self.ax.set_xlabel('X')
self.ax.set_ylabel('Y')
self.ax.set_xlim(0, count_max)
self.ax.set_ylim(-1, 1)
self.ax.grid(True)
self.line, = self.ax.plot([], [], label=legend_label)
self.ax.legend(loc='best')
def add_data(self, x: float, y: float):
x_array = np.append(self.line.get_xdata(), [x])
y_array = np.append(self.line.get_ydata(), [y])
self.line.set_xdata(x_array)
self.line.set_ydata(y_array)
self.fig.canvas.draw_idle()
self.fig.canvas.flush_events()
class Example(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle('Trend Sample')
self.resize(600, 400)
self.count = 0
self.count_max = 500
self.chart = chart = MyTrend(
count_max=self.count_max,
legend_label='TEST'
)
self.setCentralWidget(chart)
toolbar = NavigationToolbar(chart, self)
self.addToolBar(
Qt.ToolBarArea.BottomToolBarArea,
toolbar
)
self.timer = timer = QTimer(self)
timer.timeout.connect(self.update_data)
timer.start(10)
def update_data(self):
if self.count > self.count_max:
self.timer.stop()
print('Completed!')
return
x = float(self.count)
y = math.sin(x * 0.1)
self.chart.add_data(x, y)
self.count += 1
def main():
app = QApplication(sys.argv)
ex = Example()
ex.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()

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

qt_matplotlib_trend_realtime.py の実行例

最近、リアルタイムでデータを取得、統計処理をして可視化することに取り組んでいます。一歩前進できた気分です。

参考サイト

  1. bitWalk's: 動的に Matplotlib のチャートを利用する [2024-07-04]

 

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

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



2024-07-04

動的に Matplotlib のチャートを利用する

Matplotlib は、Python と NumPy のためのプロットライブラリです。Tkinter、wxPython、Qt、GTK のような汎用 GUI ツールキットを使ったアプリケーションにプロットを埋め込むためのオブジェクト指向 API を提供しています。

Wikipedia より引用、翻訳

Matplotlib で時系列データを扱うとき、ある時間間隔でデータ点を追加してチャートに反映させたい場合があります。

Matplotlib のチャート作成速度は十分高速ですので、単純なトレンドチャートであれば、一旦チャートを消去して新たなデータが追加されたデータセットで描画し直しても、十分実用レベルで利用できます。しかし、このようにチャートを書き直す方法は、プログラミング的には無駄に計算リソースを食い潰しているようで好きではありません。

トレンドチャートでデータ点を追加するのにもう少しスマートな方法がないか探していたところ、参考サイト [1] に、トレンド線の matplotlib.lines.Line2D オブジェクトのデータを更新して描画し直す方法が紹介されていました。この方法でも結局のところ新しく線を引き直しているのですが、それでもチャート全体を描画し直すよりはマシです。

参考サイト [1] で紹介されていたサンプルは、自分がやりたいこととは少々違っていましたので、紹介されていたサンプルを元にして直感的に判りやすいと思われるサンプルを作りました。

今回のテーマ
  • Matplotlib の plot によるトレンドチャートで、データ点を逐次追加したい。

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

RHEL 9.4 x86_64
Python 3.12.1
matplotlib 3.9.0

サンプルを以下に示しました。

matplotlib_set_data.py
# Reference:
# https://stackoverflow.com/questions/58186783/updating-matplotlib-figures-in-real-time-for-data-acquisition
import matplotlib.pyplot as plt
import numpy
if __name__ == '__main__':
x_max = 500
fig = plt.figure()
ax = fig.gca()
ax.set_xlim(0, x_max)
ax.set_ylim(-1, 1)
plt.grid()
line, = plt.plot([], [], label='TEST')
ax.legend(loc='best')
for i in range(x_max):
x = float(i)
y = numpy.sin(x * 0.1)
line.set_xdata(numpy.append(line.get_xdata(), [x]))
line.set_ydata(numpy.append(line.get_ydata(), [y]))
fig.canvas.draw_idle()
fig.canvas.flush_events()
# adjust pause duration here
plt.pause(0.01)
print("DONE")
plt.show()

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

matplotlib_set_data.py の実行例

サンプルの説明

まず、matplotlib.pyplot.plot で空のチャートを作成します。凡例用の文字列を 'TEST' としました。

plot の返り値は matplotlib.lines.Line2D のリストですので、その最初の要素(オブジェクト)を line として保持します。

line, = plt.plot([], [], label='TEST')

データを逐次追加するデモのため、ここでは、500 回のループを実行しています。ループの内容は次のとおりです。

新しいデータ点 (x, y) を生成します(デモ用に正弦関数を使っています)。

この新たなデータ点を追加する際、現行の x, y データ列を line オブジェクトの get_xdataget_ydata メソッドで取り出し、新たな x, y データを追加して set_xdataset_ydata メソッドで設定し直します。

line.set_xdata(numpy.append(line.get_xdata(), [x]))
line.set_ydata(numpy.append(line.get_ydata(), [y]))
 
fig.canvas.draw_idle()
fig.canvas.flush_events()

最後の二行は使用経験が浅く、詳しく説明できないので、マニュアルから抜き出した説明だけです。

  • fig.canvas.draw_idle() は、制御が GUI のイベントループに戻ったら、ウィジェットの再描画を要求します。
  • fig.canvas.flush_events() は、GUI のイベントをフラッシュ(消去)します。

最後に matplotlib.pyplot.pause で 0.01 秒のイベントループを実行します。

plt.pause(0.01)

違うサンプルをもう少し作って、使い方を咀嚼できたら、次のステップとして PySide6 の GUI に組み込んでみます。

参考サイト

  1. python - Updating matplotlib figures in real time for data acquisition - Stack Overflow [2019-10-01]
  2. matplotlib.pyplot.plot — Matplotlib documentation
  3. matplotlib.lines.Line2D — Matplotlib documentation
  4. matplotlib.backend_bases — Matplotlib documentation
  5. matplotlib.pyplot.pause — Matplotlib documentation

 

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

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