好きなキー好きなBPMでパプれるスピーカーをline botとobnizで作ってみた

#はじめに
好きな曲とか聞いていて「__あーこれ音域低めだし歌えるんじゃね？ﾖﾕｳｯｼｮ__」って思ってカラオケ行ったら全然音域足らなかったみたいな経験ありませんか？
僕はかの有名な__パプリカ__で実際ありました、裏切られた気分になりますよね。
原曲キーで歌わないにしても、どれぐらいのキーが程よいのか分かりませんよね。

そこで、自宅でも自由にパプってあらかじめ自分にあったキーを探しておけるスピーカーをobnizとline botで作ってみました。
コードはGistに載せてあるので、是非コピーしてハンズオンして__ｴﾋﾞﾊﾞﾃﾞｨパプろうぜ！__
(Gist: https://gist.github.com/canonno/2209c7d68870b99d256cb4bac110b37d)


#完成デモ
##メロディの送信
obnizの圧電スピーカは周波数を引数として関数を実行する必要がありますﾒﾝﾄﾞｸｻｲｨｨｨ
ということで、lineから「ドレミ」のような身近な表記でobnizに音を送信できるようにしました。
ピアノの白鍵が「ドレミ」、オクターブは数字を付けて「ド1レ1ミ1」、半音はシャープsかフラットfをつけて「ドs」「ミf」と表記できます。
オクターブ高めの半音も「ドs1」といった表記で対応できます。

![Screenshot_20200728-214218.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666990/f60ea3d2-e5b5-0895-6091-43d5b1a94373.png)


##obnizで再生
送った楽譜はnode.jsで周波数に変換されobnizに送られます。
画面に[play]が出ているところでスイッチを押すと再生できます。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">obnizでパプリました<a href="https://twitter.com/hashtag/obniz?src=hash&amp;ref_src=twsrc%5Etfw">#obniz</a><a href="https://twitter.com/hashtag/%E3%83%91%E3%83%97%E3%83%AA%E3%82%AB?src=hash&amp;ref_src=twsrc%5Etfw">#パプリカ</a> <a href="https://twitter.com/hashtag/IoT?src=hash&amp;ref_src=twsrc%5Etfw">#IoT</a> <a href="https://twitter.com/hashtag/protoout?src=hash&amp;ref_src=twsrc%5Etfw">#protoout</a> <a href="https://t.co/09Z97KZfF4">pic.twitter.com/09Z97KZfF4</a></p>&mdash; canonno (@canonno_blog) <a href="https://twitter.com/canonno_blog/status/1288098399896530945?ref_src=twsrc%5Etfw">July 28, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


##キー選択・BPM選択
モードを[play][key][BPM]の3つ用意しました。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">モードは3つ<br>keyでキーが変えられ、BPMで速さを変えられます<a href="https://twitter.com/hashtag/obniz?src=hash&amp;ref_src=twsrc%5Etfw">#obniz</a> <a href="https://t.co/UpQCoOSQhw">pic.twitter.com/UpQCoOSQhw</a></p>&mdash; canonno (@canonno_blog) <a href="https://twitter.com/canonno_blog/status/1288099171082199041?ref_src=twsrc%5Etfw">July 28, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

[key]を変えると曲の音程が変わります。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ハイパプリカ<a href="https://twitter.com/hashtag/obniz?src=hash&amp;ref_src=twsrc%5Etfw">#obniz</a> <a href="https://t.co/zrHaHXDRQE">pic.twitter.com/zrHaHXDRQE</a></p>&mdash; canonno (@canonno_blog) <a href="https://twitter.com/canonno_blog/status/1288099700365656064?ref_src=twsrc%5Etfw">July 28, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

[BPM]を変えると曲の速さが変わります。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ハイパプリカ2<a href="https://twitter.com/hashtag/obniz?src=hash&amp;ref_src=twsrc%5Etfw">#obniz</a> <a href="https://t.co/3gUBH2pHW7">pic.twitter.com/3gUBH2pHW7</a></p>&mdash; canonno (@canonno_blog) <a href="https://twitter.com/canonno_blog/status/1288100393340174337?ref_src=twsrc%5Etfw">July 28, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


さあ自分好みの__自分だけのパプリカ__を探せ！（大袈裟）



#環境
node v12.18.2
Visual Studio Code 1.47.1
obniz

#手順
##linebotを作る
他記事を参考にlinebotを作成しました。
(参考：https://qiita.com/n0bisuke/items/ceaa09ef8898bee8369d)
![Screenshot_20200728-213845.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666990/b5904560-dd7f-a4de-4feb-a5c39a81b4c9.png)

linebotへ投稿した「ドレミ」の楽譜をMessaging API経由で送信し、Node.jsで実装した処理を経てobnizの圧電スピーカへ送るという手順で実装しました。
Node.jsは今回ngrokを使って接続しました。


##「ドレミ」を周波数に変換するまで
「ドレミ」から周波数へ変換するにはどうするか。
オクターブ表記を含めると「ド」や「ドs」や「ド1」や「ドs1」といった様々なバリエーションに対応する必要があります。
そこで、3段階の表記変換を挟む方針にしました。
<img width="300" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666990/a706d911-4c7c-ce46-ca79-c2df69caab89.png">
一つずつ見ていきますね。

###①「ドレミ」から三文字表記へ
まずはドレミから三文字表記に直す処理を考えました。
三文字表記のお作法は
　一文字目：ドレミ
　二文字目：ナチュラルn、シャープs、フラットf
　三文字目：オクターブ0,1,2
「ファ」のみ二文字なので最初に「フ」に置き換え、lineテキストの左から一文字ずつひたすら判定と変換作業を行います。

```js
//lineからのテキストには、ただの「ド」から「ドs1」まで一音につき1文字から3文字まで表記がある
//それらを「ド」なら「ドn0」といった、半音・全音やオクターブ情報を付与した3文字コードに置き換える
function score_to_name(line_text){
    //ファが2文字なので一文字に置き換え、最後にeeを付与し、最後であることを明示する
    score = line_text.replace(/ファ/g,"フ") +"ee"

    node_list = []
    //左から一文字ずつ読んでいく
    for (i = 0; i < score.length-2;i++){
        node = score.slice(i,i+3);

        //ドレミで始まっているか
        type1 = /^[ドレミファソラシー]/g
        //ドレミの次はsか数字か
        type2 = /^[ドレミファソラシー][fs\d]/g
        //ドレミの次にsがつくか
        type3 = /^[ドレミファソラシー][fs]/g
        //ドレミの後にsと数字の両方がつくか
        type4 = /^[ドレミファソラシー][fs]\d/g

        if (type1.test(node)){
            if(type2.test(node)){
                if(type3.test(node)){
                    if(type4.test(node)){
                        //ドs1みたいな表記>>そのまま格納
                        node_list.push(node);                    
                    }else{
                        //ドsみたいな表記>>オクターブ情報を付与
                        node_list.push(node[0]+node[1]+"0");
                    }
                }else{
                    //ド1みたいな表記>>ナチュラルであるnを付与
                    node_list.push(node[0]+"n"+node[1]);
                }
            }else{
                //ドみたいな表記>>ナチュラルnとオクターブ情報を付与
                node_list.push(node[0]+"n"+"0");
            }
        }else{
            //音階から始まってない>>無視して次の文字へ
            ;
        }
    }
    return node_list
}
```

###②三文字表記から数値表記へ
人によっては「三文字表記から周波数表記に直接変換すればいいんじゃない？」と思われる方もいるかもしれません。
ここであえて数値表記を挟むことで、keyを変えることができるようになります。
数値表記でいったん保持すれば、obniz側のkey+1や+2というキー操作を、ただ数値に+1や+2するだけでキー調整ができるようになります。
なので、少々面倒ですが数値表記に一度変換しております。

```js
//上記で3文字コードになったものを、数値に置き換える
//一番低い音から1、2、3、と半音ごとに1ずつ上がる数字に置き換える
function node_to_int(node_list){
    //3文字コードと数字の対応表をインポート
    const dict = JSON.parse(fs.readFileSync('node_dict.txt', 'utf8'));
    name2int_dict = dict["name_to_int"]

    //ひたすら数値化
    int_list = []
    for (i=0;i<node_list.length;i++){
        int_list.push(name2int_dict[node_list[i]]);
    }
    return int_list;
}
```
ここでインポートしている対応表は、三文字表記⇔数値表記⇔周波数表記を行うために作ったdictになっています。
[こちらの記事](https://tomari.org/main/java/oto.html "こちらの記事")を参考に作りました。
dictはGistに置いておきますので使われる方はぜひ使ってみてくださいね。
(Gist: https://gist.github.com/canonno/2209c7d68870b99d256cb4bac110b37d)

```json
{"name_to_int":{"ドn-3":1,"ドs-3":2,"レn-3":3, ... "シf3":83},
 "int_to_hertz":{"1":32.703,"2":34.648,"3":36.708, ..., "84":3951.066}}
```

###③数値表記から周波数表記
数値表記にした後は、obniz側の操作で変化するkeyを足し引きし、周波数表記にします。
「ドー」の「ー」は数値表記で100としており、周波数表記にする場合は「ひとつ前の音の長さを1のばす」という処理にしています。
こうすれば「ドーーー」でも「ドーーーーーー」でも対応可能になります。

```js
//上記で数値情報になったものを周波数と音の長さに置き換える
//key情報を引数に入れ、keyの数値分音をずらす処理も行う
function int_to_fre(int_list,key){
    //数値と周波数との対応表のインポート
    const dict = JSON.parse(fs.readFileSync('node_dict.txt', 'utf8'));
    int2fre_dict = dict["int_to_hertz"]

    //ひたすら置き換える
    fre_list = []
    for (i=0;i<int_list.length;i++){
        //「ー」の場合（数値を100としている）、ひとつ前の音のlengthを1伸ばす
        if (int_list[i]==100){
            last_length = fre_list[fre_list.length-1]["length"]
            fre_list[fre_list.length-1]["length"] = last_length + 1
        //普通の音の場合そのまま変換
        }else{
            fre_list.push({"frequency":int2fre_dict[int_list[i]+key],"length":1});
        }
    }
    return fre_list;
}
```

###Obniz側での操作の処理
あとはobniz側での操作を実装します。
まずはobnizと接続する処理。
こちらの記事をかなり参考にしました。
（参考：https://qiita.com/Naru0607/items/b523cf9f67fa18bcf7d7）

obniz側で操作するとobniz.switch.onchange以降の処理が走るので、keyやBPMといった変数をその範囲外で宣言する必要がある点に注意です。
最初はonchange以降で宣言し、keyやBPMを変えても反映されずかなり苦戦しました。
obniz側で操作したらどこがどう走るかはしっかり理解しないとだめですね・・・。

```js

//obnizで出力
function sound_with_obniz(line_text){
    //もろもろ初期設定
    const Obniz = require('obniz');
    const { text } = require("express");
    const obniz = new Obniz('Obniz_ID');  // Obniz_IDに自分のIDを入れます

    //obnizと接続
    obniz.onconnect = async function () {
    const speaker = obniz.wired('Speaker', {signal:0, gnd:1});

    //obniz上での設定パラメータ
    key = 0;
    BPM = 180;
    mode_list = ['play','key','BPM']
    mode_idx = 300
    mode = mode_list[mode_idx%3]

    // ディスプレイ処理
    obniz.display.clear();  // 一旦クリアする
    obniz.display.print(mode + "\nkey:"+ key+" BPM:"+BPM);

    // スイッチの反応を常時監視
    obniz.switch.onchange = async function(state) {
        //表示がplayの時の操作
        if (mode == 'play'){
            //押したら音が鳴る
            if (state === 'push') {
                one_tempo = Math.round(60/2/BPM*1000);
                node_list = score_to_name(line_text);
                int_list = node_to_int(node_list);
                fre_list = int_to_fre(int_list,key)
                for (i=0;i<fre_list.length;i++){
                    sound(fre_list[i]["frequency"],fre_list[i]["length"]);
                }
                obniz.display.clear();
                obniz.display.print(mode + "\nkey:"+ key+" BPM:"+BPM);
            //離せば何も起こらない
            } else if (state === 'none') {
                speaker.stop();
            //右にすればモードが変わる
            } else if (state === 'right'){
                mode_idx += 1;
                mode = mode_list[mode_idx%3]
                obniz.display.clear()
                obniz.display.print(mode + "\nkey:"+ key+" BPM:"+BPM);
            //左にすればモードが変わる
            } else if (state === "left"){
                mode_idx -= 1;
                mode = mode_list[mode_idx%3]
                obniz.display.clear()
                obniz.display.print(mode + "\nkey:"+ key+" BPM:"+BPM);
            }
        //表示がkeyの時の操作
        } else if (mode == 'key'){
            //押したらkeyの変更画面へ
            if (state === 'push') {
                mode = 'key_select'
                obniz.display.clear();
                obniz.display.print(mode+"\nkey:"+key);
            //離せば何も起こらない
            } else if (state === 'none') {
                speaker.stop();
            //右に倒せばモードが変わる
            } else if (state === 'right'){
                mode_idx += 1;
                mode = mode_list[mode_idx%3];
                obniz.display.clear();
                obniz.display.print(mode + "\nkey:"+ key+" BPM:"+BPM);
            //左に倒してもモードが変わる
            } else if (state === "left"){
                mode_idx -= 1;
                mode = mode_list[mode_idx%3];
                obniz.display.clear();
                obniz.display.print(mode + "\nkey:"+ key+" BPM:"+BPM);
            }
        //表示がBPMの時の操作
        } else if (mode == 'BPM'){
            //押せばBPMの設定画面になる
            if (state === 'push') {
                mode = 'BPM_select'
                obniz.display.clear()
                obniz.display.print(mode+"\nBPM:"+BPM)
            //離せば何も起こらない
            } else if (state === 'none') {
                speaker.stop();
            //右に倒せばモードが変わる
            } else if (state === 'right'){
                mode_idx += 1;
                mode = mode_list[mode_idx%3]
                obniz.display.clear()
                obniz.display.print(mode + "\nkey:"+ key+" BPM:"+BPM);
            //左に倒してもモードが変わる
            } else if (state === "left"){
                mode_idx -= 1;
                mode = mode_list[mode_idx%3]
                obniz.display.clear()
                obniz.display.print(mode + "\nkey:"+ key+" BPM:"+BPM);
            }
        //キーセレクト画面での操作
        }else if (mode == 'key_select'){
            //押せば元の画面に戻る
            if (state === 'push') {
                mode = mode_list[mode_idx%3]
                obniz.display.clear()
                obniz.display.print(mode + "\nkey:"+ key+" BPM:"+BPM);
            //離せば何も起こらない
            } else if (state === 'none') {
                speaker.stop();
            //右に倒せばキーが上がる
            } else if (state === 'right'){
                key += 1;
                obniz.display.clear()
                obniz.display.print(mode+"\nkey:"+key)
            //左に倒せばキーが下がる
            } else if (state === "left"){
                key -= 1;
                obniz.display.clear()
                obniz.display.print(mode+"\nkey:"+key)
            }
        //BPM設定画面での操作
        } else if (mode == 'BPM_select'){
            //押せば元の画面に戻る
            if (state === 'push') {
                mode = mode_list[mode_idx%3]
                obniz.display.clear()
                obniz.display.print(mode + "\nkey:"+ key+" BPM:"+BPM);
            //離せば何も起こらない
            }else if (state === 'none') {
                speaker.stop();
            //右に倒せばBPMが上がる
            } else if (state === 'right'){
                BPM += 20;
                obniz.display.clear()
                obniz.display.print(mode+"\nBPM:"+BPM)
            //左に倒せばBPMが下がる
            } else if (state === "left"){
                BPM -= 20;
                obniz.display.clear()
                obniz.display.print(mode+"\nBPM:"+BPM)
            }

        }
    }

    //一つ一つの音を出力する処理
    function sound(fre,length){
        speaker.play(fre);
        obniz.wait(length*one_tempo);
        speaker.stop();
        obniz.wait(100);
    }
    }
}
```
###最後にlineと接続！
以上がすべてfunctionで実装できたので、あとは順番に実行していくだけ！

```js
'use strict';

const fs = require("fs");
const express = require('express');
const line = require('@line/bot-sdk');
const { compileFunction } = require('vm');
const PORT = process.env.PORT || 3000;

const config = {
    channelSecret: 'チャンネルシークレットトークン',
    channelAccessToken: 'チャンネルアクセストークン'
};

const app = express();

//lineからくるテキスト初期値
var line_text = "";

//リクエストによる処理
app.get('/', (req, res) => res.send('Hello LINE BOT!(GET)')); //ブラウザ確認用
app.post('/webhook', line.middleware(config), (req, res) => {
    events = req.body.events
    line_text = events[0].message.text
    Promise
      .all(req.body.events.map(handleEvent))
      .then((result) => res.json(result));
});

const client = new line.Client(config);

async function handleEvent(event) {
  //テキストでない場合は無視
  if (event.type !== 'message' || event.message.type !== 'text') {
    return Promise.resolve(null);
  }
  //lineテキストをobnizへ流し込む
  sound_with_obniz(line_text)

  //lineへの返信
  return client.replyMessage(event.replyToken, {
    type: 'text',
    text: 'obnizでの操作をお楽しみください' //実際に返信の言葉を入れる箇所
  });
}

app.listen(PORT);
console.log(`Server running at ${PORT}`);
```

#今後やりたいこと
パプリカ演奏できたことはできたけれど、低音がないと少し味気ないですよね。
次は和音に挑戦して、低音付きのガチパプリカを演奏できるか検証してみたい。
~~そしてあわよくばバズって米津玄師に会いた~~

プラス今回の実装だと、lineから楽譜を一度インポートすると、関数を一度止めるまで楽譜が変更できない実装になっています。
関数が動いている状態でlineから別の楽譜を送信しても上書きすることができません。
関数のon/offをline側でできるようになったり、楽譜管理・保存とかできたら良いなあとか思ったりしています。

いつできるやらという感じですが、生暖かい目で見守っていただけると嬉しいです！
最後までご覧いただきありがとうございました！