# Edge 流式文本转语音

`web.edgeTextToSpeech` 是一个只负责合成的 TTS 适配器。它提交一次完整文本，按顺序交付服务端返回的 MP3 字节，不负责播放、存文件或管理全局文本队列。

- 传输：`web.socket.client` + `dotNet.sslTunnel.client`。
- 不依赖 `web.SocketSharp`、BASS 或浏览器播放器。
- 支持输出音频流；**不提供向同一个已开始的合成任务追加文本的双向流**。
- 所有操作与回调在创建引擎/任务的消息循环线程使用。GUI 使用现有消息循环；独立脚本可调用 `task.wait()`。

## 快速开始：收到音频就播放

```aardio
import web.edgeTextToSpeech;
import sys.mediaFoundation.streamingMp3Player;

var player,err = sys.mediaFoundation.streamingMp3Player();
if(!player) return print(err);

var engine = web.edgeTextToSpeech({voice="zh-CN-XiaoxiaoNeural";rate=1;});
var task,err = engine.synthesize({text="这是一段流式语音，不必等完整音频才开始播放。";}, player);
if(!task){player.close();return print(err.message);}

var ok,err = player.wait(120000); // 合成失败也会传给播放器
if(!ok) print(err);
task.cancel();
player.close();
```

播放器直接作为第二个参数：收到的音频交给 `player.write`，合成成功后调用 `player.finish()`，失败时调用 `player.finish(错误信息)`。`finish()` 只表示没有更多输入，剩余声音继续播放；合成结束与播放结束是两件不同的事。

`sys.mediaFoundation.streamingMp3Player` 是 Windows 自带解码器实现的原生播放器，免费、不需要附带 DLL。

GUI 中通常不用 `wait()`：保留 `task` 与 `player`，写一个 `stop()` 依次调用 `task.cancel()`、`player.close()`，停止按钮、窗口关闭、播放结束和出错都调用它。取消、关闭之后不会再有任何回调，不需要额外的状态变量。完整示例见 `~/examples/Media/Audio/edgeTTS-mediaFoundation.aardio`。

## 输出对象

`synthesize` 的第二个参数可以是任何带 `write`、`finish` 方法的对象：

| 情况 | 引擎的调用 |
|---|---|
| 收到音频 | `output.write(data)`。返回 `false,err` 或 `null,err` 表示拒绝，合成以 `consumer_error` 终止 |
| 合成成功 | `output.finish()` |
| 合成失败 | `output.finish(err.message)` |
| 调用 `task.cancel()` | 不调用 `output`，由调用者自己 `close` |
| 参数错误 | `synthesize` 返回 `null,err`，不调用 `output` |

可以直接传入：

- `sys.mediaFoundation.streamingMp3Player`：原生边收边播；
- `wsock.tcp.httpStreamMp3`：把音频提供为本机 MP3 URL；
- `bass.streamPlayer`；
- 自己写的表，只要有这两个方法，例如把音频转交给网页播放器，参见 `edgeTTS-webView.aardio`。

## 可替换的最小契约

输出对象是回调表的便捷写法。业务代码最少只依赖：

```text
engine.synthesize(request, handlers) -> task, err
handlers.onAudio(data, format)
handlers.onBoundary(info)           // 可选
handlers.onFinish(result, err)
task.cancel()
task.wait(timeoutMs)                // 可选等待封装
engine.voices(locale)               // 可选：列出音色，每项至少有 id、locale、gender
```

传入输出对象，等价于传入下面的回调表。只支持回调表的其他引擎，用同样几行代码就能接收输出对象：

```aardio
var toHandlers = function(output){
    return {
        onAudio = function(data){ return output.write(data); };
        onFinish = function(result,err){
            if(!err) output.finish();
            elseif(err.code!=="cancelled") output.finish(err.message);
        };
    };
}
```

替换供应商时，调整引擎创建代码、音色配置及需要使用的能力。保存和播放代码不需要知道 WebSocket、SSML、鉴权参数或 .NET 类型。

### 创建引擎与请求参数

构造器 `web.edgeTextToSpeech(config)` 不联网。`config` 提供默认值，`synthesize` 的请求字段覆盖这些默认值。

| 字段 | 含义 |
|---|---|
| `text` | 必填，UTF-8 文本，非 SSML |
| `voice` | 音色 ID，默认 `zh-CN-XiaoxiaoNeural`，由具体供应商定义 |
| `rate` | 速度倍率，默认 1，允许 0.1～3 |
| `volume` | 合成音量倍率，默认 1，允许 0～2；0 不会被默认值覆盖 |
| `format` | 此适配器仅支持 `"mp3"` |
| `boundary` | `"sentence"`（默认）、`"word"`、`"none"` |
| `connectTimeoutMs` | 连接时限，默认 10000 毫秒 |
| `receiveTimeoutMs` | 收到响应之间的空闲时限，默认 30000 毫秒 |
| `timeoutMs` | 整个任务时限，默认 120000 毫秒 |
| `providerOptions` | Edge 专用表，可设 `pitch="+0Hz"`、`language="en-US"` |

