All checks were successful
		
		
	
	Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 1m7s
				
			
		
			
				
	
	
		
			431 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			431 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| // 以流式或非流式方式请求 minimaxi 大模型接口,并打印/返回内容
 | ||
| 
 | ||
| // import { text } from "express";
 | ||
| 
 | ||
| // 在文件顶部添加音频播放相关的变量和函数
 | ||
| let audioContext = null;
 | ||
| let audioQueue = []; // 音频队列
 | ||
| let isPlaying = false;
 | ||
| let isProcessingQueue = false; // 队列处理状态
 | ||
| let nextStartTime = 0; // 添加这行来声明 nextStartTime 变量
 | ||
| 
 | ||
| // 初始化音频上下文
 | ||
| function initAudioContext() {
 | ||
|   if (!audioContext) {
 | ||
|     audioContext = new (window.AudioContext || window.webkitAudioContext)();
 | ||
|   }
 | ||
|   return audioContext;
 | ||
| }
 | ||
| 
 | ||
| // 将hex字符串转换为ArrayBuffer
 | ||
| function hexToArrayBuffer(hex) {
 | ||
|   const bytes = new Uint8Array(hex.length / 2);
 | ||
|   for (let i = 0; i < hex.length; i += 2) {
 | ||
|     bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
 | ||
|   }
 | ||
|   return bytes.buffer;
 | ||
| }
 | ||
| 
 | ||
| // 将音频添加到队列(不等待播放)
 | ||
| async function addAudioToQueue(audioHex) {
 | ||
|   if (!audioHex || audioHex.length === 0) return;
 | ||
|   
 | ||
|   try {
 | ||
|     const ctx = initAudioContext();
 | ||
|     const audioBuffer = hexToArrayBuffer(audioHex);
 | ||
|     const audioData = await ctx.decodeAudioData(audioBuffer);
 | ||
|     
 | ||
|     // 将解码后的音频数据添加到队列
 | ||
|     audioQueue.push({
 | ||
|       audioData,
 | ||
|       timestamp: Date.now()
 | ||
|     });
 | ||
|     
 | ||
|     console.log(`音频已添加到队列,队列长度: ${audioQueue.length}`);
 | ||
|     
 | ||
|     // 启动队列处理器(如果还没有运行)
 | ||
|     if (!isProcessingQueue) {
 | ||
|       processAudioQueue();
 | ||
|     }
 | ||
|     
 | ||
|   } catch (error) {
 | ||
|     console.error('音频解码失败:', error);
 | ||
|   }
 | ||
| }
 | ||
| let isFirstChunk = true;
 | ||
| // 队列处理器 - 独立运行,按顺序播放音频
 | ||
| async function processAudioQueue() {
 | ||
|   if (isProcessingQueue) return;
 | ||
|   
 | ||
|   isProcessingQueue = true;
 | ||
|   
 | ||
|   
 | ||
|   while (audioQueue.length > 0 && !isPlaying) {
 | ||
|     console.log('开始处理音频队列');
 | ||
|     // 如果当前没有音频在播放,且队列中有音频
 | ||
|     if (!isPlaying && audioQueue.length > 0) {
 | ||
|       const audioItem = audioQueue.shift();
 | ||
|       const sayName = '8-4-sh'
 | ||
|       const targetVideo = window.webrtcApp.interactionVideo
 | ||
|       // 如果是第一个音频片段,触发视频切换
 | ||
|       if (sayName != window.webrtcApp.currentVideoTag && window.webrtcApp && window.webrtcApp.switchVideoStream) {
 | ||
|         try {
 | ||
|           console.log('--------------触发视频切换:', sayName);
 | ||
|           window.webrtcApp.switchVideoStream(targetVideo, 'audio', '8-4-sh');
 | ||
|           isFirstChunk = false;
 | ||
|           window.webrtcApp.currentVideoTag = sayName;
 | ||
|         } catch (error) {
 | ||
|           console.error('视频切换失败:', error);
 | ||
|         }
 | ||
|       }
 | ||
|       await playAudioData(audioItem.audioData);
 | ||
|     } else {
 | ||
|       // 等待一小段时间再检查
 | ||
|       await new Promise(resolve => setTimeout(resolve, 50));
 | ||
|     }
 | ||
|   }
 | ||
|   
 | ||
|   isProcessingQueue = false;
 | ||
|   
 | ||
|   // 等待当前音频播放完成后再切换回默认视频
 | ||
|   while (isPlaying) {
 | ||
|     await new Promise(resolve => setTimeout(resolve, 100));
 | ||
|   }
 | ||
|   
 | ||
|   const text = 'default'
 | ||
|   console.log("音频结束------------------------:", window.webrtcApp.currentVideoTag, isPlaying)
 | ||
|   if (window.webrtcApp.currentVideoTag != text) {
 | ||
|     isFirstChunk = true
 | ||
|     window.webrtcApp.currentVideoTag = text
 | ||
|     window.webrtcApp.switchVideoStream(window.webrtcApp.defaultVideo, 'audio', text);
 | ||
|   }
 | ||
|   console.log('音频队列处理完成');
 | ||
| }
 | ||
