経緯
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版使っています。
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
にアクセスできるか確認。
アプリ側はこんな感じ。
services:
node-web-app:
build: .
restart: always
ports:
- "8084:8084"
depends_on:
- "voicevox-engine"
Dockerfileでdiscord.jsとffmpegのインストールをします。
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サーバーへの招待をしておくこと。
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
Discordでテキスト読み上げ
discord.jsの実装です
- Voiceチャネルへの接続
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チャネルからの切断
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
でテキストを受信します。
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ファイルへ変換します。
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ファイルを再生します。
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