word-audio-worker.mjs 3.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
  1. #!/usr/bin/env node
  2. // Node 22+ adapter for cloud runtimes without WebSocket. The gateway owns caching and access control.
  3. import {createServer} from 'node:http';
  4. import {timingSafeEqual,webcrypto} from 'node:crypto';
  5. import {readFileSync,statSync} from 'node:fs';
  6. import {homedir} from 'node:os';
  7. import {resolve} from 'node:path';
  8. import {pathToFileURL} from 'node:url';
  9. import vm from 'node:vm';
  10. export function createWordAudioWorker(config,synthesize) {
  11. if(String(config.token||'').length<32)throw Error('TTS worker token must have at least 32 characters');
  12. const expected=Buffer.from('Bearer '+config.token);let active=0;
  13. const maxConcurrent=Math.max(1,Math.min(20,Number(config.concurrency)||1));
  14. const recent=[];
  15. const send=(res,status,body)=>{res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(body));};
  16. const server=createServer(async(req,res)=>{
  17. if(req.url==='/healthz'&&req.method==='GET')return send(res,200,{ok:true});
  18. if(req.url!=='/synthesize'||req.method!=='POST')return send(res,404,{error:'not_found'});
  19. const supplied=Buffer.from(String(req.headers.authorization||''));
  20. if(supplied.length!==expected.length||!timingSafeEqual(supplied,expected))return send(res,401,{error:'unauthorized'});
  21. if(!/^application\/json(?:;|$)/i.test(String(req.headers['content-type']||'')))return send(res,415,{error:'json_required'});
  22. const chunks=[];let bytes=0;
  23. try{for await(const chunk of req){bytes+=chunk.length;if(bytes>4096){send(res,413,{error:'too_large'});req.destroy();return;}chunks.push(chunk);}}catch(_){return;}
  24. let input;try{input=JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch(_){return send(res,400,{error:'invalid_json'});}
  25. const word=String(input.word||'').normalize('NFC').trim().replace(/\s+/g,' ');
  26. if(!word||word.length>200||/[\x00-\x1f<>\[\]{}]/.test(word)||input.voice!==config.voice||Number(input.speed)!==50)return send(res,400,{error:'invalid_word_or_voice'});
  27. const now=Date.now();while(recent.length&&recent[0]<now-60000)recent.shift();
  28. if(active>=maxConcurrent||recent.length>=60)return send(res,429,{error:'busy'});
  29. active++;recent.push(now);
  30. try{const audio=await synthesize(word);send(res,200,{audio});}
  31. catch(_){send(res,502,{error:'synthesis_failed'});}
  32. finally{active--;}
  33. });
  34. server.requestTimeout=10000;server.headersTimeout=10000;server.keepAliveTimeout=5000;
  35. return server;
  36. }
  37. async function main(){
  38. if(typeof WebSocket==='undefined')throw Error('请使用 Node.js 22 或更新版本');
  39. const file=resolve(process.env.XIAOSHU_TTS_CONFIG_FILE||homedir()+'/.config/xiaoshu/xfyun-word-audio.json');
  40. if(statSync(file).mode&0o077)throw Error('配置文件权限须为600');
  41. const settings=JSON.parse(readFileSync(file,'utf8'));
  42. const ctx=vm.createContext({crypto:webcrypto,WebSocket,TextEncoder,URL,URLSearchParams,Uint8Array,btoa,atob,AbortSignal,setTimeout,clearTimeout,Parse:{Config:{get:async()=>({get:key=>settings[key]})}},fail:(_code,message)=>{throw Error(message);}});
  43. vm.runInContext(readFileSync(new URL('./cloud/word-audio.js',import.meta.url),'utf8'),ctx);
  44. const config=await ctx.wordAudioConfig();
  45. if(!config.appId||!config.apiKey||!config.apiSecret||!config.voice)throw Error('讯飞配置不完整');
  46. const server=createWordAudioWorker({token:settings.XIAOSHU_TTS_WORKER_TOKEN,voice:config.voice,concurrency:config.concurrency},word=>ctx.wordAudioSynthesize(word,config));
  47. const port=Number(process.env.PORT)||4381,host=process.env.HOST||'127.0.0.1';
  48. server.listen(port,host,()=>console.log(JSON.stringify({listening:true,host,port,voice:config.voice,secretsPrinted:false})));
  49. for(const signal of ['SIGTERM','SIGINT'])process.on(signal,()=>server.close(()=>process.exit(0)));
  50. }
  51. if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main().catch(()=>{console.error('TTS适配器启动失败,请检查Node版本和本机私密配置。');process.exitCode=1;});