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