速度与音量会转换为整数百分比。`providerOptions` 是供应商相关参数，不应作为跨引擎能力假定。当前传输未提供 HTTP 代理接口，传入 `proxy` 会明确失败。

本适配器限制**清理并 XML 转义后的文本不超过 4096 个 UTF-8 字节**。这是保守的客户端策略，不是对服务端硬限制的声明。不会自动切片、改写句子、去重或将多个独立 MP3 混成一个请求。

### 选择音色

`voice` 决定是谁在说话：男声女声、播音腔还是活泼、普通话还是粤语或方言。`engine.voices(locale)` 联网取得服务端当前可用的音色（进程内缓存，第二次不联网；请求在工作线程执行，GUI 线程调用也不会卡住界面）：

```aardio
import web.edgeTextToSpeech;
var engine = web.edgeTextToSpeech();
var list,err = engine.voices("zh"); //按地区前缀筛选，忽略大小写；省略则返回全部（约 300 个）
for(i,v in list) print(v.id,v.gender,v.locale,v.localeName,string.join(v.personalities,"/"));
```

每项是 `{id;locale;localeName;gender;name;categories;personalities}`，`id` 就是 `synthesize` 的 `voice`，`gender` 为小写的 `"female"`/`"male"`。写错音色 ID 时合成以 `Unsupported voice` 失败，可以用列表核对。

2026 年实测可用的中文音色（标签来自服务端）：

| voice | 性别 | 口音 | 风格标签 |
|---|---|---|---|
| `zh-CN-XiaoxiaoNeural` | 女 | 普通话 | 温暖（新闻、小说） |
| `zh-CN-XiaoyiNeural` | 女 | 普通话 | 活泼（卡通、小说） |
| `zh-CN-YunxiNeural` | 男 | 普通话 | 活泼、阳光 |
| `zh-CN-YunjianNeural` | 男 | 普通话 | 激情（体育） |
| `zh-CN-YunyangNeural` | 男 | 普通话 | 专业、可靠（新闻播音） |
| `zh-CN-YunxiaNeural` | 男 | 普通话 | 可爱（童声） |
| `zh-CN-liaoning-XiaobeiNeural` | 女 | 东北话 | 幽默 |
| `zh-CN-shaanxi-XiaoniNeural` | 女 | 陕西话 | 明亮 |
| `zh-HK-HiuGaaiNeural`、`zh-HK-HiuMaanNeural` | 女 | 粤语 | 友好 |
| `zh-HK-WanLungNeural` | 男 | 粤语 | 友好 |
| `zh-TW-HsiaoChenNeural`、`zh-TW-HsiaoYuNeural` | 女 | 台湾普通话 | 友好 |
| `zh-TW-YunJheNeural` | 男 | 台湾普通话 | 友好 |

同一段文字换音色时长会不同（实测同一句 5.1~6.5 秒，粤语最长），需要对齐画面时以实际音频为准；各音色的原始响度也相差约 5dB，混音时宜逐句归一化。

### 音频回调

`onAudio(data,format)` 按收到的顺序调用：

- `data` 是稳定的编码字节串，可由调用者保存；不是 PCM。
- 一个回调块不保证是完整的 MP3 文件、完整 MP3 帧或一句话。
- `format` 包含 `codec="mp3"`、`mimeType="audio/mpeg"`、`sampleRate=24000`、`channels=1`、`bitrate=48000`。
- 回调应尽快返回，不应阻塞等待播放完毕。
- 没有返回值或返回 true 表示接受；返回 `false,err` 或 `null,err` 表示拒绝，任务以 `consumer_error` 终结。
- 回调抛错也会终结任务并释放传输资源，不会从头自动重试。

核心不会积累整段音频。慢消费者应使用有界缓冲，并在超限时明确失败（例如原生播放器的 `maxPcmBytes`）。

### 边界回调

`onBoundary(info)` 的 `info` 包含：

- `type`：`"sentence"` 或 `"word"`；
- `text`：边界文本，XML 实体已还原；
- `offset`、`duration`：相对本次音频起点的**秒数**，不是原始的 100ns tick。

一个服务器元数据消息里的多项边界会全部按序交付。

### 终结、取消与错误

