# @wenwumap/tts 通用、与框架无关的**流式语音合成(TTS)播放引擎**。 把「AI 回答 → 语音播报」这件事沉淀成一个独立模块:边流式边合成、按句排队、顺序无缝播放,专业 TTS 失败时自动降级到浏览器朗读,绝不静默。 - ✅ 零业务耦合,零运行时依赖(仅用浏览器 `fetch` / `Audio` / `speechSynthesis`) - ✅ 不绑定 React / Vue,纯 TypeScript 类,任何前端都能用 - ✅ 边流式边合成:第一句话出现后约 1 秒即可起声 - ✅ 按入队顺序播放,合成乱序完成也不会乱 - ✅ 双轨兜底:后端 TTS 限流/失败 → 自动浏览器朗读 - ✅ 处理浏览器自动播放策略(`unlock()`)、blob 资源回收、会话防串音 --- ## 1. 它依赖什么? 模块本身**不发起任何特定厂商的请求**。它只要求你提供一个**后端 TTS 接口**,满足下面的契约: ### 接口契约 ``` POST 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:同一个 monorepo(pnpm 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(null); const [speakingTag, setSpeakingTag] = useState(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 作为 tag,speakingTag === 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 ```