voice-clone-smoke-test.js 6.4 KB

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