給湯リモコンのBGM機能で遊ぶ簡易音楽サーバーを作った話


いま住んでいる家にあるお風呂の給湯リモコン(Rinnai)には3.5mmのステレオジャック(メス)がついており、これで音楽プレイヤーを繋ぐと入浴中にBGMを聴けるという代物でした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409514/a89a58fe-ce64-2068-b521-7e8cfe925cb4.png)
(画像はhttps://rinnai.jp/products/waterheater/gas/remocon/ より)
スマートフォンやウォークマンを繋いでも浴室からは操作できないため、浴室からも操作したいと思い、せっかくなので音楽配信サーバーを作ってみました。

## 構成
簡易サーバーは部屋に転がっていたラズパイに担ってもらいます。サーバーがHTTPリクエストを受けて、音楽をジャックから出力して、お風呂で再生されるという布陣でいきます。

せっかくなのでブラウザから曲や再生/停止をコントロールしたい気持ちになってきたので、ラズパイにはWEBサーバーも兼ねてもらうことにしました。
こういうお試しをするときはPythonが気楽でイイですよね。WEBサーバーはFlask、音楽再生はpygame.mixerにやってもらいましょう。

### 用意するもの
* Raspberry pi4 (4GB RAMモデル)
* SDカード(ラズパイのOSを焼く用)
* φ3.5ステレオジャックケーブル(オス-オス)
* LAN環境

pygameはラズパイのDebian OSにもともと入っていたので、flaskだけpipで入れておきます。
```shell
$pip3 install flask
```

### 結線図
![構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409514/d16844b9-f56c-8188-ea9e-01515d1c49e5.png)

## ソースコードなど

今回のプロジェクトのディレクトリ構成は以下のようになりました。
```text
flaskapp
├ main.py
├ templates/
|  └ index.html
└ music/
   └ あげあげドーナツ.mp3
```
Flaskはデフォルトではtemplatesディレクトリを読みに行って探してくれるので、そこにhtmlテンプレートを格納します。スクリプトはまとめてmain.pyに書きます。
サンプルで用意した楽曲はEテレ「おかあさんといっしょ」の名曲「あげあげドーナツ」で、ちょうどAmazonでmp3を購入していたものがあったので、テストに用いました。アゲアゲなバスタイムが始まるぜ！

mp3ファイル自体は、scpでwindows機からラズパイにコピーしました。
```shell
$scp <mp3までのパス> pi@raspberrypi.local:~/flaskapp/music/
```
なおラズベリーパイにはavahi-daemonというのが動いていて、DNSに登録しなくても他端末からraspberrypi.localでラズパイのIPアドレスを取得することができるらしいです。便利…。

### ページ表示
まずはflaskの基本的なコードで、GETリクエストでトップページ(index.html)を返す関数を定義します。
テンプレートには曲名のリストも渡し、forブロックで一覧表示します。まあ今回は１曲だけですが…。

```python:main.py
from flask import Flask, render_template
import os

app = Flask(__name__)

# musicディレクトリの中身一覧を取得
playlist = os.listdir("music")

@app.route("/", methods=["GET"])
def index():
    return render_template("index.html", musiclist=playlist)
```
画面はこんな風に古き良き(?)感じにして、最低限の動作確認だけします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409514/6481b588-4d80-03de-ae9e-cb7e01f0c4a9.png)

曲の選択、再生と停止、音量の変更のコントロールを配置します。
音量は渡す値は0～1ですが、0～100%のほうが見栄えがするので表示だけ変換しました（今思えば画面側では整数値にしておいてFlaskの処理のほうで0～1変換したほうが良かったかも…）。

