💬

discord v14でGPTと音声通話が出来るBOTを作成した

2023/03/31に公開5

nodejsの環境にてGPTと音声通話が出来る仕組みを作りました
nodejs自体普段触らないし、streamとかpipeも今回で始めて知った程度です。
discordjsも今回始めて触ったので使い方がおかしい点はあると思います。
なのでソースがやばいのはご勘弁。というか公開する意図として興味ある人が更に改良版作って公開してほしいなと思った所です。
しかも大体のコードはchatGPTと会話しながら作りました。
丸2日くらいでしたね。

現状抱えてる問題は
エンコードが下手くそなのか受信が下手くそなのか
エンコードするときのBufferにNaNが混入しててエンコードされたwavファイルの音質が悪すぎて
うまく文字起こしが出来ていない

あとヒト発音で2回streamが起きる事があって多重に動いてしまう事がある
これはstreamを破棄するとかが甘いのが問題なんじゃないかと思う

環境
"node": "16.14.2"
"discord.js": "^14.8.0",
"@discordjs/voice": "^0.15.0",
"@discordjs/opus": "^0.9.0"

やった事
ディスコードボイスチャンネル音声を取得
(自分とBOTがボイスチャンネルに居る状態)

oups音声のバイナリをPCMにデコード、PCMをwavエンコード

wavをopenAIのwhisper APIに送信して音声を文字起こし

文字起こしされたテキストをgpt3.5-turboのAPIに送って返答のテキストを得る

返答のテキストをGoogle text to speechのAPIに送信して音声化

音声ファイルをDiscordのBOTに送信して会話が成立

以下ソース全文

    if (message.content === 'BOT起動') {
      // メッセージを送信したユーザーが接続しているボイスチャンネルに参加する
      const voiceChannel = message.member.voice.channel;
      if (!voiceChannel) return message.reply('ボイスチャンネルに接続してください');
      const connection = joinVoiceChannel({
        channelId: voiceChannel.id,
        guildId: voiceChannel.guild.id,
        adapterCreator: voiceChannel.guild.voiceAdapterCreator,
        selfDeaf: false
      });
  console.log(`Connected to ${voiceChannel.name}`);
  // 音声入力のストリームを作成する
  const { createAudioPlayer } = require('@discordjs/voice');
  
  const player = createAudioPlayer();
  // プレイヤーを音声接続オブジェクトに接続する
  connection.subscribe(player);      

// `connection` は接続された `VoiceConnection` オブジェクトです。
connection.receiver.speaking.on('start', (userId) => {  
  
  const audio = connection.receiver.subscribe(userId, {
    end: {
      behavior: EndBehaviorType.AfterSilence,
      duration: 100,
    },
  });
  
const leftOpusStream = [];
const rightOpusStream = [];

let isLeftChannel = true; // 初めは左チャンネル

audio.on('data', (chunk) => {
  const decodedChunk = decodeOpus(chunk);
  
  if (isLeftChannel) {
    leftOpusStream.push(decodedChunk);
  } else {
    rightOpusStream.push(decodedChunk);
  }
  
  isLeftChannel = !isLeftChannel; // チャンネルを切り替える
});

  audio.on('end', async () => {
    console.log(`Stream from user ${userId} has ended`);
const leftPcmDataArray = await Promise.all(leftOpusStream);
const leftConcatenatedBuffer = Buffer.concat(leftPcmDataArray);
const rightPcmDataArray = await Promise.all(rightOpusStream);
const rightConcatenatedBuffer = Buffer.concat(rightPcmDataArray);    
//const concatenatedBuffer = Buffer.concat(pcmDataArray);
const wavDataPromise = encodeWav(leftConcatenatedBuffer,rightConcatenatedBuffer);
audio.destroy();
  wavDataPromise.then((wavData) => {
    whisper(player,wavData);
  }).catch((error) => {
    console.error(error);
  }).finally(() => {
    audio.destroy();
  });
  });
});

async function decodeOpus(opusStream) {
  return new Promise((resolve, reject) => {
    const opusDecoder = new OpusEncoder(48000, 2);
    const pcmData = opusDecoder.decode(opusStream);
    resolve(pcmData);
  });
}

async function encodeWav(leftPcmDataArray,rightPcmDataArray) {
const arr1 = new Float32Array(leftPcmDataArray.buffer);
const arr2 = new Float32Array(rightPcmDataArray.buffer);

  const wavData = await encode({
    format: 'wav',
    sampleRate: 48000,
    channelData: [arr1,arr2],
  });
const now = new Date();
const fileName = `${now.getHours()}h${now.getMinutes()}m${now.getSeconds()}s.wav`;
const filePath = `/tmp/${fileName}`;

fs.writeFileSync(filePath, Buffer.from(wavData), { encoding: 'binary' });

  return filePath;
}

    }
  

