mcpField
A
add-read-aloud

add-read-aloud

github.com/cursor开发14.5k324 次安装今天更新
代码仓库

关于 add-read-aloud

add-read-aloud 是一项为现有应用添加 Grok 文本转语音的技能,面向构建聊天或内容应用的开发者。它涵盖按需朗读、自动朗读和流式音频,并强调服务端保管 API 密钥。

主要功能

  1. 01为回复添加朗读按钮
  2. 02支持批量与流式合成
  3. 03清理 Markdown 后朗读
  4. 04管理播放状态与错误
  5. 05配置声音、语言和语速

适用场景

  1. 01在聊天回复旁添加朗读功能
  2. 02为长页面生成语音旁白
  3. 03文本流式生成时同步播放
  4. 04生成字幕或卡拉 OK 时间戳

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/cursor/plugins --skill add-read-aloud

安装后在 Claude Code 中输入/add-read-aloud确认已加载。

详细介绍

添加朗读功能

为现有应用添加 Grok 文本转语音功能:在助手回复上添加扬声器按钮、自动朗读,或朗读任意文本。可通过 /add-read-aloud、输入 朗读,或明确表达“朗读这段内容”/“TTS”意图来运行。Cursor 没有扬声器功能;请接入应用,而非 IDE。

文档

选择方案

需求方案
点击扬声器,收听完整回复。朗读页面。生成文件。批处理 POST https://api.x.ai/v1/tts(默认)
大模型仍在生成内容时就开始播放音频;支持插话;文本超过 15,000 个字符通过后端中继使用流式传输 wss://api.x.ai/v1/tts

朗读按钮默认使用批处理:一个请求、一份 MP3,可缓存,密钥不会离开服务器。只有当用户体验需要在文本完成前播放音频时,才使用流式传输。POST /v1/tts 没有文档说明的流式传输标志;不要自行臆造。

身份验证

  • 仅在服务器端使用 Bearer XAI_API_KEY。TTS 文档未说明临时令牌流程,而且浏览器无法设置 WebSocket 标头,因此浏览器端的流式传输须通过后端中继。
  • 切勿将密钥放入客户端包中。不要在聊天中粘贴密钥。

步骤

  1. 梳理应用

    • 查明助手消息的渲染位置、每条消息的操作所在位置(复制、重新生成)、回复流的结束方式、服务器框架和包管理器。
    • 扬声器图标用于朗读。波形图标用于语音模式(/add-voice),麦克风用于听写(/add-dictation)。在消息操作栏中添加幽灵样式的扬声器按钮;加载时显示微调器,播放时显示停止方块。一次只播放一段语音:开始播放新的语音时,停止当前语音。
    • 将操作栏对齐到回复的文本边缘,而不是按钮的边框:图标按钮会让图形居中,因此如果助手气泡没有内边距,就要按该偏移量将操作栏左移(例如,24 px 按钮中放置 12–14 px 图标时使用 -ml-[5px])。在浏览器中测量;<p> 和 <svg> 的 getBoundingClientRect 左边缘应重合。
    • 只有在回复完成流式传输后才渲染按钮;否则会朗读正在生成的部分回复。
    • 屏幕上有许多消息时,将播放器状态(活动消息 ID、loading | playing、最近一次错误)保存在一个共享状态仓库中(useSyncExternalStore、signal,或应用已有的其他方案),以便每个按钮都能反映状态,并能在应用现有的状态区域中显示错误。每个按钮各自使用一个 let current 不够。
    • 自动朗读:使用默认关闭的选择加入开关,并且只在页面上发生用户手势后启用(遵守自动播放策略)。绝不在加载时自动朗读。
    • 如果已安装 /add-voice,可使用其 AudioContext 和 PCM 播放器播放流式 TTS;不要再添加第二个音频图。
  2. 准备文本

    • 朗读普通文本,而非标记。清除 Markdown:标题 → 文本,**粗体** → 文本,链接 → 链接文字,行内代码 → 代码,围栏代码块 → [pause] Code block omitted.,表格 → 每行转换成一句话或省略。保留标点;标点会影响语速。
    • 对回复中出现的语音标签进行消毒([laugh]、<whisper>……),避免模型根据文本改变语音表现。只清除文档列出的标签名称(见步骤 5),不要清除所有方括号内容:[1] 引用和 [note] 必须保留。
    • 每个批处理请求的上限为 15,000 个字符。较长文本按段落边界、句子边界,再到单词边界拆分,并按顺序播放各部分;播放第 N 部分时,或在各部分之间出现静音间隙时,获取第 N+1 部分。也可以使用流式传输。
    • 使用 hash(text + voice_id + language + speed) 作为缓存键;同一条回复经常会被重复播放。
  3. 批处理路径(默认)

    • 服务端:你的路由接收 { text, voice_id?, language? },验证每个字段的格式(voice_id 符合 ^[a-z0-9-]{1,64}$,language 符合 BCP-47 或为 auto),转发 JSON,并使用上游的 Content-Type 和 Cache-Control: no-store 将响应正文流式传回。将上游 404 映射为“未知语音”,让客户端收到易于理解的错误。默认输出为 24 kHz / 128 kbps 的 MP3,可在所有浏览器中播放。
    • language:聊天应用默认设为 "auto",让回复使用用户的语言;仅在语言固定的产品中设为 "en" 等值。