- 参数错误：`synthesize` 立即返回 `null,err`，没有任务，也不会触发回调。
- 已接受的任务：回调在 `synthesize` 返回后开始，成功/失败/取消恰好触发一次 `onFinish`。
- 成功：`onFinish(result,null)`；`result` 包含 `id,format,audioBytes,audioChunks,firstAudioMs,elapsedMs`，不包含完整 MP3。
- 失败或取消：`onFinish(null,err)`；错误包含 `code,message,stage,partial,retryable`，以及可选 `httpStatus`。
- `partial=true` 表示已经向消费者交付过音频；不能盲目重试，否则可能重复朗读。
- `cancel()` 幂等，首次取消返回 true，已终结返回 false。它不会删除消费者已收到的音频，也不会替播放器按停止键。
- 使用回调表时，`cancel()` 会在内部同步触发一次 `onFinish(null,err)`，`err.code` 为 `cancelled`，可以在这里关闭并删除临时文件；在停止函数里调用 `cancel()` 时，`onFinish` 开头判断 `cancelled` 直接返回即可，不需要计数器。使用输出对象时，取消不会调用它。之后都不会再有任何回调。
- `wait(timeoutMs)` 的等待超时返回 `wait_timeout`，**不取消任务**。任务自己的网络/总时限则会终结任务。
- 不要在本任务回调内等待同一个任务。`onFinish` 自身抛错记录为 `task.callbackError`，不会再次通知结束。

## 只保存音频，不加载播放器

```aardio
import web.edgeTextToSpeech;

var path = io.tmpname(,".mp3");
var file,err = io.file(path,"w+b");
if(!file){print(err);return;}
var task,err = web.edgeTextToSpeech().synthesize({text="保存音频，不绑定播放器。";}, {
    onAudio = function(data){return file.write(data);};
    onFinish = function(result,err){file.close();};
});
if(!task){file.close();io.remove(path);print(err.message);return;}
var result,err = task.wait();
if(result) print(path);
else {io.remove(path);print(err.message);}
```

文件由调用者创建和关闭。正式程序若要替换已有文件，应先写临时文件，合成成功后再提交，失败时保留已有文件。

## 示例

- `~/examples/Media/Audio/edgeTTS-stream.aardio`：最小流式播放，原生播放器。
- `~/examples/Media/Audio/edgeTTS-mediaFoundation.aardio`：带朗读、停止按钮的原生播放界面。
- `~/examples/Media/Audio/edgeTTS-http.aardio`：音频写入本机 HTTP URL，播放器只需打开地址。
- `~/examples/Media/Audio/edgeTTS-webView.aardio`：自定义输出对象，把音频转交给 WebView2 的 `MediaSource` 与 `<audio>`。
- `~/examples/Media/Audio/edgeTTS.aardio`：大声朗读/翻译界面，可停止、缓存后重播；界面使用原生播放器。

网页示例使用 `wb.invoke` 异步转交音频，串行处理 `SourceBuffer.appendBuffer`，所有块进入缓冲后才 `endOfStream()`。播放器自行探测 `audio/mpeg` 支持，失败时不会假装成功。JS 反向取消 aardio 任务时使用异步调用，避免双向同步等待。

这里不假定传统 `web.form` 和 WebView2 具有相同的媒体能力。

## 按需分句

```aardio
import string.sentences;
var sentences = string.sentences("第一句。第二句。最后一句。");
for i,sentence in sentences {
    print(i,sentence);
}
```

`string.sentences` 不需要 LLM。只有文本确实需要分段时才使用它；已经完整且符合长度策略的文本，优先一次提交并流式消费音频。

如果后续文本尚未产生，可以在上层对每句创建独立任务、有限预取并按序播放。每段使用独立播放器/流生命周期，不把独立 MP3 文件当作同一编码流随意拼接，也不要用文本内容作为队列的唯一键（重复句子是合法输入）。本库不再提供隐式全局队列。

## 迁移与维护

旧的 `voiceOptions`、`ConnectAsync`、`SetProxy`、`OnResponseEnd`、`pushText/popSpeech` 已移除，不提供兼容层。

- 原构造器里的文本改放入 `synthesize({text=...}, handlers)`。
- 原百分比语速改为倍率，例如 `"-10%"` 改为 `0.9`。
- 原“合成完毕再播放”改为 `synthesize(request, player)`，收到音频就开始播放。
- 只存在一个入口：`~/lib/web/edgeTextToSpeech/_.aardio`。不要再添加同名的平铺 `.aardio` 文件遮蔽它。
- 协议参数集中在内部 `protocol.aardio`；`task.aardio` 负责异步生命周期。业务不应引用内部模块。
- 服务可能失效；403 等错误应检查系统时钟与服务变化。不会自动修改系统时间、查询浏览器更新 API 或使用付费后备服务。
