app.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. // 智能音箱测试工具 - JavaScript
  2. let mr=null,chunks=[],blob=null,startT=0,timer=null,audioCtx=null,analyser=null;
  3. let flowStep='idle'; // 当前流程步骤: idle→recording→asr→llm→tts→done
  4. // 双通道日志:同时输出到 DOM 面板 + 浏览器 console
  5. function log(m,t='info'){
  6. const ts='['+new Date().toLocaleTimeString()+'] ';
  7. // 1. DOM 日志面板
  8. const e=document.getElementById('log');
  9. if(e){
  10. const d=document.createElement('div');
  11. d.className='ln'+(t!=='info'?' '+t:'');
  12. d.textContent=ts+m;
  13. e.appendChild(d);
  14. e.scrollTop=e.scrollHeight;
  15. }
  16. // 2. 浏览器 console(即使 DOM 日志面板不工作也能看到)
  17. const cm=ts+m;
  18. if(t==='e') console.error(cm);
  19. else if(t==='s') console.log('%c'+cm,'color:#6ee7b7');
  20. else if(t==='p') console.log('%c'+cm,'color:#f59e0b;font-weight:bold');
  21. else console.log(cm);
  22. }
  23. function cl(){const e=document.getElementById('log');if(e)e.innerHTML='';console.clear();console.log('🔄 日志已清空')}
  24. function st(id,txt,c){const e=document.getElementById(id);if(e){e.textContent=txt;e.className='st '+c}}
  25. function togCfg(){document.getElementById('cb').classList.toggle('h');document.querySelector('.cb').classList.toggle('cl')}
  26. function cfg(){return{tbu:document.getElementById('tbu').value.trim().replace(/\/$/,''),vid:document.getElementById('vid').value,ap:document.getElementById('ap').value,lp:document.getElementById('lp').value,tk:document.getElementById('tk')?.value||''}}
  27. function save(){localStorage.setItem('tts_test',JSON.stringify(cfg()));log('✅ 配置已保存','s')}
  28. function box(id,txt){const e=document.getElementById(id);if(txt){e.textContent=txt;e.classList.remove('e');console.log('%c[box] '+id+' = "'+txt.substring(0,40)+'"','color:#a78bfa')}else{e.textContent=e.dataset.p||'...';e.classList.add('e');console.log('%c[box] '+id+' 已重置为占位符','color:#a78bfa')}}
  29. // 更新流程步骤指示器
  30. function setFlow(step){
  31. flowStep=step;
  32. const steps=['idle','recording','asr','llm','tts','done'];
  33. const labels={idle:'⏸ 待命',recording:'🎤 录音中',asr:'📝 ASR识别',llm:'🤖 LLM调用',tts:'🔊 TTS合成',done:'✅ 完成'};
  34. const i=steps.indexOf(step);
  35. const fs=document.getElementById('fs');
  36. if(fs){
  37. let html='';
  38. steps.forEach((s,j)=>{
  39. if(j<=i) html+='<span style="color:#6ee7b7;font-weight:bold">'+labels[s]+'</span>';
  40. else html+='<span style="color:#475569">'+labels[s]+'</span>';
  41. if(j<steps.length-1) html+=' <span style="color:#334155">→</span> ';
  42. });
  43. fs.innerHTML=html;
  44. }
  45. console.log('%c[流程] 当前步骤: '+labels[step],'color:#f59e0b;font-weight:bold;font-size:14px');
  46. }
  47. // 全局错误捕获 - 显示所有 JS 错误
  48. window.addEventListener('error',(e)=>{
  49. log('❌ JS 错误: '+(e.message||'未知'),'e');
  50. log(' 文件: '+(e.filename||'?')+':'+(e.lineno||'?'),'e');
  51. if(e.error&&e.error.stack)log(' '+e.error.stack.substring(0,200),'e');
  52. });
  53. window.addEventListener('unhandledrejection',(e)=>{
  54. log('❌ Promise 错误: '+(e.reason?.message||e.reason||'未知'),'e');
  55. });
  56. console.log('🎙️ 智能音箱测试工具已加载 v6.0 ✨');
  57. log('🎙️ 智能音箱测试工具 v6.0 已加载','s');
  58. log('📋 检查 console 日志(F12) + 本日志面板','p');
  59. log('📋 所有按钮点击都会输出日志,观察流程状态条','p');
  60. // 加载保存的配置
  61. window.addEventListener('load',()=>{
  62. log('✅ 页面加载完成','s');
  63. log('📋 [环境] navigator='+(navigator.userAgent.match(/(Chrome|Edge|Firefox|Safari)\/[0-9.]+/)?.[0]||'未知'));
  64. log('📋 [环境] isSecureContext='+window.isSecureContext+' hostname='+location.hostname);
  65. log('📋 [环境] SpeechRecognition='+!!(window.SpeechRecognition||window.webkitSpeechRecognition));
  66. log('📋 [环境] getUserMedia='+!!(navigator.mediaDevices?.getUserMedia));
  67. const s=localStorage.getItem('tts_test');
  68. if(s){try{const c=JSON.parse(s);document.getElementById('tbu').value=c.tbu||'';document.getElementById('vid').value=c.vid||'voice_01';document.getElementById('ap').value=c.ap||'browser';document.getElementById('lp').value=c.lp||'backend';if(document.getElementById('tk'))document.getElementById('tk').value=c.tk||'';log('✅ 配置已加载','s');log('📋 [加载配置] tbu='+c.tbu+' vid='+c.vid+' ap='+c.ap+' lp='+c.lp,'p')}catch(e){log('❌ '+e.message,'e')}}
  69. document.getElementById('at').dataset.p='等待录音...';
  70. document.getElementById('lt').dataset.p='等待识别...';
  71. loadVoices();
  72. });
  73. // 加载音色列表
  74. async function loadVoices(){try{const c=cfg();const r=await fetch(c.tbu+'/api/tts/voices');const d=await r.json();if(d.code===0&&d.data&&d.data.voices){const sel=document.getElementById('vid');sel.innerHTML='';d.data.voices.slice(0,30).forEach(v=>{const o=document.createElement('option');o.value=v.id;o.textContent=v.name;if(v.id===c.vid)o.selected=true;sel.appendChild(o)});log('✅ '+d.data.voices.length+' 个音色','s')}}catch(e){log('⚠️ 加载音色失败: '+e.message,'e')}}
  75. // 录音
  76. async function rec(){
  77. setFlow('recording');
  78. log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  79. log('🔴 [rec()] 录音按钮被点击!');
  80. // 诊断信息
  81. log('🔍 诊断信息:');
  82. log(' 浏览器: '+(navigator.userAgent.match(/(Chrome|Edge|Firefox|Safari)/)?.[0]||'未知'));
  83. log(' 安全上下文: '+(window.isSecureContext?'✅ 是':'❌ 否'));
  84. log(' localhost: '+(location.hostname==='localhost'||location.hostname==='127.0.0.1'?'✅':'⚠️'));
  85. log(' getUserMedia: '+(navigator.mediaDevices?.getUserMedia?'✅ 支持':'❌ 不支持'));
  86. log(' SpeechRecognition: '+((window.SpeechRecognition||window.webkitSpeechRecognition)?'✅ 支持':'❌ 不支持(仅 Chrome/Edge)'));
  87. if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){
  88. log('❌ 浏览器不支持 getUserMedia','e');
  89. log('💡 请用 Chrome 或 Edge 浏览器','e');
  90. setFlow('idle');
  91. return;
  92. }
  93. if(!window.isSecureContext&&location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'){
  94. log('❌ 非安全上下文,麦克风不可用','e');
  95. log('💡 必须用 https:// 或 http://localhost','e');
  96. setFlow('idle');
  97. return;
  98. }
  99. try{
  100. log('🎤 请求麦克风权限...');
  101. const stream=await navigator.mediaDevices.getUserMedia({
  102. audio:{
  103. echoCancellation:true,
  104. noiseSuppression:true,
  105. sampleRate:16000
  106. }
  107. });
  108. log('✅ 麦克风权限获取成功,stream.active='+stream.active,'s');
  109. chunks=[];
  110. const mime=MediaRecorder.isTypeSupported('audio/webm;codecs=opus')?'audio/webm;codecs=opus':'audio/webm';
  111. log('📋 MediaRecorder mimeType='+mime);
  112. mr=new MediaRecorder(stream,{mimeType:mime});
  113. mr.ondataavailable=e=>{
  114. if(e.data.size>0){
  115. chunks.push(e.data);
  116. const totalKB=chunks.reduce((s,c)=>s+c.size,0)/1024;
  117. log(' 📦 数据块: '+(e.data.size/1024).toFixed(1)+' KB (累计='+totalKB.toFixed(1)+'KB chunks数='+chunks.length+')');
  118. }
  119. };
  120. mr.onstop=()=>{
  121. blob=new Blob(chunks,{type:'audio/webm'});
  122. const url=URL.createObjectURL(blob);
  123. const ap=document.getElementById('ap');
  124. ap.src=url;ap.style.display='block';
  125. log('✅ 录音完成: '+(blob.size/1024).toFixed(1)+' KB, chunks='+chunks.length,'s');
  126. log('📋 [录音结果] blob存在='+!!blob+' blob.size='+blob.size+' blob.type='+blob.type,'p');
  127. document.getElementById('bf').disabled=false;
  128. log('📋 [按钮状态] 完整流程按钮已启用 (bf.disabled=false)','p');
  129. stream.getTracks().forEach(t=>t.stop());
  130. };
  131. mr.onerror=(e)=>{
  132. log('❌ MediaRecorder 错误: '+(e.error||e.message||'未知'),'e');
  133. };
  134. log('🎤 启动 MediaRecorder(state='+mr.state+')...');
  135. mr.start(100);
  136. log('✅ MediaRecorder 已启动, state='+mr.state,'s');
  137. startT=Date.now();
  138. timer=setInterval(()=>{document.getElementById('rt').textContent=((Date.now()-startT)/1000).toFixed(1)+'s'},100);
  139. document.getElementById('br').disabled=true;
  140. document.getElementById('bs').disabled=false;
  141. st('rs','录音中','r');
  142. wave(stream);
  143. log('🎤 录音中...现在可以说话了!','s');
  144. }catch(e){
  145. log('❌ 麦克风失败: '+(e.name||'Error'),'e');
  146. log(' 错误消息: '+(e.message||'未知'),'e');
  147. setFlow('idle');
  148. // 针对性建议
  149. if(e.name==='NotAllowedError'||e.name==='PermissionDeniedError'){
  150. log('💡 解决方案:','e');
  151. log(' 1. 点击地址栏左侧的锁图标','e');
  152. log(' 2. 允许麦克风权限','e');
  153. log(' 3. 刷新页面重试','e');
  154. }else if(e.name==='NotFoundError'||e.name==='DevicesNotFoundError'){
  155. log('💡 没有找到麦克风设备','e');
  156. log(' 检查: 系统设置 → 麦克风','e');
  157. }else if(e.name==='NotReadableError'||e.name==='TrackStartError'){
  158. log('💡 麦克风被其他程序占用','e');
  159. log(' 关闭其他使用麦克风的程序','e');
  160. }else if(e.name==='OverconstrainedError'){
  161. log('💡 麦克风不支持请求的参数','e');
  162. }else if(e.name==='SecurityError'){
  163. log('💡 安全错误','e');
  164. log(' 必须用 https:// 或 http://localhost','e');
  165. }
  166. }
  167. }
  168. function stp(){
  169. log('⏹ [stp()] 停止按钮被点击! mr.state='+(mr?mr.state:'mr=null'),'p');
  170. if(mr&&mr.state!=='inactive'){
  171. log('📋 [stp] 正在停止录音...');
  172. mr.stop();
  173. clearInterval(timer);
  174. document.getElementById('br').disabled=false;
  175. document.getElementById('bs').disabled=true;
  176. st('rs','已停止','i');
  177. document.getElementById('wf').textContent='录音完成';
  178. log('📋 [stp] 录音停止指令已发送,等待 onstop 回调...','p');
  179. // onstop 是异步的,blob 此时可能还是 null,这是正常的
  180. setFlow('idle');
  181. }else{
  182. log('⚠️ [stp] mr不存在或已停止,无需操作','e');
  183. }
  184. }
  185. function wave(stream){
  186. try{
  187. audioCtx=new(window.AudioContext||window.webkitAudioContext)();
  188. const src=audioCtx.createMediaStreamSource(stream);
  189. analyser=audioCtx.createAnalyser();analyser.fftSize=256;
  190. src.connect(analyser);
  191. const data=new Uint8Array(analyser.frequencyBinCount);
  192. const wf=document.getElementById('wf');wf.textContent='';
  193. const cv=document.createElement('canvas');cv.width=wf.clientWidth;cv.height=wf.clientHeight;
  194. wf.innerHTML='';wf.appendChild(cv);
  195. const ctx=cv.getContext('2d');
  196. const draw=()=>{if(!analyser)return;analyser.getByteFrequencyData(data);ctx.fillStyle='#0f172a';ctx.fillRect(0,0,cv.width,cv.height);data.forEach((v,i)=>{const h=v/255*cv.height;ctx.fillStyle='#60a5fa';ctx.fillRect(i*(cv.width/data.length),cv.height-h,cv.width/data.length-1,h)});requestAnimationFrame(draw)};
  197. draw();
  198. }catch(e){}
  199. }
  200. // 手动输入
  201. async function manualA(){
  202. log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  203. log('✏️ [manualA()] 手动输入ASR按钮被点击!','p');
  204. const atEl=document.getElementById('at');
  205. log('📋 [manualA] 当前 at.textContent="'+atEl.textContent.trim().substring(0,50)+'" at.hasClass(e)='+atEl.classList.contains('e'));
  206. log('📋 [manualA] 当前 blob='+(blob?'有('+(blob.size/1024).toFixed(1)+'KB)':'无'));
  207. const t=prompt('输入 ASR 结果:');
  208. log('📋 [manualA] prompt 返回值="'+(t||'(null/取消)')+'" 长度='+(t?t.length:0));
  209. if(t){
  210. box('at',t);
  211. log('✅ [manualA] ASR已设置: "'+t+'"','s');
  212. // 验证写入是否成功
  213. const verify=document.getElementById('at').textContent.trim();
  214. log('📋 [manualA] 验证: atEl.textContent="'+verify.substring(0,40)+'" 已写入='+(verify===t),'p');
  215. // 快捷联动:询问是否立即跑完整流程
  216. const goRun=confirm('ASR 已设置为 "'+t+'"\n\n是否立即调用 LLM + TTS?\n(点"取消"则只保存文本,可稍后手动点 🚀 完整流程)');
  217. log('📋 [manualA] 联动询问返回值='+(goRun?'✅ 确认':'❌ 取消'),'p');
  218. if(goRun){
  219. log('🚀 [manualA] 自动触发完整流程...','s');
  220. await full();
  221. }else{
  222. log('⏸ [manualA] 用户选择稍后手动触发','p');
  223. }
  224. }else{
  225. log('❌ [manualA] 用户取消了输入','e');
  226. }
  227. }
  228. async function manualL(){
  229. log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  230. log('✏️ [manualL()] 手动输入LLM按钮被点击!','p');
  231. const ltEl=document.getElementById('lt');
  232. log('📋 [manualL] 当前 lt.textContent="'+ltEl.textContent.trim().substring(0,50)+'" lt.hasClass(e)='+ltEl.classList.contains('e'));
  233. const t=prompt('输入 LLM 回复:');
  234. log('📋 [manualL] prompt 返回值="'+(t||'(null/取消)')+'" 长度='+(t?t.length:0));
  235. if(t){
  236. box('lt',t);
  237. log('✅ [manualL] LLM已设置: "'+t+'"','s');
  238. const verify=document.getElementById('lt').textContent.trim();
  239. log('📋 [manualL] 验证: ltEl.textContent="'+verify.substring(0,40)+'" 已写入='+(verify===t),'p');
  240. }else{
  241. log('❌ [manualL] 用户取消了输入','e');
  242. }
  243. }
  244. async function skipL(){
  245. log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  246. log('⏭️ [skipL()] 跳过LLM按钮被点击!','p');
  247. const asrText=document.getElementById('at').textContent.trim();
  248. log('📋 [skipL] ASR文本="'+asrText.substring(0,50)+'" 长度='+asrText.length);
  249. if(!asrText){
  250. log('⚠️ [skipL] ASR框为空!LLM框将不会被设置','e');
  251. }
  252. box('lt',asrText);
  253. log('⏭️ [skipL] LLM已设为ASR文本: "'+asrText.substring(0,40)+'"','p');
  254. const verify=document.getElementById('lt').textContent.trim();
  255. log('📋 [skipL] 验证: ltEl.textContent="'+verify.substring(0,40)+'"','p');
  256. }
  257. // 完整流程 - 一键智能音箱体验
  258. async function full(){
  259. log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  260. log('🚀 [full()] 完整流程按钮被点击!','p');
  261. const c=cfg();
  262. log('📋 [配置] tbu='+c.tbu+' vid='+c.vid+' ap='+c.ap+' lp='+c.lp+' tk='+(c.tk?'已设置':'空'),'p');
  263. try{
  264. // ============ 步骤 0:诊断 DOM 状态 ============
  265. const atEl=document.getElementById('at');
  266. const ltEl=document.getElementById('lt');
  267. log('📋 [DOM状态] at.textContent="'+atEl.textContent.trim().substring(0,50)+'" at.hasClass(e)='+atEl.classList.contains('e'));
  268. log('📋 [DOM状态] lt.textContent="'+ltEl.textContent.trim().substring(0,50)+'" lt.hasClass(e)='+ltEl.classList.contains('e'));
  269. log('📋 [DOM状态] blob='+(blob?'有录音('+(blob.size/1024).toFixed(1)+'KB)':'无录音'));
  270. log('📋 [DOM状态] 按钮 bf.disabled='+document.getElementById('bf').disabled+' bp.disabled='+document.getElementById('bp').disabled);
  271. // ============ 步骤 1:ASR ============
  272. log('━━━ 步骤1: ASR 识别 ━━━','p');
  273. setFlow('asr');
  274. let asr='';
  275. const isPlaceholder=atEl.classList.contains('e');
  276. log('📋 [ASR分支] ap='+c.ap+' isPlaceholder='+isPlaceholder);
  277. if(c.ap==='browser'){
  278. log('📋 [ASR] 进入 browser 分支');
  279. if(isPlaceholder){
  280. log('💡 国内浏览器不支持原生语音识别,请手动输入','p');
  281. log('📋 [ASR] 即将弹出 prompt 对话框...');
  282. const t=prompt('请输入要说的话(模拟 ASR 识别结果):','你好我是配音侠');
  283. log('📋 [ASR] prompt 返回值="'+(t||'(null/取消)')+'"');
  284. if(!t){log('❌ 用户取消了输入','e'); st('ts','❌ 已取消','e'); setFlow('idle'); return}
  285. asr=t;box('at',asr);
  286. log('✅ [ASR结果] 手动输入: "'+asr+'" (长度='+asr.length+')','s');
  287. }else{
  288. asr=atEl.textContent.trim();
  289. log('📋 [ASR] 使用已有文本: "'+asr+'" (长度='+asr.length+')');
  290. if(!asr){log('❌ ASR 框为空','e'); st('ts','❌ 无输入','e'); setFlow('idle'); return}
  291. log('✅ [ASR结果] 已有输入: "'+asr+'"','s');
  292. }
  293. }else if(c.ap==='openai_whisper'){
  294. log('📋 [ASR] 进入 openai_whisper 分支');
  295. if(!blob){log('❌ 请先录音','e'); st('ts','❌ 无录音','e'); setFlow('idle'); return}
  296. if(!c.ok){log('❌ 缺少 OpenAI Key','e'); st('ts','❌ 缺Key','e'); setFlow('idle'); return}
  297. st('ts','ASR (Whisper)...','p');
  298. log('📋 [ASR] 调用 doAsr, blob大小='+(blob.size/1024).toFixed(1)+'KB');
  299. asr=await doAsr(blob,c);
  300. box('at',asr);
  301. log('✅ [ASR结果] Whisper: "'+asr+'"','s');
  302. }else{
  303. log('📋 [ASR] 进入 else 分支 (ap='+c.ap+')');
  304. if(isPlaceholder){
  305. log('📋 [ASR] 即将弹出 prompt 对话框...');
  306. const t=prompt('请输入要说的话:','你好我是配音侠');
  307. log('📋 [ASR] prompt 返回值="'+(t||'(null/取消)')+'"');
  308. if(!t){log('❌ 用户取消了输入','e'); st('ts','❌ 已取消','e'); setFlow('idle'); return}
  309. asr=t;box('at',asr);
  310. log('✅ [ASR结果] 手动输入: "'+asr+'"','s');
  311. }else{
  312. asr=atEl.textContent.trim();
  313. log('📋 [ASR] 使用已有文本: "'+asr+'" (长度='+asr.length+')');
  314. if(!asr){log('❌ 无 ASR 结果','e'); st('ts','❌ 无输入','e'); setFlow('idle'); return}
  315. log('✅ [ASR结果] 已有输入: "'+asr+'"','s');
  316. }
  317. }
  318. // ============ 步骤 2:LLM ============
  319. log('━━━ 步骤2: LLM 调用 ━━━','p');
  320. setFlow('llm');
  321. let llm='';
  322. let llmTime=0; // 外层声明,避免 ReferenceError
  323. log('📋 [LLM分支] lp='+c.lp);
  324. if(c.lp==='backend'){
  325. log('📋 [LLM] 进入 backend 分支');
  326. st('ts','LLM (后端代理)...','p');
  327. log('📋 [LLM] 即将调用 doLlm(asr="'+asr+'")');
  328. log('📋 [LLM] 后端URL='+c.tbu+'/api/test-tools/llm');
  329. log('📋 [LLM] 模型=qwen3.6-plus');
  330. const startLlm=Date.now();
  331. llm=await doLlm(asr,c);
  332. llmTime=((Date.now()-startLlm)/1000).toFixed(1);
  333. log('📋 [LLM] 耗时='+llmTime+'s');
  334. box('lt',llm);
  335. log('✅ [LLM结果] 回复: "'+llm+'" (长度='+llm.length+')','s');
  336. }else if(c.lp==='deepseek'||c.lp==='openai'){
  337. log('📋 [LLM] 进入 '+c.lp+' 分支');
  338. const key=c.lp==='deepseek'?c.dk:c.ok;
  339. log('📋 [LLM] key状态='+(key?'已设置(长度='+key.length+')':'❌ 空'));
  340. if(!key){log('❌ 缺少 LLM Key','e'); st('ts','❌ 缺Key','e'); setFlow('idle'); return}
  341. st('ts','LLM...','p');
  342. log('📋 [LLM] 即将调用 doLlm(asr="'+asr+'", key)');
  343. const startLlm=Date.now();
  344. llm=await doLlm(asr,c,key);
  345. llmTime=((Date.now()-startLlm)/1000).toFixed(1);
  346. log('📋 [LLM] 耗时='+llmTime+'s');
  347. box('lt',llm);
  348. log('✅ [LLM结果] 回复: "'+llm+'"','s');
  349. }else{
  350. log('📋 [LLM] 进入 none/其他分支 (lp='+c.lp+')');
  351. llmTime='0'; // 跳过LLM时耗时为0
  352. if(ltEl.classList.contains('e')){
  353. log('⏭️ LLM 跳过,直接用 ASR 文本合成语音','p');
  354. llm=asr;
  355. log('📋 [LLM] llm=asr="'+llm+'"');
  356. }else{
  357. llm=ltEl.textContent.trim();
  358. log('📋 [LLM] 已有LLM框内容: "'+llm+'"');
  359. if(!llm){llm=asr;log('⚠️ LLM 框为空,回退用 ASR 文本','e')}
  360. }
  361. }
  362. // ============ 步骤 3:TTS ============
  363. log('━━━ 步骤3: TTS 合成 ━━━','p');
  364. setFlow('tts');
  365. st('ts','TTS...','p');
  366. log('📋 [TTS] 即将调用 doTts(text="'+llm.substring(0,50)+'", vid='+c.vid+')');
  367. log('📋 [TTS] 后端URL='+c.tbu+'/api/tts/synthesize');
  368. const startTts=Date.now();
  369. const url=await doTts(llm,c);
  370. const ttsTime=((Date.now()-startTts)/1000).toFixed(1);
  371. log('📋 [TTS] 耗时='+ttsTime+'s, 返回url='+(url||'(null)'));
  372. if(url){
  373. const ta=document.getElementById('ta');
  374. const fullUrl=url.startsWith('http')||url.startsWith('blob')?url:c.tbu+url;
  375. log('📋 [TTS] 完整URL='+fullUrl);
  376. ta.src=fullUrl;
  377. ta.play().then(()=>log('✅ [TTS] 播放成功','s')).catch(e=>log('⚠️ [TTS] 播放失败: '+e.message,'e'));
  378. document.getElementById('bp').disabled=false;
  379. document.getElementById('bd').disabled=false;
  380. document.getElementById('tu').textContent='🔗 '+url;
  381. const totalTime=(parseFloat(llmTime)+parseFloat(ttsTime)).toFixed(1);
  382. st('ts','✅ 完成 ('+totalTime+'s)','s');
  383. log('✅ 全部完成! ASR→LLM→TTS 流程跑通','s');
  384. log('📋 [最终状态] asr="'+asr+'" llm="'+llm.substring(0,50)+'" url='+url,'s');
  385. setFlow('done');
  386. }else{
  387. st('ts','❌ 失败','e');
  388. log('❌ [TTS] 合成失败,url为空','e');
  389. setFlow('idle');
  390. }
  391. }catch(e){
  392. log('❌ [异常] '+e.message,'e');
  393. if(e.stack)log('📋 [堆栈] '+e.stack.substring(0,300),'e');
  394. st('ts','❌ 错误: '+e.message,'e');
  395. setFlow('idle');
  396. }
  397. }
  398. // ASR - 使用浏览器原生 Web Speech API(实时识别)
  399. async function doAsr(blob,c){
  400. return new Promise((resolve,reject)=>{
  401. const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
  402. if(!SR){reject(new Error('浏览器不支持 Web Speech API(请用 Chrome 或 Edge)'));return}
  403. const rec=new SR();
  404. rec.lang='zh-CN';
  405. rec.interimResults=false;
  406. rec.maxAlternatives=1;
  407. rec.continuous=false;
  408. rec.onresult=(e)=>{
  409. const text=e.results[0][0].transcript;
  410. log('🎤 浏览器识别: '+text,'s');
  411. resolve(text);
  412. };
  413. rec.onerror=(e)=>{
  414. log('❌ ASR 错误: '+e.error,'e');
  415. reject(new Error('ASR 错误: '+e.error));
  416. };
  417. rec.onend=()=>{
  418. log('🎤 ASR 结束');
  419. };
  420. try{
  421. rec.start();
  422. log('🎤 开始实时识别(请说话)...');
  423. }catch(e){
  424. reject(new Error('启动 ASR 失败: '+e.message));
  425. }
  426. });
  427. }
  428. // LLM - 调后端 /api/test-tools/llm(自动用 models.json 的 Key)
  429. async function doLlm(text,c,key){
  430. log('📤 [LLM请求] text="'+text+'" (长度='+text.length+')');
  431. log('📤 [LLM请求] URL='+c.tbu+'/api/test-tools/llm');
  432. log('📤 [LLM请求] model=qwen3.6-plus');
  433. const body={
  434. model:'qwen3.6-plus',
  435. messages:[
  436. {role:'system',content:'你是配音侠,一个友好活泼的中文AI语音助手。你必须用中文回复,不要用英文。回答要简短(20字以内),语气亲切自然。'},
  437. {role:'user',content:text}
  438. ]
  439. };
  440. log('📤 [LLM请求] body大小='+JSON.stringify(body).length+'字符');
  441. const fetchStart=Date.now();
  442. const r=await fetch(c.tbu+'/api/test-tools/llm',{
  443. method:'POST',
  444. headers:{'Content-Type':'application/json'},
  445. body:JSON.stringify(body)
  446. });
  447. log('📤 [LLM响应] HTTP状态='+r.status+' '+r.statusText+' (耗时='+((Date.now()-fetchStart)/1000).toFixed(1)+'s)');
  448. if(!r.ok)throw new Error('LLM HTTP '+r.status+' '+r.statusText);
  449. const d=await r.json();
  450. log('📤 [LLM响应] code='+d.code+' message='+(d.message||'无'));
  451. if(d.code!==0)throw new Error(d.message||'LLM返回错误');
  452. log('📥 [LLM回复] 原始内容: "'+(d.data?.reply||'(空)').substring(0,100)+'"');
  453. log('📥 [LLM回复] 长度='+(d.data?.reply||'').length);
  454. return (d.data?.reply||'').trim();
  455. }
  456. // TTS (后端)
  457. async function doTts(text,c){
  458. log('📤 [TTS] text="'+text.substring(0,50)+'" (长度='+text.length+') vid='+c.vid);
  459. // 尝试同步端点
  460. try{
  461. const syncUrl=c.tbu+'/api/tts/synthesize';
  462. log('📤 [TTS同步] URL='+syncUrl);
  463. const syncBody={text:text.substring(0,500), voiceId:c.vid, voiceParams:{speed:1.0,pitch:0,volume:50}};
  464. log('📤 [TTS同步] body='+JSON.stringify(syncBody).substring(0,150));
  465. const r=await fetch(syncUrl,{
  466. method:'POST',
  467. headers:{'Content-Type':'application/json',...(c.tk?{Authorization:'Bearer '+c.tk}:{})},
  468. body:JSON.stringify(syncBody)
  469. });
  470. log('📤 [TTS同步] HTTP状态='+r.status);
  471. const d=await r.json();
  472. log('📤 [TTS同步] code='+d.code+' message='+(d.message||'无')+' hasAudioUrl='+!!(d.data?.audioUrl));
  473. if(d.code===0&&d.data&&d.data.audioUrl){
  474. log('✅ [TTS同步] 成功! audioUrl='+d.data.audioUrl,'s');
  475. return d.data.audioUrl;
  476. }
  477. log('⚠️ 同步端点失败: '+(d.message||'未知')+' - 切换异步','e');
  478. }catch(e){log('⚠️ 同步异常: '+e.message+' - 切换异步','e')}
  479. // 异步端点
  480. log('📤 [TTS异步] 提交任务...');
  481. const asyncUrl=c.tbu+'/api/tts/generate';
  482. log('📤 [TTS异步] URL='+asyncUrl);
  483. const r2=await fetch(asyncUrl,{
  484. method:'POST',
  485. headers:{'Content-Type':'application/json',...(c.tk?{Authorization:'Bearer '+c.tk}:{})},
  486. body:JSON.stringify({text:text, voiceId:c.vid, voiceParams:{speed:1.0,pitch:0,volume:50}})
  487. });
  488. const d2=await r2.json();
  489. log('📤 [TTS异步] 提交响应 code='+d2.code+' chapterId='+(d2.data?.chapterId||'无'));
  490. if(d2.code!==0)throw new Error('提交失败: '+d2.message);
  491. const cid=d2.data.chapterId;
  492. log('⏳ [TTS异步] 任务ID='+cid+', 开始轮询(最多60次)...');
  493. for(let i=0;i<60;i++){
  494. await new Promise(r=>setTimeout(r,1500));
  495. const statusUrl=c.tbu+'/api/tts/chapter-status/'+cid;
  496. const sr=await fetch(statusUrl,{headers: c.tk ? {Authorization:'Bearer '+c.tk} : {}});
  497. const sd=await sr.json();
  498. const hasUrl=!!(sd.data?.audioUrl);
  499. const isFailed=!!(sd.data?.isFailed);
  500. if(i%5===0)log('⏳ [TTS异步] 轮询#'+(i+1)+' hasUrl='+hasUrl+' isFailed='+isFailed);
  501. if(sd.code===0&&sd.data&&sd.data.audioUrl){
  502. log('✅ [TTS异步] 成功! 在第'+(i+1)+'次轮询拿到 audioUrl='+sd.data.audioUrl,'s');
  503. return sd.data.audioUrl;
  504. }
  505. if(sd.data&&sd.data.isFailed)throw new Error('生成失败(isFailed=true)');
  506. }
  507. throw new Error('轮询超时(60次)');
  508. }
  509. // 播放
  510. function play(){
  511. log('▶ [play()] 播放按钮被点击!','p');
  512. const ta=document.getElementById('ta');
  513. log('📋 [play] audio.src='+(ta.src?ta.src.substring(0,80):'(空)')+' audio.readyState='+ta.readyState);
  514. if(!ta.src){log('❌ [play] 无音频源','e');return}
  515. ta.play().then(()=>log('✅ [play] 播放成功','s')).catch(e=>log('❌ [play] 播放失败: '+e.message,'e'));
  516. }
  517. // 下载
  518. function dl(){
  519. log('⬇ [dl()] 下载按钮被点击!','p');
  520. const url=document.getElementById('ta').src;
  521. log('📋 [dl] audio.src='+(url?url.substring(0,80):'(空)'));
  522. if(!url){log('❌ 无音频','e');return}
  523. const a=document.createElement('a');
  524. a.href=url;a.download='tts-'+Date.now()+'.mp3';a.click();
  525. log('⬇️ 下载: '+url,'s');
  526. }