LINE BOT + ObnizでIoTアルペジエーターを作ってみた

# これはなに？
Botに対して、LINEメッセージでChord名を送信すると、そのChordの構成音を返してくれます。
同時に、そのChordをObnizがアルペジオで演奏してくれます。

# 動作の流れ
1. ユーザはChord名を送信する(ここではC Minor・Seventh)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154885/76cb4171-dc50-e986-7a29-cbdb80084e0d.png)
2. BotはChordの構成音を返す
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154885/cc76a1f6-6fd2-6fd7-e5ee-53f53afdc9c9.png)
3. ObnizがChordをアルペジオする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154885/54f69e74-58a9-69b2-0a2d-71c040b128cc.png)

# 動作の様子

<blockquote class="twitter-tweet" data-lang="en"><p lang="ja" dir="ltr">コードネームを入力すると構成音を返しつつアルペジオしてくれるBotを作ってみました。 <a href="https://t.co/fH1xxamvHD">pic.twitter.com/fH1xxamvHD</a></p>&mdash; 武志 (@takeaship) <a href="https://twitter.com/takeaship/status/1143887098631483392?ref_src=twsrc%5Etfw">June 26, 2019</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 構成図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154885/a4c2185f-e645-ffcb-5e65-afe859a68149.png)

# 使用したもの
* LINE Messaging API
* Obniz
* [圧電スピーカー PKM13EPYH4000-A0](http://akizukidenshi.com/catalog/g/gP-04118/)
* [Uberchord API](https://api.uberchord.com/)
* node.js

# ソースコード
* node.jsでローカル起動し、ngrokでトンネリングします。
* LINE BotのWebhook URLにngrokのURLを指定してください。
* LINE BotのToken, SecretおよびObniz IDはjsファイルと同じ階層に置いたsecret.jsファイルに保持しています。

``` server.js
'use strict';

const express = require('express');
const line = require('@line/bot-sdk');
const axios = require('axios');
const fs = require('fs');
const { promisify } = require('util');
const Obniz = require('obniz');
const PORT = 3000;
const SECRET_PATH = "secret.json";

const keyHzMapping = {
  'C': 261.626,
  'C#': 277.183,
  'Db': 277.183,
  'D': 293.665,
  'D#': 311.127,
  'Eb': 311.127,
  'E': 329.628,
  'F': 349.228,
  'F#': 369.994,
  'Gb': 369.994,
  'G': 391.995,
  'G#': 415.305,
  'Ab': 415.305,
  'A': 440,
  'A#': 466.164,
  'Bb': 466.164,
  'B': 493.883
};

async function main() {
  const { channelSecret, channelAccessToken, obnizId } = JSON.parse(await promisify(fs.readFile)(SECRET_PATH, 'utf-8'));
  const config = {
    channelSecret: channelSecret,
    channelAccessToken: channelAccessToken
  };
  let speaker;
  const obniz = new Obniz(obnizId);
  obniz.onconnect = async function () {
    speaker = obniz.wired("Speaker", { signal: 0, gnd: 1 })
    const id = setInterval(() => {
      const tones = queue.shift();
      if (tones) {
        playArpeggio(tones);
      }
    }, 3200);
  }
  const queue = [];
  async function playArpeggio(tones) {
    let hrzs = tones.map(tone => keyHzMapping[tone]);
    const duration = 600 / hrzs.length;
    hrzs.sort();
    await playSpeaker(hrzs[hrzs.length - 1], duration);
    for (let i = 0; i < 5; i++) {
      for (let hrz of hrzs) {
        await playSpeaker(hrz, duration);
      }
    }
    speaker.stop();
  }
  async function queueArpeggio(tones) {
    queue.push(tones);
  }
  async function playSpeaker(hertz, duration) {
    speaker.play(hertz);
    await obniz.wait(duration);
  }

  const app = express();
  app.get('/', (req, res) => res.send('Hello LINE BOT!'));
  app.post('/webhook', line.middleware(config), (req, res) => {
    console.log(req.body.events);
    // 接続確認用
    if (req.body.events[0].replyToken === '00000000000000000000000000000000' && req.body.events[1].replyToken === 'ffffffffffffffffffffffffffffffff') {
      res.send('Hello LINE BOT!(POST)');
      console.log('疎通確認用');
      return;
    }
    Promise
      .all(req.body.events.map(handleEvent))
      .then((result) => res.json(result));
  });

  const client = new line.Client(config);
  const waitingMessage = "調べています…";
  const chordDoesNotExistMessage = "そのコードは存在しません";
  async function handleEvent(event) {
    if (event.type !== 'message' || event.message.type !== 'text') {
      return Promise.resolve(null);
    }
    // 非同期処理に入る前にプッシュメッセージ
    pushMessage(event.source.userId, waitingMessage);
    const tones = await getComposition(event.message.text);
    if (tones) {
      queueArpeggio(tones.split(','));
    }
    return client.replyMessage(event.replyToken, {
      type: 'text',
      text: tones || chordDoesNotExistMessage
    });
  }

  const pushMessage = async (userId, message) => {
    await client.pushMessage(userId, {
      type: 'text',
      text: message,
    });
  }

  const chordApiUrl = 'https://api.uberchord.com/v1/chords/';
  const getComposition = async (chordName) => {
    const res = await axios.get(chordApiUrl + encodeURIComponent(chordName));
    return res.data.length ? res.data[0].tones : "";
  }

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

main();
```

# 工夫したところ
Chordが鳴っている最中にLINEで新たなChordを送信しても、割り込みが起きないようにしています。前の音が最後まで鳴ってから、次の音が鳴り始めます。
キューを用意してあり、送信されたChordは構成音に分解後、エンキューされます。
スピーカーを操作するfunctionは、3200msに1度Chordの構成音をデキューします。次の音を取り出すまでの3200msの間、そのChordをアルペジオし続けます。
これにより、Chordが途切れないようメッセージを送信し続けることで、曲を演奏しているかのように音を再生し続けることが可能になります。