前書き
ディスコードで音楽かけてくれるbotありますよね?よく見かけるのって「!play ”URL”」という感じでテキストチャンネルに文字とURLを打って再生すると思うんですが、今回はボットをボイスチャンネルに参加させ、音声認識で音楽を流そうという試みです。
Discordで喋っている時に友達が「Alexa、音楽かけて」と言っているのを聞いて、ディスコード上で喋って動く音楽botあったら良いなと思い今回に至ります。
実現イメージ
・音声認識で動くDiscordBot・Botがボイスチャンネルに参加した状態でユーザが「〇〇かけて」と言うと、Botが反応してその曲が流れるイメージ
※今回はアーティスト名や曲名を直接言う形で実装しています。
開発説明
通常のnode.js開発環境に加え、ffmpegというソフトウェアが必要になります。(音声ファイル形式をいじるので)ライブラリ・環境
・Speech-to-Text: Googleクラウド上の音声認識サービスです。・YouTube Data API: 再生にURLが必要なので、YouTube Data APIで検索しURLを取得します。
・ytdl-core:YouTube動画からオーディオを抽出するためのNode.jsパッケージ。今回はYouTube Data APIで取得したURLを使って音楽再生します。
・discord.js:Node.jsで書かれたDiscord APIのラッパーライブラリ。
・discordjs/voice:Discordの音声通話機能を制御するためのNode.jsベースのライブラリ。
開発環境のヴァージョン等
ffmpeg version 5.1.2
npm 9.4.0
node v18.13.0
"@discordjs/opus": "0.8.0",
"@discordjs/voice": "0.14.0",
"@google-cloud/speech": "5.1.1",
"chalk": "5.2.0",
"discord.js": "14.7.1",
"dotenv": "16.0.3",
"prism-media": "1.3.4",
"youtube-node": 1.3.3",
"ytdl-core": "4.11.2"
※2023/02/11の最新パッケージを想定しています
⚠️ 音楽botの為、導入の際は自己責任でお願いいたします。
サービスフロー
ざっくり流れを説明すると、ユーザーがディスコード入る、コマンドでbotを同じボイスチャンネルに参加させる、ユーザーが喋る、喋った内容が文字としてグーグルから送られてくる→内部処理→再生の流れです。以下詳細です。
1.Discordで喋る
2.喋った内容を録音→ローカルファイルに出力
3.ローカルファイルのままでは音声認識のデータ形式と一致しない為、ffmpegで変換します。
4.ffmpegで変換した音声ファイルをSpeech-to-Textのリクエストとして送ります。
5.Speech-to-Textから音声を文字に変換した結果がレスポンスされます。
6.結果から流したい曲のタイトルだけ抽出
7.抽出結果を使ってYouTube Data APIで再生するURLを取得
8.URLからytdl-coreで音楽を再生します
以上の流れになっています。
コード
index.js
client.on('messageCreate', message => {
  if(message.content === '!sjoin') {
    const guild = message.guild
    const vc = message.member.voice.channel;
    const connection = joinVoiceChannel({
      guildId: guild.id,
      channelId: vc.id,
      adapterCreator: guild.voiceAdapterCreator,
      selfMute: false,
      selfDeaf: false,
    });
    const receiver = connection.receiver;
    receiver.speaking.on('start', (userId) => {
      startRecognizeStream(guild, connection, userId);
    })
  }
})
//speech-to-textに送る音声ファイル形式
const encoding = 'FLAC';
const sampleRateHertz = 44100;
const languageCode = 'ja-JP';
async function startRecognizeStream(guild, connection, userId) {
  const receiver = connection.receiver;
 //recordingsディレクトリを作ってそこに録音ファイルを保存
  await fs.promises.mkdir('./recordings', { recursive: true })
    const voiceStream = receiver.subscribe(userId, { 
      end: {
        behavior: EndBehaviorType.AfterSilence,
        duration: 1000,
      }
    })
    
    const decoder = new prism.opus.Decoder({ rate: 48000, channels: 1, frameSize: 960 });
    //ファイル名は何でもOK
    const filename = `./recordings/${Date.now()}-${userId}.pcm`;
    console.log(`👂 Started recording ${filename}`);
    //音声からcreateWriteStreamでファイル作成→ファイルの作成が終わったタイミングでffmpegを実行
    const writer = voiceStream
    .pipe(decoder)
    .pipe(fs.createWriteStream(`${filename}`))
    writer.on("finish", () => {
      
      //speech-to-textに送る音声ファイル形式に変換
      const ffmpeg = exec(`ffmpeg -f s16le -ar 44.1k -ac 1 -i ${filename} ./recordings/output.flac`)
      const request = {
        config: {
          encoding: encoding,
          sampleRateHertz: sampleRateHertz,
          languageCode: languageCode,
        },
        interimResults: false,
      };
    
      const recognizeStream = speechClient
      .streamingRecognize(request)
      .on('error', console.error)
      .on('data', data => {
        console.log(
          `Transcription: ${data.results[0].alternatives[0].transcript}`
        );
        //(中略)
      });
      //ffmpegのデータ変換が終わったタイミングでrecognizeStreamを発火→発火後ディレクトリ毎削除して再帰的な処理に
      ffmpeg.on("exit", async () => {
        try {
          fs.readFileSync('./recordings/output.flac')
          fs.createReadStream('./recordings/output.flac').pipe(recognizeStream)
          await fs.promises.rm('./recordings', { recursive: true, force: true })
        } catch (error) {
          console.log(error)
        }
      })
    })
  
}
参考資料
参考:下の「ローカル ファイルでストリーミング音声認識を実行する」を参考にしました。
音声認識のデータ形式が一致しないと空のレスポンスが返ってきます。公式トラブルシューティングを参照↓
感想
無事音声認識で動くDiscord音楽Botが作れました。途中でphpから切り替えたのでnode.js自体も初めてで、GCP、音声認識、この記事では触れていませんが仮想サーバーでサービスをホスト、永続化など普段しない体験が出来て満足しました、 終わり。
