Pythonでネットワーク越しにマイク入力とwavファイルをストリーミング

## はじめに
こんにちは。長く通っていた🐍蛇拳道場🐍を破門になってしまいました。トコロテンです。
本記事では、タイトルの記事の機能を実装したプログラムとその簡単な説明をします。
基本的な方針は、Pythonのライブラリたちを悪魔召喚して良い感じにキメラを生み出す感じです。

## 機能
クライアントからサーバーに対してマイク入力とwavファイルのデータをミックスしたデータを送信し、リアルタイムストリーミングを行う。

## 使用ライブラリ

- [PyAudio](https://people.csail.mit.edu/hubert/pyaudio/)
- [NumPy](https://numpy.org/)
- [socket](https://docs.python.org/3/library/socket.html)
- [wave](https://docs.python.org/3/library/wave.html)
- [threading](https://docs.python.org/3/library/threading.html)

## プログラム

サーバーとクライアントはTCPでデータを送受信しています。
クライアント側で読み込んだwavファイルのオーディオプロパティを基準にしてマイクの入力ストリームやサーバー側のオーディオ出力ストリームのプロパティも設定されます。そのため、このプログラムに関連するすべての機器がwavファイルの量子化ビット数、チャンネル数、サンプリングレートに対応していないと正常に動作しません。

### クライアント

```Python3:MixedSoundStreamClient.py
import numpy as np
import wave
import pyaudio
import socket
import threading

class MixedSoundStreamClient(threading.Thread):
    def __init__(self, server_host, server_port, wav_filename):
        threading.Thread.__init__(self)
        self.SERVER_HOST = server_host
        self.SERVER_PORT = int(server_port)
        self.WAV_FILENAME = wav_filename

    def run(self):
        audio = pyaudio.PyAudio()

        # 音楽ファイル読み込み
        wav_file = wave.open(self.WAV_FILENAME, 'rb')

        # オーディオプロパティ
        FORMAT = pyaudio.paInt16
        CHANNELS = wav_file.getnchannels()
        RATE = wav_file.getframerate()
        CHUNK = 1024

        # マイクの入力ストリーム生成
        mic_stream = audio.open(format=FORMAT,
                            channels=CHANNELS,
                            rate=RATE,
                            input=True,
                            frames_per_buffer=CHUNK)

        # サーバに接続
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.connect((self.SERVER_HOST, self.SERVER_PORT))

            # サーバにオーディオプロパティを送信
            sock.send("{},{},{},{}".format(FORMAT, CHANNELS, RATE, CHUNK).encode('utf-8'))

            # メインループ
            while True:
                # 音楽ファイルとマイクからデータ読み込み
                wav_data = wav_file.readframes(CHUNK)
                mic_data = mic_stream.read(CHUNK)
                
                # 音楽ファイルリピート再生処理
                if wav_data == b'':
                    wav_file.rewind()
                    wav_data = wav_file.readframes(CHUNK)

                # サーバに音データを送信
                sock.send(self.mix_sound(wav_data, mic_data, CHANNELS, CHUNK, 0.5, 0.5))

        # 終了処理
        mic_stream.stop_stream()
        mic_stream.close()

        audio.terminate()

    # 2つの音データを1つの音データにミックス
    def mix_sound(self, data1, data2, channels, frames_per_buffer, volume1, volume2):
        # 音量チェック
        if volume1 + volume2 > 1.0:
            return None
        # デコード
        decoded_data1 = np.frombuffer(data1, np.int16).copy()
        decoded_data2 = np.frombuffer(data2, np.int16).copy()
        # データサイズの不足分を0埋め
        decoded_data1.resize(channels * frames_per_buffer, refcheck=False)
        decoded_data2.resize(channels * frames_per_buffer, refcheck=False)
        # 音量調整 & エンコード
        return (decoded_data1 * volume1 + decoded_data2 * volume2).astype(np.int16).tobytes()

if __name__ == '__main__':
    mss_client = MixedSoundStreamClient("localhost", 12345, "sample.wav")
    mss_client.start()
    mss_client.join()
```

クライアントはサーバーに接続するとオーディオプロパティを送信し、その後マイク入力とwavファイルのデータを送信し続けます。音のデータを送信する際には、`mix_sound()`メソッドにて2つの音の波形を足し合わせた平均の波形を生成してから送信します。

```Python3:mix_sound()
# 2つの音データを1つの音データにミックス
def mix_sound(self, data1, data2, channels, frames_per_buffer, volume1, volume2):
    # 音量チェック
    if volume1 + volume2 > 1.0:
        return None
    # デコード
    decoded_data1 = np.frombuffer(data1, np.int16).copy()
    decoded_data2 = np.frombuffer(data2, np.int16).copy()
    # データサイズの不足分を0埋め
    decoded_data1.resize(channels * frames_per_buffer, refcheck=False)
    decoded_data2.resize(channels * frames_per_buffer, refcheck=False)
    # 音量調整 & エンコード
    return (decoded_data1 * volume1 + decoded_data2 * volume2).astype(np.int16).tobytes()
```

`mix_sound()`メソッド内にて、以下のように波形データのリストをリサイズしている部分があります。

```Python3
# データサイズの不足分を0埋め
decoded_data1.resize(channels * frames_per_buffer, refcheck=False)
decoded_data2.resize(channels * frames_per_buffer, refcheck=False)
```

これは、引数の`data1`と`data2`に渡された波形データが同じ長さであるとは限らないからです。
data1とdata2が異なる長さであった場合、`bytes`からデコードされた`numpy.ndarray`で加算は行なえません。
したがって、以下の音の合成部分のコードを実行することができなくなってしまいます。

```Python3
# 音量調整 & エンコード
return (decoded_data1 * volume1 + decoded_data2 * volume2).astype(np.int16).tobytes()
```

`MixedSoundStreamClient.py`内にて`mix_sound()`メソッドの呼び出し部分は以下のようになっています。

```Python3
wav_data = wav_file.readframes(CHUNK)
mic_data = mic_stream.read(CHUNK)

# 省略

sock.send(self.mix_sound(wav_data, mic_data, CHANNELS, CHUNK, 0.5, 0.5))
```

この場合、`readframes()`メソッドや`read()`メソッドは最大、引数で渡されたフレーム数までフレームを読み込みますが、これを下回る場合ももちろん存在します。例えば、曲の終わりのフレームを読み込むときがそうです。全体で101フレームあるwavファイルを毎回50フレームずつ読み込むとしたとき、最初の2回で50フレームずつ読み込み、次に読み込みを行うと最後の1フレームのみ読み込まれることになります。

### サーバー

```Python3:MixedSoundStreamServer.py
import pyaudio
import socket
import threading

class MixedSoundStreamServer(threading.Thread):
    def __init__(self, server_host, server_port):
        threading.Thread.__init__(self)
        self.SERVER_HOST = server_host
        self.SERVER_PORT = int(server_port)

    def run(self):
        audio = pyaudio.PyAudio()

        # サーバーソケット生成
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_sock:
            server_sock.bind((self.SERVER_HOST, self.SERVER_PORT))
            server_sock.listen(1)

            # クライアントと接続
            client_sock, _ = server_sock.accept()
            with client_sock:
                # クライアントからオーディオプロパティを受信
                settings_list = client_sock.recv(256).decode('utf-8').split(",")
                FORMAT = int(settings_list[0])
                CHANNELS = int(settings_list[1])
                RATE = int(settings_list[2])
                CHUNK = int(settings_list[3])

                # オーディオ出力ストリーム生成 
                stream = audio.open(format=FORMAT,
                                    channels=CHANNELS,
                                    rate=RATE,
                                    output=True,
                                    frames_per_buffer=CHUNK)

                # メインループ
                while True:
                    # クライアントから音データを受信
                    data = client_sock.recv(CHUNK)

                    # 切断処理
                    if not data:
                        break

                    # オーディオ出力ストリームにデータ書き込み
                    stream.write(data)
    
        # 終了処理
        stream.stop_stream()
        stream.close()

        audio.terminate()

if __name__ == '__main__':
    mss_server = MixedSoundStreamServer("localhost", 12345)
    mss_server.start()
    mss_server.join()
```

サーバーはクライアントに接続するとクライアントからオーディオプロパティを受信するまで待機します。
オーディオプロパティを受信できた後は基本的にクライアントから受け取った音のデータを再生するだけです。

## 結論
PyAudioくん好き。
今回は、クライアントからサーバーに一方的に音のデータを送信するといったことをしましたが、これを応用して双方向にデータを送受信できるようにすれば通話アプリケーションを作ることができます。ということで次の記事は通話アプリケーションの実装でいこうと思います。