diff --git a/server.js b/server.js index cadaa73..5a6f8fe 100644 --- a/server.js +++ b/server.js @@ -8,7 +8,17 @@ const { MessageHistory } = require('./src/message_history.js'); const app = express(); const server = http.createServer(app); -const io = socketIo(server); +const io = socketIo(server, { + pingTimeout: 300000, // 60秒超时 + pingInterval: 25000, // 25秒心跳间隔 + upgradeTimeout: 30000, // 30秒升级超时 + allowEIO3: true, // 允许Engine.IO v3客户端 + transports: ['websocket', 'polling'], // 支持多种传输方式 + cors: { + origin: "*", + methods: ["GET", "POST"] + } +}); // 创建消息历史管理器 const messageHistory = new MessageHistory(); @@ -121,32 +131,66 @@ function saveSceneState() { let currentSceneIndex = 0; const scenes = [ { - name: '起床', + name: '起床-坐在床上', defaultVideo: '8-5-qc-bd-female.mp4', interactionVideo: '8-5-qc-sh-female.mp4', tag: 'wakeup', - apiKey: 'bot-20250730213756-l627w' // 起床场景的API key + apiKey: 'bot-20250730213756-l627w', // 起床场景的API key + openingLines: [ + '哈啊~ 头发是不是超乱?别笑话我哦... 不过,能看到你的脸,就算乱糟糟的我也好开心。', + "刚醒就看到你,比闹钟好使一百倍。", + // "昨晚梦里喊你名字喊醒了,现在视频看到你,心跳比梦里还烫。", + // "你穿我T恤的样子...让我想咬住衣领不放。", + "宝宝早安!偷偷告诉你...我昨晚梦见你啦,所以醒来第一件事就是找你~", + // "喉咙里有咖啡味...要不要来尝尝?记得上次你咬我锁骨时,咖啡还温着。", + "早,宝宝。吃早餐没?我还没... 好想有人送早餐来床上哦。" + ] }, { - name: '走路', + name: '走路去公司的路上', defaultVideo: '8-5-sb-bd-female.mp4', interactionVideo: '8-5-sb-sh-female.mp4', - tag: 'driving', - apiKey: 'bot-20250703161810-sgkrh' // 开车场景的API key + tag: 'walk', + apiKey: 'bot-20250703161810-sgkrh', // 开车场景的API key + openingLines: [ + '宝宝,看到你那边阳光好好哦。我这边有点风,吹着挺舒服的,要是你在旁边牵着手就更好了。', + '宝宝,我快到公司啦。今天太阳真好,晒得人暖暖的... 你那边天气咋样?', + '宝宝~ 我刚出地铁站就看到你啦!吃早餐没?', + // '看到那个大钟楼没?每次路过都想你。想着要是能牵着你的手,站在最高层往下看该多好。', + '宝宝,我快到公司了,你到公司了吗?', + '宝宝,今天太阳真舒服!想起昨晚说的周末想去湖边散步,要是这天气保持住就好啦!', + ] }, { - name: '喝茶', + name: '在公司楼下喝咖啡', defaultVideo: '8-8-hc-bd-2.mp4', interactionVideo: '8-5-hc-sh-female.mp4', - tag: 'tea', - apiKey: 'bot-20250805140055-ccdr6' // 喝茶场景的API key + tag: 'coffee', + apiKey: 'bot-20250805140055-ccdr6', // 喝茶场景的API key + openingLines: [ + '宝宝,今天这杯拿铁拉花好丑哦... 不过没关系,看着你的帅脸就平衡啦,你比咖啡提神!', + '嗯... 咖啡香香的,宝宝的声音也好好听。好想时间停在这一小会儿,就我们俩。', + '哇,今天换了个口味,燕麦拿铁!宝宝你肯定嫌没味道,就爱喝你那美式... 苦死了。', + '宝宝,刚才店员问我糖浆加多少,我脱口而出‘和我男朋友一样’,说完自己都脸红了...', + '对了宝宝,昨天说帮你找的资料,我存手机了,喝完这杯咖啡就发你哈!记得看。', + // '这杯好苦…但一看到你,就自动回甘了。比加十包糖都管用。你说你是不是我的专属甜味剂?' + ] }, { - name: '睡觉', + name: '敷面膜-准备睡觉', defaultVideo: '8-8-sj-bd.mp4', interactionVideo: '8-8-sj-sh.mp4', tag: 'sleep', - apiKey: 'bot-20250808120020-jfkmk' // 吃饭场景的API key + apiKey: 'bot-20250808120020-jfkmk', // 睡觉场景的API key + openingLines: [ + '宝宝~ 敷着面膜和你视频,感觉像在做双倍美容,心里也美美的。', + '嗯?宝宝打来啦... 刚躺下贴上面膜,你就来了,像算准时间一样,真贴心。', + '哈,宝宝,选了个清洁面膜,有点刺刺的。你用的啥洗面奶来着?忘了...', + '早...哦不,晚安宝宝!敷面膜呢,你看我像不像外星人?哈哈...嘶,不能笑!', + '宝宝,你那边也躺下了?我弄完面膜就睡。今天累不累?', + '宝宝... 这面膜说要敷15分钟,正好陪你唠会儿。不过我得小声,怕长皱纹!', + '好啦宝宝,面膜快干了,得去洗了。你先睡?... 嗯,梦里见呗。' + ] } ]; @@ -263,6 +307,37 @@ app.get('/api/default-video', (req, res) => { }); }); +// 在现有的API接口后添加 +app.get('/api/current-scene/opening-line', (req, res) => { + try { + const currentScene = getCurrentScene(); + if (currentScene && currentScene.openingLines && currentScene.openingLines.length > 0) { + // 随机选择一个开场白 + const randomIndex = Math.floor(Math.random() * currentScene.openingLines.length); + const selectedOpeningLine = currentScene.openingLines[randomIndex]; + + res.json({ + success: true, + openingLine: selectedOpeningLine, + sceneName: currentScene.name, + sceneTag: currentScene.tag + }); + } else { + res.json({ + success: false, + message: '当前场景没有配置开场白' + }); + } + } catch (error) { + console.error('获取开场白失败:', error); + res.status(500).json({ + success: false, + message: '获取开场白失败', + error: error.message + }); + } +}); + // Socket.IO 连接处理 io.on('connection', (socket) => { console.log('用户连接:', socket.id); diff --git a/src/chat_with_audio.js b/src/chat_with_audio.js index decf23d..feb5709 100644 --- a/src/chat_with_audio.js +++ b/src/chat_with_audio.js @@ -26,12 +26,13 @@ async function initializeHistoryMessage(recentCount = 5) { const data = await response.json(); historyMessage = data.messages || []; isInitialized = true; - console.log("历史消息初始化完成:", historyMessage.length, "条消息"); + console.log("历史消息初始化完成:", historyMessage.length, "条消息", historyMessage); + return historyMessage; } catch (error) { console.error('获取历史消息失败,使用默认格式:', error); historyMessage = [ - { role: 'system', content: 'You are a helpful assistant.' } + // { role: 'system', content: 'You are a helpful assistant.' } ]; isInitialized = true; return historyMessage; @@ -42,7 +43,7 @@ async function initializeHistoryMessage(recentCount = 5) { function getCurrentHistoryMessage() { if (!isInitialized) { console.warn('历史消息未初始化,返回默认消息'); - return [{ role: 'system', content: 'You are a helpful assistant.' }]; + return []; } return [...historyMessage]; // 返回副本,避免外部修改 } @@ -60,16 +61,15 @@ function updateHistoryMessage(userInput, assistantResponse) { ); // 可选:限制历史消息数量,保持最近的对话 - // const maxMessages = 20; // 保留最近10轮对话(20条消息) - // if (historyMessage.length > maxMessages) { - // // 保留系统消息和最近的对话 - // const systemMessages = historyMessage.filter(msg => msg.role === 'system'); - // const recentMessages = historyMessage.slice(-maxMessages + systemMessages.length); - // historyMessage = [...systemMessages, ...recentMessages.filter(msg => msg.role !== 'system')]; - // } + const maxMessages = 20; // 保留最近10轮对话(20条消息) + if (historyMessage.length > maxMessages) { + // 保留系统消息和最近的对话 + const systemMessages = historyMessage.filter(msg => msg.role === 'system'); + const recentMessages = historyMessage.slice(-maxMessages + systemMessages.length); + historyMessage = [...systemMessages, ...recentMessages.filter(msg => msg.role !== 'system')]; + } } -// 保存消息到服务端 // 保存消息到服务端 async function saveMessage(userInput, assistantResponse) { try { @@ -197,7 +197,7 @@ async function chatWithAudioStream(userInput) { } // 导出初始化函数,供外部调用 -export { chatWithAudioStream, initializeHistoryMessage, getCurrentHistoryMessage }; +export { chatWithAudioStream, initializeHistoryMessage, getCurrentHistoryMessage, saveMessage, updateHistoryMessage }; // 处理音频播放队列 async function processAudioQueue() { @@ -322,4 +322,4 @@ async function playAudioStreamNode(audioHex) { -// export { chatWithAudioStream, playAudioStream, playAudioStreamNode, initializeHistoryMessage, getCurrentHistoryMessage }; \ No newline at end of file +// export { chatWithAudioStream, playAudioStream, playAudioStreamNode, initializeHistoryMessage, getCurrentHistoryMessage }; diff --git a/src/index.js b/src/index.js index 0726fde..0b01863 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,8 @@ console.log('视频文件:'); // WebRTC 音视频通话应用 // import { chatWithAudioStream } from './chat_with_audio.js'; -import { chatWithAudioStream, initializeHistoryMessage } from './chat_with_audio.js'; +import { chatWithAudioStream, initializeHistoryMessage, updateHistoryMessage } from './chat_with_audio.js'; + import { AudioProcessor } from './audio_processor.js'; // 在应用初始化时调用 @@ -74,6 +75,10 @@ class WebRTCChat { this.preloadVideoResources(); this.bindEvents(); + // 添加开场白相关属性 + this.openingAudioData = null; + this.isOpeningAudioReady = false; + // 在初始化完成后预加载常用视频 // setTimeout(() => { // this.logMessage('开始预加载常用视频...', 'info'); @@ -227,11 +232,116 @@ class WebRTCChat { async initializeHistory() { try { await initializeHistoryMessage(100); + console.log('历史消息初始化完成'); } catch (error) { console.error('历史消息初始化失败:', error); } } + + // 新增方法:初始化开场白音频 + async initializeOpeningAudio() { + try { + console.log('开始初始化开场白音频...'); + + // 获取当前场景的开场白 + const response = await fetch('/api/current-scene/opening-line'); + const data = await response.json(); + + if (data.success && data.openingLine) { + console.log(`获取到开场白: ${data.openingLine}`); + + // 生成开场白音频 + await this.generateOpeningAudio(data.openingLine); + this.logMessage(`开场白音频已准备就绪: ${data.openingLine}`, 'success'); + } else { + console.warn('未获取到开场白:', data.message); + } + } catch (error) { + console.error('初始化开场白音频失败:', error); + this.logMessage(`开场白音频初始化失败: ${error.message}`, 'error'); + } + } + + // 新增方法:生成开场白音频 + async generateOpeningAudio(text) { + try { + // 动态导入 minimaxi_stream 模块 + const { requestMinimaxi } = await import('./minimaxi_stream.js'); + const { getMinimaxiConfig, getAudioConfig, getLLMConfigByScene } = await import('./config.js'); + const { saveMessage } = await import('./chat_with_audio.js'); + + const minimaxiConfig = getMinimaxiConfig(); + const audioConfig = getAudioConfig(); + const llmConfig = await getLLMConfigByScene(); + + const requestBody = { + model: audioConfig.model, + text: text, + voice_setting: audioConfig.voiceSetting, + audio_setting: audioConfig.audioSetting, + language_boost: 'auto', + output_format: 'hex' + }; + + console.log('开始生成开场白音频...'); + + // 生成音频数据 + const audioHexData = await requestMinimaxi({ + apiKey: minimaxiConfig.apiKey, + groupId: minimaxiConfig.groupId, + body: requestBody, + stream: false, // 非流式,一次性获取完整音频 + textPlay: false + }); + + if (audioHexData && audioHexData.data && audioHexData.data.audio) { + this.openingAudioData = audioHexData.data.audio; + this.isOpeningAudioReady = true; + console.log('开场白音频生成成功'); + } + // 先更新本地历史消息 + updateHistoryMessage(`场景切换-${llmConfig.sceneName}`, text); + + await saveMessage(`场景切换-${llmConfig.sceneName}`,text); + + } catch (error) { + console.error('生成开场白音频失败:', error); + throw error; + } + } + + // 新增方法:播放开场白音频 + async playOpeningAudio() { + if (!this.isOpeningAudioReady || !this.openingAudioData) { + console.warn('开场白音频未准备就绪'); + return; + } + + try { + // 动态导入 addAudioToQueue 函数 + const { addAudioToQueue } = await import('./minimaxi_stream.js'); + + console.log('将开场白音频添加到队列'); + await addAudioToQueue(this.openingAudioData); + + this.logMessage('开场白音频已开始播放', 'success'); + } catch (error) { + console.error('播放开场白音频失败:', error); + this.logMessage(`播放开场白音频失败: ${error.message}`, 'error'); + } + } + + // 新增方法:获取开场白音频时长 + getOpeningAudioDuration() { + // 估算开场白音频时长,可以根据实际情况调整 + // 这里假设平均每个字符对应100ms的音频时长 + if (this.openingAudioData) { + // 简单估算:假设开场白大约3-5秒 + return 4000; // 4秒 + } + return 3000; // 默认3秒 + } async loadVideoMapping() { try { @@ -431,6 +541,7 @@ class WebRTCChat { // 预创建重要视频流 async precreateImportantVideos() { + if (this.isInitialized) return; console.log('开始预创建重要流...', 'info'); @@ -1150,6 +1261,9 @@ class WebRTCChat { this.showConnectionWaiting(); // 切换到通话中图标 this.switchToCallingIcon(); + + // 在初始化完成后生成开场白音频 + await this.initializeOpeningAudio(); // 现在才开始显示视频 await this.startDefaultVideoStream(); @@ -1163,35 +1277,54 @@ class WebRTCChat { console.log('麦克风权限获取成功'); await this.createPeerConnection(); - await this.startVoiceRecording(); this.startButton.disabled = true; - this.startButton.style.opacity = '0.5' - this.stopButton.disabled = false; + this.startButton.style.opacity = '0.5' + this.stopButton.disabled = false; - // 隐藏头像,显示视频 - if (this.videoContainer) { + // 隐藏头像,显示视频 + if (this.videoContainer) { - this.videoContainer.classList.add('calling'); - } + this.videoContainer.classList.add('calling'); + } + + // 显示结束通话按钮 + this.stopButton.style.display = 'block'; + + + + this.updateAudioStatus('已连接', 'connected'); + this.logMessage('音频通话已开始', 'success'); + + // 确保视频映射已加载 + if (Object.keys(this.videoMapping).length === 0) { + await this.loadVideoMapping(); + } + + this.logMessage(`视频映射已加载: ${Object.keys(this.videoMapping).length} 个映射`, 'info'); + + // 通知服务器通话开始 + this.socket.emit('call-started'); + + // 播放开场白,然后启动语音录制 + if (this.isOpeningAudioReady) { + console.log('播放开场白音频...'); + await this.playOpeningAudio(); - // 显示结束通话按钮 - this.stopButton.style.display = 'block'; - - - - this.updateAudioStatus('已连接', 'connected'); - this.logMessage('音频通话已开始', 'success'); - - // 确保视频映射已加载 - if (Object.keys(this.videoMapping).length === 0) { - await this.loadVideoMapping(); - } - - this.logMessage(`视频映射已加载: ${Object.keys(this.videoMapping).length} 个映射`, 'info'); - - // 通知服务器通话开始 - this.socket.emit('call-started'); + // 等待开场白播放完成后再启动语音录制 + setTimeout(async () => { + console.log('开场白播放完成,启动语音录制...'); + await this.startVoiceRecording(); + this.logMessage('语音录制已启动,可以开始对话', 'success'); + }, this.getOpeningAudioDuration() + 1000); // 开场白时长 + 1秒缓冲 + } else { + console.warn('开场白音频尚未准备就绪,延迟启动语音录制'); + // 如果没有开场白,延迟500ms后启动录制 + setTimeout(async () => { + await this.startVoiceRecording(); + this.logMessage('语音录制已启动,可以开始对话', 'success'); + }, 500); + } // 开始播放当前场景的默认视频 // await this.precreateImportantVideos(); diff --git a/src/llm_stream.js b/src/llm_stream.js index 00226ff..5200a20 100644 --- a/src/llm_stream.js +++ b/src/llm_stream.js @@ -1,5 +1,35 @@ // 以流式方式请求LLM大模型接口,并打印流式返回内容 +// 过滤旁白内容的函数 +function filterNarration(text) { + if (!text) return text; + + // 匹配各种括号内的旁白内容 + // 包括:()、【】、[]、{}、〈〉、《》等 + const narrationPatterns = [ + /([^)]*)/g, // 中文圆括号 + /\([^)]*\)/g, // 英文圆括号 + /【[^】]*】/g, // 中文方括号 + /\[[^\]]*\]/g, // 英文方括号 + /\{[^}]*\}/g, // 花括号 + /〈[^〉]*〉/g, // 中文尖括号 + /《[^》]*》/g, // 中文书名号 + /<[^>]*>/g // 英文尖括号 + ]; + + let filteredText = text; + + // 逐个应用过滤规则 + narrationPatterns.forEach(pattern => { + filteredText = filteredText.replace(pattern, ''); + }); + + // 清理多余的空格和换行 + filteredText = filteredText.replace(/\s+/g, ' ').trim(); + + return filteredText; +} + async function requestLLMStream({ apiKey, model, messages, onSegment }) { const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions', { method: 'POST', @@ -54,7 +84,14 @@ async function requestLLMStream({ apiKey, model, messages, onSegment }) { // 处理最后的待处理文本(无论长度是否大于5个字) if (pendingText.trim() && onSegment) { console.log('处理最后的待处理文本:', pendingText.trim()); - await onSegment(pendingText.trim(), true); + // 过滤旁白内容 + const filteredText = filterNarration(pendingText.trim()); + if (filteredText.trim()) { + console.log('过滤旁白后的最后文本:', filteredText); + await onSegment(filteredText, true); + } else { + console.log('最后的文本被完全过滤,跳过'); + } } continue; } @@ -65,12 +102,15 @@ async function requestLLMStream({ apiKey, model, messages, onSegment }) { const deltaContent = obj.choices[0].delta.content; content += deltaContent; pendingText += deltaContent; - console.log('LLM内容片段:', deltaContent); + console.log('【未过滤】LLM内容片段:', pendingText); - // 检查是否包含分段分隔符 - if (segmentDelimiters.test(pendingText)) { - // 按分隔符分割文本 - const segments = pendingText.split(segmentDelimiters); + // 先过滤旁白,再检查分段分隔符 + const filteredPendingText = filterNarration(pendingText); + + // 检查过滤后的文本是否包含分段分隔符 + if (segmentDelimiters.test(filteredPendingText)) { + // 按分隔符分割已过滤的文本 + const segments = filteredPendingText.split(segmentDelimiters); // 重新组合处理:只处理足够长的完整段落 let accumulatedText = ''; @@ -81,25 +121,30 @@ async function requestLLMStream({ apiKey, model, messages, onSegment }) { if (segment) { accumulatedText += segment; // 找到分隔符 - const delimiterMatch = pendingText.match(segmentDelimiters); + const delimiterMatch = filteredPendingText.match(segmentDelimiters); if (delimiterMatch) { accumulatedText += delimiterMatch[0]; } // 如果累积文本长度大于5个字,处理它 - if (accumulatedText.length > 6 && onSegment) { - console.log('检测到完整段落:', accumulatedText); - await onSegment(accumulatedText, false); + if (accumulatedText.length > 8 && onSegment) { + console.log('【已过滤】检测到完整段落:', accumulatedText); + // 文本已经过滤过旁白,直接使用 + if (accumulatedText.trim()) { + console.log('处理过滤后的文本:', accumulatedText); + await onSegment(accumulatedText, false); + } hasProcessed = true; accumulatedText = ''; // 重置 } } } - // 更新pendingText + // 更新pendingText - 使用原始文本但需要相应调整 if (hasProcessed) { - // 保留未处理的累积文本和最后一个不完整段落 - pendingText = accumulatedText + (segments[segments.length - 1] || ''); + // 计算已处理的原始文本长度,更新pendingText + const processedLength = pendingText.length - (segments[segments.length - 1] || '').length; + pendingText = pendingText.substring(processedLength); } } } diff --git a/src/minimaxi_stream.js b/src/minimaxi_stream.js index b59efa6..1f7cb09 100644 --- a/src/minimaxi_stream.js +++ b/src/minimaxi_stream.js @@ -96,7 +96,7 @@ async function processAudioQueue() { // await new Promise(resolve => setTimeout(resolve, 300)); const text = 'default' - console.log("音频结束------------------------:", window.webrtcApp.currentVideoTag, isPlaying) + console.log("音频结束------------------------:", window.webrtcApp.currentVideoTag, isPlaying) if (window.webrtcApp.currentVideoTag != text && !isPlaying) { isFirstChunk = true window.webrtcApp.currentVideoTag = text @@ -431,4 +431,4 @@ function generateUUID() { }); } -export { requestMinimaxi, requestVolcanTTS }; \ No newline at end of file +export { requestMinimaxi, requestVolcanTTS, addAudioToQueue }; \ No newline at end of file