作ったもの: HanaseLLM
https://github.com/10nm/HanaseLLM
概要
ChatGPTのAdvancedAudioModeのような対話機能をDiscordBotとして構築した。
- 音声認識にはGoogle Cloud Speech-to-Text APIを使用
- 応答生成にはGemini Developer APIを使用
- 音声合成にはVoicevox engineを使用
構成
技術
- Discord.js: Discord Bot開発用Node.jsライブラリ (https://discord.js.org)
- Node.js: JavaScript実行環境 (https://nodejs.org/ja)
- Google Cloud Speech-to-Text API: Googleの音声認識API (https://cloud.google.com/speech-to-text?hl=ja)
- GeminiAPI: GoogleのLLM API (https://ai.google.dev/gemini-api/docs?hl=ja)
- VoiceVox: 音声合成エンジン (https://github.com/VOICEVOX/voicevox_engine)
ディレクトリ構成
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が起動する。
音声ストリーミング
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
関数が呼び出され、以下の処理を行う。
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による音声認識
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による応答生成
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による音声合成
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
}
}
- 音声クエリの生成
axios.post
でVoiceVoxのローカルサーバーにリクエストを送信する。- エンドポイント:
http://127.0.0.1:50021/audio_query
- パラメータ:
speaker
(話者),text
(テキストメッセージ) レスポンスデータはquery
に格納される。
- エンドポイント:
- 音声合成
再度
axios.post
でVoiceVoxサーバーにリクエストを送信する。- エンドポイント:
http://127.0.0.1:50021/synthesis
- リクエストボディ: 生成したクエリ
- エンドポイント:
- エンコードとファイル保存 Base64エンコードを経由してwav形式で保存する。
- 成功時
音声ファイルのパスを返す (
'./src/temp/output.wav'
)。
音声の再生
const player = createAudioPlayer();
const resource = createAudioResource(filePath);
player.play(resource);
connection.subscribe(player);
player.on(AudioPlayerStatus.Idle, () => {
console.log('Voice played');
});