|
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";
|
|
|
|
const CHARACTER_ID = "1";
|
|
|
|
const MAX_RETRY_COUNT = 3;
|
|
|
|
const RETRY_DELAY = 5000;
|
|
|
|
|
|
const voiceLock = new AsyncLock();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function createSpeech(
|
|
model = MODEL_NAME,
|
|
input: string,
|
|
voice: string,
|
|
token: string
|
|
) {
|
|
|
|
const answer = await chat.createRepeatCompletion(
|
|
model,
|
|
input.replace(/\n/g, "。"),
|
|
token
|
|
);
|
|
const { id: convId, message_id: messageId } = answer;
|
|
|
|
const deviceInfo = await core.acquireDeviceInfo(token);
|
|
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function receiveTrasciptionResult(stream: any): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
let text = "";
|
|
const parser = createParser((event) => {
|
|
try {
|
|
if (event.type !== "event") return;
|
|
|
|
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();
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} catch (err) {
|
|
logger.error(err);
|
|
reject(err);
|
|
}
|
|
});
|
|
|
|
stream.on("data", (buffer) => parser.feed(buffer.toString()));
|
|
stream.once("error", (err) => reject(err));
|
|
stream.once("close", () => resolve(text));
|
|
});
|
|
}
|
|
|
|
export default {
|
|
createSpeech,
|
|
createTranscriptions,
|
|
};
|
|
|