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

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

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

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

## 今回実装した内容

* GUI
    * オーディオ再生中を表すプログレスバーとタイム表示
* 機能
    * 各オーディオコントロールボタンに対応するコールバック関数
    * マルチスレッド処理
        * オーディオの再生
        * プログレスバーとタイム表示の更新

## ソースコード

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

##  環境
Windows11
Python3.12.7

## 動作イメージ
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/483587/d43712fa-5360-15e9-2bb9-63799cfca67c.gif" width=60%>

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

## ソースコードの補足
イベントループを処理するスレッド以外に２つのスレッドを新たに生成しています。

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

再生以外の処理が重くなるとスムーズに再生できなくなる恐れがあるため、マルチスレッド処理よりも別プロセス（別のCPUリソース）で処理させたほうが良いかもしれません。

```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.3.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.__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_play.start()
        self.__thread_for_progress_bar.start()
```
再生ボタンが押されると上記の関数がコールバックされます。オーディオプレイヤーの状態をAudioPlayerState.PLAYINGに変更し、オーディオを再生するためのスレッドと、プログレスバーとタイム表示を更新する（ためのイベントを発生させる）スレッドを生成し、起動します。

```python: simple_audio_player_ver0.3.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
            break
```
スレッドを終了させるためには、上記の関数を呼びます。オーディオプレイヤーの状態をAudioPlayerState.NOT_READYに変更し、それぞれのスレッドが処理を終えるのを待ちます。Thread.join()を使ってスレッドの終了を待つこともできますが、プログレスバーとタイム表示を更新するためのイベントが残っているとデッドロックになるため注意が必要です。

```python: simple_audio_player_ver0.3.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
```
ポーズボタンが押されると上記の関数がコールバックされます。オーディオプレイヤーの状態が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.3.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

    def __update_audio_progress_bar(self):
        current_pos = self._audio.current_pos
        self._audio_progress_bar.set(current_pos)
        self._audio_progress_label.configure(text=self.__pos_to_time(current_pos) + " / " + self.__pos_to_time(self._audio.nframes-1))
```
プログレスバーとタイム表示を更新するスレッドは、AudioPlayerState.PLAYINGである場合のみ更新をし続け、それ以外の場合は処理を停止し、スレッドを終了します。


## 備考
* オーディオファイルはWAVとMP3だけに対応
    * 他のフォーマットは次回以降に対応
* マルチプロセス化も検討中

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