voice-clone-smoke-test.js 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. 'use strict';
  2. const assert = require('assert/strict');
  3. const fs = require('fs');
  4. const os = require('os');
  5. const path = require('path');
  6. const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-voice-clone-smoke-'));
  7. process.env.QIWEI_OUTPUTS_DIR = path.join(root, 'outputs');
  8. const {
  9. VoiceCloneService,
  10. inferVoiceTone,
  11. resolveTone,
  12. readResponseBuffer,
  13. } = require('../mcp/src/core/voice-clone-service');
  14. const { __testing: voiceToolTesting } = require('../mcp/src/tools/qiwei-voice-run');
  15. const { FmodeQiweiClient } = require('../mcp/src/providers/fmode-agent-transport');
  16. const { readFmodeVoiceToken, readQiweiAuthToken } = require('../mcp/src/core/credentials');
  17. function wavBuffer({ duration = 1, sampleRate = 16000, frequency = 220 } = {}) {
  18. const samples = Math.floor(duration * sampleRate);
  19. const dataSize = samples * 2;
  20. const buffer = Buffer.alloc(44 + dataSize);
  21. buffer.write('RIFF', 0);
  22. buffer.writeUInt32LE(36 + dataSize, 4);
  23. buffer.write('WAVEfmt ', 8);
  24. buffer.writeUInt32LE(16, 16);
  25. buffer.writeUInt16LE(1, 20);
  26. buffer.writeUInt16LE(1, 22);
  27. buffer.writeUInt32LE(sampleRate, 24);
  28. buffer.writeUInt32LE(sampleRate * 2, 28);
  29. buffer.writeUInt16LE(2, 32);
  30. buffer.writeUInt16LE(16, 34);
  31. buffer.write('data', 36);
  32. buffer.writeUInt32LE(dataSize, 40);
  33. for (let index = 0; index < samples; index += 1) {
  34. const value = Math.round(Math.sin((2 * Math.PI * frequency * index) / sampleRate) * 4000);
  35. buffer.writeInt16LE(value, 44 + index * 2);
  36. }
  37. return buffer;
  38. }
  39. async function main() {
  40. const emptySources = { processEnv: {}, fileEnv: {}, claudeEnv: {}, fmodeConfig: {} };
  41. assert.equal(readFmodeVoiceToken(
  42. { authToken: 'parse-session-token' },
  43. { ...emptySources, processEnv: { FMODE_API_TOKEN: 'sk-fmode-voice' } },
  44. ), 'sk-fmode-voice');
  45. assert.equal(readFmodeVoiceToken({ authToken: 'parse-session-token' }, emptySources), '');
  46. assert.equal(readFmodeVoiceToken(
  47. {},
  48. { ...emptySources, processEnv: { ANTHROPIC_AUTH_TOKEN: 'sk-ant-upstream', ANTHROPIC_BASE_URL: 'https://api.anthropic.com' } },
  49. ), '');
  50. assert.equal(readFmodeVoiceToken(
  51. {},
  52. { ...emptySources, processEnv: { FMODE_API_TOKEN: 'sk-other-provider', FMODE_API_BASE: 'https://api.openai.com' } },
  53. ), '');
  54. assert.equal(readQiweiAuthToken({ authToken: 'parse-session-token' }), 'parse-session-token');
  55. const sessionOnlyVoice = new VoiceCloneService({
  56. config: { authToken: 'parse-session-token' },
  57. qiwei: { context: () => ({ token: 'parse-session-token' }) },
  58. });
  59. assert.equal(sessionOnlyVoice.status().configured, false);
  60. assert.throws(() => sessionOnlyVoice.requireReady(), /https:\/\/api\.fmode\.cn\/keys\//);
  61. assert.equal(inferVoiceTone('非常抱歉给您带来了不好的体验').id, 'apology');
  62. assert.equal(inferVoiceTone('恭喜,您的申请已经审核通过').id, 'friendly');
  63. assert.equal(inferVoiceTone('提醒您,请于明天下午前提交资料').id, 'reminder');
  64. assert.equal(inferVoiceTone('理解您的心情,这段时间辛苦了').id, 'empathetic');
  65. assert.equal(inferVoiceTone('资料已经收到,我稍后回复您').id, 'natural');
  66. assert.equal(resolveTone('natural', '恭喜').automatic, false);
  67. assert.throws(() => resolveTone('angry', '测试'), /不支持/);
  68. assert.equal(voiceToolTesting.isPrivateAddress('127.0.0.1'), true);
  69. assert.equal(voiceToolTesting.isPrivateAddress('10.0.0.8'), true);
  70. assert.equal(voiceToolTesting.isPrivateAddress('8.8.8.8'), false);
  71. await assert.rejects(() => voiceToolTesting.fetchVoice('http://example.com/voice.wav'), /只允许使用/);
  72. await assert.rejects(
  73. () => readResponseBuffer(new Response('small', { headers: { 'Content-Length': String(51 * 1024 * 1024) } }), 50 * 1024 * 1024),
  74. /响应过大/
  75. );
  76. const referencePath = path.join(root, 'reference.wav');
  77. fs.writeFileSync(referencePath, wavBuffer({ duration: 6 }));
  78. const calls = [];
  79. let sendSuccess = true;
  80. const qiwei = {
  81. context: () => ({ token: 'parse-session-token', uid: 'account-smoke', guid: 'guid-smoke' }),
  82. isConfigured: () => true,
  83. async uploadVoiceFile(filePath) {
  84. calls.push({ type: 'upload', filePath });
  85. assert.equal(fs.existsSync(filePath), true);
  86. return { fileId: 'file-smoke', fileAesKey: 'aes-smoke', fileSize: fs.statSync(filePath).size };
  87. },
  88. async sendVoice(toId, media) {
  89. calls.push({ type: 'send', toId, media });
  90. return sendSuccess ? { isSendSuccess: 1, msgType: 16, msgServerId: 1001 } : { isSendSuccess: 0 };
  91. },
  92. };
  93. const service = new VoiceCloneService({
  94. qiwei,
  95. config: {
  96. endpoint: 'https://server.fmode.cn/api/voice/indextts2',
  97. authToken: 'sk-fmode-smoke-token',
  98. model: 'fmode-voice',
  99. },
  100. });
  101. await service.enroll({ filePath: referencePath, originalName: '本人录音.wav', mime: 'audio/wav' });
  102. const status = service.status();
  103. assert.equal(status.enrolled, true);
  104. assert.equal(status.previewEnabled, false);
  105. assert.equal(status.profile.sampleRate, 16000);
  106. assert.ok(status.profile.duration >= 5.9 && status.profile.duration <= 6.1);
  107. await service.enroll({ filePath: referencePath, originalName: '本人录音-更新.wav', mime: 'audio/wav' });
  108. assert.equal(service.status().enrolled, true);
  109. const originalFetch = global.fetch;
  110. const generated = wavBuffer({ duration: 1.2, sampleRate: 22050, frequency: 330 });
  111. global.fetch = async (url, options = {}) => {
  112. assert.equal(url, 'https://server.fmode.cn/api/voice/indextts2');
  113. assert.equal(options.headers.Authorization, 'Bearer sk-fmode-smoke-token');
  114. const payload = JSON.parse(options.body.get('payload'));
  115. assert.equal(payload.use_random, false);
  116. assert.equal([0, 3].includes(payload.emo_control_method), true);
  117. if (payload.emo_control_method === 3) assert.equal(payload.emo_text.includes('歉意'), true);
  118. assert.equal(options.body.get('stream_mode'), 'true');
  119. return new Response(generated, { status: 200, headers: { 'Content-Type': 'audio/wav' } });
  120. };
  121. try {
  122. await assert.rejects(
  123. service.synthesizeAndSend({ text: '未经确认', tone: 'natural', toId: 'contact-smoke' }),
  124. /必须获得人工确认/
  125. );
  126. const result = await service.synthesizeAndSend({
  127. text: '非常抱歉,我们马上为您处理。',
  128. tone: 'auto',
  129. toId: 'contact-smoke',
  130. confirmed: true,
  131. });
  132. assert.equal(result.tone.id, 'apology');
  133. assert.ok(result.duration >= 1.1 && result.duration <= 1.3);
  134. assert.equal(calls[0].type, 'upload');
  135. assert.equal(calls[1].type, 'send');
  136. assert.equal(calls[1].toId, 'contact-smoke');
  137. assert.equal(calls[1].media.voiceTime, 1);
  138. assert.equal(fs.existsSync(path.join(result.runDir, 'speech.wav')), true);
  139. assert.equal(fs.existsSync(path.join(result.runDir, 'speech.silk')), false);
  140. assert.match(path.basename(result.runDir), /-clone-[a-f0-9]{8}$/);
  141. const manifest = JSON.parse(fs.readFileSync(path.join(result.runDir, 'manifest.json'), 'utf8'));
  142. assert.equal(manifest.outcome, 'sent');
  143. assert.equal(Object.hasOwn(manifest, 'text'), false);
  144. sendSuccess = false;
  145. await assert.rejects(
  146. service.synthesizeAndSend({ text: '这次发送应失败', tone: 'natural', toId: 'contact-smoke', confirmed: true }),
  147. /未明确确认/
  148. );
  149. const retainedWavs = fs.readdirSync(path.dirname(result.runDir), { withFileTypes: true })
  150. .filter(item => item.isDirectory())
  151. .map(item => path.join(path.dirname(result.runDir), item.name, 'speech.wav'))
  152. .filter(filePath => fs.existsSync(filePath));
  153. assert.deepEqual(retainedWavs, [result.audioPath]);
  154. } finally {
  155. global.fetch = originalFetch;
  156. }
  157. const uploadClient = new FmodeQiweiClient({
  158. authToken: 'fmode-upload-token',
  159. uid: 'upload-account',
  160. guid: 'upload-guid',
  161. apiBase: 'https://server.fmode.cn/api/qiwei',
  162. });
  163. assert.equal(uploadClient.context().token, 'fmode-upload-token');
  164. const silkPath = path.join(root, 'upload.silk');
  165. fs.writeFileSync(silkPath, Buffer.from('silk-smoke'));
  166. global.fetch = async (url, options = {}) => {
  167. assert.equal(url, 'https://server.fmode.cn/api/qiwei/doFileApi');
  168. assert.equal(options.headers.Authorization, 'Bearer fmode-upload-token');
  169. assert.equal(options.body.get('uid'), 'upload-account');
  170. assert.equal(options.body.get('guid'), 'upload-guid');
  171. assert.equal(options.body.get('method'), '/cloud/cdnBigUpload');
  172. assert.equal(options.body.get('fileType'), '5');
  173. assert.equal(options.body.get('file').name, 'upload.silk');
  174. return new Response(JSON.stringify({
  175. code: 200,
  176. data: { data: { fileId: 'file-upload', fileAesKey: 'aes-upload', fileSize: 10 } },
  177. }), { status: 200, headers: { 'Content-Type': 'application/json' } });
  178. };
  179. try {
  180. assert.deepEqual(await uploadClient.uploadVoiceFile(silkPath), {
  181. fileId: 'file-upload',
  182. fileAesKey: 'aes-upload',
  183. fileSize: 10,
  184. });
  185. } finally {
  186. global.fetch = originalFetch;
  187. }
  188. service.revoke();
  189. assert.equal(service.status().enrolled, false);
  190. console.log('[ok] Fmode voice synthesis, doFileApi upload, tone, enrollment, SILK and send orchestration');
  191. }
  192. main()
  193. .catch(error => {
  194. console.error(error);
  195. process.exitCode = 1;
  196. })
  197. .finally(() => fs.rmSync(root, { recursive: true, force: true }));