| 
 | ||
| // 播放单个音频数据
 | ||
| function playAudioData(audioData) {
 | ||
|   return new Promise((resolve) => {
 | ||
|     try {
 | ||
|       const ctx = initAudioContext();
 | ||
|       const source = ctx.createBufferSource();
 | ||
|       source.buffer = audioData;
 | ||
|       source.connect(ctx.destination);
 | ||
|       
 | ||
|       isPlaying = true;
 | ||
|       
 | ||
|       source.onended = () => {
 | ||
|         console.log('音频片段播放完成');
 | ||
|         isPlaying = false;
 | ||
|         resolve();
 | ||
|       };
 | ||
|       
 | ||
|       // 超时保护
 | ||
|       setTimeout(() => {
 | ||
|         if (isPlaying) {
 | ||
|           console.log('音频播放超时,强制结束');
 | ||
|           isPlaying = false;
 | ||
|           resolve();
 | ||
|         }
 | ||
|       }, (audioData.duration + 0.5) * 1000);
 | ||
|       
 | ||
|       source.start(0);
 | ||
|       console.log(`开始播放音频片段,时长: ${audioData.duration}秒`);
 | ||
|       
 | ||
|     } catch (error) {
 | ||
|       console.error('播放音频失败:', error);
 | ||
|       isPlaying = false;
 | ||
|       resolve();
 | ||
|     }
 | ||
|   });
 | ||
| }
 | ||
| 
 | ||
| // 修改原来的playAudioChunk函数,改为addAudioToQueue
 | ||
| const playAudioChunk = addAudioToQueue;
 | ||
| 
 | ||
| // 清空音频队列
 | ||
| function clearAudioQueue() {
 | ||
|   audioQueue.length = 0;
 | ||
|   console.log('音频队列已清空');
 | ||
| }
 | ||
| 
 | ||
| // 获取队列状态
 | ||
| function getQueueStatus() {
 | ||
|   return {
 | ||
|     queueLength: audioQueue.length,
 | ||
|     isPlaying,
 | ||
|     isProcessingQueue
 | ||
|   };
 | ||
| }
 | ||
| 
 | ||
| // 移除waitForCurrentAudioToFinish函数,不再需要
 | ||
| 
 | ||
