// 智能音箱测试工具 - JavaScript let mr=null,chunks=[],blob=null,startT=0,timer=null,audioCtx=null,analyser=null; function log(m,t='info'){const e=document.getElementById('log'),d=document.createElement('div');d.className='ln'+(t!=='info'?' '+t:'');d.textContent='['+new Date().toLocaleTimeString()+'] '+m;e.appendChild(d);e.scrollTop=e.scrollHeight} function cl(){document.getElementById('log').innerHTML=''} function st(id,txt,c){const e=document.getElementById(id);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')}else{e.textContent=e.dataset.p||'...';e.classList.add('e')}} // 全局错误捕获 - 显示所有 JS 错误 window.addEventListener('error',(e)=>{ 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 ✨'); // 加载保存的配置 window.addEventListener('load',()=>{ log('✅ 页面加载完成','s'); 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')}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(){ // 诊断信息 log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); 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'); return; } if(!window.isSecureContext&&location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'){ log('❌ 非安全上下文,麦克风不可用','e'); log('💡 必须用 https:// 或 http://localhost','e'); return; } try{ log('🎤 请求麦克风权限...'); const stream=await navigator.mediaDevices.getUserMedia({ audio:{ echoCancellation:true, noiseSuppression:true, sampleRate:16000 } }); log('✅ 麦克风权限获取成功','s'); chunks=[]; mr=new MediaRecorder(stream,{mimeType:'audio/webm'}); mr.ondataavailable=e=>{ if(e.data.size>0){ chunks.push(e.data); log(' 📦 数据块: '+(e.data.size/1024).toFixed(1)+' KB'); } }; 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','s'); document.getElementById('bf').disabled=false; stream.getTracks().forEach(t=>t.stop()); }; mr.onerror=(e)=>{ log('❌ MediaRecorder 错误: '+e.message,'e'); }; log('🎤 启动 MediaRecorder...'); mr.start(100); log('✅ MediaRecorder 已启动','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'); // 针对性建议 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(){ if(mr&&mr.state!=='inactive'){ mr.stop();clearInterval(timer); document.getElementById('br').disabled=false; document.getElementById('bs').disabled=true; st('rs','已停止','i'); document.getElementById('wf').textContent='录音完成'; } } 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(){const t=prompt('输入 ASR 结果:');if(t){box('at',t);log('✏️ ASR: '+t)}} async function manualL(){const t=prompt('输入 LLM 回复:');if(t){box('lt',t);log('✏️ LLM: '+t)}} async function skipL(){box('lt',document.getElementById('at').textContent);log('⏭️ 跳过 LLM')} // 完整流程 - 一键智能音箱体验 async function full(){ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); log('🚀 full() 函数被调用'); const c=cfg(); log('配置: ASR='+c.ap+', LLM='+c.lp); try{ let asr=''; // 1. ASR - 国内 Web Speech API 不可用,改用录音 + 手动输入 if(c.ap==='browser'){ log('⚠️ 浏览器原生 ASR 在国内需要 Google 服务(被墙)','e'); log('💡 请用以下方式之一:','p'); log(' ① 点"🎤 录音"按钮,录完后下方手动输入文字','p'); log(' ② 点"✏️ 手动输入"按钮直接输入文字','p'); asr=document.getElementById('at').textContent.trim(); if(!asr||document.getElementById('at').classList.contains('e')){ log('❌ 请先在 ASR 框输入文字','e'); return; } log('✅ 使用手动输入: '+asr); }else if(c.ap==='openai_whisper'){ if(!blob){log('❌ 请先录音','e');return} if(!c.ok){log('❌ 缺少 OpenAI Key','e');return} st('ts','ASR (Whisper)...','p'); asr=await doAsr(blob,c); box('at',asr); }else{ asr=document.getElementById('at').textContent; if(!asr||document.getElementById('at').classList.contains('e')){log('❌ 无 ASR 结果','e');return} } // 2. LLM let llm=''; if(c.lp==='backend'){ st('ts','LLM (后端代理)...','p'); llm=await doLlm(asr,c); box('lt',llm); }else if(c.lp==='deepseek'||c.lp==='openai'){ const key=c.lp==='deepseek'?c.dk:c.ok; if(!key){log('❌ 缺少 LLM Key','e');return} st('ts','LLM...','p'); llm=await doLlm(asr,c,key); box('lt',llm); }else{ // none - 用手动输入 llm=document.getElementById('lt').textContent; if(!llm||document.getElementById('lt').classList.contains('e')){log('❌ 无 LLM 结果','e');return} } // 3. TTS st('ts','TTS...','p'); const url=await doTts(llm,c); if(url){ const ta=document.getElementById('ta'); ta.src=url.startsWith('http')||url.startsWith('blob')?url:c.tbu+url; ta.play().catch(()=>{}); document.getElementById('bp').disabled=false; document.getElementById('bd').disabled=false; document.getElementById('tu').textContent='🔗 '+url; st('ts','✅ 完成','s'); log('✅ 完成! URL: '+url,'s'); }else{ st('ts','❌ 失败','e'); } }catch(e){log('❌ '+e.message,'e');st('ts','❌ 错误','e')} } // 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){ const r=await fetch(c.tbu+'/api/test-tools/llm',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ messages:[ {role:'system',content:'你是配音侠,友好的 AI 助手。请用简短中文回复(不超过 50 字)。'}, {role:'user',content:text} ] }) }); if(!r.ok)throw new Error('LLM '+r.status); const d=await r.json(); if(d.code!==0)throw new Error(d.message); return d.data.reply.trim(); } // TTS (后端) async function doTts(text,c){ // 尝试同步端点 try{ const r=await fetch(c.tbu+'/api/tts/synthesize',{ method:'POST', headers:{'Content-Type':'application/json',...(c.tk?{Authorization:'Bearer '+c.tk}:{})}, body:JSON.stringify({ text:text.substring(0,500), voiceId:c.vid, voiceParams:{speed:1.0,pitch:0,volume:50} }) }); const d=await r.json(); if(d.code===0&&d.data&&d.data.audioUrl)return d.data.audioUrl; log('⚠️ 同步端点失败: '+(d.message||'未知')+' - 切换异步'); }catch(e){log('⚠️ 同步异常: '+e.message+' - 切换异步')} // 异步端点 log('📤 提交异步任务...'); const r2=await fetch(c.tbu+'/api/tts/generate',{ 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(); if(d2.code!==0)throw new Error('提交失败: '+d2.message); const cid=d2.data.chapterId; log('⏳ 任务 ID: '+cid+', 轮询中...'); for(let i=0;i<60;i++){ await new Promise(r=>setTimeout(r,1500)); const sr=await fetch(c.tbu+'/api/tts/chapter-status/'+cid,{ headers: c.tk ? {Authorization:'Bearer '+c.tk} : {} }); const sd=await sr.json(); if(sd.code===0&&sd.data&&sd.data.audioUrl)return sd.data.audioUrl; if(sd.data&&sd.data.isFailed)throw new Error('生成失败'); } throw new Error('超时'); } // 播放 function play(){document.getElementById('ta').play().catch(e=>log('❌ '+e.message,'e'))} // 下载 function dl(){ const url=document.getElementById('ta').src; if(!url){log('❌ 无音频','e');return} const a=document.createElement('a'); a.href=url;a.download='tts-'+Date.now()+'.mp3';a.click(); log('⬇️ 下载: '+url); }