作ったもの: HanaseLLM

https://github.com/10nm/HanaseLLM

概要

ChatGPTのAdvancedAudioModeのような対話機能をDiscordBotとして構築した。

構成

技術

ディレクトリ構成

HanaseLLM/
├── .env
├── index.js
├── README.md
└── src/
    ├── config.js
    ├── discord_bot.js  # Discord Bot本体
    ├── handle_streaming.js # 音声ストリーミング
    ├── history.js
    ├── llm.js          # 応答生成
    ├── main.js
    ├── STT.js          # GCP STTによる文字起こし
    └── voice_synthesis.js # VoiceVox制御
voicevox_server/

会話処理の流れ

メイン処理

node index.jsで起動する。discord_bot.jsがトリガーされBotが起動する。

音声ストリーミング

handle_streaming.js

DiscordのVCから音声データを受信し、処理後にwavデータをmain関数へ渡す。

 
function handleStreaming(connection, message) {
    const receiver = connection.receiver;
    let flag = false;
 
    receiver.speaking.on('start', userId => {
        if (flag) {
            console.log(`Already listening to ${userId}`);
            return;
        }
        flag = true;
        console.log(`Listening to ${userId}`);
        const audioStream = receiver.subscribe(userId, {
            end: {
                behavior: EndBehaviorType.AfterSilence,
                duration: silence
            }
        });
 
        const pcmStream = audioStream.pipe(new prism.opus.Decoder({ rate: sampleRate, channels: 1, frameSize: 960 }));
 
        const pcmChunks = [];
        pcmStream.on('data', chunk => {
            pcmChunks.push(chunk);
        });
 
        let startTime = Date.now();
        pcmStream.on('end', () => {
            
            const endTime = Date.now();
            const session_time = (endTime - startTime) / 1000;
            console.log(`Recording duration: ${session_time} seconds`);
 
            if (session_time > duration) {
                const pcmBuffer = Buffer.concat(pcmChunks);
                const wavBuffer = wavConverter.encodeWav(pcmBuffer, {
                    numChannels: 1,
                    sampleRate: sampleRate,
                    byteRate: 16
                });
 
                main(userId, connection, message, wavBuffer);
                flag = false;
 
            } else {
                console.log(`Recording for ${userId} was too short`);
                flag = false;
            }
        });
    });
}
 
  • receiver.speaking.on('start', userId => { ... });: ユーザーが話し始めた時に発火する。userIdは話しているユーザーのID。
  • audioStream.pipe(new prism.opus.Decoder({ rate: sampleRate, channels: 2, frameSize: 960 }));: audioStreamから受け取ったデータをデコードし、PCMデータに変換する。
  • let startTime = Date.now();: 録音開始時間を記録する。
  • pcmStream.on('end', () => { ... });: PCMストリーム終了時に実行される処理を定義する。
  • const endTime = Date.now();: 録音終了時間を記録する。
  • const session_time = (endTime - startTime) / 1000;: 録音時間を計算する。
  • if (session_time > duration) { ... }: 録音時間が最低録音時間を超えているか確認する。
  • const pcmBuffer = Buffer.concat(pcmChunks);: PCMデータを結合する。
  • const wavBuffer = wavConverter.encodeWav(pcmBuffer, { ... });: PCMバッファをWAV形式にエンコードする。
  • main(userId, connection, message, wavBuffer);: 録音データを処理するためのmain関数を呼び出す。

メイン関数

main.js

ユーザーの発話終了後にmain関数が呼び出され、以下の処理を行う。

