Files
WenwuMap/packages/tts/README.md
T
selfrelease 4a9397bccc feat(tts): 新增通用流式 TTS 引擎并接入 AI 对话
- 新增 @wenwumap/tts 独立包:边流式边合成、按句排队顺序播放、
  专业 TTS 失败自动降级浏览器朗读,含 README 使用说明
- AI 后端新增 /ai/tts 接口,改用 DashScope CosyVoice(cosyvoice-v3-flash)
  输出 mp3,串行+退避重试规避 429 限流
- web 对话面板接入 SpeechQueue,按角色配音色,加语音开关与朗读按钮
- admin 支持 /admin/ 基路径部署
- 地图页移除大面积 backdrop-blur,降低 GPU 占用
2026-06-14 23:13:26 +08:00

259 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# @wenwumap/tts
通用、与框架无关的**流式语音合成(TTS)播放引擎**。
把「AI 回答 → 语音播报」这件事沉淀成一个独立模块:边流式边合成、按句排队、顺序无缝播放,专业 TTS 失败时自动降级到浏览器朗读,绝不静默。
- ✅ 零业务耦合,零运行时依赖(仅用浏览器 `fetch` / `Audio` / `speechSynthesis`
- ✅ 不绑定 React / Vue,纯 TypeScript 类,任何前端都能用
- ✅ 边流式边合成:第一句话出现后约 1 秒即可起声
- ✅ 按入队顺序播放,合成乱序完成也不会乱
- ✅ 双轨兜底:后端 TTS 限流/失败 → 自动浏览器朗读
- ✅ 处理浏览器自动播放策略(`unlock()`)、blob 资源回收、会话防串音
---
## 1. 它依赖什么?
模块本身**不发起任何特定厂商的请求**。它只要求你提供一个**后端 TTS 接口**,满足下面的契约:
### 接口契约
```
POST <endpoint>
Content-Type: application/json
请求体: { "text": "要合成的纯文本", "voice": "音色ID(可选)" }
成功(2xx) 返回音频二进制(mp3 / wav 等,Content-Type 为 audio/*
失败(非2xx): 引擎自动对这一段降级为浏览器朗读
```
> 引擎用 `fetch(endpoint).blob()` 拿音频并用 `URL.createObjectURL` 播放,所以**同源**或正确的 CORS 即可。
### 后端实现参考(任选一种 TTS 服务)
以阿里云百炼 CosyVoice 为例(Node/NestJS 伪代码):
```ts
// POST /api/tts -> 返回 audio/mpeg
app.post("/api/tts", async (req, res) => {
const { text, voice } = req.body;
const r = await fetch(
"https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer",
{
method: "POST",
headers: { Authorization: `Bearer ${process.env.DASHSCOPE_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
model: "cosyvoice-v3-flash",
input: { text: text.slice(0, 800), voice: voice || "longxiaochun_v3", format: "mp3", sample_rate: 22050 },
}),
}
);
const { output } = await r.json();
const audio = await fetch(output.audio.url); // 拿到临时音频 URL 再回源,避免跨域/过期
res.setHeader("Content-Type", "audio/mpeg");
res.send(Buffer.from(await audio.arrayBuffer()));
});
```
> 提示:CosyVoice/qwen-tts 有**并发限流(429)**。建议后端把对上游的并发限制为 1,并对 429 做退避重试——引擎层即便偶发失败也会自动降级浏览器朗读,不会静默。
---
## 2. 安装 / 引入
### 方式 A:同一个 monorepopnpm workspace,推荐)
1.`packages/tts` 整个目录拷到目标仓库的 `packages/` 下(或保留在本仓库共用)。
2. 在要使用的 app 里加依赖:
```jsonc
// apps/your-app/package.json
{ "dependencies": { "@wenwumap/tts": "workspace:*" } }
```
3. 因为包是「源码直出」(`main` 指向 `src/index.ts`),构建工具需要转译它:
- **Next.js**`next.config.js`
```js
module.exports = { transpilePackages: ["@wenwumap/tts"] };
```
- **tsconfig 路径别名**(编辑器类型解析):
```jsonc
{ "compilerOptions": { "paths": { "@wenwumap/tts": ["../../packages/tts/src/index.ts"] } } }
```
- **Vite**:无需特殊配置;如遇到未转译可在 `optimizeDeps`/`build` 里包含它。
4. `pnpm install` 链接工作区依赖。
### 方式 B:独立项目,直接拷贝源码
包没有任何依赖,直接把 `src/` 三个文件拷进你的项目即可:
```
src/text.ts # stripMarkdown / splitSpeakable
src/speech-queue.ts # SpeechQueue
src/index.ts # 导出
```
然后 `import { SpeechQueue } from "./tts"`。
### 方式 C:改名复用
它叫 `@wenwumap/tts` 只是包名,与业务无关。换个项目可以把包名改成 `@yourorg/tts`,逻辑完全通用。
---
## 3. 快速上手
```ts
import { SpeechQueue } from "@wenwumap/tts";
const tts = new SpeechQueue({
endpoint: "/api/tts", // 你的后端 TTS 接口
voice: "longxiaochun_v3", // 默认音色(可选)
lang: "zh-CN", // 浏览器朗读兜底语言(可选)
});
// 必须在“用户点击”里调一次,解锁浏览器自动播放
button.addEventListener("click", () => {
tts.unlock();
tts.speakWhole("你好,我是这件文物,已经三千岁啦。");
});
```
---
## 4. 两种典型用法
### 4.1 一次性朗读整段(已有完整文本)
```ts
tts.unlock(); // 用户手势内
tts.speakWhole(fullText); // 内部自动按句切分、排队、顺序播放
```
### 4.2 边流式边合成(配合 LLM 流式输出,首声最快)
```ts
tts.unlock(); // 用户点击“发送”时
tts.begin(); // 开启一轮朗读会话
for await (const delta of llmStream) { // 大模型逐 token 输出
appendToUI(delta);
tts.feed(delta); // 凑齐整句即开始合成、播放
}
tts.flush(); // 流结束,朗读剩余尾句
```
---
## 5. React 用法
直接用 `useRef` 持有实例即可(引擎自带状态,组件只需同步 UI):
```tsx
import { useCallback, useEffect, useRef, useState } from "react";
import { SpeechQueue } from "@wenwumap/tts";
function useTts(endpoint: string) {
const ref = useRef<SpeechQueue | null>(null);
const [speakingTag, setSpeakingTag] = useState<unknown>(null);
const get = useCallback(() => {
if (!ref.current) {
ref.current = new SpeechQueue({
endpoint,
onSpeakingChange: (tag) => setSpeakingTag(tag), // tag=null 表示停止
});
}
return ref.current;
}, [endpoint]);
useEffect(() => () => ref.current?.destroy(), []);
return { get, speakingTag };
}
// 组件内:
const { get, speakingTag } = useTts("/api/tts");
// 发送提问(用户手势)
function onSend(idx: number) {
const q = get();
q.unlock();
q.setVoice("longsanshu_v3");
q.begin(idx); // 用 idx 作为 tagspeakingTag === idx 即“这条在朗读”
}
// 流式: q.feed(delta);结束: get().flush()
// 重播某条: get().speakWhole(text, idx)
// 停止: get().stop()
```
> `onSpeakingChange(tag)`:开始播放某会话时回调你传入的 `tag``begin(tag)` / `speakWhole(text, tag)`),停止或播完回调 `null`。用它驱动「朗读中」高亮。
---
## 6. API 参考
### `new SpeechQueue(options)`
| 选项 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `endpoint` | `string` | (必填) | 后端 TTS 接口,`POST {text, voice}` → 音频二进制 |
| `voice` | `string` | `""` | 默认音色 ID(由你的后端/TTS 服务定义) |
| `lang` | `string` | `"zh-CN"` | 浏览器朗读兜底语言 |
| `minSentenceLen` | `number` | `14` | 成句最小长度(去标记后字符数),越小起声越早、请求越碎 |
| `maxInFlight` | `number` | `3` | 最大并发合成请求数 |
| `fetchImpl` | `typeof fetch` | 全局 `fetch` | 自定义 fetch(如带鉴权头) |
| `onSpeakingChange` | `(tag) => void` | — | 播放开始回调 tag,停止/结束回调 `null` |
| `onError` | `(err) => void` | — | 合成/播放出错(非致命,会自动降级或跳过) |
### 方法
| 方法 | 说明 |
|---|---|
| `unlock()` | **必须在用户手势内同步调用一次**,解锁浏览器自动播放权限 |
| `setVoice(v)` | 设置后续合成使用的音色 |
| `begin(tag?)` | 开启一轮新的流式朗读会话(作废上一轮) |
| `feed(delta)` | 喂入增量文本,凑齐整句即合成、播放 |
| `flush()` | 流式结束,朗读尾部剩余文本 |
| `speakWhole(text, tag?)` | 一次性朗读整段(内部 = begin + 切分入队 + flush |
| `stop()` | 停止播放、清空队列、取消浏览器朗读 |
| `destroy()` | 释放资源(组件卸载时调用) |
### 工具函数
```ts
import { stripMarkdown, splitSpeakable } from "@wenwumap/tts";
stripMarkdown("**加粗** `代码`"); // -> "加粗 代码"
splitSpeakable("第一句。第二句还没完"); // -> { chunks: ["第一句。"], rest: "第二句还没完" }
```
---
## 7. 重要注意事项
- **自动播放**:浏览器要求音频播放源于用户手势。务必在点击事件里**同步**调用一次 `unlock()`(它会播放一段静音占位以获授权)。否则首次自动朗读可能被拦截,此时降级到浏览器朗读或等用户手动点击。
- **音色 ID 由谁定义**`voice` 只是透传给你的后端;具体支持哪些音色取决于你接的 TTS 服务(如 CosyVoice 的 `longxiaochun_v3`、`longsanshu_v3` 等)。
- **限流**:高并发下后端 TTS 可能 429。建议后端串行 + 退避;引擎本身已对失败段自动降级浏览器朗读,体验不中断。
- **仅浏览器环境**:引擎使用 `Audio`/`speechSynthesis`,请在客户端(如 Next.js 的 `"use client"` 组件)中使用。
---
## 8. 工作原理(简述)
```
feed(delta) ──► 累积文本,按句号/问号/换行切句
│ (凑够 minSentenceLen)
入队 + 受限并发合成 (maxInFlight)
│ 失败→标记“浏览器朗读”
按入队顺序逐段播放(audio 或 speechSynthesis
onSpeakingChange(tag / null) 通知 UI
```