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

9.2 KiB
Raw Blame History

@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 伪代码):

// 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 里加依赖:

    // apps/your-app/package.json
    { "dependencies": { "@wenwumap/tts": "workspace:*" } }
    
  3. 因为包是「源码直出」(main 指向 src/index.ts),构建工具需要转译它:

    • Next.jsnext.config.js
      module.exports = { transpilePackages: ["@wenwumap/tts"] };
      
    • tsconfig 路径别名(编辑器类型解析):
      { "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. 快速上手

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 一次性朗读整段(已有完整文本)

tts.unlock();                 // 用户手势内
tts.speakWhole(fullText);     // 内部自动按句切分、排队、顺序播放

4.2 边流式边合成(配合 LLM 流式输出,首声最快)

tts.unlock();                 // 用户点击“发送”时
tts.begin();                  // 开启一轮朗读会话

for await (const delta of llmStream) {     // 大模型逐 token 输出
  appendToUI(delta);
  tts.feed(delta);            // 凑齐整句即开始合成、播放
}

tts.flush();                  // 流结束,朗读剩余尾句

5. React 用法

直接用 useRef 持有实例即可(引擎自带状态,组件只需同步 UI):

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):开始播放某会话时回调你传入的 tagbegin(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() 释放资源(组件卸载时调用)

工具函数

import { stripMarkdown, splitSpeakable } from "@wenwumap/tts";

stripMarkdown("**加粗** `代码`");        // -> "加粗 代码"
splitSpeakable("第一句。第二句还没完");   // -> { chunks: ["第一句。"], rest: "第二句还没完" }

7. 重要注意事项

  • 自动播放:浏览器要求音频播放源于用户手势。务必在点击事件里同步调用一次 unlock()(它会播放一段静音占位以获授权)。否则首次自动朗读可能被拦截,此时降级到浏览器朗读或等用户手动点击。
  • 音色 ID 由谁定义voice 只是透传给你的后端;具体支持哪些音色取决于你接的 TTS 服务(如 CosyVoice 的 longxiaochun_v3longsanshu_v3 等)。
  • 限流:高并发下后端 TTS 可能 429。建议后端串行 + 退避;引擎本身已对失败段自动降级浏览器朗读,体验不中断。
  • 仅浏览器环境:引擎使用 Audio/speechSynthesis,请在客户端(如 Next.js 的 "use client" 组件)中使用。

8. 工作原理(简述)

feed(delta) ──► 累积文本,按句号/问号/换行切句
                     │  (凑够 minSentenceLen)
                     ▼
              入队 + 受限并发合成 (maxInFlight)
                     │  失败→标记“浏览器朗读”
                     ▼
        按入队顺序逐段播放(audio 或 speechSynthesis
                     │
            onSpeakingChange(tag / null) 通知 UI