Raspberry Pi Pico W で WAVファイルをストリーミング再生する

# 1. 背景

WAVファイルは、MP3ファイルに比べるとファイル容量が大きく、数秒の短い小さいファイルを除き、Raspberry Pi Picoの2MBしかないフラッシュメモリーには置けません。
大きいWAVデータを再生したい場合は、SDカードに置く方法がありますが。。。

そんな中、WAVデータをSDカードにコピーする作業をしているときに、<i><b>ストリーミング再生</b></i> を思い付きました。

Pico W は Wifi が使えます。HTTPでアクセスできるファイルサーバにWAVデータを置いておけば、いちいちSDカードにコピーする手間が省けますし、インターネットで公開されているWAVデータも再生することもがきます。

ただし、フラッシュメモリーの空き容量が少ないため、ダウンロード方式は使えません。あくまで**ストリーミング**で再生する必要があります。

最終的に MicroPython で実現できたので、ここにまとめておきます。

# 2. おさらい

Pico で WAVファイルを再生する方法は、次のとおり。

### ・ CircuitPython の場合

```python:CircuitPython
wav = audiocore.WaveFile("sample.wav")
audio = audioio.AudioOut(board.A0)
audio.play(wav)
```

CircuitPythonのライブラリが使えるため、実質3行のコードでWAVファイルを再生できます。

https://docs.circuitpython.org/en/latest/shared-bindings/audiocore/index.html#audiocore.WaveFile

> 当初、`audiocore.WaveFile`メソッドの第一引数`BinaryIO`に`socket`を渡す方法を模索し試行錯誤したのですが、結局、よく分からずで諦めました。
（今でもこのアプローチで実現できるのでは と思っています）

<hr>

### ・ MicroPython の場合

↓ こちらのサイトで紹介されているコードにて、実現できます。

[How to get i2s playback with Raspberry Pi Pico](https://orionrobots.co.uk/2023/09/04/i2s-playback.html)

https://orionrobots.co.uk/2023/09/04/i2s-playback.html

# 2. WAVファイルヘッダーを読み取る

前述の [How to get i2s playback with Raspberry Pi Pico](https://orionrobots.co.uk/2023/09/04/i2s-playback.html) で紹介されているコードをベースに、WAVデータを読み込んだバッファからヘッダー情報を得るように改修しました。

_`WaveFileHeader.from_buffer()`_

**主なヘッダー情報；**

- チャンネル数　　　　ex) 1:モノラル、2:ステレオ
- サンプリング周波数　ex) 44.1KHz、22.05KHz
- ビットレート　　　　ex) 16bits、8bits
- オフセット：WAVファイルヘッダー"RIFF"が始まる位置


```python:wave_file.py
import struct
class WaveFileHeader:
    @staticmethod
    def from_buffer(file_buffer):
        offset = file_buffer.find(b"RIFF")
        if offset < 0:
            raise ValueError("No RIFF header.")

        ident = file_buffer[offset: offset+4]
        if ident != b"RIFF":
            raise ValueError(f"Expected RIFF header, got {ident}.")

        file_size = struct.unpack("I", file_buffer[offset+4: offset+8])
        file_type = file_buffer[offset+8: offset+16]
        if file_type[:7] != b"WAVEfmt":
            raise ValueError(f"Not WAVE type. Got {file_type}")

        fmt = "IHHIIHHHI"
        fmt_size, wave_fmt, channels, sample_rate, byte_rate, block_align, bits_per_sample, extra, data_size = \
            struct.unpack(fmt, file_buffer[offset+16: offset+16 + struct.calcsize(fmt)])

        header = WaveFileHeader()
        header.wave_fmt = wave_fmt
        header.channels = channels
        header.sample_rate = sample_rate
        header.bits_per_sample = bits_per_sample
        header.data_size = data_size
        header.offset = offset
        return header

    def __init__(self):
        self.data_size = 0xFFFF
        self.wave_fmt = 1 # PCM
        self.channels = 1
        self.sample_rate = 22_050
        self.bits_per_sample = 16
        self.offset = 0

    def print(self, filename=None):
        if filename: print(f"File Name: {filename}")
        print(f"Data size: {self.data_size:,} bytes")
        print(f"Wave format: {self.wave_fmt}")
        print(f"Bits per sample: {self.bits_per_sample}")
        print(f"Sample rate: {self.sample_rate:,} Hz")
        print(f"Channels: {self.channels}")
        print(f"Offset: {self.offset}")
```

# 4. ストリーミング再生するコード

### 4.1 sock_stream_play

