DiscordでChatGPTのレスポンスを喋らせてみた

# はじめに
コンソールの入力からでも相当便利で強力なChatGPTですが、もし音声でレスポンスが返ってくるなら更に愛着が湧くはず。
音声で出力を試みる方法は色々あるが、Discord上で動作するBOTの開発経験があったのでDiscordを選択して挑戦。

## 構成
- nodejs 16.18.1
- DiscordJS v14  
- Docker (voicevox用HTTPサーバに使用)
- ChatGPT(GPT3.5 turbo)


# 作ったもの
以下のような構成で開発。
![system.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1138401/5c76df55-f723-a05b-f297-dc46f169add5.png)

丸番号については以下の通り。
① ローカルPC上で、作成したJSおよびvoicevoxの構成が入ったDockerを起動しておく
② Discordで話しかけたいメッセージを指定の形式で送信する
③ JSが指定の形式で送信されたメッセージを拾う
④ ChatGPTへリクエスト送信。レスポンスを受信する
⑤ ChatGPTのレスポンスで受け取った文章を音声化するためにvoicevoxのAPIへリクエスト
⑥ ②と同じサーバのボイスチャンネル上でボイス再生

# コード
- DiscordJSやVoicevoxの使用方法については省略します
- 各種キーは各々取得して環境変数なりなんなりで補完してください
- 余計な宣言が入ってると思われるので各自で加工してください
- Discord上で、「!talk <メッセージ内容>」を受け取った想定の処理です
```index.js
const TOKEN = "<Discord BOT のトークンを入れる>";
const CLIENT_ID = "<Discord BOT のクライアントIDを入れる>";
const { 
  Discord,
  REST, 
  Routes, 
  Client, 
  Partials,
  GatewayIntentBits,
  ModalBuilder, 
  ActionRowBuilder,
  TextInputStyle,
  TextInputBuilder,
  VoiceChannel
} = require('discord.js');
const { 
  joinVoiceChannel,
  entersState,
  VoiceConnectionStatus,
  createAudioResource,
  StreamType,
  createAudioPlayer,
  AudioPlayerStatus,
  NoSubscriberBehavior,
  generateDependencyReport
} = require("@discordjs/voice");
console.log(generateDependencyReport());
const client = new Client({
  restRequestTimeout: 60000, 
  intents: [
    GatewayIntentBits.Guilds, 
    GatewayIntentBits.GuildVoiceStates, 
    GatewayIntentBits.MessageContent,
    GatewayIntentBits.GuildMessages
  ], 
  partials: [Partials.Channel]});
const { exec } = require('child_process');
const { default: axios } = require("axios");
const fs = require("fs");

const rest = new REST({ version: '10' }).setToken(TOKEN);
const rpc = axios.create({ baseURL: "http://127.0.0.1:50021", proxy: false });

(async () => {
  try {
    console.log('Started refreshing applications.');
  } catch (error) {
    console.error(error);
  }
})();

//スタート
client.on('ready', () => {
  console.log(`Logged in as ${client.user.tag}!`);
});

//返答
client.on('messageCreate', async message => {
    if (message.author.bot) {
        return;
    }
    let restext = "";
    if (message.content.includes('!talk')) {
        //受信メッセージ加工("!talk "を消す)
        let text = message.content.slice(6);
        console.log(text);
        await fetchResponse(text).then(
        response_text => {
          restext = response_text;
          exec("rm audio.wav");
          exec("rm text.txt");
          return new Promise((resolve, reject) => {
            exec('echo "' + response_text + '" > text.txt', (err, stdout, stderr) => {
              if (err) {
                console.error(err);
                reject(err);
              } else {
                console.log("text output OK");
                resolve();
              }
            });
          });
        }).then(() => {
          //voicevox関係の処理
          console.log("genaudio");
          return genAudio(restext,"audio.wav");
        }).then(() => {
          //メッセージ送信
          console.log("then");
          const channel = message.member.voice.channel;
          if (!channel) return message.reply('ボイスチャンネルに接続していません。');
          playAudio(channel);
          message.reply(restext);
      });
    }
});

async function fetchResponse(text) {
  //ChatGPTの処理
  const base_url = "<ChatGPT APIのURL>";
  const api_key = "<ChatGPTのAPIキー>";

  const data = {
    model: "gpt-3.5-turbo",
    messages: [ { role: "system", content: "<BOTの設定などを記載するとよい>"},
                { role: "user", content: text }],
    top_p: 0.8
  };

  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${api_key}`
  };

  const response_text = "";
  try {
    const response = await axios.post(base_url, data, { headers });
    const result = response.data;
    const response_text = result.choices[0].message.content;
    return response_text;
  } catch (error) {
    console.error(error);
    const response_text = "なんかエラー！";
  }
}

