オーディオプレイヤーを作る DAY4

## はじめに
Pythonを使ったオーディオプレイヤーの実装過程を順次投稿しています。
前回までの進捗は以下の通りです。

https://qiita.com/Jun-JP/items/ba0e131fb27f4e0e9031

https://qiita.com/Jun-JP/items/faa2e86b158d60db34ab

https://qiita.com/Jun-JP/items/365ee46ea6aeef6aa0b2

## 今回実装した内容

* GUI
    * オーディオ波形のリアルタイム表示
        * 波形自体はmatplolibを利用
    * 再生ボタンとポーズボタンの切り替え
* 機能
    * マルチスレッド処理の追加
        * オーディオ波形をリアルタイムに更新
        * オーディオプレイヤーの状態を監視

## ソースコード

https://github.com/junpiyo/Simple-Audio-Player-with-Python/tree/main/Day4

##  環境

Windows11
Python3.12.7

## 動作イメージ
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/483587/3484fc46-f9f8-c78e-57ae-4ffea0f25722.gif" width=60%>

音楽 [BGMer](https://bgmer.net)

## ソースコードの補足
### マルチスレッド処理
イベントループを処理するスレッド以外に、４つのスレッドを新たに生成するよう仕様を変更

* オーディオを再生するスレッド
* プログレスバーとタイム表示を更新する（ためのイベントを発生させる）スレッド
* オーディオ波形を更新するスレッド
* オーディオプレイヤーの状態を監視するスレッド

オーディオプレイヤーの状態を監視するスレッドは、オーディオを再生するスレッドを別プロセスで走らせるときに、プロセス間通信で利用予定（たぶん）。

```python: audio.py
class AudioPlayerState(IntEnum):
    PLAYING = auto()
    POSED = auto()
    READY = auto()
    NOT_READY = auto()
```
スレッドを協調して動作させるために、オーディオプレイヤーの状態を上記のIDで管理します。再生中ならAudioPlayerState.PLAYING、停止中ならAudioPlayerState.POSEDのように。アクセスは、Thread.Lock()を使って保護されます。

```python: simple_audio_player_ver0.4.py
    def __play(self):
        state = self._player.state
        if state == AudioPlayerState.PLAYING:
            return
        if state == AudioPlayerState.NOT_READY:
            return

        self._player.state = AudioPlayerState.PLAYING
        self._audio_pose_button.grid()
        self._audio_play_button.grid_remove()


        self.__thread_for_play = Thread(target=self._player.play, daemon=False)
        self.__thread_for_progress_bar = Thread(target=self.__update_audio_progress_bar_while_playing, daemon=False)
        self.__thread_for_audio_form = Thread(target=self.__update_audio_form_while_playing, daemon=False)
        self.__thread_for_state = Thread(target=self.__check_state_while_playing, daemon=False)
        self.__thread_for_play.start()
        self.__thread_for_progress_bar.start()
        self.__thread_for_audio_form.start()
        self.__thread_for_state.start()
```
再生ボタンが押されると上記の関数がコールバックされます。オーディオプレイヤーの状態をAudioPlayerState.PLAYINGに変更し、４つのスレッドを生成し、起動します。また、再生ボタンをポーズボタンに置き換えます。

```python: simple_audio_player_ver0.4.py
    def close(self): # kill living threads
        self._player.state = AudioPlayerState.NOT_READY
        while True:
            self.update()
            if self.__thread_for_play.is_alive():
                time.sleep(0.01)
                continue
            if self.__thread_for_progress_bar.is_alive():
                time.sleep(0.01)
                continue
            if self.__thread_for_audio_form.is_alive():
                time.sleep(0.01)
                continue
            if self.__thread_for_state.is_alive():
                time.sleep(0.01)
                continue
            break
```
スレッドを終了させるためには、上記の関数を呼びます。オーディオプレイヤーの状態をAudioPlayerState.NOT_READYに変更し、それぞれのスレッドが処理を終えるのを待ちます。アプリの初期化や終了時にも呼ばれます。

```python: simple_audio_player_ver0.4.py
    def __pose(self): # kill living threads
        state = self._player.state
        if state == AudioPlayerState.POSED:
            return
        if state == AudioPlayerState.NOT_READY:
            return
        if state == AudioPlayerState.PLAYING:
            self.close()
            self._player.state = AudioPlayerState.POSED
        self._audio_play_button.grid()
        self._audio_pose_button.grid_remove()
```
ポーズボタンが押されると上記の関数がコールバックされます。オーディオプレイヤーの状態がAudioPlayerState.PLAYINGだった場合、スレッドを終了し、AudioPlayerState.POSEDに変更します。また、ポーズボタンを再生ボタンに置き換えます。

```python: audio.py
    def play(self):
        try:
            p = pyaudio.PyAudio()
            stream = p.open(format=p.get_format_from_width(self.__audio.samplewidth), channels=self.__audio.nchannels, rate=self.__audio.framerate, output=True)

            while True:
                data = self.__audio.read_frames(CHUNK_SIZE)
                if len(data) == 0:
                    self.__audio.rewind()
                    break
                stream.write(data)

                if self.state != AudioPlayerState.PLAYING:
                    break
            
            stream.close()
            p.terminate()
            self.state = AudioPlayerState.READY
        except:
            print('Error: cannot play the audio')
            return
```
オーディオを再生するスレッドは、AudioPlayerState.PLAYINGである場合のみ再生をし続け、それ以外の場合は再生を停止し、スレッドを終了します。再生が自然終了した場合も、スレッドを終了します。

```python: simple_audio_player_ver0.4.py
    def __update_audio_progress_bar_while_playing(self):
        while True:
            self.__update_audio_progress_bar()
            time.sleep(0.01)
            
            state = self._player.state
            if state != AudioPlayerState.PLAYING:
                break

        self.__update_audio_progress_bar()
```
プログレスバーとタイム表示を更新するスレッドは、AudioPlayerState.PLAYINGである場合のみ更新をし続け、それ以外の場合は処理を停止し、スレッドを終了します。

```python: simple_audio_player_ver0.4.py
    def __update_audio_form_while_playing(self):
        while True:
            self.master._audio_form_frame.update_audio_form()
            time.sleep(0.1)
            
            state = self._player.state
            if state != AudioPlayerState.PLAYING:
                break

        self.master._audio_form_frame.update_audio_form()
```
オーディオ波形を更新するスレッドは、AudioPlayerState.PLAYINGである場合のみ更新をし続け、それ以外の場合は処理を停止し、スレッドを終了します。

```python: simple_audio_player_ver0.4.py
    def __check_state_while_playing(self):
        while True:
            time.sleep(0.01)
            
            state = self._player.state
            if state != AudioPlayerState.PLAYING:
                self._audio_play_button.grid()
                self._audio_pose_button.grid_remove()
                break

```
オーディオの状態を監視するスレッドは、AudioPlayerState.PLAYINGである場合のみ監視し続け、それ以外の場合は処理を停止し、スレッドを終了します。再生ボタンと停止ボタンの切り替えもやっていますが、再生が自然終了したときに停止ボタンが再生ボタンに置き換わらないためのバグ対策です。

### オーディオ波形の表示
```python: audio_form_frame.py
        self.__fig, self.__ax = plt.subplots(figsize=(4, 1), dpi=100, tight_layout=True, facecolor='#2B2B2B')
        self.__ax.axis("off")

        self.grid_rowconfigure(0, weight=1)
        self.grid_columnconfigure(0, weight=1)
        self.__canvas = FigureCanvasTkAgg(self.__fig, master=self)
        self.__canvas.get_tk_widget().grid(row=0, column=0, padx=(5, 5), pady=(5, 5), sticky='WE')
```
まず、matplotlibで生成したグラフをCustomTkinterに埋め込みます。

```python: audio_form_frame.py
    def update_audio_form(self):
        current_pos = self.__audio.current_pos
        y = self.__read_frames(current_pos)
        if y is None:
            return

        x = np.arange(-self.__audio_form_radius, self.__audio_form_radius + 1)

        self.__ax.cla()
        self.__ax.axis('off')
        self.__ax.set_xlim(-self.__audio_form_radius, self.__audio_form_radius)
        self.__ax.set_ylim(-1, 1)
        self.__ax.vlines(0, -1, 1, colors='#888888', linewidth=1.5)
        self.__ax.plot(x, y)

        self.__canvas.draw()
```
上記は、オーディオデータをグラフにプロットする関数です。オーディオ波形を更新するスレッドから呼ばれ、時々刻々変化する波形をプロットしていきます。draw()は処理が重く、リアルタイム表示には不適かもしれません。


## 備考
* オーディオファイルはWAVとMP3だけに対応
    * 他のフォーマットは次回以降に対応
* マルチプロセス化を検討中
    * オーディオ波形を表示する処理が重く、音切れが発生するため、オーディオを再生するスレッドを別プロセスにすることは必須


## おわりに
次回に続きます。
Qiitaの作法やソースコードに関するアドバイス、質問などがありましたらコメント欄までお願いします。