DiscordのStage Channelに読み上げbotを導入する

この記事は Akatsuki Games Advent Calendar 2023 11日目の記事です。

昨日は tomotaka-yamasakiさんの「AWS Device Farm の adb プロセスを Airtest に認識してもらうための方法」でした。adbのパス決定ロジックに関する問題意識と調査、解決までが分かりやすくまとめられていて、とても参考になる記事でした!

きっかけ

皆さん、Discordはお使いですか?元々はゲーマー向けのボイスチャットアプリでしたが、コロナ禍を契機に一気に非ゲーマー層にも普及した感覚があります。 「電話をかける」のではなく、「部屋に入る」ような感覚でVCができて使いやすいです。個人的にも大好きなサービスで、毎日利用しています。 そんな便利なDiscordですが、最近Stage Channelという機能が追加されました。

Stage Channelに関しての詳細な解説は公式のFAQを見ていただければと思いますが、要するに登壇スペースを仮想的に再現する特殊なボイスチャンネルです。 通常のボイスチャンネルでは入退室音が鳴りますが、Stage Channelでは入退室音は再生されませんし、発話する際も、発話者になる権限を持っているか、あるいは「発話したい」ボタンを押下し、モデレーターから発話許可を貰う必要があります。 この特殊チャンネルはなかなか便利で、私の所属するコミュニティではこのチャンネルを使ってLT会のようなイベントを開催したりしています。

ですが、このStage Channelでイベントを行う際、一つ課題が発生しました。 Stage Channelを利用する目的として「他の人の入退室音や発話によって、発表者の邪魔をしたくない」というモチベーションが一つあると思いますが、とはいえ一人きりで喋るのは寂しいものです。なので参加しているリスナーもコメントなどで反応をするわけですが、シングルディスプレイの発表者は(画面共有をしている前提です)そのコメントを拾えない/拾いづらいのです。

これを解消するべく、Stage Channelに読み上げbotを招待し、状況に応じてコメントを読み上げることで、臨場感のある発表をDiscord上でできるようにしたい、というのが今回の記事執筆のきっかけとなります。 既存のbotで対応しているものがないか軽く調べたのですが、見つからず(多分あるとは思うのですが自分の検索能力では発見できませんでした)、またWeb上でもあまりステージチャンネル上での読み上げといった記事は確認できず。同じような課題感を持つ人の一助になれば幸いです。

用意するもの

というわけで、今回はVOICEVOXというOSSの読み上げエンジンと、同じくOSSのdiscord.jsというdiscordのAPIライブラリを利用して、自前のbotを構築していこうと思います。

実装

一言で言ってしまうと、「botの入退室時にステージチャンネルであるかを確認し、ステージチャンネルであれば自動で発話者にする」という処理を行えば良いです。 コード的には以下でおおよそ方が付きます。

  const guild = interaction.guild;
  const member = await guild?.members.fetch(interaction.user.id);
  const memberVC = member?.voice.channel;

  // 接続処理など

  if (memberVC?.type === ChannelType.GuildStageVoice) {
    guild?.members.me?.voice.setSuppressed(false);
  }

とはいえそれだけではあまりにもなので、もう少しこの記事は続きます。

VOICEVOXを用意する

嬉しいことにVOICEVOXにはDockerイメージが用意されており、手元で簡単に音声合成環境を用意することができます。ありがたいですね。 Docker及びdocker-composeを利用して、まずはVOICEVOXのコンテナを用意します。

今回はCPU版を利用しますが、より高速/低負荷に音声合成を行いたい場合、GPU版の選択肢もあります。

ついでに、下記で用意する自前のアプリケーションコンテナ用に、アプリケーションサービスの記述もしておきます。

version: '3.8'
services:
  app:
    build: .
    environment:
      - DISCORD_TOKEN=your_token

  voicevox_engine:
    image: voicevox/voicevox_engine:cpu-ubuntu20.04-latest