async function genAudio(text, filepath) {
  /* まずtextを渡してsynthesis宛のパラメータを生成する、textはURLに付けるのでencodeURIで変換しておく。*/
  const audio_query = await rpc.post('audio_query?text=' + encodeURI(text) + '&speaker=30');

  //audio_queryで受け取った結果がaudio_query.dataに入っている。
  //このデータをメソッド:synthesisに渡すことで音声データを作ってもらえる
  //audio_query.dataはObjectで、synthesisに送る為にはstringで送る必要があるのでJSON.stringifyでstringに変換する
  const synthesis = await rpc.post("synthesis?speaker=30", 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');
}

async function playAudio(channel) {
  console.log("Audio play!");
  const connection = joinVoiceChannel({
    channelId: channel.id,
    guildId: channel.guild.id,
    adapterCreator: channel.guild.voiceAdapterCreator,
  });
  const resource = createAudioResource("audio.wav",
    {
      inputType: StreamType.Arbitrary,
    });
  const player = createAudioPlayer({
    behaviors: {
      noSubscriber: NoSubscriberBehavior.Pause,
    },
  });
  player.play(resource);
  connection.subscribe(player);
  //音声発信が終わってもボイチャに居続けたいのであえて処理無し
  //connection.destroy();  
}

client.login(TOKEN);

```

# 参考にさせていただいた情報
この場をお借りして御礼申し上げます。
- [VOICEVOX](https://voicevox.hiroshiba.jp/)
ボイスサンプル選んだり、あの手この手考えてる時間が一番楽しかった。
- [VOICEVOX/voicevox_engine (github)](https://github.com/VOICEVOX/voicevox_engine/)
Dockerイメージを使わせていただきました。ありがとうございます。
- [@discordjs/voiceを使用して音声を再生する](https://scrapbox.io/discordjs-japan/@discordjs%2Fvoice%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%A6%E9%9F%B3%E5%A3%B0%E3%82%92%E5%86%8D%E7%94%9F%E3%81%99%E3%82%8B)
- [Node.jsからVOICEVOXを使ってみる。](https://qiita.com/ahogemarumetano/items/554e95faf8749fac8631)
execで愚直にvoicevoxの音声合成をしていた最中、この記事に出会いました。ありがとうございます！
- [VoiceConnection stuck in signalling state about a minute after creating it](https://github.com/discordjs/discord.js/issues/9185)
DiscordJS v14だと、player.play(resource)しても再生がうまく行かない事象が発生しているらしい。
本開発でも”this.configureNetworking();”を強引に突っ込んで解決。修正待ちかなあ。

# これからやりたいこと
- Looking Glassを活用して３Dっぽく
- ローカルではなくどこかにデプロイ
- ChatGPTが429吐くとどうしようもないのでエラーのハンドリング

# その他
- 当初はスラッシュコマンドでメッセージを送る予定だったが、送信するメッセージ量が多いとDiscordJSがエラー吐くので断念した
- コードもChatGPTに書かせたりしながら、設計->完成までかなり短時間でできたことがとても良かった
- メッセージだけでなく音声でレスポンスがあるとやっぱり違う