app.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. // 智能音箱测试工具 - JavaScript
  2. let mr=null,chunks=[],blob=null,startT=0,timer=null,audioCtx=null,analyser=null;
  3. 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}
  4. function cl(){document.getElementById('log').innerHTML=''}
  5. function st(id,txt,c){const e=document.getElementById(id);e.textContent=txt;e.className='st '+c}
  6. function togCfg(){document.getElementById('cb').classList.toggle('h');document.querySelector('.cb').classList.toggle('cl')}
  7. 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||''}}
  8. function save(){localStorage.setItem('tts_test',JSON.stringify(cfg()));log('✅ 配置已保存','s')}
  9. 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')}}
  10. // 全局错误捕获 - 显示所有 JS 错误
  11. window.addEventListener('error',(e)=>{
  12. log('❌ JS 错误: '+(e.message||'未知'),'e');
  13. log(' 文件: '+(e.filename||'?')+':'+(e.lineno||'?'),'e');
  14. if(e.error&&e.error.stack)log(' '+e.error.stack.substring(0,200),'e');
  15. });
  16. window.addEventListener('unhandledrejection',(e)=>{
  17. log('❌ Promise 错误: '+(e.reason?.message||e.reason||'未知'),'e');
  18. });
  19. console.log('🎙️ 智能音箱测试工具已加载 v6.0 ✨');
  20. // 加载保存的配置
  21. window.addEventListener('load',()=>{
  22. log('✅ 页面加载完成','s');
  23. const s=localStorage.getItem('tts_test');
  24. 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')}}
  25. document.getElementById('at').dataset.p='等待录音...';
  26. document.getElementById('lt').dataset.p='等待识别...';
  27. loadVoices();
  28. });
  29. // 加载音色列表
  30. 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')}}
  31. // 录音
  32. async function rec(){
  33. // 诊断信息
  34. log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  35. log('🔍 诊断信息:');
  36. log(' 浏览器: '+(navigator.userAgent.match(/(Chrome|Edge|Firefox|Safari)/)?.[0]||'未知'));
  37. log(' 安全上下文: '+(window.isSecureContext?'✅ 是':'❌ 否'));
  38. log(' localhost: '+(location.hostname==='localhost'||location.hostname==='127.0.0.1'?'✅':'⚠️'));
  39. log(' getUserMedia: '+(navigator.mediaDevices?.getUserMedia?'✅ 支持':'❌ 不支持'));
  40. log(' SpeechRecognition: '+((window.SpeechRecognition||window.webkitSpeechRecognition)?'✅ 支持':'❌ 不支持(仅 Chrome/Edge)'));
  41. if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){
  42. log('❌ 浏览器不支持 getUserMedia','e');
  43. log('💡 请用 Chrome 或 Edge 浏览器','e');
  44. return;
  45. }
  46. if(!window.isSecureContext&&location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'){
  47. log('❌ 非安全上下文,麦克风不可用','e');
  48. log('💡 必须用 https:// 或 http://localhost','e');
  49. return;
  50. }
  51. try{
  52. log('🎤 请求麦克风权限...');
  53. const stream=await navigator.mediaDevices.getUserMedia({
  54. audio:{
  55. echoCancellation:true,
  56. noiseSuppression:true,
  57. sampleRate:16000
  58. }
  59. });
  60. log('✅ 麦克风权限获取成功','s');
  61. chunks=[];
  62. mr=new MediaRecorder(stream,{mimeType:'audio/webm'});
  63. mr.ondataavailable=e=>{
  64. if(e.data.size>0){
  65. chunks.push(e.data);
  66. log(' 📦 数据块: '+(e.data.size/1024).toFixed(1)+' KB');
  67. }
  68. };
  69. mr.onstop=()=>{
  70. blob=new Blob(chunks,{type:'audio/webm'});
  71. const url=URL.createObjectURL(blob);
  72. const ap=document.getElementById('ap');
  73. ap.src=url;ap.style.display='block';
  74. log('✅ 录音完成: '+(blob.size/1024).toFixed(1)+' KB','s');
  75. document.getElementById('bf').disabled=false;
  76. stream.getTracks().forEach(t=>t.stop());
  77. };
  78. mr.onerror=(e)=>{
  79. log('❌ MediaRecorder 错误: '+e.message,'e');
  80. };
  81. log('🎤 启动 MediaRecorder...');
  82. mr.start(100);
  83. log('✅ MediaRecorder 已启动','s');
  84. startT=Date.now();
  85. timer=setInterval(()=>{document.getElementById('rt').textContent=((Date.now()-startT)/1000).toFixed(1)+'s'},100);
  86. document.getElementById('br').disabled=true;
  87. document.getElementById('bs').disabled=false;
  88. st('rs','录音中','r');
  89. wave(stream);
  90. log('🎤 录音中...说话吧','s');
  91. }catch(e){
  92. log('❌ 麦克风失败: '+(e.name||'Error'),'e');
  93. log(' 错误消息: '+(e.message||'未知'),'e');
  94. // 针对性建议
  95. if(e.name==='NotAllowedError'||e.name==='PermissionDeniedError'){
  96. log('💡 解决方案:','e');
  97. log(' 1. 点击地址栏左侧的锁图标','e');
  98. log(' 2. 允许麦克风权限','e');
  99. log(' 3. 刷新页面重试','e');
  100. }else if(e.name==='NotFoundError'||e.name==='DevicesNotFoundError'){
  101. log('💡 没有找到麦克风设备','e');
  102. log(' 检查: 系统设置 → 麦克风','e');
  103. }else if(e.name==='NotReadableError'||e.name==='TrackStartError'){
  104. log('💡 麦克风被其他程序占用','e');
  105. log(' 关闭其他使用麦克风的程序','e');
  106. }else if(e.name==='OverconstrainedError'){
  107. log('💡 麦克风不支持请求的参数','e');
  108. }else if(e.name==='SecurityError'){
  109. log('💡 安全错误','e');
  110. log(' 必须用 https:// 或 http://localhost','e');
  111. }
  112. }
  113. }
  114. function stp(){
  115. if(mr&&mr.state!=='inactive'){
  116. mr.stop();clearInterval(timer);
  117. document.getElementById('br').disabled=false;
  118. document.getElementById('bs').disabled=true;
  119. st('rs','已停止','i');
  120. document.getElementById('wf').textContent='录音完成';
  121. }
  122. }
  123. function wave(stream){
  124. try{
  125. audioCtx=new(window.AudioContext||window.webkitAudioContext)();
  126. const src=audioCtx.createMediaStreamSource(stream);
  127. analyser=audioCtx.createAnalyser();analyser.fftSize=256;
  128. src.connect(analyser);
  129. const data=new Uint8Array(analyser.frequencyBinCount);
  130. const wf=document.getElementById('wf');wf.textContent='';
  131. const cv=document.createElement('canvas');cv.width=wf.clientWidth;cv.height=wf.clientHeight;
  132. wf.innerHTML='';wf.appendChild(cv);
  133. const ctx=cv.getContext('2d');
  134. 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)};
  135. draw();
  136. }catch(e){}
  137. }
  138. // 手动输入
  139. async function manualA(){const t=prompt('输入 ASR 结果:');if(t){box('at',t);log('✏️ ASR: '+t)}}
  140. async function manualL(){const t=prompt('输入 LLM 回复:');if(t){box('lt',t);log('✏️ LLM: '+t)}}
  141. async function skipL(){box('lt',document.getElementById('at').textContent);log('⏭️ 跳过 LLM')}
  142. // 完整流程 - 一键智能音箱体验
  143. async function full(){
  144. log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  145. log('🚀 full() 函数被调用');
  146. const c=cfg();
  147. log('配置: ASR='+c.ap+', LLM='+c.lp);
  148. try{
  149. let asr='';
  150. // 1. ASR - 国内 Web Speech API 不可用,改用录音 + 手动输入
  151. if(c.ap==='browser'){
  152. log('⚠️ 浏览器原生 ASR 在国内需要 Google 服务(被墙)','e');
  153. log('💡 请用以下方式之一:','p');
  154. log(' ① 点"🎤 录音"按钮,录完后下方手动输入文字','p');
  155. log(' ② 点"✏️ 手动输入"按钮直接输入文字','p');
  156. asr=document.getElementById('at').textContent.trim();
  157. if(!asr||document.getElementById('at').classList.contains('e')){
  158. log('❌ 请先在 ASR 框输入文字','e');
  159. return;
  160. }
  161. log('✅ 使用手动输入: '+asr);
  162. }else if(c.ap==='openai_whisper'){
  163. if(!blob){log('❌ 请先录音','e');return}
  164. if(!c.ok){log('❌ 缺少 OpenAI Key','e');return}
  165. st('ts','ASR (Whisper)...','p');
  166. asr=await doAsr(blob,c);
  167. box('at',asr);
  168. }else{
  169. asr=document.getElementById('at').textContent;
  170. if(!asr||document.getElementById('at').classList.contains('e')){log('❌ 无 ASR 结果','e');return}
  171. }
  172. // 2. LLM
  173. let llm='';
  174. if(c.lp==='backend'){
  175. st('ts','LLM (后端代理)...','p');
  176. llm=await doLlm(asr,c);
  177. box('lt',llm);
  178. }else if(c.lp==='deepseek'||c.lp==='openai'){
  179. const key=c.lp==='deepseek'?c.dk:c.ok;
  180. if(!key){log('❌ 缺少 LLM Key','e');return}
  181. st('ts','LLM...','p');
  182. llm=await doLlm(asr,c,key);
  183. box('lt',llm);
  184. }else{
  185. // none - 用手动输入
  186. llm=document.getElementById('lt').textContent;
  187. if(!llm||document.getElementById('lt').classList.contains('e')){log('❌ 无 LLM 结果','e');return}
  188. }
  189. // 3. TTS
  190. st('ts','TTS...','p');
  191. const url=await doTts(llm,c);
  192. if(url){
  193. const ta=document.getElementById('ta');
  194. ta.src=url.startsWith('http')||url.startsWith('blob')?url:c.tbu+url;
  195. ta.play().catch(()=>{});
  196. document.getElementById('bp').disabled=false;
  197. document.getElementById('bd').disabled=false;
  198. document.getElementById('tu').textContent='🔗 '+url;
  199. st('ts','✅ 完成','s');
  200. log('✅ 完成! URL: '+url,'s');
  201. }else{
  202. st('ts','❌ 失败','e');
  203. }
  204. }catch(e){log('❌ '+e.message,'e');st('ts','❌ 错误','e')}
  205. }
  206. // ASR - 使用浏览器原生 Web Speech API(实时识别)
  207. async function doAsr(blob,c){
  208. return new Promise((resolve,reject)=>{
  209. const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
  210. if(!SR){reject(new Error('浏览器不支持 Web Speech API(请用 Chrome 或 Edge)'));return}
  211. const rec=new SR();
  212. rec.lang='zh-CN';
  213. rec.interimResults=false;
  214. rec.maxAlternatives=1;
  215. rec.continuous=false;
  216. rec.onresult=(e)=>{
  217. const text=e.results[0][0].transcript;
  218. log('🎤 浏览器识别: '+text,'s');
  219. resolve(text);
  220. };
  221. rec.onerror=(e)=>{
  222. log('❌ ASR 错误: '+e.error,'e');
  223. reject(new Error('ASR 错误: '+e.error));
  224. };
  225. rec.onend=()=>{
  226. log('🎤 ASR 结束');
  227. };
  228. try{
  229. rec.start();
  230. log('🎤 开始实时识别(请说话)...');
  231. }catch(e){
  232. reject(new Error('启动 ASR 失败: '+e.message));
  233. }
  234. });
  235. }
  236. // LLM - 调后端 /api/test-tools/llm(自动用 models.json 的 Key)
  237. async function doLlm(text,c,key){
  238. const r=await fetch(c.tbu+'/api/test-tools/llm',{
  239. method:'POST',
  240. headers:{'Content-Type':'application/json'},
  241. body:JSON.stringify({
  242. messages:[
  243. {role:'system',content:'你是配音侠,友好的 AI 助手。请用简短中文回复(不超过 50 字)。'},
  244. {role:'user',content:text}
  245. ]
  246. })
  247. });
  248. if(!r.ok)throw new Error('LLM '+r.status);
  249. const d=await r.json();
  250. if(d.code!==0)throw new Error(d.message);
  251. return d.data.reply.trim();
  252. }
  253. // TTS (后端)
  254. async function doTts(text,c){
  255. // 尝试同步端点
  256. try{
  257. const r=await fetch(c.tbu+'/api/tts/synthesize',{
  258. method:'POST',
  259. headers:{'Content-Type':'application/json',...(c.tk?{Authorization:'Bearer '+c.tk}:{})},
  260. body:JSON.stringify({
  261. text:text.substring(0,500),
  262. voiceId:c.vid,
  263. voiceParams:{speed:1.0,pitch:0,volume:50}
  264. })
  265. });
  266. const d=await r.json();
  267. if(d.code===0&&d.data&&d.data.audioUrl)return d.data.audioUrl;
  268. log('⚠️ 同步端点失败: '+(d.message||'未知')+' - 切换异步');
  269. }catch(e){log('⚠️ 同步异常: '+e.message+' - 切换异步')}
  270. // 异步端点
  271. log('📤 提交异步任务...');
  272. const r2=await fetch(c.tbu+'/api/tts/generate',{
  273. method:'POST',
  274. headers:{'Content-Type':'application/json',...(c.tk?{Authorization:'Bearer '+c.tk}:{})},
  275. body:JSON.stringify({
  276. text:text,
  277. voiceId:c.vid,
  278. voiceParams:{speed:1.0,pitch:0,volume:50}
  279. })
  280. });
  281. const d2=await r2.json();
  282. if(d2.code!==0)throw new Error('提交失败: '+d2.message);
  283. const cid=d2.data.chapterId;
  284. log('⏳ 任务 ID: '+cid+', 轮询中...');
  285. for(let i=0;i<60;i++){
  286. await new Promise(r=>setTimeout(r,1500));
  287. const sr=await fetch(c.tbu+'/api/tts/chapter-status/'+cid,{
  288. headers: c.tk ? {Authorization:'Bearer '+c.tk} : {}
  289. });
  290. const sd=await sr.json();
  291. if(sd.code===0&&sd.data&&sd.data.audioUrl)return sd.data.audioUrl;
  292. if(sd.data&&sd.data.isFailed)throw new Error('生成失败');
  293. }
  294. throw new Error('超时');
  295. }
  296. // 播放
  297. function play(){document.getElementById('ta').play().catch(e=>log('❌ '+e.message,'e'))}
  298. // 下载
  299. function dl(){
  300. const url=document.getElementById('ta').src;
  301. if(!url){log('❌ 无音频','e');return}
  302. const a=document.createElement('a');
  303. a.href=url;a.download='tts-'+Date.now()+'.mp3';a.click();
  304. log('⬇️ 下载: '+url);
  305. }