これも前述の [How to get i2s playback with Raspberry Pi Pico](https://orionrobots.co.uk/2023/09/04/i2s-playback.html) のコードを参考にさせてもらいました。

↓ i2s DACを使用していますが、PWM Audioでも応用できると思います。 

<img width="200" alt="pcm5102_dac.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/0173046f-e58a-47a9-bb5d-df27b380d5c8.png" />

```python:sock_stream_play.py
from machine import I2S, Pin
import socket
import ucontextlib
import wave_file

# I2S setup
i2s_sck_pin = Pin(0)     #BCK
i2s_word_select= Pin(1) #LCK
i2s_data_out = Pin(2)   #DIN
i2s_id = 0
BUFFER_LENGTH_IN_BYTES = 40000

@ucontextlib.contextmanager
def managed_i2s(wav_header):
    i2s = I2S(
        i2s_id,
        sck=i2s_sck_pin,
        ws=i2s_word_select,
        sd=i2s_data_out,
        mode=I2S.TX,
        bits=wav_header.bits_per_sample,
        format=(I2S.MONO if wav_header.channels == 1 else I2S.STEREO),
        rate=wav_header.sample_rate,
        ibuf=BUFFER_LENGTH_IN_BYTES,
    )
    try:
        yield i2s
    finally:
        i2s.deinit()


@ucontextlib.contextmanager
def sock_stream(url):
    _, _, host, path = url.split('/', 3)
    filename = path.split('/')[-1]
    addr_info = socket.getaddrinfo(host, 80, 0, socket.SOCK_STREAM)
    addr = addr_info[0][-1]

    sock = socket.socket()
    sock.connect(addr)
    http_req = bytes(f'GET /{path} HTTP/1.0\r\nHost: {host}\r\n\r\n', 'utf8')
    sock.send(http_req)

    try:
        yield sock
    finally:
        sock.close()


def play(url, sample_buf=2048):
    with sock_stream(url) as sock:
        data = sock.recv(sample_buf)
        if data.find(b'200') < 0:
            raise ValueError(f"http request error. {data[:32]}")

        wav_header = wave_file.WaveFileHeader.from_buffer(data)
        wav_header.print()
        data = data[wav_header.offset:]
        with managed_i2s(wav_header) as i2s:
            while data:
                _ = i2s.write(data)
                data = sock.read(sample_buf)
```


- Http の Getリクエストを組み立て、socket を使用して目的のホストに送信し、レスポンスを2KBずつ受信する
- 最初のレスポンスデータに WAVファイルヘッダーが含まれているので、取得したWAVヘッダー情報をもとにi2sオーディオを生成
- eofになるまで、recv(soket)し write(i2s)する

試行錯誤の末に、このコードに辿り着きました。



### 4.2 使用例

Wifiにつながっている前提のため、このコードに先立ってWifiに接続しておくこと。

```python
from sock_stream_play import play
play('https://www.ne.jp/asahi/music/myuu/wave/musicbox.wav') # 1.4MB、8秒
#play('https://www.ne.jp/asahi/music/myuu/wave/strings.wav') # 5.2MB、30秒
#play('https://server.local/sounds/bgm1.wav') # 0.9GB、1.5時間
#play('https://server.local/sounds/bgm2.wav') # 2.4GB、4.0時間
```


4.0時間のWAVデータ（容量2.4GB、44.1KHz、16bits）も 4時間ノンストップで再生できました。ときどきノイズが出るのは通信の乱れの影響と思いますが、ネットワークアクセスが安定していれば普通に再生します。

# 5. 考察

1. 2KBずつ受信としていますが、家WiFiだと ホストによらず 800バイトずつ受信していました。どこかをいじればTCP/IPのパケット長を変更できるかも知れません<br>　

1. ぶつぶつ音が飛ぶようなら、非同期IOとダブルバッファでの実装を考えていましたが、同期IOでも再生が十分間に合ったため、いまのコードに落ち着きました<br>　 

1. 4時間のWAVデータは、正確には 4時間2分46秒です。PicoWでストリーミング再生した時間を測ると、4時間3分0秒で差はわずか14秒です。<br>4時間の再生時間の中での差としては、無視できる程度と言えるでしょう<br>　

1. ESP32でも同じコードで動くはずです（i2sのGPIOを変えるだけ）

# 6. フリー音源

↓こちらで公開されているWAVファイルを、何度も利用させていただきました。

https://www.ne.jp/asahi/music/myuu/wave/wave.htm

ありがとうございます。

# 7. おわりに

Raspberry Pi Pico W の半値程度のESP32モジュールを使って、ストリーミング再生機を作ろうか思案中。

[ダイソーの300円アンプ付きスピーカ](https://jp.daisonet.com/products/4549131578874)に内蔵してしまうのも面白い。 
ESP32の内蔵DACで実装すれば 安価に製作できる。おそらくモノラル再生となってしまうが、どうせBGM用途のため 問題は無い。
その場合は、44.1KHzの高いサンプリングレートは不要で、16KHz程度でよいと思う。

<br>

_**2024.5.27 追記**_

さっそく、ESP32で同じコードで試したのですが、ノイズだらけでとても聴けたもんではありませんでした。おそらく、socketの受信が追いついて無いのだと思います。・・・残念
なお、SDカードからの再生は なんの問題もありませんでした。




#### 以上