discord.js で ボイスチャットの音声録音ボットを作ってみる

<font color='red'>
**2019/11/06現在 この記事通りに音声を受信できません。推測ですが、Discord側の仕様が変わった可能性があります**
</font>

# 1. 概要
node用 discord botライブラリ discord.jsを使用して、ボイスチャット音声を録音する方法を紹介します(a)。
また、併せて筆者のネット環境で発生したNAT越えの問題の解決方法を紹介します(b)。
(b) は当方の環境(NAT)でうまく動いただけで、必ずしも問題解決ができるとは限りませんが、何らかの参考になれば幸いです。

**<font color="OrangeRed">※追記 2019/05/19</font>** 
**<font color="OrangeRed">(b): NATがないレンタルサーバでも (b) 4.4の修正を行わないと、音声を受信できませんでした。よって、NATのある/なしは根本原因ではないようです。でも、とりあえず動きました。</font>**
NATのない、レンタルサーバで(a)のコードを実行したところ、音声収録ができませんでした。しかし、(b)の修正を行うことで、実行可能でした。
なので、(b)では、『NAT越えの問題』と書いてありますが、関係がない可能性があります。(どのような理屈なのかはわかっていません)

動作確認をしたサーバ
- sakura VPS 2G
- AWS CE2(Amazon Linux 2 AMI, C5d.xlarge)

# 2. 動作確認環境
- プロバイダ: JCOM
- NAT : HG100R-02JG 
- マシン : iMac Late 2015
- OS : mac OSX 10.14.1 (Mojave)
- node : v8.12.0
- **discord.js: 11.4.2**

## 3. (a) discord.js でボイスを収録する
ありがたいことに Évelyne Lachance(eslachance) 様がサンプルプログラムを公開してくださっております。
https://gist.github.com/eslachance/fb70fc036183b7974d3b9191601846ba

NAT超えの問題がなければ、設定ファイル auth.jsonを追加するだけで、お手軽にボイスの録音ができます。
動作確認しやすいように、package.jsonなどを追加したレポジトリを用意しましたのでこちらを使用すること前提で説明します。
https://github.com/YoshikazuOota/discord_voice

```javascript:v9-voice-reveive.js
const Discord = require("discord.js");
const fs = require('fs');

const client = new Discord.Client();

const config = require('./auth.json');

// make a new stream for each time someone starts to talk
function generateOutputFile(channel, member) {
    // use IDs instead of username cause some people have stupid emojis in their name
    const fileName = `./recordings/${channel.id}-${member.id}-${Date.now()}.pcm`;
    return fs.createWriteStream(fileName);
}

client.on('message', msg => {
    if (msg.content.startsWith(config.prefix+'join')) {
        let [command, ...channelName] = msg.content.split(" ");
        if (!msg.guild) {
            return msg.reply('no private service is available in your area at the moment. Please contact a service representative for more details.');
        }
        const voiceChannel = msg.guild.channels.find("name", channelName.join(" "));
        //console.log(voiceChannel.id);
        if (!voiceChannel || voiceChannel.type !== 'voice') {
            return msg.reply(`I couldn't find the channel ${channelName}. Can you spell?`);
        }
        voiceChannel.join()
            .then(conn => {
                msg.reply('ready!');
                // create our voice receiver
                const receiver = conn.createReceiver();

                conn.on('speaking', (user, speaking) => {
                    if (speaking) {
                        msg.channel.sendMessage(`I'm listening to ${user}`);
                        // this creates a 16-bit signed PCM, stereo 48KHz PCM stream.
                        const audioStream = receiver.createPCMStream(user);
                        // create an output stream so we can dump our data in a file
                        const outputStream = generateOutputFile(voiceChannel, user);
                        // pipe our audio data into the file stream
                        audioStream.pipe(outputStream);
                        outputStream.on("data", console.log);
                        // when the stream ends (the user stopped talking) tell the user
                        audioStream.on('end', () => {
                            msg.channel.sendMessage(`I'm no longer listening to ${user}`);
                        });
                    }
                });
            })
            .catch(console.log);
    }
    if(msg.content.startsWith(config.prefix+'leave')) {
        let [command, ...channelName] = msg.content.split(" ");
        let voiceChannel = msg.guild.channels.find("name", channelName.join(" "));
        voiceChannel.leave();
    }
});

client.login(config.token);

