diff --git a/bot.js b/bot.js index 6bec12d..83f2b1a 100644 --- a/bot.js +++ b/bot.js @@ -1,10 +1,11 @@ const sodium = require('libsodium-wrappers'); const { Client, GatewayIntentBits } = require('discord.js'); -const { joinVoiceChannel, getVoiceConnection, VoiceConnectionStatus, EndBehaviorType } = require('@discordjs/voice'); +const { joinVoiceChannel, getVoiceConnection, VoiceConnectionStatus, EndBehaviorType, createAudioPlayer, createAudioResource, AudioPlayerStatus } = require('@discordjs/voice'); const fs = require('fs'); const { OpusEncoder } = require('@discordjs/opus'); -const { exec } = require('child_process'); +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'; @@ -13,10 +14,7 @@ if (!apiKey || apiKey === 'your-openai-api-key') { process.exit(1); // エラーコード1で終了 } -// OpenAI APIクライアントの作成 -const openai = new OpenAI({ - apiKey: apiKey, -}); +const openai = new OpenAI({ apiKey }); sodium.ready.then(() => { console.log('Sodium library loaded!'); @@ -36,7 +34,7 @@ client.once('ready', () => { }); client.on('messageCreate', async message => { - if (message.content === '!join') { + if (message.content === '!cs_join') { if (message.member.voice.channel) { const connection = joinVoiceChannel({ channelId: message.member.voice.channel.id, @@ -55,10 +53,17 @@ client.on('messageCreate', async message => { } } - if (message.content === '!leave') { + if (message.content === '!cs_leave') { const connection = getVoiceConnection(message.guild.id); if (connection) { - connection.destroy(); + connection.destroy(); // ボイスチャンネルから切断 + + // 進行中のFFmpegなどのプロセスを強制終了 + if (ffmpeg) { + ffmpeg.kill('SIGKILL'); + console.log('FFmpeg process killed on leave'); + } + message.reply('ボイスチャンネルを離れました。'); } else { message.reply('ボイスチャンネルに接続していません。'); @@ -96,7 +101,6 @@ function startRecording(connection, voiceChannel) { try { const pcmData = opusDecoder.decode(chunk); writableStream.write(pcmData); - console.log(`Received ${chunk.length} bytes of audio data from user ${user.id}`); } catch (err) { console.error('Error decoding audio chunk:', err); } @@ -125,7 +129,7 @@ function startRecording(connection, voiceChannel) { return; } if (stats.size > 0) { - convertToWavAndTranscribe(filePath); // バックグラウンドで変換と文字起こしを実行 + convertToWavAndTranscribe(filePath, connection); // バックグラウンドで変換と文字起こしを実行 } else { console.log(`File ${filePath} is empty, skipping conversion.`); fs.unlinkSync(filePath); // 空のファイルを削除 @@ -135,43 +139,56 @@ function startRecording(connection, voiceChannel) { }); } -function convertToWavAndTranscribe(pcmPath) { +// 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 = exec(`ffmpeg -f s16le -ar 48000 -ac 2 -i ${pcmPath} ${wavPath}`, (error, stdout, stderr) => { - if (error) { - console.error(`Error during conversion: ${error.message}`); - return; - } - if (stderr) { - console.error(`FFmpeg stderr: ${stderr}`); - return; - } + 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); + 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) { +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に指定 + response_format: 'json', // レスポンス形式をJSONに指定 + language: 'ja' // 言語を日本語に固定 }); // 文字起こし結果を取得 @@ -179,13 +196,39 @@ async function transcribeAudio(filePath) { 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); - // 必要に応じて、返信内容をメッセージとして送信 - // 例えば、メッセージを元のチャンネルに送信するなど - // message.channel.send(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.'); @@ -195,19 +238,47 @@ async function transcribeAudio(filePath) { } } +// 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-3.5-turbo', // ChatGPTのモデルを指定 + model: 'gpt-4', messages: [ - { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'system', content: systemPrompt }, { role: 'user', content: transcription } ] }); if (chatResponse && chatResponse.choices && chatResponse.choices.length > 0) { - return chatResponse.choices[0].message.content; // 応答内容を返す + return chatResponse.choices[0].message.content; } else { console.error('Error: ChatGPT response is missing choices.'); return null; @@ -218,4 +289,76 @@ async function generateChatResponse(transcription) { } } +// 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); diff --git a/docker-compose.yml b/docker-compose.yml index 58b985d..671eadf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,8 @@ services: build: . container_name: discord-voice-bot environment: - - BOT_TOKEN=MTI0MDY1MDc3NTE2MDY4NDU1NA.Gl_Z2X.SBWCjk3OzIViNbSXocoPBt_817BZQ12fONLVSY + - BOT_TOKEN=MTI4MjM0OTkwMjM3ODMwNzY2Nw.G4AEby.jtFyPkoNbWH3OHPLNzbo0-QZ1odddtu-r9bzdo - OPENAI_API_KEY=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 volumes: - ./recordings:/usr/src/app/recordings + - ./voices:/usr/src/app/voices