音声認識で動くDiscord音楽botを作った

<h1>前書き</h1>
ディスコードで音楽かけてくれるbotありますよね？<br>
よく見かけるのって「!play　”URL”」という感じでテキストチャンネルに文字とURLを打って再生すると思うんですが、今回はボットをボイスチャンネルに参加させ、音声認識で音楽を流そうという試みです。<br><br>
Discordで喋っている時に友達が「Alexa、音楽かけて」と言っているのを聞いて、ディスコード上で喋って動く音楽botあったら良いなと思い今回に至ります。<br>

<h1>実現イメージ</h1>
・音声認識で動くDiscordBot<br>
・Botがボイスチャンネルに参加した状態でユーザが「〇〇かけて」と言うと、Botが反応してその曲が流れるイメージ<br>
※今回はアーティスト名や曲名を直接言う形で実装しています。<br><br>

<h1>開発説明</h1>
通常のnode.js開発環境に加え、ffmpegというソフトウェアが必要になります。（音声ファイル形式をいじるので）
<h2>ライブラリ・環境</h2>
・<b>Speech-to-Text</b>: Googleクラウド上の音声認識サービスです。<br>
・<b>YouTube Data API</b>: 再生にURLが必要なので、YouTube Data APIで検索しURLを取得します。<br>
・<b>ytdl-core</b>：YouTube動画からオーディオを抽出するためのNode.jsパッケージ。今回はYouTube Data APIで取得したURLを使って音楽再生します。<br>
・<b>discord.js</b>：Node.jsで書かれたDiscord APIのラッパーライブラリ。<br>
・<b>discordjs/voice</b>：Discordの音声通話機能を制御するためのNode.jsベースのライブラリ。<br>

<br>
開発環境のヴァージョン等<br>
ffmpeg version 5.1.2<br>
npm 9.4.0<br>
node v18.13.0<br><br>
    "@discordjs/opus": "0.8.0",<br>
    "@discordjs/voice": "0.14.0",<br>
    "@google-cloud/speech": "5.1.1",<br>
    "chalk": "5.2.0",<br>
    "discord.js": "14.7.1",<br>
    "dotenv": "16.0.3",<br>
    "prism-media": "1.3.4",<br>
    "youtube-node": 1.3.3",<br>
    "ytdl-core": "4.11.2"<br>
※2023/02/11の最新パッケージを想定しています<br>⚠️ 音楽botの為、導入の際は自己責任でお願いいたします。

<h2>サービスフロー</h2>
ざっくり流れを説明すると、ユーザーがディスコード入る、コマンドでbotを同じボイスチャンネルに参加させる、ユーザーが喋る、喋った内容が文字としてグーグルから送られてくる→内部処理→再生の流れです。<br><br>
以下詳細です。<br>
1.Discordで喋る<br>
2.喋った内容を録音→ローカルファイルに出力<br>
3.ローカルファイルのままでは音声認識のデータ形式と一致しない為、ffmpegで変換します。<br>
4.ffmpegで変換した音声ファイルをSpeech-to-Textのリクエストとして送ります。<br>
5.Speech-to-Textから音声を文字に変換した結果がレスポンスされます。<br>
6.結果から流したい曲のタイトルだけ抽出<br>
7.抽出結果を使ってYouTube Data APIで再生するURLを取得<br>
8.URLからytdl-coreで音楽を再生します<br><br>
以上の流れになっています。<br>

<h2>コード</h2>

```javascript: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)
        }
      })
    })
  
}
```
<h2>参考資料</h2>
参考：下の「ローカル ファイルでストリーミング音声認識を実行する」を参考にしました。

https://cloud.google.com/speech-to-text/docs/streaming-recognize?hl=ja#perform_streaming_speech_recognition_on_a_local_file

https://scrapbox.io/discordjs-japan/ytdl-core_%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%A6_YouTube_%E3%81%AE%E9%9F%B3%E6%BA%90%E3%82%92%E9%85%8D%E4%BF%A1%E3%81%99%E3%82%8B%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB

<br>

音声認識のデータ形式が一致しないと空のレスポンスが返ってきます。公式トラブルシューティングを参照↓

https://cloud.google.com/speech-to-text/docs/troubleshooting?hl=ja

<h2>感想</h2>
無事音声認識で動くDiscord音楽Botが作れました。<br>
途中でphpから切り替えたのでnode.js自体も初めてで、GCP、音声認識、この記事では触れていませんが仮想サーバーでサービスをホスト、永続化など普段しない体験が出来て満足しました、
終わり。