discord_crew/bot.js

365 lines
14 KiB
JavaScript

const sodium = require('libsodium-wrappers');
const { Client, GatewayIntentBits } = require('discord.js');
const { joinVoiceChannel, getVoiceConnection, VoiceConnectionStatus, EndBehaviorType, createAudioPlayer, createAudioResource, AudioPlayerStatus } = require('@discordjs/voice');
const fs = require('fs');
const { OpusEncoder } = require('@discordjs/opus');
const { spawn } = require('child_process'); // execからspawnに変更
const OpenAI = require('openai');
const axios = require('axios');
// OpenAI APIキーの確認
const apiKey = process.env.OPENAI_API_KEY || 'your-openai-api-key';
if (!apiKey || apiKey === 'your-openai-api-key') {
console.error('Error: OpenAI API key is missing or invalid. Set the API key in the environment variables.');
process.exit(1); // エラーコード1で終了
}
const openai = new OpenAI({ apiKey });
sodium.ready.then(() => {
console.log('Sodium library loaded!');
});
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
client.once('ready', () => {
console.log('Bot is ready!');
});
client.on('messageCreate', async message => {
if (message.content === '!cs_join') {
if (message.member.voice.channel) {
const connection = joinVoiceChannel({
channelId: message.member.voice.channel.id,
guildId: message.guild.id,
adapterCreator: message.guild.voiceAdapterCreator,
selfDeaf: false,
selfMute: false,
});
connection.on(VoiceConnectionStatus.Ready, () => {
console.log('Bot has connected to the channel!');
startRecording(connection, message.member.voice.channel);
});
} else {
message.reply('ボイスチャンネルに接続してください。');
}
}
if (message.content === '!cs_leave') {
const connection = getVoiceConnection(message.guild.id);
if (connection) {
connection.destroy(); // ボイスチャンネルから切断
// 進行中のFFmpegなどのプロセスを強制終了
if (ffmpeg) {
ffmpeg.kill('SIGKILL');
console.log('FFmpeg process killed on leave');
}
message.reply('ボイスチャンネルを離れました。');
} else {
message.reply('ボイスチャンネルに接続していません。');
}
}
});
function startRecording(connection, voiceChannel) {
const receiver = connection.receiver;
const activeStreams = new Map(); // ユーザーごとの音声ストリーム管理
receiver.speaking.on('start', (userId) => {
const user = voiceChannel.members.get(userId);
if (!user) {
console.error('User is undefined');
return;
}
console.log(`Started receiving audio from user: ${user.id}`);
if (activeStreams.has(user.id)) {
return;
}
const audioStream = receiver.subscribe(user.id, {
end: EndBehaviorType.Manual
});
const filePath = `./recordings/${user.id}-${Date.now()}.pcm`;
const writableStream = fs.createWriteStream(filePath);
activeStreams.set(user.id, { audioStream, writableStream, filePath });
const opusDecoder = new OpusEncoder(48000, 2); // Opusデコーダーを使用
audioStream.on('data', (chunk) => {
try {
const pcmData = opusDecoder.decode(chunk);
writableStream.write(pcmData);
} catch (err) {
console.error('Error decoding audio chunk:', err);
}
});
audioStream.on('error', (err) => {
console.error('Error in the audio stream:', err);
});
});
receiver.speaking.on('end', (userId) => {
const streamData = activeStreams.get(userId);
if (!streamData) {
return;
}
const { writableStream, filePath } = streamData;
writableStream.end(() => {
console.log(`Recording saved to ${filePath}`);
activeStreams.delete(userId);
fs.stat(filePath, (err, stats) => {
if (err) {
console.error('Error checking file:', err);
return;
}
if (stats.size > 0) {
convertToWavAndTranscribe(filePath, connection); // バックグラウンドで変換と文字起こしを実行
} else {
console.log(`File ${filePath} is empty, skipping conversion.`);
fs.unlinkSync(filePath); // 空のファイルを削除
}
});
});
});
}
// PCMファイルをWAVファイルに変換し、文字起こしを実行
function convertToWavAndTranscribe(pcmPath, connection) {
const wavPath = pcmPath.replace('.pcm', '.wav');
console.log(`Converting ${pcmPath} to ${wavPath} and transcribing in the background`);
const ffmpeg = spawn('ffmpeg', ['-f', 's16le', '-ar', '48000', '-ac', '2', '-i', pcmPath, wavPath]);
// プロセスがフリーズした場合、タイムアウトして強制終了する
const ffmpegTimeout = setTimeout(() => {
ffmpeg.kill('SIGKILL'); // 強制終了
console.error('FFmpeg process timed out and was killed');
}, 10000); // 10秒でタイムアウト
ffmpeg.stdout.on('data', (data) => {
console.log(`FFmpeg stdout: ${data}`);
});
ffmpeg.stderr.on('data', (data) => {
console.error(`FFmpeg stderr: ${data}`);
});
ffmpeg.on('close', (code) => {
clearTimeout(ffmpegTimeout); // 正常に終了したらタイムアウトをクリア
if (code === 0) {
console.log(`Successfully converted ${pcmPath} to ${wavPath}`);
fs.unlinkSync(pcmPath); // PCMファイルを削除
// Whisper APIを使って文字起こしを実行
transcribeAudio(wavPath, connection);
} else {
console.error(`FFmpeg exited with code ${code}, conversion failed.`);
}
});
ffmpeg.on('error', (err) => {
console.error('FFmpeg process error:', err);
});
}
// Whisper APIを使って音声ファイルを文字起こしする関数
async function transcribeAudio(filePath, connection) {
try {
console.log(`Transcribing file: ${filePath}`);
const response = await openai.audio.transcriptions.create({
file: fs.createReadStream(filePath),
model: 'whisper-1',
response_format: 'json', // レスポンス形式をJSONに指定
language: 'ja' // 言語を日本語に固定
});
// 文字起こし結果を取得
if (response && response.text) {
const transcription = response.text;
console.log('Transcription:', transcription);
// ChatGPTに反応すべきか判断させる
const shouldReact = await shouldReactToTranscription(transcription);
if (!shouldReact) {
console.log('ChatGPT decided not to react to this transcription.');
// 文字起こし完了後、WAVファイルを削除
fs.unlink(filePath, (err) => {
if (err) {
console.error(`Error deleting recording file: ${err}`);
} else {
console.log(`Deleted recording file: ${filePath}`);
}
});
return;
}
// ChatGPTを使用して返答を生成
const reply = await generateChatResponse(transcription);
console.log('ChatGPT response:', reply);
// 合成音声を生成して再生
const voiceFilePath = await generateVoiceFromText(reply);
if (voiceFilePath) {
playVoiceInChannel(connection, voiceFilePath); // 音声を再生
}
// 文字起こし完了後、WAVファイルを削除
fs.unlink(filePath, (err) => {
if (err) {
console.error(`Error deleting recording file: ${err}`);
} else {
console.log(`Deleted recording file: ${filePath}`);
}
});
} else {
console.error('Error: Transcription response is missing "text" field.');
}
} catch (error) {
console.error('Error during transcription:', error.response ? error.response.data : error.message);
}
}
// ChatGPTに反応すべきかどうかを判断させる関数
async function shouldReactToTranscription(transcription) {
try {
const systemPrompt = 'あなたは「セカイ」というキャラクターです。次のユーザーの発言に対して反応すべきかどうかを判断してください。反応すべき場合は「はい」、反応しない場合は「いいえ」と答えてください。';
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: transcription }
]
});
if (response && response.choices && response.choices.length > 0) {
const result = response.choices[0].message.content.trim();
return result.toLowerCase() === 'はい';
} else {
console.error('Error: ChatGPT response is missing choices.');
return false;
}
} catch (error) {
console.error('Error during shouldReactToTranscription generation:', error.response ? error.response.data : error.message);
return false;
}
}
// ChatGPTに応答を生成させる関数
async function generateChatResponse(transcription) {
try {
const systemPrompt = 'あなたはこれから「セカイ」という名前の幼い少女を演じてユーザーとの会話を楽しんでください。回答メッセージは最大でも200字になるようにしてください。';
const chatResponse = await openai.chat.completions.create({
model: 'gpt-4',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: transcription }
]
});
if (chatResponse && chatResponse.choices && chatResponse.choices.length > 0) {
return chatResponse.choices[0].message.content;
} else {
console.error('Error: ChatGPT response is missing choices.');
return null;
}
} catch (error) {
console.error('Error during ChatGPT response generation:', error.response ? error.response.data : error.message);
return null;
}
}
// ChatGPTの返答を合成音声に変換する関数
async function generateVoiceFromText(text, retryCount = 10) {
try {
const response = await axios.post('http://192.168.100.252:5000/generate_voice', {
text: text,
narrator: "SEKAI",
emotion_happy: "80",
emotion_sad: "20",
emotion_angry: "10",
emotion_fun: "50",
pitch: "1"
}, {
responseType: 'arraybuffer' // 音声ファイルをバイナリデータとして取得
});
const outputFilePath = `./voices/output-${Date.now()}.wav`;
await fs.promises.writeFile(outputFilePath, response.data); // 非同期でファイルを書き込み
console.log(`Voice file saved to ${outputFilePath}`);
return outputFilePath; // 音声ファイルのパスを返す
} catch (error) {
console.error('Error generating voice:', error.message);
// リトライ処理
if (retryCount > 0) {
console.log(`Retrying to generate the voice... (${retryCount} retries left)`);
await new Promise(resolve => setTimeout(resolve, 1000)); // 1秒待機
return generateVoiceFromText(text, retryCount - 1); // リトライ
} else {
console.error('Max retries reached. Could not generate the voice.');
return null;
}
}
}
// ボイスチャンネルで音声を再生する関数
function playVoiceInChannel(connection, filePath, retryCount = 3) {
const player = createAudioPlayer();
const resource = createAudioResource(filePath);
player.play(resource);
connection.subscribe(player);
player.on(AudioPlayerStatus.Idle, () => {
console.log('Finished playing voice.');
// 再生が終了した後にWAVファイルを削除
setTimeout(() => {
fs.unlink(filePath, (err) => {
if (err) {
console.error(`Error deleting file: ${err}`);
} else {
console.log(`Deleted file: ${filePath}`);
}
});
}, 1000); // 1秒の待機後にファイル削除
});
player.on('error', error => {
console.error('Error playing audio:', error.message);
// リトライ処理
if (retryCount > 0) {
console.log(`Retrying to play the voice... (${retryCount} retries left)`);
setTimeout(() => {
playVoiceInChannel(connection, filePath, retryCount - 1);
}, 1000); // 1秒待機してリトライ
} else {
console.error('Max retries reached. Could not play the audio.');
}
});
}
client.login(process.env.BOT_TOKEN);