File size: 6,178 Bytes
2162a57 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 |
import path from 'path';
import { ClientHttp2Session, ClientHttp2Stream } from "http2";
import _ from "lodash";
import fs from "fs-extra";
import axios from "axios";
import { createParser } from "eventsource-parser";
import AsyncLock from "async-lock";
import core from "./core.ts";
import chat from "./chat.ts";
import modelMap from "../consts/model-map.ts";
import logger from "@/lib/logger.ts";
import util from "@/lib/util.ts";
// 模型名称
const MODEL_NAME = "hailuo";
// 角色ID
const CHARACTER_ID = "1";
// 最大重试次数
const MAX_RETRY_COUNT = 3;
// 重试延迟
const RETRY_DELAY = 5000;
// 语音生成异步锁
const voiceLock = new AsyncLock();
/**
* 创建语音
*
* @param model 模型名称
* @param input 语音内容
* @param voice 发音人
*/
async function createSpeech(
model = MODEL_NAME,
input: string,
voice: string,
token: string
) {
// 先由hailuo复述语音内容获得会话ID和消息ID
const answer = await chat.createRepeatCompletion(
model,
input.replace(/\n/g, "。"),
token
);
const { id: convId, message_id: messageId } = answer;
const deviceInfo = await core.acquireDeviceInfo(token);
// OpenAI模型映射转换
if (modelMap[model]) voice = modelMap[model][voice] || voice;
const audioUrls = await voiceLock.acquire(token, async () => {
// 请求切换发音人
const result = await core.request(
"POST",
"/v1/api/chat/update_robot_custom_config",
{
robotID: "1",
config: { robotVoiceID: voice },
},
token,
deviceInfo
);
core.checkResult(result);
// 请求生成语音
let requestStatus = 0,
audioUrls = [];
let startTime = Date.now();
while (requestStatus < 2) {
if (Date.now() - startTime > 30000) throw new Error("语音生成超时");
const result = await core.request(
"GET",
`/v1/api/chat/msg_tts?msgID=${messageId}&timbre=${voice}`,
{},
token,
deviceInfo
);
({ requestStatus, result: audioUrls } = core.checkResult(result));
}
return audioUrls;
});
// 移除对话
await chat.removeConversation(convId, token);
if (audioUrls.length == 0) throw new Error("语音未生成");
// 请求下载流
const downloadResults = await Promise.all(
audioUrls.map((url) =>
axios.get(url, {
headers: {
Referer: "https://hailuoai.com/",
},
timeout: 30000,
responseType: "arraybuffer",
})
)
);
let audioBuffer = Buffer.from([]);
for (let result of downloadResults) {
if (result.status != 200)
throw new Error(`语音下载失败:[${result.status}]${result.statusText}`);
audioBuffer = Buffer.concat([audioBuffer, result.data]);
}
return audioBuffer;
}
async function createTranscriptions(
model = MODEL_NAME,
filePath: string,
token: string,
retryCount = 0
) {
const name = path.basename(filePath).replace(path.extname(filePath), '');
const transcodedFilePath = `tmp/${name}_transcodeed.mp3`;
await util.transAudioCode(filePath, transcodedFilePath);
const buffer = await fs.readFile(transcodedFilePath);
fs.remove(transcodedFilePath)
.catch(err => logger.error('移除临时文件失败:', err));
let session: ClientHttp2Session;
return (async () => {
// 请求流
const deviceInfo = await core.acquireDeviceInfo(token);
let stream: ClientHttp2Stream;
({ session, stream } = await core.requestStream(
"POST",
"/v1/api/chat/phone_msg",
{
chatID: "0",
voiceBytes: buffer,
characterID: CHARACTER_ID,
playSpeedLevel: "1",
},
token,
deviceInfo,
{
headers: {
Accept: "text/event-stream",
Referer: "https://hailuoai.com/",
},
}
));
// 接收流为输出文本
const text = await receiveTrasciptionResult(stream);
session.close();
return text;
})().catch((err) => {
session && session.close();
session = null;
if (retryCount < MAX_RETRY_COUNT) {
logger.error(`Stream response error: ${err.stack}`);
logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`);
return (async () => {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
return createTranscriptions(model, filePath, token, retryCount + 1);
})();
}
throw err;
});
}
/**
* 从流接收转写结果
*
* @param stream 响应流
*/
async function receiveTrasciptionResult(stream: any): Promise<any> {
return new Promise((resolve, reject) => {
let text = "";
const parser = createParser((event) => {
try {
if (event.type !== "event") return;
// 解析JSON
const result = _.attempt(() => JSON.parse(event.data));
if (_.isError(result))
throw new Error(`Stream response invalid: ${event.data}`);
const { status_code, err_message, data } = result;
if(status_code == 1200041) {
resolve("");
stream.close();
return;
}
if(status_code != 0)
throw new Error(`Stream response error: ${err_message}`);
if (event.event == "asr_chunk") {
resolve(data.text);
stream.close();
}
// 目前首个asr_chunk就可以获得完整的文本,如果有变动再启用下面这个代替它
// if (event.event == "asr_chunk")
// text += data.text;
// else if (event.event == "audio_chunk") {
// resolve(text);
// stream.close();
// }
} catch (err) {
logger.error(err);
reject(err);
}
});
// 将流数据喂给SSE转换器
stream.on("data", (buffer) => parser.feed(buffer.toString()));
stream.once("error", (err) => reject(err));
stream.once("close", () => resolve(text));
});
}
export default {
createSpeech,
createTranscriptions,
};
|