async function whisper(player,wavData) {
  const { Configuration, OpenAIApi } = require("openai");
  const configuration = new Configuration({
    apiKey: process.env.OPENAI_API_KEY,
  });
  const openai = new OpenAIApi(configuration);

  try {
    const data = await fs.promises.readFile(wavData);
    const base64 = Buffer.from(data).toString('base64');
    const resp = await openai.createTranscription(
      fs.createReadStream(wavData,),
      "whisper-1"
    );
    console.log(resp.data.text);
  if ((resp.data.text ?? "").length > 3){  
try {    
    
const { Configuration, OpenAIApi } = require("openai");

const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);

const completion = await openai.createChatCompletion({
  model: "gpt-3.5-turbo",
  temperature: 1,
  max_tokens: 500,
  messages: [{role: "system",content:"botの設定、キャラ作りとか"},{role: "user", content: "指示:必ず日本語で回答をしてください。\n内容:" + resp.data.text}],
});  
  client.channels.cache.find((ch) => ch.name === "general").send(completion.data.choices[0].message);
  const responseText = completion.data.choices[0].message.content;
        // 返答をGoogle Cloud Text-to-Speech APIで音声に変換して再生する
  console.log(responseText)
      const [responseAudio] = await ttsClient.synthesizeSpeech({
        input: { text: responseText },
        voice: { languageCode: 'ja-JP', name:"ja-JP-Standard-C", ssmlGender: 'MALE' },
        audioConfig: { audioEncoding: 'MP3' },
      });

// base64データを保存する
const now = new Date();
const buffer = Buffer.from(responseAudio.audioContent, 'base64');
const filename = `audio_${now.getHours()}${now.getMinutes()}${now.getSeconds()}.mp3`;
const url = `/tmp/${filename}`;
fs.writeFile(url, buffer, (err) => {
  if (err) {
    console.error(err);
  } else {
    console.log(`File saved as ${filename}`);
  }
});  

            // Performs the text-to-speech request
            // 音声ファイルを書き出します。
            const writeFile = util.promisify(fs.writeFile);
            await writeFile(url, responseAudio.audioContent, 'binary');
    
const resource2 = createAudioResource(url);
        player.play(resource2);
  
    }catch (error) {
      if (error.response) {
        console.log(error.response);
        client.channels.cache.find((ch) => ch.name === "general").send("openAI落ちてね?" + error.response.status);
      } else {
        client.channels.cache.find((ch) => ch.name === "general").send(error.message);
      }
    }}
  
  } catch (err) {
    console.error(err);
  }
}

