discord.js+VOICEAVOXでdiscordのテキスト読み上げbot作成

# 経緯
discord.py + openjtalkでdiscordのテキスト読み上げbot環境を構築していましたが、
discord.pyの開発終了受けて別の読み上げbotを作ろうかなと考えていました。

※openjtalkで間延びする問題も気になっていましたが、パッチはでているみたいです。
https://www.techscore.com/blog/2015/06/29/open-jtalk-japanese-text/
https://gist.github.com/ikegami-yukino/51cb4feb0739412be2e1


# 環境
* Docker
https://www.docker.com/
* discord.js
https://discord.js.org/#/
* VOICEVOX
https://voicevox.hiroshiba.jp/

# Docker構築

VOICEVOXはエディタ/エンジン/コアの3つのモジュールに分かれていますが、今回はエンジンを使用します。
* Github
https://github.com/VOICEVOX/voicevox_engine
* Dockerイメージ
https://hub.docker.com/r/voicevox/voicevox_engine

※私はDockerイメージ使用しました。CPU版とGPU版ありますが、CPU版使っています。
```docker-compose.xml
version: "3"
services:
  voicevox-engine:
    container_name: voicevox-engine
    image: voicevox/voicevox_engine:cpu-ubuntu20.04-latest
    restart: always
    ports:
      - "50021:50021"
```

以下コマンドで起動します。
```
docker-compose build
docker-compose up
```

起動成功したら、
http://localhost:50021/docs
にアクセスできるか確認。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20061/1d48743a-36bf-aa00-c57a-45f09944c627.png)


アプリ側はこんな感じ。
```docker-compose.xml
services:
  node-web-app:
    build: .
    restart: always
    ports:
      - "8084:8084"
    depends_on:
      - "voicevox-engine"
```

Dockerfileでdiscord.jsとffmpegのインストールをします。
```Dockerfile
FROM node:17

WORKDIR /usr/src/app
COPY package*.json ./

RUN npm install
RUN npm install discord.js
RUN npm install @discordjs/voice @discordjs/opus tweetnacl
RUN npm install axios --save
RUN apt-get update
RUN apt-get -y install ffmpeg

COPY . .
EXPOSE 50021
CMD [ "node", "index.js" ]
```

必要そうな箇所のみ抜粋。
DISCORD_TOKENは事前に取得して.envに設定しておくこと。
事前にDiscordサーバーへの招待をしておくこと。
```index.js
const { getVoiceConnection, createAudioResource, StreamType, createAudioPlayer, NoSubscriberBehavior, generateDependencyReport } = require("@discordjs/voice");

const { Client, Intents } = require('discord.js')
const client = new Client({
    intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_VOICE_STATES, Intents.FLAGS.GUILD_MESSAGES]
  });
 
const dotenv = require('dotenv');
dotenv.config()

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

client.login(process.env.DISCORD_TOKEN)
```


起動していた場合、Ctrl+Cで終了。再度コマンドで起動します。
```
docker-compose build
docker-compose up
```

コンソールに`Ready!`が表示されればOK!!
![スクリーンショット 2022-03-30 010603.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20061/7ef55759-bc50-8a54-5114-10d6443f60a7.png)


# Discordでテキスト読み上げ
discord.jsの実装です

* Voiceチャネルへの接続
```join.js
const { joinVoiceChannel } = require("@discordjs/voice");
module.exports = {
    data: {
        name: "join",
        description: "Join voice channel!"
    },
    async execute(interaction) {
        const guild = interaction.guild;
        const member = await guild.members.fetch(interaction.member.id);
        const memberVC = member.voice.channel;
        if (!memberVC) {
            console.log("接続先のVCが見つかりません。")
        }
        if (!memberVC.joinable) {
            console.log("VCに接続できません。")
        }
        if (!memberVC.speakable) {
            console.log("VCで音声を再生する権限がありません。")
        }
        const connection = joinVoiceChannel({
            guildId: guild.id,
            channelId: memberVC.id,
            adapterCreator: guild.voiceAdapterCreator,
            selfMute: false,
            selfDeaf: true,
        });
        await interaction.reply('Join VC');
        return;
    },
}
```


* Voiceチャネルからの切断
```bye.js

const { getVoiceConnection } = require("@discordjs/voice");
module.exports = {
    data: {
        name: "bye",
        description: "Disconnect voice channel!"
    },
    async execute(interaction) {
        const guild = interaction.guild;
        const member = await guild.members.fetch(interaction.member.id);
        const memberVC = member.voice.channel;
        if (!memberVC) {
            console.log("接続先のVCが見つかりません。")
        }
        if (!memberVC.joinable) {
            console.log("VCに接続できません。")
        }
        if (!memberVC.speakable) {
            console.log("VCで音声を再生する権限がありません。")
        }

        const connection = getVoiceConnection(memberVC.guild.id);
        connection.destroy();
        await interaction.reply('Bye VC');
    },
}
```

* テキスト読み上げ
`messageCreate`でテキストを受信します。
```index.js
client.on('messageCreate', async (msg) => {
  if (msg.author.bot) {
      return;
  }
  const filepath = "sounds/" + msg.author.id + ".wav"
  var voice = voiceMap.get(msg.author.id);
  if (!voice) {
      voice = default_voice;
  }

  var message = convertMessage(msg.cleanContent);
  await generateAudio(message, filepath, voice);
  await play(msg, filepath)
});
```

generateAudioでVOICEVOXのAPI叩いて、wavファイルへ変換します。
```index.js
async function generateAudio(text, filepath, voice) {
  /* まずtextを渡してsynthesis宛のパラメータを生成する、textはURLに付けるのでencodeURIで変換しておく。*/
  const audio_query = await rpc.post('audio_query?text=' + encodeURI(text) + '&speaker=' + voice, {
      headers: { 'accept': 'application/json' },
  })

  //audio_queryで受け取った結果がaudio_query.dataに入っている。
  //このデータをメソッド:synthesisに渡すことで音声データを作ってもらえる
  //audio_query.dataはObjectで、synthesisに送る為にはstringで送る必要があるのでJSON.stringifyでstringに変換する
  const synthesis = await rpc.post("synthesis?speaker=" + voice, JSON.stringify(audio_query.data), {
      responseType: 'arraybuffer',
      headers: {
          "accept": "audio/wav",
          "Content-Type": "application/json"
      }
  });

  //受け取った後、Bufferに変換して書き出す
  fs.writeFileSync(filepath, new Buffer.from(synthesis.data), 'binary');
}
```

wavファイルを再生します。
```index.js
async function play(interaction, filepath) {
  const connection = await getConnection(interaction);

  if (!connection) {
    console.log("VCに接続していません。")
    return;
  }
  const resource = createAudioResource(filepath, { inputType: StreamType.Arbitrary });
  const player = createAudioPlayer({
    behaviors: {
      noSubscriber: NoSubscriberBehavior.Pause,
    },
  });
  player.play(resource);

  connection.subscribe(player);
}
```

いろいろ割愛していますが、ソースコードをGithubにあげているので確認してみてください！
https://github.com/gettingsignals/discord-voicebox

# その他
## Discordのbot権限

![スクリーンショット 2022-03-30 010601.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20061/d9c50ce8-d469-06b8-2203-de55c58484fc.png)

![スクリーンショット 2022-03-30 010602.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20061/82c01e10-f15d-0bb3-99a3-b7d1a7a4e3e5.png)