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'); const OpenAI = require('openai'); const axios = require('axios'); let isProcessing = false; // 処理中フラグ let isPlaying = false; // 音声再生中フラグ // 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で終了 } // ChatGPTのモデル const chatGptModel = process.env.CHAT_GPT_MODEL || 'gpt-4o'; 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 (isProcessing) { message.reply('現在処理中です。少々お待ちください。'); return; // 処理中なら新しい処理を行わない } 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(); // ボイスチャンネルから切断 message.reply('ボイスチャンネルを離れました。'); } else { message.reply('ボイスチャンネルに接続していません。'); } } }); async 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); 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); } }); }); }); } async function convertToWavAndTranscribe(pcmPath, connection) { if (isProcessing) return; // 処理中ならスキップ isProcessing = true; // 処理開始 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'); isProcessing = false; // タイムアウトで処理終了 }, 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.`); isProcessing = false; // エラーで処理終了 } }); ffmpeg.on('error', (err) => { console.error('FFmpeg process error:', err); isProcessing = false; // エラーで処理終了 }); } 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', language: 'ja' }); if (response && response.text) { const transcription = response.text; console.log('Transcription:', transcription); const shouldReact = await shouldReactToTranscription(transcription); if (!shouldReact) { console.log('ChatGPT decided not to react to this transcription.'); fs.unlink(filePath, (err) => { if (err) console.error(`Error deleting recording file: ${err}`); else console.log(`Deleted recording file: ${filePath}`); }); return; } const reply = await generateChatResponse(transcription); console.log('ChatGPT response:', reply); if (reply) { const voiceFilePath = await generateVoiceFromText(reply); if (voiceFilePath) { await playVoiceInChannel(connection, voiceFilePath); // 音声再生が完了するまで待つ } } 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); } finally { isProcessing = false; // 最後に処理フラグをリセット } } async function shouldReactToTranscription(transcription) { try { const systemPrompt = 'あなたは「セカイ」というキャラクターです。次のユーザーの発言に対して反応すべきかどうかを判断してください。反応すべき場合は「はい」、反応しない場合は「いいえ」と答えてください。'; const response = await openai.chat.completions.create({ model: chatGptModel, 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; } } async function generateChatResponse(transcription) { try { const systemPrompt = 'あなたはこれから「セカイ」という名前の少女を演じてユーザーとの会話を楽しんでください。回答メッセージは最大でも80字になるようにしてください。'; const chatResponse = await openai.chat.completions.create({ model: chatGptModel, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: transcription } ] }); console.log('ChatGPT full response:', chatResponse); 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; } finally { isProcessing = false; // 処理終了後にフラグを必ずリセット } } 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)); return generateVoiceFromText(text, retryCount - 1); } else { console.error('Max retries reached. Could not generate the voice.'); return null; } } } async function playVoiceInChannel(connection, filePath, retryCount = 3) { if (isPlaying) { console.log('Another audio is still playing, waiting...'); return; } isPlaying = true; const player = createAudioPlayer(); const resource = createAudioResource(filePath); player.play(resource); connection.subscribe(player); player.on(AudioPlayerStatus.Playing, () => { console.log('Audio is playing...'); }); player.on(AudioPlayerStatus.Idle, () => { console.log('Finished playing voice.'); setTimeout(() => { // 再生が終了した後にWAVファイルを削除 fs.unlink(filePath, (err) => { if (err) { console.error(`Error deleting file: ${err}`); } else { console.log(`Deleted file: ${filePath}`); } }); }, 1000); isPlaying = false; // 音声再生終了 isProcessing = false; // 処理終了 }); 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.'); isPlaying = false; // 音声再生失敗 isProcessing = false; // 処理終了 } }); } client.login(process.env.BOT_TOKEN);