client.on('ready', () => {
    console.log('ready!');
});
```

### 3.1 準備
下記のgit clone または、ダウンロードをしてファイル一式を取得してください

```console
git clone git@github.com:YoshikazuOota/discord_voice.git
```

### 3.2 依存ライブラリのインストール
package.json も用意しましたので、npm install で一括インストールできます

```console
npm install
```

次に、下記コマンドで "opusscript" をアンインストールして、"node-opus"をインストールしてください。このコマンドで、discord.js が利用するオーディオエンジンが,"node-opus"になります。本記事では"node-opus"を使用することを前提としています。

```console
npm uninstall opusscript
npm install node-opus
```

**オーディオエンジンの補足**
discord.js のオーディオエンジンは "node-opus", "opusscript" の2つが選べます。しかし、私が試した MacOS, Linux 環境では "opusscript" を使用するとクラッシュします。
”node-opus”を使うためには"opusscript"を削除して、に"node-opus"をインストールする必要があります。

### 3.3 botのトークンを設定
botのトークンを auth.josn に記述します。 
※ bot トークンの取得方法は、下記URLなどを参照願います。
http://djs-jpn.ga/make/step1.html

```json:auth.json
{
  "prefix" : "sister_",
  "token" : "MzgxMjYy....(ここにトークンを記載する)" 
}
```

### 3.4 実行・動作確認

下記コマンドで実行すると、登録しているサーバにボットがログインします。

```$xslt
node v9-voice-reveive.js
```

下記の一連の操作で音声を ./recordings 以下に保存します。
* prefix の設定は 3.3にあるように "sister_"とします
 
- (1) 適当なボイスチャットチャンネル"妹雑談" を作ります
- (2) テキストチャットから ”sister_join 妹雑談”と入力  
- (3) botが ”ready!” と返事をすれば、録音可能です
- (4) (3) の状態で発話するとその内容を保存します


![スクリーンショット 2018-12-18 14.49.26.png](https://qiita-image-store.s3.amazonaws.com/0/12429/7750a4cc-4d4b-aa19-20b2-4f2427d56f05.png)

<img src="https://qiita-image-store.s3.amazonaws.com/0/12429/7addd1a2-d850-7b15-fb1d-717e5fec112b.png" width=100px>
＊bot アイコンは キヨイチ様に制作していただきました(https://twitter.com/kiyoichi261)


### 3.5 録音ファイルの再生方法
./recordings の 3.4(4)で録音したファイルを見てみましょう。
もし、ファイルサイズが 0であれば、音声データがNAT超えできておらず、録音ができておりません。もしかすると、後述する方法で対応可能かもしれません。

もし音声ファイルサイズが0以上であれば、おそらく正常に録音できており、audacityで再生できます。
audacityの 生データ取り込みでを選択して

- 量子化: singed 16 bit 
- チャンネル : 2ch(ステレオ)
- サンプリング : 48000Hz

とすることで、取り込みできます。

<img width="945" alt="スクリーンショット 2018-12-16 16.58.53.png" src="https://qiita-image-store.s3.amazonaws.com/0/12429/359eeea6-934d-b9d5-97f5-37e06bd3c640.png">
<img width="315" alt="スクリーンショット 2018-12-16 17.04.06.png" src="https://qiita-image-store.s3.amazonaws.com/0/12429/8809c9ed-1491-fc0b-0bd0-bef11b02f566.png">

## 4. (b) NAT超え問題の解決方法
discord の api では 音声データはUDPで送信され、NAT越えしてボットを動かしているPCで受信できるようにする必要があります。
NATと discord.jsのUDPポートパンチングの実装との相性が良ければ、そのようなことすら意識する必要がなく、(a)のようにプログラム実行するだけで、勝手にNAT越えします。
以下は(a)でうまく動かなかった筆者の対処方法になります。
（＊NATによってはこの方法で問題解決しません。あくまでも一例として参考ください)

下記の自分がやったこと順に説明いたします

- 4.1 音声データはどのうように送受信するのか？(仕様確認) 
- 4.2 discord.js のNAT越えはどのようになっているのか？ (実装確認)
- 4.3 うまく動いていないのはどこなのか？(現状確認)
- 4.4 どう解決するか？

##### 補足:UDPポートパンチングについて
UDPポートパンチングとはポートフォワードなどのNATの設定を変更することなく、UDPパケットをNAT越えして送受信させるためのテクニックです。
しかし、『UDPポートパンチング』は明確な仕様があるわけでなく、大抵の場合はうまくいくけど確実性はないものになります(規格化の動きはあるようですが...)。

https://ja.wikipedia.org/wiki/UDP%E3%83%9B%E3%83%BC%E3%83%AB%E3%83%91%E3%83%B3%E3%83%81%E3%83%B3%E3%82%B0
https://tech-blog.cerevo.com/adventcalendar2016/advent24/

### 4.1 仕様確認: 音声データはどのうように受信するのか？
まずは、何でうまく動かないのか検討がつかないため、Discord APIページのVoice connectionsを読んでそれらしい原因を探しました。
https://discordapp.com/developers/docs/topics/voice-connections

すると、上記ページの冒頭に下記のようなことが書いてありました。

- 音声はUDPで送受信するよ
- NAT越えして受信できるよにしてね
- NAT越えはUDPポートパンチングなどがあり、そのために IP Discoveryを用意してあるよ

とのことです。音声受信以外の処理は正常に動いていることから、NAT越えに問題があるとあたりをつけました。

＊ IP Discovery ってのはUDPポートパンチング実装に必要な グローバル IP やポート番号を提供する API。

### 4.2 実装確認: discord.jsのUDPポートパンチング実装で期待している挙動
NAT越えの処理が怪しいとあたりをつけたので、discord.jsのソースを読み、NAT越えの処理を確認しました。
すると、Discordが推奨する UDPポートパンチングでNAT越えする実装がありました。

discord.js の実装やUDPポートパンチングの手法およびNATの挙動から、正常系動きを推定しました。

<img width="888" alt="スクリーンショット 2018-12-18 18.06.06.png" src="https://qiita-image-store.s3.amazonaws.com/0/12429/6fedafef-bf1a-eab7-33b7-14f42bc1a192.png">

### 4.3 現状確認: 想定外のNAT動きはなんなのか？ 
次に 4.2の推定通り動いているかを一つ一つチェックしていきました。
結果、音声データはNATまではUDPが届いているが、PCには送信されないと言うことがわかりました。

<img width="922" alt="スクリーンショット 2018-12-18 18.06.12.png" src="https://qiita-image-store.s3.amazonaws.com/0/12429/18ffdbc1-c64b-2f1c-fe71-5ea4b81516f3.png">

### 4.4 解決方法
どうやらNATまでは音声データは届いているので、これを無理やりdiscord.jsが開いているポートに突っ込むことができれば、音声受信できそうです。

乱暴な方法なりますが、

- (1) NATにポートフォワード設定して、UDPデータをPCまで持ってくる
- (2) (1)でフォワードされているUDPポートと discord.js が開いているポートを繋ぐ

の2つで解決させました。

<img width="885" alt="スクリーンショット 2018-12-18 18.09.16.png" src="https://qiita-image-store.s3.amazonaws.com/0/12429/c6b7e1fe-21c2-f603-6839-74fd44b3c7ff.png">

#### (1) NATにポートフォワード設定をする
4.2 にあるように、NATはランダムでUPDポートを決定するようなので、ウェルノウンポート(0-1023)以外を全部あけました。 
(NATが空けるUDPポートを固定できればいいのですが...)

<img width="633" alt="スクリーンショット 2018-12-17 16.39.31.png" src="https://qiita-image-store.s3.amazonaws.com/0/12429/23b5acd8-0137-5054-722f-1f7bd0082a1c.png">

#### (2) NATでフォワードされているUDPポートと discord.js が開いているポートを繋ぐ 

原始的ですが、ライブラリに手を加え
- discord.js が開けるポート番号を固定(33333番)
- NATが開けるランダムポートは IP Discovery の応答から特定
するようにしました。
具体的には下記のようにモジュールを修正しました。

node_modules/discord.js/src/client/voice/VoiceUDPClient.js
discord.js : version 11.4.2
<img width="1089" alt="スクリーンショット 2018-12-18 18.28.45.png" src="https://qiita-image-store.s3.amazonaws.com/0/12429/1f27e8be-8f6d-a511-e279-f23a7a271261.png">

＊レポジトリの下記パスに修正済みのソースがあるので、マージしてください。
<b>node_modules_modify</b>/discord.js/src/client/voice/VoiceUDPClient.js


* 変数 bind_port が 「discord.js が開いている 音声UDPポート(33333番)」
* 変数 packet.port が「NATでフォワードされるUDPポート」(ランダム)

公開レポジトリで　npm install を行なっていれば node-udp-forwarder もインストールされているので、標準出力に表示されるコマンドを実行するだけで、音声データを正常に受信できるようになります。

```console:表示コマンド例
udpforwarder --protocol udp4 --port 13698 --address 0.0.0.0 --forwarderPort 0 --forwarderAddress 0.0.0.0 --destinationPort 33333 --destinationAddress 0.0.0.0
```

#### 4.5 確認
4.4 までを行うと、今まで空ファイルだった recordings/*.pcm が、数100kb程度のファイルになると思います。
3.5 の要領で再生を行うことができれば確認終了です。

# 6 最後に
強引ですが、上記で音声を取得できるDiscordボットを作る足がかりが作れました。あまり情報がなく、手探りでやっておりエレガントさがありませんが、参考になれば幸いです。

また、この記事を書いている途中で気がついたのですが、一般にUDPポートパンチングでは『定期的なNATへのUDP通信(パンチング)』をするのですが、discord.jsでは行われていないようです。これをすることで、問題が解決するかもしれません。

本記事は以上ですが、わかりにくいところやご質問ありましたら、ぜひコメントください。出来るだけお答えいたします。