async function main(userId, connection, message, data) {
    // googleSTT に音声認識させる
    const usermessage = await googleSTT(data);
    if (usermessage) {
        console.log(`User message: ${usermessage}`);
        const username = message.author.displayName;
        message.channel.send(`[transcription] ${username}: ${usermessage}`);
    }
 
    // gemini に応答を生成させる
    const LLM_Message = await llm(userId, usermessage, MODEL_NAME);
    if (LLM_Message) {
        console.log(`LLM message: ${LLM_Message}`);
        message.channel.send(`[LLM_gen] ${MODEL_NAME}: ${LLM_Message}`);
    } else {
        console.log("Error: No LLM message received.");
        return;
    }
    
    // voicevox に音声合成させる ・ 流す
    const result = await VoiceVox(LLM_Message);
    if (result) {
        console.log(`Voice synthesis result: ${result}`);
    } else {
        console.log("Error: No voice synthesis result received.");
        return;
    }
    
    // 音声を流す
    if (result) {
        await playAudio(connection, result);
    } else {
        console.log("Error: No audio to play.");
        return;
    }
}
  • googleSTT: GCP STTで音声認識 (STT.js)
  • llm: GeminiAPIで応答文を生成 (llm.js)
  • VoiceVox: VoiceVoxに応答文を送信して音声データを取得 (voice_synthesis.js)
  • playAudio: Discordで音声データを再生 (voice_synthesis.js)

GCP STTによる音声認識

STT.js

import { SpeechClient } from '@google-cloud/speech';
 
const client = new SpeechClient();
 
async function googleSTT(wavdata) {
 
    const request = {
        config: {
            encoding: 'LINEAR16',
            sampleRateHertz: sampleRate,
            languageCode: 'ja-JP',
        },
        audio: {
            content: wavdata.toString('base64')
        }
    }
    const [response] = await client.recognize(request);
    const transcription = response.results
        .map(result => result.alternatives[0].transcript)
        .join('\n');
    console.log('Transcription: ', transcription);
    return transcription;
}
 
export { googleSTT };

Geminiによる応答生成

llm.js

async function llm(userId, userMessage) {
  // Initialize GoogleGenAI and chat only once
  if (!ai) {
    ai = new GoogleGenAI({ apiKey: API_KEY });
  }
  if (!chat) {
    init_history();
    const history = get_history();
    chat = ai.chats.create({
      model: "gemini-2.0-flash",
      config: {
        systemInstruction: "日本語での音声通話のシミュレーションを行います。ユーザーの発言に対しては話し言葉で、応答のみを出力してください。また、ユーザー側のメッセージの初めにはユーザー名が提示されますから、複数人の対話であることを考慮しながら応答してください。",
      },
      history: history,
    });
  }
 
  // Push user message to history
  push_history({ role: 'user', parts: [{ text: userMessage }] });
 
  const userdisplayname = userId.displayName
 
  const sendMSG = `${userdisplayname}: ${userMessage}`;
 
  try {
    const response = await chat.sendMessage({
      message: sendMSG
    });
 
    const LLM_Message = response.text;

VoiceVoxによる音声合成

voice_synthesis.js

async function VoiceVox(llmMessage) {
    try {
        const msg = llmMessage;
        const responseQuery = await axios.post(
            `http://127.0.0.1:50021/audio_query?speaker=${speaker}&text="${msg}"`
        );
        const query = responseQuery.data;
        const responseSynthesis = await axios.post(
            `http://127.0.0.1:50021/synthesis?speaker=${speaker}`,
            query,
            { responseType: 'arraybuffer' }
        );
        const base64Data = Buffer.from(responseSynthesis.data, 'binary').toString('base64');
        
        // Convert base64 to binary and save to file
        const buf = Buffer.from(base64Data, 'base64');
        fs.writeFileSync('./src/temp/output.wav', buf);
        return './src/temp/output.wav';
    } catch (error) {
        console.error("Error in VoiceVox:", error);
        return null; // Or throw the error, or return a default value
    }
}
  1. 音声クエリの生成 axios.postでVoiceVoxのローカルサーバーにリクエストを送信する。
    • エンドポイント: http://127.0.0.1:50021/audio_query
    • パラメータ: speaker (話者), text (テキストメッセージ) レスポンスデータはqueryに格納される。
  2. 音声合成 再度axios.postでVoiceVoxサーバーにリクエストを送信する。
    • エンドポイント: http://127.0.0.1:50021/synthesis
    • リクエストボディ: 生成したクエリ
  3. エンコードとファイル保存 Base64エンコードを経由してwav形式で保存する。
  4. 成功時 音声ファイルのパスを返す ('./src/temp/output.wav')。

音声の再生

voice_synthesis.js

const player = createAudioPlayer();
const resource = createAudioResource(filePath);
player.play(resource);
connection.subscribe(player);
player.on(AudioPlayerStatus.Idle, () => {
	console.log('Voice played');
});