aardio 文档
aardio 范例: 同一个合成接口,换成网页播放器:自定义的输出对象把音频转交给 WebView2。
// 同一个合成接口,换成网页播放器:自定义的输出对象把音频转交给 WebView2。
import win.ui;
import web.view;
import web.edgeTextToSpeech;
import crypt;
var winform=win.form(text="Edge TTS · WebView2 流式播放";right=859;bottom=569);
var wb=web.view(winform);
var engine=web.edgeTextToSpeech();
var task,session;
wb.external={
start=function(text,id){
if(task) task.cancel(); // 取消后旧任务不会再调用它的输出对象
session=id;
// 任何有 write/finish 方法的对象都能接收音频,这里转交给网页里的播放器。
var err;
task,err=engine.synthesize({text=text;voice="zh-CN-XiaoxiaoNeural";},{
write=function(data){wb.invoke("ttsPlayer.push",id,crypt.encodeBin(data));};
finish=function(err){
if(err) wb.invoke("ttsPlayer.fail",id,err);
else wb.invoke("ttsPlayer.finish",id);
};
});
if(!task) wb.invoke("ttsPlayer.fail",id,err.message);
return !!task;
};
stop=function(id){
if(task && id===session) task.cancel(); // 忽略网页里已经过期的停止请求
return true;
};
};
wb.html=/**
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>
*{box-sizing:border-box}body{margin:0;padding:28px;font:15px/1.6 "Segoe UI","Microsoft YaHei",sans-serif;background:#f4f7fc;color:#17263b}
main{max-width:880px;margin:auto}h1{font-size:25px;margin:0 0 6px}p{margin:0 0 18px;color:#5c6d82}
textarea{width:100%;height:225px;resize:vertical;border:1px solid #ccd6e5;border-radius:10px;padding:16px;font:16px/1.7 inherit;background:white;color:#17263b;outline-color:#2563eb}
.row{display:flex;align-items:center;gap:12px;margin:16px 0}button{padding:10px 22px;border:0;border-radius:8px;background:#2563eb;color:white;font:inherit;cursor:pointer}button.secondary{background:#e0e7f1;color:#273c57}button:disabled{opacity:.45;cursor:default}
audio{width:100%;height:44px}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:18px}.card{padding:12px 16px;background:white;border-radius:10px;border:1px solid #e0e7f1}.card span{display:block;font-size:12px;color:#65758a}.card strong{font-size:19px}#status{font-size:13px;color:#5c6d82}#status.error{color:#b42318}
</style></head><body><main>
<h1>流式语音,不绑定播放器</h1><p>aardio 接收 MP3 字节,WebView2 使用 MediaSource 连续解码播放。</p>
<textarea id="text">这是使用网页播放器的流式语音示例。无需等待整段语音生成完毕,收到音频后就可以开始朗读。</textarea>
<div class="row"><button id="start">流式朗读</button><button id="stop" class="secondary" disabled>停止</button><span id="status">正在检查播放器…</span></div>
<audio id="audio" controls></audio>
<div class="stats"><div class="card"><span>已收到 MP3</span><strong id="received">0 KB</strong></div><div class="card"><span>已播放</span><strong id="played">0.00 s</strong></div><div class="card"><span>音频分块</span><strong id="chunks">0</strong></div></div>
<script>
const audio=document.getElementById('audio'), startButton=document.getElementById('start'), stopButton=document.getElementById('stop'), status=document.getElementById('status');
let session=0, mediaSource=null, sourceBuffer=null, objectUrl=null, queue=[], queuedBytes=0;
let receivedBytes=0, chunkCount=0, appendCount=0, inputEnded=false, playbackEnded=false, failed=false;
const supported=!!window.MediaSource && MediaSource.isTypeSupported('audio/mpeg');
function label(text,error=false){status.textContent=text;status.className=error?'error':'';}
function dispose(){
audio.pause();
audio.removeAttribute('src');audio.load();
if(objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl=null;sourceBuffer=null;mediaSource=null;queue=[];queuedBytes=0;
}
function pump(){
if(failed||!sourceBuffer||sourceBuffer.updating||!mediaSource||mediaSource.readyState!=='open') return;
try{
if(queue.length){const bytes=queue.shift();queuedBytes-=bytes.byteLength;sourceBuffer.appendBuffer(bytes);appendCount++;}
else if(inputEnded) mediaSource.endOfStream();
}catch(e){ttsPlayer.fail(session,'浏览器解码失败:'+e.message);}
}
window.ttsPlayer={
open(id){
session=id;dispose();
receivedBytes=0;chunkCount=0;appendCount=0;inputEnded=false;playbackEnded=false;failed=false;
if(!supported){this.fail(id,'当前 WebView2 不支持 MediaSource MP3,请改用原生播放示例 edgeTTS-mediaFoundation。');return false;}
mediaSource=new MediaSource();const current=mediaSource;
current.addEventListener('sourceopen',()=>{
if(id!==session||current!==mediaSource) return;
try{
sourceBuffer=current.addSourceBuffer('audio/mpeg');sourceBuffer.mode='sequence';
sourceBuffer.addEventListener('updateend',()=>{if(id===session)pump();});
sourceBuffer.addEventListener('error',()=>{if(id===session)this.fail(id,'浏览器报告音频格式错误');});
pump();
}catch(e){this.fail(id,e.message);}
});
objectUrl=URL.createObjectURL(current);audio.src=objectUrl;
startButton.disabled=true;stopButton.disabled=false;label('等待首段音频…');
// Called synchronously from a user click. Tests mute audio before calling open.
audio.play().catch(e=>{if(id===session&&!failed)this.fail(id,'无法自动播放,请点击播放器播放键:'+e.message);});
return true;
},
push(id,base64){
if(id!==session||failed||inputEnded) return;
const binary=atob(base64), bytes=Uint8Array.from(binary,c=>c.charCodeAt(0));
if(queuedBytes+bytes.byteLength>4*1024*1024){this.fail(id,'浏览器待解码缓冲超限');return;}
queue.push(bytes);queuedBytes+=bytes.byteLength;receivedBytes+=bytes.byteLength;chunkCount++;pump();
},
finish(id,result){
if(id!==session||failed) return;
inputEnded=true;label('合成已结束,继续播放剩余音频…');pump();
},
fail(id,message){
if(id!==session||failed) return;
failed=true;audio.pause();queue=[];queuedBytes=0;
startButton.disabled=false;stopButton.disabled=true;label(message,true);
// Never synchronously reverse-call aardio from an aardio-invoked JS function.
setTimeout(()=>aardio.stop(id).catch(()=>{}),0);
},
metrics(){return {supported,session,receivedBytes,chunkCount,appendCount,queuedBytes,inputEnded,playbackEnded,failed,currentTime:audio.currentTime,readyState:audio.readyState};}
};
startButton.onclick=async()=>{
const text=document.getElementById('text').value;if(!text.trim()){label('请输入朗读文本',true);return;}
const id=session+1;
if(!ttsPlayer.open(id)) return;
try{await aardio.start(text,id);}catch(e){ttsPlayer.fail(id,String(e));}
};
stopButton.onclick=()=>{
const previous=session;session++;dispose();inputEnded=false;
startButton.disabled=false;stopButton.disabled=true;label('已停止');
aardio.stop(previous).catch(()=>{});
};
audio.addEventListener('playing',()=>{if(!inputEnded&&!failed)label('正在流式播放,后续音频继续到达…');});
audio.addEventListener('ended',()=>{playbackEnded=true;startButton.disabled=false;stopButton.disabled=true;label('播放完毕');});
audio.addEventListener('error',()=>{if(mediaSource&&!failed)ttsPlayer.fail(session,'音频播放错误');});
setInterval(()=>{
document.getElementById('received').textContent=(receivedBytes/1024).toFixed(1)+' KB';
document.getElementById('played').textContent=audio.currentTime.toFixed(2)+' s';
document.getElementById('chunks').textContent=chunkCount;
},100);
label(supported?'播放器就绪 · MediaSource / audio/mpeg':'此运行时不支持 MP3 MediaSource',!supported);
startButton.disabled=!supported;
document.body.dataset.ready='true';
</script></main></body></html>
**/;
winform.onDestroy=function(){
if(task) task.cancel();
};
winform.show();
win.loopMessage();
Markdown 格式