少し解説

    if (message.content === 'BOT起動') {
      // メッセージを送信したユーザーが接続しているボイスチャンネルに参加する
      const voiceChannel = message.member.voice.channel;
      if (!voiceChannel) return message.reply('ボイスチャンネルに接続してください');
      const connection = joinVoiceChannel({
        channelId: voiceChannel.id,
        guildId: voiceChannel.guild.id,
        adapterCreator: voiceChannel.guild.voiceAdapterCreator,
        selfDeaf: false
      });

諸々割愛してますが
client.on('messageCreate', async message => {で
message検知してmessage.contentに発言された内容が入ってくるので
今回は「BOT起動」というワードに反応して発言した人がいるVCにBOTがJOINする
入ってなかったら接続してくださいって発言する

// `connection` は接続された `VoiceConnection` オブジェクトです。
connection.receiver.speaking.on('start', (userId) => {  
  
  const audio = connection.receiver.subscribe(userId, {
    end: {
      behavior: EndBehaviorType.AfterSilence,
      duration: 100,
    },
  });
  
const leftOpusStream = [];
const rightOpusStream = [];

let isLeftChannel = true; // 初めは左チャンネル

audio.on('data', (chunk) => {
  const decodedChunk = decodeOpus(chunk);
  
  if (isLeftChannel) {
    leftOpusStream.push(decodedChunk);
  } else {
    rightOpusStream.push(decodedChunk);
  }
  
  isLeftChannel = !isLeftChannel; // チャンネルを切り替える
});

  audio.on('end', async () => {
    console.log(`Stream from user ${userId} has ended`);
const leftPcmDataArray = await Promise.all(leftOpusStream);
const leftConcatenatedBuffer = Buffer.concat(leftPcmDataArray);
const rightPcmDataArray = await Promise.all(rightOpusStream);
const rightConcatenatedBuffer = Buffer.concat(rightPcmDataArray);    
//const concatenatedBuffer = Buffer.concat(pcmDataArray);
const wavDataPromise = encodeWav(leftConcatenatedBuffer,rightConcatenatedBuffer);
audio.destroy();
  wavDataPromise.then((wavData) => {
    whisper(player,wavData);
  }).catch((error) => {
    console.error(error);
  }).finally(() => {
    audio.destroy();
  });
  });
});

connection.receiver.speaking.on('start', (userId) => {
でユーザーの発音を検知する。
discordのVCで言うアイコンの周りが緑に光って喋ってる所です。
これが一度光って消えるまでを検知しています。なので
端的に言うと二度以上の発光で言葉を伝えると切れちゃうので会話になりません。

end: {
  behavior: EndBehaviorType.AfterSilence,
  duration: 100,
}
これで発音終了の定義をしている。
簡単に言うと喋り終えて100ms経ったら終わりだよってことだと思います。
詳しくはドキュメント見て

const leftOpusStream = [];
const rightOpusStream = [];

let isLeftChannel = true; // 初めは左チャンネル

audio.on('data', (chunk) => {
const decodedChunk = decodeOpus(chunk);

if (isLeftChannel) {
leftOpusStream.push(decodedChunk);
} else {
rightOpusStream.push(decodedChunk);
}

isLeftChannel = !isLeftChannel; // チャンネルを切り替える
});
ここが結構困った所。
なんで左と右で配列分けているかというと
discordから送られてくる音声は2ch(ステレオ)になっているので、
後でwavとかにエンコードする時にチャンネル分けて保存してエンコードしないといけない
discordから送られてくる音声はopus形式でエンコードされたバイナリファイルである
なので一旦デコードの処理が必要になる
一行ずつデコードして保存しないとエラーになるので一旦decodeOpus関数に投げてからそれぞれの配列にpushしている
左右の音声データは交互に一行ずつ送られてくる為pushする度に左右を振り分けてpushしている

audio.on('end', async () => {
はendを検知した時に発火してstreamを破棄したり
エンコードしたりwhisperに送ったりをしている。

async function decodeOpus(opusStream) {
return new Promise((resolve, reject) => {
  const opusDecoder = new OpusEncoder(48000, 2);
  const pcmData = opusDecoder.decode(opusStream);
  resolve(pcmData);
});
}

async function encodeWav(leftPcmDataArray,rightPcmDataArray) {
const arr1 = new Float32Array(leftPcmDataArray.buffer);
const arr2 = new Float32Array(rightPcmDataArray.buffer);

const wavData = await encode({
  format: 'wav',
  sampleRate: 48000,
  channelData: [arr1,arr2],
});
const now = new Date();
const fileName = `${now.getHours()}h${now.getMinutes()}m${now.getSeconds()}s.wav`;
const filePath = `/tmp/${fileName}`;

fs.writeFileSync(filePath, Buffer.from(wavData), { encoding: 'binary' });

return filePath;
}

この辺はデコードとかエンコードの関数
discord/oupsとかwav-encodeってライブラリ使ってるけど
他にいいライブラリあったら教えて欲しい
でもoupsのデコードはDiscordの専用の奴使った方がいいかも
なんかoupsとはいえDiscord特有のヘッダがバイナリに追加されてたりするらしい
からそれに対応出来るように今後アプデとかあったらライブラリが対応してくれるっしょ
って感じで専用のを使うべきかなと思う
wavエンコードについては前述した通りクソ音質悪いのでパラメータがおかしいのかもしれない
直し方教えて欲しい

async function whisper(player,wavData) {
  const { Configuration, OpenAIApi } = require("openai");
  const configuration = new Configuration({
    apiKey: process.env.OPENAI_API_KEY,
  });
  const openai = new OpenAIApi(configuration);

  try {
    const data = await fs.promises.readFile(wavData);
    const base64 = Buffer.from(data).toString('base64');
    const resp = await openai.createTranscription(
      fs.createReadStream(wavData,),
      "whisper-1"
    );
    console.log(resp.data.text);
  if ((resp.data.text ?? "").length > 3){  
try {    
    
const { Configuration, OpenAIApi } = require("openai");

const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);

const completion = await openai.createChatCompletion({
  model: "gpt-3.5-turbo",
  temperature: 1,
  max_tokens: 500,
  messages: [{role: "system",content:"botの設定文"},{role: "user", content: "指示:必ず日本語で回答をしてください。\n内容:" + resp.data.text}],
});  
  client.channels.cache.find((ch) => ch.name === "general").send(completion.data.choices[0].message);
  const responseText = completion.data.choices[0].message.content;
        // 返答をGoogle Cloud Text-to-Speech APIで音声に変換して再生する
  console.log(responseText)
      const [responseAudio] = await ttsClient.synthesizeSpeech({
        input: { text: responseText },
        voice: { languageCode: 'ja-JP', name:"ja-JP-Standard-C", ssmlGender: 'MALE' },
        audioConfig: { audioEncoding: 'MP3' },
      });

// base64データを保存する
const now = new Date();
const buffer = Buffer.from(responseAudio.audioContent, 'base64');
const filename = `audio_${now.getHours()}${now.getMinutes()}${now.getSeconds()}.mp3`;
const url = `/tmp/${filename}`;
fs.writeFile(url, buffer, (err) => {
  if (err) {
    console.error(err);
  } else {
    console.log(`File saved as ${filename}`);
  }
});  

            // Performs the text-to-speech request
            // 音声ファイルを書き出します。
            const writeFile = util.promisify(fs.writeFile);
            await writeFile(url, responseAudio.audioContent, 'binary');
    
const resource2 = createAudioResource(url);
        player.play(resource2);
  
    }catch (error) {
      if (error.response) {
        console.log(error.response);
        client.channels.cache.find((ch) => ch.name === "general").send("openAI落ちてね?" + error.response.status);
      } else {
        client.channels.cache.find((ch) => ch.name === "general").send(error.message);
      }
    }}
  
  } catch (err) {
    console.error(err);
  }
}

この辺は他のAPIを使うだけ
別に難しい事してない
openAIのライブラリがあるのでソレにそってwhisperとGPTに送信して
返ってきたResponseをまた送り直してるだけ
gpt-3.5-turboを何故採用しているかというとレスポンスのスピードが早いから。
会話はレスポンス重視だと思ったのでgpt-4は今回使用していない。

文字起こしの精度が悪いのはwhisperのせいかもしれないので
GoogleのAPIに変えてみるとかもありかもしれない。
まあどちらにせよお金はかかるけど。

const resource2 = createAudioResource(url);
player.play(resource2);
最後のコレはDiscordのライブラリ。
playerをconnectionに繋いで
音声ファイルをcreateAudioResourceかます事でDiscordの再生用の書式に変換されるので
それをそのままplayに引き渡してやると再生が行われる。

GCPも使用しているのでGCPの使い方はライブラリのドキュメントを読んで下さい
認証用のファイルを置くだけだけどね。

whisper関数はもう最後の方力尽きたのでベタ書きしてしまったけど
それぞれのAPI別ややってる事別に関数化した方が普通に使いやすいと思う。
とりあえず動いてるから良し!

今後GPTにプラグイン機能も実装されるっぽいし
なんならGPT4でマルチモーダル言うてるなら音声もwhisper経由じゃなくて受け入れろよという所もありますが、声で色々操作が出来る様になるというのは面白いなと思って作ってみました。

目指せジャービス。アイアンマンの世界はもう近い。

Discussion

田中田中
const wavData = await encode({
   format: 'wav',
   sampleRate: 48000,
   channelData: [arr1,arr2],
 });

この部分のencodeとは何のモジュールを使っていますか?
現状はこのような感じになっております。
https://teratail.com/questions/8fvese4t2ei96g
ご教授いただけると幸いです

shunsatoshunsato

自分も田中さんと同じ様な症状があったので、現在自分の環境では記事を書いた時よりモジュールを変更していて
https://www.npmjs.com/package/wav-converter
こちらを使っています。
こちらの方が音質良くノイズも乗らず変換出来るのでこちらを使ってみてください

田中田中

返信ありがとうございます。
できればでいいので、wav-converterを使った時どのようなコードになるのか教えてくれると助かります。

shunsatoshunsato

const testStream = [];
audio.on('data', (chunk) => {
const decodedChunk = decodeOpus(chunk);
testStream.push(decodedChunk);

});

const testPcmDataArray = await Promise.all(testStream);
const testConcatenatedBuffer = Buffer.concat(testPcmDataArray);

var wavData = wavConverter.encodeWav(testConcatenatedBuffer, {
numChannels: 2,
sampleRate: 48000,
byteRate: 16
})

です