// server (any runtime with fetch)
export async function speak(text: string, voice_id = "eve", language = "auto") {
  if (!text.trim() || text.length > 15_000) throw new Error("TTS text must be 1–15,000 chars");
  const res = await fetch("https://api.x.ai/v1/tts", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.XAI_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      text,
      voice_id,
      language,                       // required: `auto` or BCP-47 (`en`, `pt-BR`); omitting it → 422
      // output_format: { codec: "mp3", sample_rate: 24000, bit_rate: 128000 }, // default
      // speed: 1.0,                  // 0.7–1.5
      // text_normalization: true,    // "$5" → "five dollars"
      // replace: { nginx: "/ˈɛndʒɪn ˈɛks/" },
    }),
  });
  if (!res.ok) throw new Error(`TTS ${res.status}`); // 400 bad text/format, 401 key, 404 unknown voice_id, 422 missing required field (e.g. language), 429/500/503 back off and retry
  return new Response(res.body, { headers: { "Content-Type": res.headers.get("content-type") ?? "audio/mpeg" } });
}
// client
let current: HTMLAudioElement | null = null;
async function readAloud(text: string, voiceId = "eve") {
  current?.pause(); current = null;                       // one utterance at a time
  const res = await fetch("/api/tts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, voice_id: voiceId }) });
  if (!res.ok) throw new Error("TTS request failed");
  const url = URL.createObjectURL(await res.blob());
  const audio = new Audio(url);
  audio.addEventListener("ended", () => URL.revokeObjectURL(url)); // avoid blob leaks
  current = audio;
  await audio.play();                                      // call from the click handler’s promise chain
}
function stop() { current?.pause(); current = null; }
  • Safari:在 blob URL 上,audio.duration 为 Infinity。如需进度条,可使用 AudioContext.decodeAudioData(buf.slice(0)) 解码,或请求 with_timestamps: true,并从 JSON 封装中读取 duration(此时音频以 base64 编码,位于 audio 中)。
  • Safari 会永久挂起在手势之外创建的 AudioContext。请在点击处理程序中、任何 await 之前同步创建它。
  1. 流式路径
    • 中继:服务端保管密钥,升级浏览器套接字,构建查询字符串,将客户端 JSON 向上转发,并将服务端 JSON 向下转发。为浏览器请求 codec=pcm:原始 PCM16 数据块到达后即可安排播放,而 MP3 数据块无法在没有 MediaSource 的情况下逐块解码。
import { WebSocketServer, WebSocket } from "ws";

new WebSocketServer({ port: 8789 }).on("connection", (client) => {
  const q = new URLSearchParams({ language: "en", voice: "eve", codec: "pcm", sample_rate: "24000" /* optimize_streaming_latency: "1" */ });
  const up = new WebSocket(`wss://api.x.ai/v1/tts?${q}`, { headers: { Authorization: `Bearer ${process.env.XAI_API_KEY}` } });
  up.on("message", (d) => client.send(d.toString()));                                   // audio.delta, audio.done, audio.clear, session.updated, error
  client.on("message", (d) => up.readyState === WebSocket.OPEN && up.send(d.toString())); // text.delta, text.done, text.clear, session.update
  const end = () => { client.close(); up.close(); };
  up.on("close", end); up.on("error", end); client.on("close", end);
});
  • 客户端:每个聊天会话使用一个套接字(在多轮语句间保持打开;每个团队最多 50 个并发会话,许可 TTL 为 600 s,因此连接关闭时需重新连接)。将 LLM 词元转发为 text.delta(每条不超过 15,000 个字符),回复结束时发送 text.done,停止时或开始新回复时发送 text.clear;收到 audio.clear 时丢弃排队的音频。
const ws = new WebSocket(relayUrl);
const ctx = new AudioContext({ sampleRate: 24000 }); // create in the click handler; resume if suspended
let playhead = 0, sources: AudioBufferSourceNode[] = [];

ws.addEventListener("message", (e) => {
  const ev = JSON.parse(e.data);
  if (ev.type === "audio.delta") {
    const bytes = Uint8Array.from(atob(ev.delta), (c) => c.charCodeAt(0));
    const pcm = new Int16Array(bytes.buffer, 0, bytes.byteLength >> 1);
    const buf = ctx.createBuffer(1, pcm.length, 24000);
    const ch = buf.getChannelData(0);
    for (let i = 0; i < pcm.length; i++) ch[i] = pcm[i] / 32768;
    const src = ctx.createBufferSource(); src.buffer = buf; src.connect(ctx.destination);
    playhead = Math.max(playhead, ctx.currentTime + 0.15);   // ~150 ms lead so chunks butt together
    src.start(playhead); playhead += buf.duration; sources.push(src);
  } else if (ev.type === "audio.done") { /* utterance finished; socket stays open */ }
  else if (ev.type === "audio.clear") { sources.forEach((s) => s.stop()); sources = []; playhead = 0; }
  else if (ev.type === "error") showError(ev.message);
});
// on each LLM token:  ws.send(JSON.stringify({ type: "text.delta", delta: token }))
// on reply finished:  ws.send(JSON.stringify({ type: "text.done" }))
// on stop / barge-in: ws.send(JSON.stringify({ type: "text.clear" }))  → wait for audio.clear before the next text.delta
  • 跨越 text.delta 边界拆分单词没有问题;匹配和合成会跨多个增量进行。
  1. 选项(批处理使用 JSON 字段,流式处理使用查询参数)
需求设置
更换语音voice_id(批处理)/ voice(流式)。内置语音可通过 GET /v1/tts/voices 获取:eve(默认)、ara、rex、leo、luna、atlas、aurora、orion、……共 28 种,均支持多语言且不区分大小写。自定义语音:从控制台或 GET /v1/custom-voices 获取的 8 个字符 ID
非英语或混合语言language:en、ar-EG、ar-SA、ar-AE、bn、zh、fr、de、hi、id、it、ja、ko、pt-BR、pt-PT、ru、es-MX、es-ES、tr、vi 或 auto
加快或放慢速度speed 0.7–1.5
将“$5”、“Dr.”、“3/4”读作单词text_normalization: true
品牌名称、首字母缩写、术语replace: { "Acme Mobile": "Acme Mobull", "nginx": "/ˈɛndʒɪn ˈɛks/" };最多 200 个条目,键最多 100 个字符(字母、数字、撇号、空格),值最多 128 个字符;整词匹配,不区分大小写,优先匹配最长项。流式处理:在第一个 text.delta 之前发送 session.update { replace }
富有表现力的演绎内联使用 [pause]、[long-pause]、[laugh]、[chuckle]、[giggle]、[cry]、[sigh]、[breath]、[inhale]、[exhale]、[tsk]、[tongue-click]、[lip-smack]、[hum-tune]。将 <whisper>、<soft>、<loud>、<emphasis>、<build-intensity>、<decrease-intensity>、<slow>、<fast>、<higher-pitch>、<lower-pitch>、<singing>、<sing-song> 包裹在完整短语外围
字幕、卡拉 OK、口型同步with_timestamps: true → JSON { audio (base64), content_type, duration, audio_timestamps: { graph_chars[], graph_times[][start,end] } };按顺序逐步处理 graph_chars,切勿按索引切分输入
更快获得首段音频(流式)optimize_streaming_latency=1(文档还列出 2;API 参考列出 0/1)
电话 / IVRoutput_format: { codec: "mulaw" | "alaw", sample_rate: 8000 };无法在浏览器中播放
编辑、后期制作codec: "wav"、sample_rate: 44100 或 48000
更小的文件codec: "mp3"、bit_rate: 64000
  1. Python 对应实现(仅当服务端使用 Python 时)
import os, requests
r = requests.post(
    "https://api.x.ai/v1/tts",
    headers={"Authorization": f"Bearer {os.environ['XAI_API_KEY']}"},
    json={"text": text, "voice_id": "eve", "language": "en"},
)
r.raise_for_status(); audio_bytes = r.content  # audio/mpeg
# streaming: websockets.connect(url, additional_headers={"Authorization": f"Bearer {key}"}); send {"type":"text.delta",...}, {"type":"text.done"}; read audio.delta / audio.done
  1. 冒烟测试
    • 批量测试:curl -X POST https://api.x.ai/v1/tts -H "Authorization: Bearer $XAI_API_KEY" -H "Content-Type: application/json" -d '{"text":"Hello from read aloud.","voice_id":"eve","language":"en"}' --output /tmp/hello.mp3 → 200 audio/mpeg(MP3、24 kHz、128 kbps、单声道),可正常播放。省略 language → 422(实测;文档的表格仅列出 400)。voice_id: "nope" → 404。
    • 在应用中:回复流式输出期间不显示扬声器;回复结束时才显示。点击后 → 加载动画 → 停止方块 → 音频结束后恢复为扬声器;播放期间再次点击 → 立即停止;第一条回复播放时点击第二条回复 → 第一条停止,第二条播放。Safari:首次点击即可播放,URL.revokeObjectURL 会在 ended 时触发。
    • 文本预处理:一条含围栏代码块、表格、[1] 引用和多余的 [laugh] 的回复 → 念出“已省略代码块”,每行念成一个句子,保留引用,移除标签。为此编写单元测试;它是纯函数。
    • 流式测试:逐个 token 发送一条由两个句子组成的回复;音频应在 text.done 之前开始。发送 text.clear 中断当前语句 → audio.clear,停止播放且不残留旧内容。同一 socket 上的第二段语音 → 收到全新的 audio.delta,不会混入第一段内容。
    • 在客户端打包文件中搜索 XAI_API_KEY;其中不得包含该密钥。针对正在运行的开发服务器,获取 /,收集其中引用的 /_next/static/chunks/*.js(或同等路径)URL,并逐个搜索;不要依赖尚未构建的生产版本。
    • 根据日志,通过 /debug-voice 调试;将其钩子点替换为 audio.delta(字节数)、audio.done、audio.clear、error。

不在范围内

  • 会聆听并作答的语音 Agent(/add-voice)、语音转文本(/add-dictation)
  • 除传入现有自定义 voice_id 外的语音克隆
  • 臆造 TTS token 流程、POST /v1/tts 上的 stream 标志,或文档中未列出的事件名称

评论

支持 Markdown