| async function requestMinimaxi({ apiKey, groupId, body, stream = true , textPlay = false}) {
 | ||
|   const url = `https://api.minimaxi.com/v1/t2a_v2`;
 | ||
|   const reqBody = { ...body, stream };
 | ||
|   isPlaying = textPlay
 | ||
|   // 添加这两行变量定义
 | ||
|   let isFirstChunk = true;
 | ||
|   // const currentText = body.text;
 | ||
|   
 | ||
|   const response = await fetch(url, {
 | ||
|     method: 'POST',
 | ||
|     headers: {
 | ||
|       'Authorization': `Bearer ${apiKey}`,
 | ||
|       'Content-Type': 'application/json',
 | ||
|       'Accept': 'text/event-stream',
 | ||
|       'Cache-Control': 'no-cache',
 | ||
|     },
 | ||
|     body: JSON.stringify(reqBody),
 | ||
|   });
 | ||
| 
 | ||
|   if (!response.ok) {
 | ||
|     throw new Error(`HTTP error! status: ${response.status}`);
 | ||
|   }
 | ||
| 
 | ||
|   if (!stream) {
 | ||
|     // 非流式,直接返回JSON
 | ||
|     const result = await response.json();
 | ||
|     console.log(JSON.stringify(result, null, 2));
 | ||
|     return result;
 | ||
|   } else {
 | ||
|     // 流式,解析每个chunk,实时播放音频
 | ||
|     const reader = response.body.getReader();
 | ||
|     const decoder = new TextDecoder('utf-8');
 | ||
|     let done = false;
 | ||
|     let buffer = '';
 | ||
|     let audioHex = '';
 | ||
|     let lastFullResult = null;
 | ||
|     
 | ||
|     // 重置播放状态
 | ||
|     nextStartTime = 0;
 | ||
|     if (audioContext) {
 | ||
|       nextStartTime = audioContext.currentTime;
 | ||
|     }
 | ||
| 
 | ||
|     while (!done) {
 | ||
|       const { value, done: doneReading } = await reader.read();
 | ||
|       done = doneReading;
 | ||
|       if (value) {
 | ||
|         const chunk = decoder.decode(value, { stream: true });
 | ||
|         buffer += chunk;
 | ||
|         
 | ||
|         // 处理SSE格式的数据(以\n分割)
 | ||
|         let lines = buffer.split('\n');
 | ||
|         buffer = lines.pop(); // 最后一行可能是不完整的,留到下次
 | ||
|         for (const line of lines) {
 | ||
|           if (!line.trim()) continue;
 | ||
|           
 | ||
|           // 检查是否是SSE格式的数据行
 | ||
|           if (line.startsWith('data:')) {
 | ||
|             const jsonStr = line.substring(6); // 移除 'data: ' 前缀
 | ||
|             
 | ||
|             if (jsonStr.trim() === '[DONE]') {
 | ||
|               console.log('SSE流结束');
 | ||
|               continue;
 | ||
|             }
 | ||
|             
 | ||
|             try {
 | ||
|               const obj = JSON.parse(jsonStr);
 | ||
|               // 流式,解析每个chunk,实时播放音频
 | ||
|               if (obj.data && obj.data.audio && obj.data.status === 1) {
 | ||
|                 console.log('收到音频数据片段!', obj.data.audio.length);
 | ||
|                 // audioHex += obj.data.audio;
 | ||
|                 audioHex = obj.data.audio;
 | ||
|                 // const sayName = 'say-5s-m-sw'
 | ||
|                 // // 如果是第一个音频片段,触发视频切换
 | ||
|                 // if (isFirstChunk && sayName != window.webrtcApp.currentVideoName && window.webrtcApp && window.webrtcApp.handleTextInput) {
 | ||
|                 //   try {
 | ||
|                 //     await window.webrtcApp.handleTextInput(sayName);
 | ||
|                 //     isFirstChunk = false;
 | ||
|                 //     window.webrtcApp.currentVideoName = sayName;
 | ||
|                 //   } catch (error) {
 | ||
|                 //     console.error('视频切换失败:', error);
 | ||
|                 //   }
 | ||
|                 // }
 | ||
|                 
 | ||
|                 // 立即播放这个音频片段
 | ||
|                 await playAudioChunk(obj.data.audio);
 | ||
|               }
 | ||
|               // status=2为最后一个chunk,记录完整结构
 | ||
|               if (obj.data && obj.data.status === 2) {
 | ||
|                 // const text = 'default'
 | ||
|                 // await window.webrtcApp.socket.emit('text-input', { text });
 | ||
|                 // await window.webrtcApp.handleTextInput(text);
 | ||
|                 lastFullResult = null;
 | ||
|                 console.log('收到最终状态');
 | ||
|               }
 | ||
|             } catch (e) {
 | ||
|               console.error('解析SSE数据失败:', e, '原始数据:', jsonStr);
 | ||
|             }
 | ||
|           } else if (line.startsWith('event: ') || line.startsWith('id: ') || line.startsWith('retry: ')) {
 | ||
|             // 忽略SSE的其他字段
 | ||
|             console.log('忽略SSE字段:', line);
 | ||
|             continue;
 | ||
|           } else if (line.trim() && !line.startsWith('data:')) {
 | ||
|             // 尝试直接解析(兼容非SSE格式,但避免重复处理)
 | ||
|             console.log('尝试直接解析:', line);
 | ||
|             try {
 | ||
|               const obj = JSON.parse(line);
 | ||
|               if (obj.data && obj.data.audio) {
 | ||
|                 console.log('收到无data:音频数据!', obj.data.audio.length);
 | ||
|                 audioHex = obj.data.audio;
 | ||
|                 
 | ||
|                 // 立即播放这个音频片段
 | ||
|                 await playAudioChunk(obj.data.audio);
 | ||
|               }
 | ||
|               if (obj.data && obj.data.status === 2) {
 | ||
|                 lastFullResult = obj;
 | ||
|               }
 | ||
|               console.log('直接解析成功:', JSON.stringify(obj));
 | ||
|             } catch (e) {
 | ||
|               console.error('解析chunk失败:', e, line);
 | ||
|             }
 | ||
|           }
 | ||
|         }
 | ||
|       }
 | ||
|     }
 | ||
|     // 合成最终结构
 | ||
|     console.log('音频数据总长度:', audioHex.length);
 | ||
|     if (lastFullResult) {
 | ||
|       lastFullResult.data.audio = audioHex;
 | ||
|       console.log('最终合成结果:', JSON.stringify(lastFullResult, null, 2));
 | ||
|       return lastFullResult;
 | ||
|     } else {
 | ||
|       // 没有完整结构,返回合成的audio
 | ||
|       return { data: { audio: audioHex } };
 | ||
|     }
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| // 火山引擎TTS方法
 | ||
| async function requestVolcanTTS({ 
 | ||
|   appId, 
 | ||
|   accessKey, 
 | ||
|   resourceId = 'volc.service_type.10029', 
 | ||
|   appKey = 'aGjiRDfUWi',
 | ||
|   body, 
 | ||
|   stream = true 
 | ||
| }) {
 | ||
|   const url = 'https://openspeech.bytedance.com/api/v3/tts/unidirectional';
 | ||
|   
 | ||
|   // 生成请求ID
 | ||
|   const requestId = generateUUID();
 | ||
|   
 | ||
|   const response = await fetch(url, {
 | ||
|     method: 'POST',
 | ||
|     headers: {
 | ||
|       'X-Api-App-Id': appId,
 | ||
|       'X-Api-Access-Key': accessKey,
 | ||
|       'X-Api-Resource-Id': resourceId,
 | ||
|       'X-Api-App-Key': appKey,
 | ||
|       'X-Api-Request-Id': requestId,
 | ||
|       'Content-Type': 'application/json',
 | ||
|       'Accept': stream ? 'text/event-stream' : 'application/json',
 | ||
|       'Cache-Control': 'no-cache',
 | ||
|     },
 | ||
|     body: JSON.stringify(body),
 | ||
|   });
 | ||
| 
 | ||
|   if (!response.ok) {
 | ||
|     throw new Error(`HTTP error! status: ${response.status}`);
 | ||
|   }
 | ||
| 
 | ||
|   if (!stream) {
 | ||
|     // 非流式,直接返回JSON
 | ||
|     const result = await response.json();
 | ||
|     console.log('火山引擎TTS非流式结果:', JSON.stringify(result, null, 2));
 | ||
|     return result;
 | ||
|   } else {
 | ||
|     // 流式,解析每个chunk,合并audio
 | ||
|     const reader = response.body.getReader();
 | ||
|     const decoder = new TextDecoder('utf-8');
 | ||
|     let done = false;
 | ||
|     let buffer = '';
 | ||
|     let audioBase64 = '';
 | ||
|     let lastFullResult = null;
 | ||
| 
 | ||
|     while (!done) {
 | ||
|       const { value, done: doneReading } = await reader.read();
 | ||
|       done = doneReading;
 | ||
|       if (value) {
 | ||
|         const chunk = decoder.decode(value, { stream: true });
 | ||
|         buffer += chunk;
 | ||
|         
 | ||
|         // 处理SSE格式的数据(以\n分割)
 | ||
|         let lines = buffer.split('\n');
 | ||
|         buffer = lines.pop(); // 最后一行可能是不完整的,留到下次
 | ||
|         
 | ||
|         for (const line of lines) {
 | ||
|           if (!line.trim()) continue;
 | ||
|           
 | ||
|           // 检查是否是SSE格式的数据行
 | ||
|           if (line.startsWith('data:')) {
 | ||
|             const jsonStr = line.substring(6); // 移除 'data: ' 前缀
 | ||
|             
 | ||
|             if (jsonStr.trim() === '[DONE]') {
 | ||
|               console.log('火山引擎TTS流结束');
 | ||
|               continue;
 | ||
|             }
 | ||
|             
 | ||
|             try {
 | ||
|               const obj = JSON.parse(jsonStr);
 | ||
|               // 流式,解析每个chunk,合并audio base64数据
 | ||
|               if (obj.data) {
 | ||
|                 audioBase64 += obj.data;
 | ||
|                 lastFullResult = obj;
 | ||
|               }
 | ||
|               // 实时打印每个chunk
 | ||
|               console.log('火山引擎TTS解析成功:', JSON.stringify(obj));
 | ||
|             } catch (e) {
 | ||
|               console.error('解析火山引擎TTS数据失败:', e, '原始数据:', jsonStr);
 | ||
|             }
 | ||
|           } else if (line.startsWith('event: ') || line.startsWith('id: ') || line.startsWith('retry: ')) {
 | ||
|             // 忽略SSE的其他字段
 | ||
|             console.log('忽略SSE字段:', line);
 | ||
|             continue;
 | ||
|           } else if (line.trim() && !line.startsWith('data:')) {
 | ||
|             // 尝试直接解析(兼容非SSE格式)
 | ||
|             try {
 | ||
|               const obj = JSON.parse(line);
 | ||
|               if (obj.data) {
 | ||
|                 audioBase64 += obj.data;
 | ||
|                 lastFullResult = obj;
 | ||
|               }
 | ||
|               console.log('火山引擎TTS直接解析成功:', JSON.stringify(obj));
 | ||
|             } catch (e) {
 | ||
|               console.error('解析火山引擎TTS chunk失败:', e, line);
 | ||
|             }
 | ||
|           }
 | ||
|         }
 | ||
|       }
 | ||
|     }
 | ||
|     
 | ||
|     // 合成最终结构
 | ||
|     console.log('火山引擎TTS音频数据总长度:', audioBase64.length);
 | ||
|     
 | ||
|     if (lastFullResult) {
 | ||
|       // 更新最终结果的音频数据
 | ||
|       lastFullResult.data = audioBase64;
 | ||
|       console.log('火山引擎TTS最终合成结果:', JSON.stringify(lastFullResult, null, 2));
 | ||
|       return lastFullResult;
 | ||
|     } else {
 | ||
|       // 没有完整结构,返回合成的audio
 | ||
|       return { 
 | ||
|         code: 0, 
 | ||
|         message: '', 
 | ||
|         data: audioBase64 
 | ||
|       };
 | ||
|     }
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| // 生成UUID的辅助函数
 | ||
| function generateUUID() {
 | ||
|   return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
 | ||
|     const r = Math.random() * 16 | 0;
 | ||
|     const v = c === 'x' ? r : (r & 0x3 | 0x8);
 | ||
|     return v.toString(16);
 | ||
|   });
 | ||
| }
 | ||
| 
 | ||
| export { requestMinimaxi, requestVolcanTTS }; |