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 }; |