voice-clone-service.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. 'use strict';
  2. const crypto = require('crypto');
  3. const fs = require('fs');
  4. const os = require('os');
  5. const path = require('path');
  6. const { spawn } = require('child_process');
  7. const Ffmpeg = require('@ffmpeg-installer/ffmpeg');
  8. const Ffprobe = require('@ffprobe-installer/ffprobe');
  9. const WxVoiceModule = require('@binsee/wx-voice');
  10. const WxVoice = WxVoiceModule.WxVoice || WxVoiceModule.default || WxVoiceModule;
  11. const { categoryDir, createRunDir, writeRunManifest } = require('./output-paths');
  12. const TONE_PRESETS = Object.freeze({
  13. natural: Object.freeze({ id: 'natural', label: '自然', method: 0, alpha: null, text: '' }),
  14. friendly: Object.freeze({ id: 'friendly', label: '友好', method: 3, alpha: 0.4, text: '自然、友好、轻松,略带愉快和肯定,表达清晰' }),
  15. apology: Object.freeze({ id: 'apology', label: '真诚致歉', method: 3, alpha: 0.5, text: '真诚、耐心、克制,带有适度歉意,语速稍慢,不夸张' }),
  16. empathetic: Object.freeze({ id: 'empathetic', label: '温和关怀', method: 3, alpha: 0.45, text: '温和、关怀、耐心,语气柔和沉稳,不过度悲伤' }),
  17. reminder: Object.freeze({ id: 'reminder', label: '明确提醒', method: 3, alpha: 0.4, text: '明确、专注、专业,稍微强调时间但不焦虑、不催促' }),
  18. });
  19. const TONE_RULES = Object.freeze([
  20. ['apology', /抱歉|对不起|歉意|给您带来|不好的体验|未能及时|处理不周|投诉|不满意/],
  21. ['empathetic', /遗憾|难过|理解您的心情|辛苦了|保重|安慰|担心|不容易|身体不适/],
  22. ['reminder', /请于|截止|到期|超时|尽快|尽早|抓紧|马上|立即|提醒您|别忘了|时间安排/],
  23. ['friendly', /恭喜|好消息|审核通过|申请成功|已经成功|感谢|欢迎|很高兴|祝您|顺利完成/],
  24. ]);
  25. const MAX_SYNTH_AUDIO_BYTES = 50 * 1024 * 1024;
  26. const MAX_ERROR_BODY_BYTES = 1024 * 1024;
  27. function removeFile(filePath) {
  28. if (!filePath) return;
  29. try {
  30. fs.unlinkSync(filePath);
  31. } catch (error) {
  32. if (error?.code !== 'ENOENT') throw error;
  33. }
  34. }
  35. function removePath(targetPath) {
  36. let stat;
  37. try {
  38. stat = fs.lstatSync(targetPath);
  39. } catch (error) {
  40. if (error?.code === 'ENOENT') return;
  41. throw error;
  42. }
  43. if (stat.isDirectory() && !stat.isSymbolicLink()) {
  44. for (const entry of fs.readdirSync(targetPath)) {
  45. removePath(path.join(targetPath, entry));
  46. }
  47. fs.rmdirSync(targetPath);
  48. return;
  49. }
  50. removeFile(targetPath);
  51. }
  52. async function readResponseBuffer(response, maxBytes) {
  53. const declared = Number(response.headers.get('content-length')) || 0;
  54. if (declared > maxBytes) throw new Error('声音合成响应过大');
  55. if (!response.body) return Buffer.alloc(0);
  56. const reader = response.body.getReader();
  57. const chunks = [];
  58. let size = 0;
  59. try {
  60. while (true) {
  61. const { done, value } = await reader.read();
  62. if (done) break;
  63. size += value.byteLength;
  64. if (size > maxBytes) {
  65. await reader.cancel();
  66. throw new Error('声音合成响应过大');
  67. }
  68. chunks.push(Buffer.from(value));
  69. }
  70. } finally {
  71. reader.releaseLock();
  72. }
  73. return Buffer.concat(chunks, size);
  74. }
  75. function isExplicitSendSuccess(result) {
  76. return Boolean(result && [true, 1, '1'].includes(result.isSendSuccess));
  77. }
  78. function inferVoiceTone(text, context = '') {
  79. const primary = String(text || '').trim();
  80. const combined = `${primary}\n${String(context || '').trim()}`;
  81. const match = TONE_RULES.find(([, pattern]) => pattern.test(combined));
  82. const preset = TONE_PRESETS[match ? match[0] : 'natural'];
  83. return {
  84. ...preset,
  85. automatic: true,
  86. reason: match ? `命中文本场景:${preset.label}` : '未识别到特殊情绪,使用自然语气',
  87. };
  88. }
  89. function resolveTone(input, text, context) {
  90. const requested = String(input || 'auto').trim().toLowerCase();
  91. if (!requested || requested === 'auto') return inferVoiceTone(text, context);
  92. const preset = TONE_PRESETS[requested];
  93. if (!preset) throw new Error('不支持的语音语气');
  94. return { ...preset, automatic: false, reason: `人工选择:${preset.label}` };
  95. }
  96. function runBinary(executable, args) {
  97. return new Promise((resolve, reject) => {
  98. const child = spawn(executable, args, { windowsHide: true });
  99. let stdout = '';
  100. let stderr = '';
  101. child.stdout.on('data', chunk => { stdout += chunk; });
  102. child.stderr.on('data', chunk => { stderr += chunk; });
  103. child.on('error', reject);
  104. child.on('close', code => {
  105. if (code === 0) resolve({ stdout, stderr });
  106. else reject(new Error(`音频处理失败(code ${code}):${stderr.slice(-500)}`));
  107. });
  108. });
  109. }
  110. async function probeAudio(filePath) {
  111. const result = await runBinary(Ffprobe.path, [
  112. '-v', 'error', '-show_entries', 'format=duration,size,format_name',
  113. '-show_entries', 'stream=codec_name,sample_rate,channels', '-of', 'json', filePath,
  114. ]);
  115. const parsed = JSON.parse(result.stdout || '{}');
  116. const stream = (parsed.streams || [])[0] || {};
  117. return {
  118. duration: Number(parsed.format?.duration) || 0,
  119. size: Number(parsed.format?.size) || fs.statSync(filePath).size,
  120. format: parsed.format?.format_name || '',
  121. codec: stream.codec_name || '',
  122. sampleRate: Number(stream.sample_rate) || 0,
  123. channels: Number(stream.channels) || 0,
  124. };
  125. }
  126. function safeAccountKey(qiwei) {
  127. const context = qiwei?.context?.() || {};
  128. const source = String(context.uid || context.guid || 'default');
  129. return crypto.createHash('sha256').update(source).digest('hex').slice(0, 16);
  130. }
  131. function extensionFor(name, mime = '') {
  132. const ext = path.extname(String(name || '')).toLowerCase();
  133. if (['.wav', '.mp3', '.m4a', '.webm', '.ogg', '.aac'].includes(ext)) return ext;
  134. if (/wav/i.test(mime)) return '.wav';
  135. if (/mpeg|mp3/i.test(mime)) return '.mp3';
  136. if (/mp4|m4a/i.test(mime)) return '.m4a';
  137. if (/webm/i.test(mime)) return '.webm';
  138. if (/ogg/i.test(mime)) return '.ogg';
  139. return '.audio';
  140. }
  141. class VoiceCloneService {
  142. constructor({ config = {}, qiwei }) {
  143. this.config = {
  144. endpoint: String(config.endpoint || 'https://server.fmode.cn/api/voice/indextts2').trim(),
  145. authToken: String(config.authToken || '').trim().replace(/^Bearer\s+/i, ''),
  146. model: String(config.model || 'fmode-voice').trim(),
  147. requestTimeoutMs: Math.max(15000, Math.min(300000, Number(config.requestTimeoutMs) || 180000)),
  148. };
  149. this.qiwei = qiwei;
  150. }
  151. accountKey() {
  152. return safeAccountKey(this.qiwei);
  153. }
  154. profileDir() {
  155. return path.join(categoryDir('voice'), 'profiles', this.accountKey());
  156. }
  157. profilePath() {
  158. return path.join(this.profileDir(), 'reference.wav');
  159. }
  160. metadataPath() {
  161. return path.join(this.profileDir(), 'profile.json');
  162. }
  163. readMetadata() {
  164. try {
  165. return JSON.parse(fs.readFileSync(this.metadataPath(), 'utf8'));
  166. } catch {
  167. return null;
  168. }
  169. }
  170. status() {
  171. const metadata = this.readMetadata();
  172. const authToken = this.config.authToken || String(this.qiwei?.context?.()?.token || '').trim();
  173. return {
  174. configured: Boolean(authToken && this.config.endpoint),
  175. provider: 'fmode-voice',
  176. model: this.config.model,
  177. enrolled: Boolean(metadata && fs.existsSync(this.profilePath())),
  178. profile: metadata ? {
  179. createdAt: metadata.createdAt,
  180. duration: metadata.duration,
  181. sampleRate: metadata.sampleRate,
  182. originalName: metadata.originalName,
  183. } : null,
  184. tones: Object.values(TONE_PRESETS).map(item => ({ id: item.id, label: item.label })),
  185. automaticTone: true,
  186. previewEnabled: false,
  187. uploadMode: 'doFileApi',
  188. sendConfigured: Boolean(this.qiwei?.isConfigured?.()),
  189. };
  190. }
  191. async enroll({ filePath, originalName = '', mime = '' }) {
  192. if (!filePath || !fs.existsSync(filePath)) throw new Error('缺少声音参考文件');
  193. const sourceSize = fs.statSync(filePath).size;
  194. if (sourceSize > 20 * 1024 * 1024) throw new Error('声音参考文件不能超过 20MB');
  195. const dir = this.profileDir();
  196. fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
  197. const target = this.profilePath();
  198. const staging = path.join(dir, `reference-${crypto.randomUUID()}.wav`);
  199. try {
  200. await runBinary(Ffmpeg.path, ['-y', '-i', filePath, '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', staging]);
  201. const info = await probeAudio(staging);
  202. if (info.duration < 5 || info.duration > 30) throw new Error('参考录音时长需在 5~30 秒之间');
  203. const backup = `${target}.bak`;
  204. removeFile(backup);
  205. if (fs.existsSync(target)) fs.renameSync(target, backup);
  206. try {
  207. fs.renameSync(staging, target);
  208. removeFile(backup);
  209. } catch (error) {
  210. if (!fs.existsSync(target) && fs.existsSync(backup)) fs.renameSync(backup, target);
  211. throw error;
  212. }
  213. try { fs.chmodSync(target, 0o600); } catch {}
  214. const metadata = {
  215. version: 1,
  216. createdAt: new Date().toISOString(),
  217. originalName: String(originalName || `reference${extensionFor(originalName, mime)}`).slice(0, 160),
  218. mime: String(mime || '').slice(0, 100),
  219. duration: Number(info.duration.toFixed(3)),
  220. sampleRate: info.sampleRate,
  221. channels: info.channels,
  222. size: info.size,
  223. };
  224. fs.writeFileSync(this.metadataPath(), JSON.stringify(metadata, null, 2), 'utf8');
  225. try { fs.chmodSync(this.metadataPath(), 0o600); } catch {}
  226. return { status: 'ok', profile: this.status().profile };
  227. } finally {
  228. removeFile(staging);
  229. }
  230. }
  231. revoke() {
  232. const dir = path.resolve(this.profileDir());
  233. const root = path.resolve(path.join(categoryDir('voice'), 'profiles'));
  234. if (!dir.startsWith(`${root}${path.sep}`)) throw new Error('声音档案路径无效');
  235. removePath(dir);
  236. return { status: 'ok', revoked: true };
  237. }
  238. requireReady() {
  239. const authToken = this.config.authToken || String(this.qiwei?.context?.()?.token || '').trim();
  240. if (!authToken) throw new Error('缺少 Fmode 鉴权,请先在 Fmode Studio 中完成登录');
  241. if (!fs.existsSync(this.profilePath())) throw new Error('尚未初始化本人声音,请先录制或上传参考音频');
  242. return authToken;
  243. }
  244. async synthesize({ text, context = '', tone = 'auto' }) {
  245. const authToken = this.requireReady();
  246. const input = String(text || '').trim();
  247. if (!input) throw new Error('语音文本不能为空');
  248. if (input.length > 600) throw new Error('语音文本不能超过 600 字');
  249. const selectedTone = resolveTone(tone, input, context);
  250. const payload = {
  251. input,
  252. emo_control_method: selectedTone.method,
  253. use_random: false,
  254. };
  255. if (selectedTone.method === 3) {
  256. payload.emo_alpha = selectedTone.alpha;
  257. payload.emo_text = selectedTone.text;
  258. }
  259. const form = new FormData();
  260. const reference = fs.readFileSync(this.profilePath());
  261. form.append('spk_audio_file', new Blob([reference], { type: 'audio/wav' }), 'reference.wav');
  262. form.append('payload', JSON.stringify(payload));
  263. form.append('model', this.config.model);
  264. form.append('stream_mode', 'true');
  265. const controller = new AbortController();
  266. const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
  267. let response;
  268. try {
  269. response = await fetch(this.config.endpoint, {
  270. method: 'POST',
  271. headers: { Authorization: `Bearer ${authToken}` },
  272. body: form,
  273. signal: controller.signal,
  274. });
  275. } catch (error) {
  276. if (error.name === 'AbortError') throw new Error('声音合成超时,请稍后重试');
  277. throw new Error(`Fmode 语音服务不可用:${error.message}`);
  278. } finally {
  279. clearTimeout(timeout);
  280. }
  281. const contentType = String(response.headers.get('content-type') || '').toLowerCase();
  282. if (!response.ok || contentType.includes('json')) {
  283. const raw = (await readResponseBuffer(response, MAX_ERROR_BODY_BYTES)).toString('utf8');
  284. let parsed;
  285. try { parsed = JSON.parse(raw); } catch { parsed = {}; }
  286. throw new Error(parsed.mess || parsed.message || parsed.error || `声音合成失败(HTTP ${response.status})`);
  287. }
  288. const runDir = createRunDir('voice', `clone-${crypto.randomBytes(4).toString('hex')}`);
  289. const wavPath = path.join(runDir, 'speech.wav');
  290. fs.writeFileSync(wavPath, await readResponseBuffer(response, MAX_SYNTH_AUDIO_BYTES), { mode: 0o600 });
  291. const wavInfo = await probeAudio(wavPath);
  292. if (!wavInfo.duration || wavInfo.duration > 180) throw new Error('声音合成结果无效');
  293. const manifestPath = writeRunManifest(runDir, {
  294. type: 'voice-clone',
  295. provider: 'fmode-voice',
  296. model: this.config.model,
  297. textLength: input.length,
  298. textSha256: crypto.createHash('sha256').update(input).digest('hex'),
  299. tone: selectedTone,
  300. audio: wavInfo,
  301. files: ['speech.wav'],
  302. });
  303. return { text: input, tone: selectedTone, wavPath, wavInfo, runDir, manifestPath };
  304. }
  305. async encodeSilk(wavPath) {
  306. const silkPath = path.join(
  307. os.tmpdir(),
  308. `qiwei-voice-${process.pid}-${crypto.randomBytes(8).toString('hex')}.silk`
  309. );
  310. const voice = new WxVoice();
  311. voice.on('error', () => {});
  312. try {
  313. await voice.encode(wavPath, silkPath, { format: 'silk', frequency: 24000, channels: 1 });
  314. const duration = await voice.duration(silkPath);
  315. if (!duration) throw new Error('SILK 编码结果无效');
  316. return { silkPath, duration, size: fs.statSync(silkPath).size };
  317. } catch (error) {
  318. removeFile(silkPath);
  319. throw error;
  320. }
  321. }
  322. async synthesizeAndSend({ text, context = '', tone = 'auto', toId, confirmed = false }) {
  323. if (confirmed !== true) throw new Error('发送克隆语音前必须获得人工确认');
  324. if (!this.qiwei?.isConfigured?.()) throw new Error('企微语音媒体上传尚未配置,请先完成 Fmode 鉴权和企微登录');
  325. if (!String(toId || '').trim()) throw new Error('企微语音接收人不能为空');
  326. const result = await this.synthesize({ text, context, tone });
  327. let silk;
  328. let outcome = 'failed';
  329. try {
  330. silk = await this.encodeSilk(result.wavPath);
  331. const uploaded = await this.qiwei.uploadVoiceFile(silk.silkPath);
  332. if (!uploaded?.fileId || !uploaded?.fileAesKey) throw new Error('企微语音媒体上传未返回有效文件信息');
  333. const sent = await this.qiwei.sendVoice(toId, {
  334. fileAesKey: uploaded.fileAesKey,
  335. fileId: uploaded.fileId,
  336. fileSize: uploaded.fileSize || silk.size,
  337. voiceTime: Math.max(1, Math.round(silk.duration)),
  338. });
  339. if (!isExplicitSendSuccess(sent)) throw new Error('企微未明确确认语音发送成功');
  340. outcome = 'sent';
  341. return {
  342. text: result.text,
  343. tone: result.tone,
  344. duration: silk.duration,
  345. fileSize: silk.size,
  346. sendResult: sent,
  347. runDir: result.runDir,
  348. audioPath: result.wavPath,
  349. };
  350. } finally {
  351. removeFile(silk?.silkPath);
  352. if (outcome !== 'sent') removeFile(result.wavPath);
  353. writeRunManifest(result.runDir, {
  354. type: 'voice-clone',
  355. provider: 'fmode-voice',
  356. model: this.config.model,
  357. textLength: result.text.length,
  358. textSha256: crypto.createHash('sha256').update(result.text).digest('hex'),
  359. tone: result.tone,
  360. outcome,
  361. files: outcome === 'sent' ? ['speech.wav'] : [],
  362. });
  363. }
  364. }
  365. }
  366. module.exports = {
  367. VoiceCloneService,
  368. TONE_PRESETS,
  369. inferVoiceTone,
  370. resolveTone,
  371. probeAudio,
  372. isExplicitSendSuccess,
  373. readResponseBuffer,
  374. };