voice-clone-smoke-test.js 7.8 KB

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