// 智能音箱测试工具 - JavaScript let mr=null,chunks=[],blob=null,startT=0,timer=null,audioCtx=null,analyser=null; let flowStep='idle'; // 当前流程步骤: idle→recording→asr→llm→tts→done // 双通道日志:同时输出到 DOM 面板 + 浏览器 console function log(m,t='info'){ const ts='['+new Date().toLocaleTimeString()+'] '; // 1. DOM 日志面板 const e=document.getElementById('log'); if(e){ const d=document.createElement('div'); d.className='ln'+(t!=='info'?' '+t:''); d.textContent=ts+m; e.appendChild(d); e.scrollTop=e.scrollHeight; } // 2. 浏览器 console(即使 DOM 日志面板不工作也能看到) const cm=ts+m; if(t==='e') console.error(cm); else if(t==='s') console.log('%c'+cm,'color:#6ee7b7'); else if(t==='p') console.log('%c'+cm,'color:#f59e0b;font-weight:bold'); else console.log(cm); } function cl(){const e=document.getElementById('log');if(e)e.innerHTML='';console.clear();console.log('🔄 日志已清空')} function st(id,txt,c){const e=document.getElementById(id);if(e){e.textContent=txt;e.className='st '+c}} function togCfg(){document.getElementById('cb').classList.toggle('h');document.querySelector('.cb').classList.toggle('cl')} 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||''}} function save(){localStorage.setItem('tts_test',JSON.stringify(cfg()));log('✅ 配置已保存','s')} 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')}} // 更新流程步骤指示器 function setFlow(step){ flowStep=step; const steps=['idle','recording','asr','llm','tts','done']; const labels={idle:'⏸ 待命',recording:'🎤 录音中',asr:'📝 ASR识别',llm:'🤖 LLM调用',tts:'🔊 TTS合成',done:'✅ 完成'}; const i=steps.indexOf(step); const fs=document.getElementById('fs'); if(fs){ let html=''; steps.forEach((s,j)=>{ if(j<=i) html+=''+labels[s]+''; else html+=''+labels[s]+''; if(j{ log('❌ JS 错误: '+(e.message||'未知'),'e'); log(' 文件: '+(e.filename||'?')+':'+(e.lineno||'?'),'e'); if(e.error&&e.error.stack)log(' '+e.error.stack.substring(0,200),'e'); }); window.addEventListener('unhandledrejection',(e)=>{ log('❌ Promise 错误: '+(e.reason?.message||e.reason||'未知'),'e'); }); console.log('🎙️ 智能音箱测试工具已加载 v6.0 ✨'); log('🎙️ 智能音箱测试工具 v6.0 已加载','s'); log('📋 检查 console 日志(F12) + 本日志面板','p'); log('📋 所有按钮点击都会输出日志,观察流程状态条','p'); // 加载保存的配置 window.addEventListener('load',()=>{ log('✅ 页面加载完成','s'); log('📋 [环境] navigator='+(navigator.userAgent.match(/(Chrome|Edge|Firefox|Safari)\/[0-9.]+/)?.[0]||'未知')); log('📋 [环境] isSecureContext='+window.isSecureContext+' hostname='+location.hostname); log('📋 [环境] SpeechRecognition='+!!(window.SpeechRecognition||window.webkitSpeechRecognition)); log('📋 [环境] getUserMedia='+!!(navigator.mediaDevices?.getUserMedia)); const s=localStorage.getItem('tts_test'); 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')}} document.getElementById('at').dataset.p='等待录音...'; document.getElementById('lt').dataset.p='等待识别...'; loadVoices(); }); // 加载音色列表 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')}} // 录音 async function rec(){ setFlow('recording'); log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); log('🔴 [rec()] 录音按钮被点击!'); // 诊断信息 log('🔍 诊断信息:'); log(' 浏览器: '+(navigator.userAgent.match(/(Chrome|Edge|Firefox|Safari)/)?.[0]||'未知')); log(' 安全上下文: '+(window.isSecureContext?'✅ 是':'❌ 否')); log(' localhost: '+(location.hostname==='localhost'||location.hostname==='127.0.0.1'?'✅':'⚠️')); log(' getUserMedia: '+(navigator.mediaDevices?.getUserMedia?'✅ 支持':'❌ 不支持')); log(' SpeechRecognition: '+((window.SpeechRecognition||window.webkitSpeechRecognition)?'✅ 支持':'❌ 不支持(仅 Chrome/Edge)')); if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){ log('❌ 浏览器不支持 getUserMedia','e'); log('💡 请用 Chrome 或 Edge 浏览器','e'); setFlow('idle'); return; } if(!window.isSecureContext&&location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'){ log('❌ 非安全上下文,麦克风不可用','e'); log('💡 必须用 https:// 或 http://localhost','e'); setFlow('idle'); return; } try{ log('🎤 请求麦克风权限...'); const stream=await navigator.mediaDevices.getUserMedia({ audio:{ echoCancellation:true, noiseSuppression:true, sampleRate:16000 } }); log('✅ 麦克风权限获取成功,stream.active='+stream.active,'s'); chunks=[]; const mime=MediaRecorder.isTypeSupported('audio/webm;codecs=opus')?'audio/webm;codecs=opus':'audio/webm'; log('📋 MediaRecorder mimeType='+mime); mr=new MediaRecorder(stream,{mimeType:mime}); mr.ondataavailable=e=>{ if(e.data.size>0){ chunks.push(e.data); const totalKB=chunks.reduce((s,c)=>s+c.size,0)/1024; log(' 📦 数据块: '+(e.data.size/1024).toFixed(1)+' KB (累计='+totalKB.toFixed(1)+'KB chunks数='+chunks.length+')'); } }; mr.onstop=()=>{ blob=new Blob(chunks,{type:'audio/webm'}); const url=URL.createObjectURL(blob); const ap=document.getElementById('ap'); ap.src=url;ap.style.display='block'; log('✅ 录音完成: '+(blob.size/1024).toFixed(1)+' KB, chunks='+chunks.length,'s'); log('📋 [录音结果] blob存在='+!!blob+' blob.size='+blob.size+' blob.type='+blob.type,'p'); document.getElementById('bf').disabled=false; log('📋 [按钮状态] 完整流程按钮已启用 (bf.disabled=false)','p'); stream.getTracks().forEach(t=>t.stop()); }; mr.onerror=(e)=>{ log('❌ MediaRecorder 错误: '+(e.error||e.message||'未知'),'e'); }; log('🎤 启动 MediaRecorder(state='+mr.state+')...'); mr.start(100); log('✅ MediaRecorder 已启动, state='+mr.state,'s'); startT=Date.now(); timer=setInterval(()=>{document.getElementById('rt').textContent=((Date.now()-startT)/1000).toFixed(1)+'s'},100); document.getElementById('br').disabled=true; document.getElementById('bs').disabled=false; st('rs','录音中','r'); wave(stream); log('🎤 录音中...现在可以说话了!','s'); }catch(e){ log('❌ 麦克风失败: '+(e.name||'Error'),'e'); log(' 错误消息: '+(e.message||'未知'),'e'); setFlow('idle'); // 针对性建议 if(e.name==='NotAllowedError'||e.name==='PermissionDeniedError'){ log('💡 解决方案:','e'); log(' 1. 点击地址栏左侧的锁图标','e'); log(' 2. 允许麦克风权限','e'); log(' 3. 刷新页面重试','e'); }else if(e.name==='NotFoundError'||e.name==='DevicesNotFoundError'){ log('💡 没有找到麦克风设备','e'); log(' 检查: 系统设置 → 麦克风','e'); }else if(e.name==='NotReadableError'||e.name==='TrackStartError'){ log('💡 麦克风被其他程序占用','e'); log(' 关闭其他使用麦克风的程序','e'); }else if(e.name==='OverconstrainedError'){ log('💡 麦克风不支持请求的参数','e'); }else if(e.name==='SecurityError'){ log('💡 安全错误','e'); log(' 必须用 https:// 或 http://localhost','e'); } } } function stp(){ log('⏹ [stp()] 停止按钮被点击! mr.state='+(mr?mr.state:'mr=null'),'p'); if(mr&&mr.state!=='inactive'){ log('📋 [stp] 正在停止录音...'); mr.stop(); clearInterval(timer); document.getElementById('br').disabled=false; document.getElementById('bs').disabled=true; st('rs','已停止','i'); document.getElementById('wf').textContent='录音完成'; log('📋 [stp] 录音停止指令已发送,等待 onstop 回调...','p'); // onstop 是异步的,blob 此时可能还是 null,这是正常的 setFlow('idle'); }else{ log('⚠️ [stp] mr不存在或已停止,无需操作','e'); } } function wave(stream){ try{ audioCtx=new(window.AudioContext||window.webkitAudioContext)(); const src=audioCtx.createMediaStreamSource(stream); analyser=audioCtx.createAnalyser();analyser.fftSize=256; src.connect(analyser); const data=new Uint8Array(analyser.frequencyBinCount); const wf=document.getElementById('wf');wf.textContent=''; const cv=document.createElement('canvas');cv.width=wf.clientWidth;cv.height=wf.clientHeight; wf.innerHTML='';wf.appendChild(cv); const ctx=cv.getContext('2d'); 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)}; draw(); }catch(e){} } // 手动输入 async function manualA(){ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); log('✏️ [manualA()] 手动输入ASR按钮被点击!','p'); const atEl=document.getElementById('at'); log('📋 [manualA] 当前 at.textContent="'+atEl.textContent.trim().substring(0,50)+'" at.hasClass(e)='+atEl.classList.contains('e')); log('📋 [manualA] 当前 blob='+(blob?'有('+(blob.size/1024).toFixed(1)+'KB)':'无')); const t=prompt('输入 ASR 结果:'); log('📋 [manualA] prompt 返回值="'+(t||'(null/取消)')+'" 长度='+(t?t.length:0)); if(t){ box('at',t); log('✅ [manualA] ASR已设置: "'+t+'"','s'); // 验证写入是否成功 const verify=document.getElementById('at').textContent.trim(); log('📋 [manualA] 验证: atEl.textContent="'+verify.substring(0,40)+'" 已写入='+(verify===t),'p'); // 快捷联动:询问是否立即跑完整流程 const goRun=confirm('ASR 已设置为 "'+t+'"\n\n是否立即调用 LLM + TTS?\n(点"取消"则只保存文本,可稍后手动点 🚀 完整流程)'); log('📋 [manualA] 联动询问返回值='+(goRun?'✅ 确认':'❌ 取消'),'p'); if(goRun){ log('🚀 [manualA] 自动触发完整流程...','s'); await full(); }else{ log('⏸ [manualA] 用户选择稍后手动触发','p'); } }else{ log('❌ [manualA] 用户取消了输入','e'); } } async function manualL(){ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); log('✏️ [manualL()] 手动输入LLM按钮被点击!','p'); const ltEl=document.getElementById('lt'); log('📋 [manualL] 当前 lt.textContent="'+ltEl.textContent.trim().substring(0,50)+'" lt.hasClass(e)='+ltEl.classList.contains('e')); const t=prompt('输入 LLM 回复:'); log('📋 [manualL] prompt 返回值="'+(t||'(null/取消)')+'" 长度='+(t?t.length:0)); if(t){ box('lt',t); log('✅ [manualL] LLM已设置: "'+t+'"','s'); const verify=document.getElementById('lt').textContent.trim(); log('📋 [manualL] 验证: ltEl.textContent="'+verify.substring(0,40)+'" 已写入='+(verify===t),'p'); }else{ log('❌ [manualL] 用户取消了输入','e'); } } async function skipL(){ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); log('⏭️ [skipL()] 跳过LLM按钮被点击!','p'); const asrText=document.getElementById('at').textContent.trim(); log('📋 [skipL] ASR文本="'+asrText.substring(0,50)+'" 长度='+asrText.length); if(!asrText){ log('⚠️ [skipL] ASR框为空!LLM框将不会被设置','e'); } box('lt',asrText); log('⏭️ [skipL] LLM已设为ASR文本: "'+asrText.substring(0,40)+'"','p'); const verify=document.getElementById('lt').textContent.trim(); log('📋 [skipL] 验证: ltEl.textContent="'+verify.substring(0,40)+'"','p'); } // 完整流程 - 一键智能音箱体验 async function full(){ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); log('🚀 [full()] 完整流程按钮被点击!','p'); const c=cfg(); log('📋 [配置] tbu='+c.tbu+' vid='+c.vid+' ap='+c.ap+' lp='+c.lp+' tk='+(c.tk?'已设置':'空'),'p'); try{ // ============ 步骤 0:诊断 DOM 状态 ============ const atEl=document.getElementById('at'); const ltEl=document.getElementById('lt'); log('📋 [DOM状态] at.textContent="'+atEl.textContent.trim().substring(0,50)+'" at.hasClass(e)='+atEl.classList.contains('e')); log('📋 [DOM状态] lt.textContent="'+ltEl.textContent.trim().substring(0,50)+'" lt.hasClass(e)='+ltEl.classList.contains('e')); log('📋 [DOM状态] blob='+(blob?'有录音('+(blob.size/1024).toFixed(1)+'KB)':'无录音')); log('📋 [DOM状态] 按钮 bf.disabled='+document.getElementById('bf').disabled+' bp.disabled='+document.getElementById('bp').disabled); // ============ 步骤 1:ASR ============ log('━━━ 步骤1: ASR 识别 ━━━','p'); setFlow('asr'); let asr=''; const isPlaceholder=atEl.classList.contains('e'); log('📋 [ASR分支] ap='+c.ap+' isPlaceholder='+isPlaceholder); if(c.ap==='browser'){ log('📋 [ASR] 进入 browser 分支'); if(isPlaceholder){ log('💡 国内浏览器不支持原生语音识别,请手动输入','p'); log('📋 [ASR] 即将弹出 prompt 对话框...'); const t=prompt('请输入要说的话(模拟 ASR 识别结果):','你好我是配音侠'); log('📋 [ASR] prompt 返回值="'+(t||'(null/取消)')+'"'); if(!t){log('❌ 用户取消了输入','e'); st('ts','❌ 已取消','e'); setFlow('idle'); return} asr=t;box('at',asr); log('✅ [ASR结果] 手动输入: "'+asr+'" (长度='+asr.length+')','s'); }else{ asr=atEl.textContent.trim(); log('📋 [ASR] 使用已有文本: "'+asr+'" (长度='+asr.length+')'); if(!asr){log('❌ ASR 框为空','e'); st('ts','❌ 无输入','e'); setFlow('idle'); return} log('✅ [ASR结果] 已有输入: "'+asr+'"','s'); } }else if(c.ap==='openai_whisper'){ log('📋 [ASR] 进入 openai_whisper 分支'); if(!blob){log('❌ 请先录音','e'); st('ts','❌ 无录音','e'); setFlow('idle'); return} if(!c.ok){log('❌ 缺少 OpenAI Key','e'); st('ts','❌ 缺Key','e'); setFlow('idle'); return} st('ts','ASR (Whisper)...','p'); log('📋 [ASR] 调用 doAsr, blob大小='+(blob.size/1024).toFixed(1)+'KB'); asr=await doAsr(blob,c); box('at',asr); log('✅ [ASR结果] Whisper: "'+asr+'"','s'); }else{ log('📋 [ASR] 进入 else 分支 (ap='+c.ap+')'); if(isPlaceholder){ log('📋 [ASR] 即将弹出 prompt 对话框...'); const t=prompt('请输入要说的话:','你好我是配音侠'); log('📋 [ASR] prompt 返回值="'+(t||'(null/取消)')+'"'); if(!t){log('❌ 用户取消了输入','e'); st('ts','❌ 已取消','e'); setFlow('idle'); return} asr=t;box('at',asr); log('✅ [ASR结果] 手动输入: "'+asr+'"','s'); }else{ asr=atEl.textContent.trim(); log('📋 [ASR] 使用已有文本: "'+asr+'" (长度='+asr.length+')'); if(!asr){log('❌ 无 ASR 结果','e'); st('ts','❌ 无输入','e'); setFlow('idle'); return} log('✅ [ASR结果] 已有输入: "'+asr+'"','s'); } } // ============ 步骤 2:LLM ============ log('━━━ 步骤2: LLM 调用 ━━━','p'); setFlow('llm'); let llm=''; let llmTime=0; // 外层声明,避免 ReferenceError log('📋 [LLM分支] lp='+c.lp); if(c.lp==='backend'){ log('📋 [LLM] 进入 backend 分支'); st('ts','LLM (后端代理)...','p'); log('📋 [LLM] 即将调用 doLlm(asr="'+asr+'")'); log('📋 [LLM] 后端URL='+c.tbu+'/api/test-tools/llm'); log('📋 [LLM] 模型=qwen3.6-plus'); const startLlm=Date.now(); llm=await doLlm(asr,c); llmTime=((Date.now()-startLlm)/1000).toFixed(1); log('📋 [LLM] 耗时='+llmTime+'s'); box('lt',llm); log('✅ [LLM结果] 回复: "'+llm+'" (长度='+llm.length+')','s'); }else if(c.lp==='deepseek'||c.lp==='openai'){ log('📋 [LLM] 进入 '+c.lp+' 分支'); const key=c.lp==='deepseek'?c.dk:c.ok; log('📋 [LLM] key状态='+(key?'已设置(长度='+key.length+')':'❌ 空')); if(!key){log('❌ 缺少 LLM Key','e'); st('ts','❌ 缺Key','e'); setFlow('idle'); return} st('ts','LLM...','p'); log('📋 [LLM] 即将调用 doLlm(asr="'+asr+'", key)'); const startLlm=Date.now(); llm=await doLlm(asr,c,key); llmTime=((Date.now()-startLlm)/1000).toFixed(1); log('📋 [LLM] 耗时='+llmTime+'s'); box('lt',llm); log('✅ [LLM结果] 回复: "'+llm+'"','s'); }else{ log('📋 [LLM] 进入 none/其他分支 (lp='+c.lp+')'); llmTime='0'; // 跳过LLM时耗时为0 if(ltEl.classList.contains('e')){ log('⏭️ LLM 跳过,直接用 ASR 文本合成语音','p'); llm=asr; log('📋 [LLM] llm=asr="'+llm+'"'); }else{ llm=ltEl.textContent.trim(); log('📋 [LLM] 已有LLM框内容: "'+llm+'"'); if(!llm){llm=asr;log('⚠️ LLM 框为空,回退用 ASR 文本','e')} } } // ============ 步骤 3:TTS ============ log('━━━ 步骤3: TTS 合成 ━━━','p'); setFlow('tts'); st('ts','TTS...','p'); log('📋 [TTS] 即将调用 doTts(text="'+llm.substring(0,50)+'", vid='+c.vid+')'); log('📋 [TTS] 后端URL='+c.tbu+'/api/tts/synthesize'); const startTts=Date.now(); const url=await doTts(llm,c); const ttsTime=((Date.now()-startTts)/1000).toFixed(1); log('📋 [TTS] 耗时='+ttsTime+'s, 返回url='+(url||'(null)')); if(url){ const ta=document.getElementById('ta'); const fullUrl=url.startsWith('http')||url.startsWith('blob')?url:c.tbu+url; log('📋 [TTS] 完整URL='+fullUrl); ta.src=fullUrl; ta.play().then(()=>log('✅ [TTS] 播放成功','s')).catch(e=>log('⚠️ [TTS] 播放失败: '+e.message,'e')); document.getElementById('bp').disabled=false; document.getElementById('bd').disabled=false; document.getElementById('tu').textContent='🔗 '+url; const totalTime=(parseFloat(llmTime)+parseFloat(ttsTime)).toFixed(1); st('ts','✅ 完成 ('+totalTime+'s)','s'); log('✅ 全部完成! ASR→LLM→TTS 流程跑通','s'); log('📋 [最终状态] asr="'+asr+'" llm="'+llm.substring(0,50)+'" url='+url,'s'); setFlow('done'); }else{ st('ts','❌ 失败','e'); log('❌ [TTS] 合成失败,url为空','e'); setFlow('idle'); } }catch(e){ log('❌ [异常] '+e.message,'e'); if(e.stack)log('📋 [堆栈] '+e.stack.substring(0,300),'e'); st('ts','❌ 错误: '+e.message,'e'); setFlow('idle'); } } // ASR - 使用浏览器原生 Web Speech API(实时识别) async function doAsr(blob,c){ return new Promise((resolve,reject)=>{ const SR=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SR){reject(new Error('浏览器不支持 Web Speech API(请用 Chrome 或 Edge)'));return} const rec=new SR(); rec.lang='zh-CN'; rec.interimResults=false; rec.maxAlternatives=1; rec.continuous=false; rec.onresult=(e)=>{ const text=e.results[0][0].transcript; log('🎤 浏览器识别: '+text,'s'); resolve(text); }; rec.onerror=(e)=>{ log('❌ ASR 错误: '+e.error,'e'); reject(new Error('ASR 错误: '+e.error)); }; rec.onend=()=>{ log('🎤 ASR 结束'); }; try{ rec.start(); log('🎤 开始实时识别(请说话)...'); }catch(e){ reject(new Error('启动 ASR 失败: '+e.message)); } }); } // LLM - 调后端 /api/test-tools/llm(自动用 models.json 的 Key) async function doLlm(text,c,key){ log('📤 [LLM请求] text="'+text+'" (长度='+text.length+')'); log('📤 [LLM请求] URL='+c.tbu+'/api/test-tools/llm'); log('📤 [LLM请求] model=qwen3.6-plus'); const body={ model:'qwen3.6-plus', messages:[ {role:'system',content:'你是配音侠,一个友好活泼的中文AI语音助手。你必须用中文回复,不要用英文。回答要简短(20字以内),语气亲切自然。'}, {role:'user',content:text} ] }; log('📤 [LLM请求] body大小='+JSON.stringify(body).length+'字符'); const fetchStart=Date.now(); const r=await fetch(c.tbu+'/api/test-tools/llm',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) }); log('📤 [LLM响应] HTTP状态='+r.status+' '+r.statusText+' (耗时='+((Date.now()-fetchStart)/1000).toFixed(1)+'s)'); if(!r.ok)throw new Error('LLM HTTP '+r.status+' '+r.statusText); const d=await r.json(); log('📤 [LLM响应] code='+d.code+' message='+(d.message||'无')); if(d.code!==0)throw new Error(d.message||'LLM返回错误'); log('📥 [LLM回复] 原始内容: "'+(d.data?.reply||'(空)').substring(0,100)+'"'); log('📥 [LLM回复] 长度='+(d.data?.reply||'').length); return (d.data?.reply||'').trim(); } // TTS (后端) async function doTts(text,c){ log('📤 [TTS] text="'+text.substring(0,50)+'" (长度='+text.length+') vid='+c.vid); // 尝试同步端点 try{ const syncUrl=c.tbu+'/api/tts/synthesize'; log('📤 [TTS同步] URL='+syncUrl); const syncBody={text:text.substring(0,500), voiceId:c.vid, voiceParams:{speed:1.0,pitch:0,volume:50}}; log('📤 [TTS同步] body='+JSON.stringify(syncBody).substring(0,150)); const r=await fetch(syncUrl,{ method:'POST', headers:{'Content-Type':'application/json',...(c.tk?{Authorization:'Bearer '+c.tk}:{})}, body:JSON.stringify(syncBody) }); log('📤 [TTS同步] HTTP状态='+r.status); const d=await r.json(); log('📤 [TTS同步] code='+d.code+' message='+(d.message||'无')+' hasAudioUrl='+!!(d.data?.audioUrl)); if(d.code===0&&d.data&&d.data.audioUrl){ log('✅ [TTS同步] 成功! audioUrl='+d.data.audioUrl,'s'); return d.data.audioUrl; } log('⚠️ 同步端点失败: '+(d.message||'未知')+' - 切换异步','e'); }catch(e){log('⚠️ 同步异常: '+e.message+' - 切换异步','e')} // 异步端点 log('📤 [TTS异步] 提交任务...'); const asyncUrl=c.tbu+'/api/tts/generate'; log('📤 [TTS异步] URL='+asyncUrl); const r2=await fetch(asyncUrl,{ method:'POST', headers:{'Content-Type':'application/json',...(c.tk?{Authorization:'Bearer '+c.tk}:{})}, body:JSON.stringify({text:text, voiceId:c.vid, voiceParams:{speed:1.0,pitch:0,volume:50}}) }); const d2=await r2.json(); log('📤 [TTS异步] 提交响应 code='+d2.code+' chapterId='+(d2.data?.chapterId||'无')); if(d2.code!==0)throw new Error('提交失败: '+d2.message); const cid=d2.data.chapterId; log('⏳ [TTS异步] 任务ID='+cid+', 开始轮询(最多60次)...'); for(let i=0;i<60;i++){ await new Promise(r=>setTimeout(r,1500)); const statusUrl=c.tbu+'/api/tts/chapter-status/'+cid; const sr=await fetch(statusUrl,{headers: c.tk ? {Authorization:'Bearer '+c.tk} : {}}); const sd=await sr.json(); const hasUrl=!!(sd.data?.audioUrl); const isFailed=!!(sd.data?.isFailed); if(i%5===0)log('⏳ [TTS异步] 轮询#'+(i+1)+' hasUrl='+hasUrl+' isFailed='+isFailed); if(sd.code===0&&sd.data&&sd.data.audioUrl){ log('✅ [TTS异步] 成功! 在第'+(i+1)+'次轮询拿到 audioUrl='+sd.data.audioUrl,'s'); return sd.data.audioUrl; } if(sd.data&&sd.data.isFailed)throw new Error('生成失败(isFailed=true)'); } throw new Error('轮询超时(60次)'); } // 播放 function play(){ log('▶ [play()] 播放按钮被点击!','p'); const ta=document.getElementById('ta'); log('📋 [play] audio.src='+(ta.src?ta.src.substring(0,80):'(空)')+' audio.readyState='+ta.readyState); if(!ta.src){log('❌ [play] 无音频源','e');return} ta.play().then(()=>log('✅ [play] 播放成功','s')).catch(e=>log('❌ [play] 播放失败: '+e.message,'e')); } // 下载 function dl(){ log('⬇ [dl()] 下载按钮被点击!','p'); const url=document.getElementById('ta').src; log('📋 [dl] audio.src='+(url?url.substring(0,80):'(空)')); if(!url){log('❌ 无音频','e');return} const a=document.createElement('a'); a.href=url;a.download='tts-'+Date.now()+'.mp3';a.click(); log('⬇️ 下载: '+url,'s'); }