```html:index.html
<!DOCTYPE html>
<html>
<head><title>Bathroom music player</title></head>
<body>
<h1>Bathroom Music Player</h1>
<hr>
<select id="musicselector">
    {% for music in musiclist %}
    <option value="{{ music }}">{{ music }}</option>
    {% endfor %}
</select>

<button onclick="play()">Play</button>
<button onclick="stop()">Stop</button>
<br />
Volume <input id="volume_slider" type="range" min="0" max="1" value="0.5" step="0.01"> <span id="volume_val">50</span>

<script>
function play(){
    var title = document.getElementById("musicselector").selectedOptions[0].value;
    var volume = document.getElementById("volume_slider").value;
    if(title){
        fetch("/controls/play", {
            method: "post",
            credentials: "same-origin",
            headers:{
                "Content-TYpe": "application/json"
            },
            body: JSON.stringify({
                title, volume: parseFloat(volume)
            })
        }).then(ret=>{
            console.log(ret);
        })
    }
}

function stop(){
    fetch("/controls/stop", {
        method: "post",
        headers:{
            "Content-TYpe": "application/json"
        }
    }).then(ret=>{
        console.log(ret);
    })
};

document.getElementById("volume_slider").addEventListener("change", function(){
    var volume = document.getElementById("volume_slider").value;
    document.getElementById("volume_val").innerText = parseInt(volume * 100);
    fetch("/controls/change_volume", {
        method: "post",
        credentials: "same-origin",
        headers:{
            "Content-TYpe": "application/json"
        },
        body: JSON.stringify({
            volume: parseFloat(volume)
        })
    }).then(ret=>{
        console.log(ret);
    })
});
</script>
</body>
</html>
```

### コントロール

音楽の再生、停止、ボリューム調整は、トップページのJavaScriptからajaxでPOSTリクエストしてもらうことで実現します。音楽の再生はpygameでやります。自分しか使わないので値のバリデーションとかは後回しです。音が鳴るのかどうかそれが問題なのです。

```python:main.py
from pygame import mixer

pygame.mixer.init()#初期化処理(書き忘れ)
@app.route("/controls/play", methods=["POST"])
def play():
    data = request.json #json形式で{title: ファイル名, volume: 音量}を想定
    mixer.music.load("./music/{}".format(data["title"]) #音楽をロード
    mixer.music.set_volume(data["volume"]) #音量セット(0～1のfloat)
    mixer.music.play() #再生!!
    return "ok" #一応レスポンスを返す

@app.route("/controls/stop", methods=["POST"])
def stop():
    mixer.music.stop() #停止
    return "ok"

@app.route("/controls/change_volume", methods=["POST"])
def change_volume():
    mixer.music.set_volume() #音量セット(0-1)
    return "ok"
```
最後にapp.runの部分をかいて、実行します。
```python:main.py
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)
```
```shell
$python3 main.py
```
> pygame 2.5.2 (SDL 2.28.3, Python 3.9.2)
> Hello from the pygame community. https://www.pygame.org/contribute.html
> * Serving Flask app 'main'
> * Debug mode: off
>WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
> * Running on all addresses (0.0.0.0)
> * Running on http://127.0.0.1:8080

## 結果

お風呂にスマートフォンを持ち込み、ブラウザから`raspberrypi.local:8080`にアクセスしてページを表示します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409514/6481b588-4d80-03de-ae9e-cb7e01f0c4a9.png)

Playボタンを押すと、再生…
・
・
・
されました！！！！！！
クソデカボリューム！！！！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409514/05c78388-8974-3af6-e5ec-d9d8dcfa92cd.png)

音量は2% (0.02)くらいでちょうどよかったです。お風呂の給湯リモコンのスピーカーにはアンプがついていて増幅されるんだと思います。シャワー中などは音がかき消されるのでもっと大きくしたほうがよかったです。さすがよく設計されている…。

これで快適なお風呂音楽環境ができました！　楽しむことにします！！！

### おまけ
* お風呂に持ち込んで操作する前提ならスマホで色々聴けばよくない？<br>→ それはそう。でも音量は正義
* Amazon echoとかを直に繋げばもっと色々できたんじゃない？
  → ほらechoよりラズパイのほうが安いから…
  → 安くなかった😐
  > Echo Dot 第5世代がAmazonで7,480円<br>vs<br>ラズパイ4 ModelB/4GBがSWITCH SCIENCEで10,890円
* でもエンジニアって経済合理性よりやってみたいドリブンなところありますよね（？）