botを召喚するためのslash commandを用意

公式ドキュメントを参考に、接続部分及びハンドリング部分を記述します

import { ChannelType, Interaction } from 'discord.js';
import { SlashCommandBuilder } from '@discordjs/builders';
import { joinVoiceChannel } from '@discordjs/voice';

module.exports = {
  data: new SlashCommandBuilder()
    .setName('join')
    .setDescription('Join a voice channel'),
  async execute(interaction: Interaction) {
    const guild = interaction.guild;
    const member = await guild?.members.fetch(interaction.user.id);
    const memberVC = member?.voice.channel;

    // コマンド実行者がVCに接続していない場合は何もしない
    if (!memberVC) return;

    // VCに接続
    joinVoiceChannel({
      channelId: memberVC.id,
      guildId: memberVC.guild.id,
      adapterCreator: memberVC.guild.voiceAdapterCreator,
    });

    // Stage Channelの場合はスピーカーをオンにする
    if (memberVC?.type === ChannelType.GuildStageVoice) {
      guild?.members.me?.voice.setSuppressed(false);
    }
  },
};

音声合成の準備

VOICEVOXのAPIドキュメントを参考に、音声合成を行う関数を定義します

async function generateVoice(text: string): Promise<ArrayBuffer> {
  // 音声合成用クエリを発行
  const query = await axios.post('http://voicevox_engine:50021/audio_query', {
    params: {
      text: text,
    },
  });

  // 音声合成し、結果をArrayBufferで返却
  const voice = await axios.post(
    'http://voicevox_engine:50021/synthesis',
    query.data,
    {
      responseType: 'arraybuffer',
    }
  );

  return voice.data;
}

// ArrayBufferからストリームを作成する
function bufferToStream(buffer: ArrayBuffer) {
  const stream = new Readable();
  stream.push(buffer);
  stream.push(null);
  return stream;
}

受信したメッセージを読み上げる

上記で定義した関数を利用し、Discord上のメッセージ読み上げを実行します。 なお、下記コードはあくまでエッセンスなので、実運用では必要なハンドリングを追加してください。

client.on(Events.MessageCreate, async (message) => {
  const connection = getVoiceConnection(message.guildId);
  if (!connection) return;

  const player = createAudioPlayer();
  const buf = await generateVoice(message.content);
  const stream = bufferToStream(buf);
  const resource = createAudioResource(stream, {
    inputType: StreamType.Arbitrary,
  });

  player.play(resource);
  connection.subscribe(player);
});

アプリコンテナのDockerfile

用意します。マルチステージビルドを利用すると、イメージのサイズをコンパクトにできますし、distrolessイメージを利用することでコンテナをセキュアにすることができます。

# Build
FROM node:20-slim as build
WORKDIR /app

COPY . .
RUN npm i
RUN npm run build


# Run
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app

COPY --from=build /app/dist/index.js index.js

CMD [ "index.js" ]

実行

$ docker compose up -d でアプリケーションが立ち上がります。

終わりに

自分のスケジューリングがガバガバなため公開日はだいぶ駆け足の記事が公開されている羽目になっていると思いますが、上記コードを利用することで無事にステージチャンネルで読み上げをすることができました。

今回のコードはあくまで簡易的なbotですので、例えば読み上げ音声の再生が終わっていないときに追加のメッセージを受信しても無視されてしまったりなど、人によっては困る挙動があるかと思います。 Queueingするか、上書きして再生するか(ちなみにこの挙動の実装は昔やったことがあるのですが、少々手こずった記憶があります)などの選択肢があると思いますが、利用環境に合わせて拡張できるのが自前でbotを実装するメリットですね。

とはいえ、OSSを利用することでサクッと読み上げbotの機構を作成できるのは本当に便利です。

明日の記事は蘇俐文さんの「Doxygenでコードを分析して、CIで自動コメントしてみた」です。お楽しみに!