关于 add-read-aloud
add-read-aloud 是一项为现有应用添加 Grok 文本转语音的技能,面向构建聊天或内容应用的开发者。它涵盖按需朗读、自动朗读和流式音频,并强调服务端保管 API 密钥。
主要功能
- 01为回复添加朗读按钮
- 02支持批量与流式合成
- 03清理 Markdown 后朗读
- 04管理播放状态与错误
- 05配置声音、语言和语速
适用场景
- 01在聊天回复旁添加朗读功能
- 02为长页面生成语音旁白
- 03文本流式生成时同步播放
- 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。
文档
- https://docs.x.ai/developers/model-capabilities/audio/text-to-speech
- API 参考:https://docs.x.ai/developers/rest-api-reference/inference/voice
- 自定义语音:https://docs.x.ai/developers/model-capabilities/audio/custom-voices
- 定价(仅引用文档):https://docs.x.ai/developers/pricing
选择方案
| 需求 | 方案 |
|---|---|
| 点击扬声器,收听完整回复。朗读页面。生成文件。 | 批处理 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 标头,因此浏览器端的流式传输须通过后端中继。 - 切勿将密钥放入客户端包中。不要在聊天中粘贴密钥。
步骤
-
梳理应用
- 查明助手消息的渲染位置、每条消息的操作所在位置(复制、重新生成)、回复流的结束方式、服务器框架和包管理器。
- 扬声器图标用于朗读。波形图标用于语音模式(
/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;不要再添加第二个音频图。
-
准备文本
- 朗读普通文本,而非标记。清除 Markdown:标题 → 文本,
**粗体**→ 文本,链接 → 链接文字,行内代码 → 代码,围栏代码块 →[pause] Code block omitted.,表格 → 每行转换成一句话或省略。保留标点;标点会影响语速。 - 对回复中出现的语音标签进行消毒(
[laugh]、<whisper>……),避免模型根据文本改变语音表现。只清除文档列出的标签名称(见步骤 5),不要清除所有方括号内容:[1]引用和[note]必须保留。 - 每个批处理请求的上限为 15,000 个字符。较长文本按段落边界、句子边界,再到单词边界拆分,并按顺序播放各部分;播放第 N 部分时,或在各部分之间出现静音间隙时,获取第 N+1 部分。也可以使用流式传输。
- 使用
hash(text + voice_id + language + speed)作为缓存键;同一条回复经常会被重复播放。
- 朗读普通文本,而非标记。清除 Markdown:标题 → 文本,
-
批处理路径(默认)
- 服务端:你的路由接收
{ 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之前同步创建它。
- 流式路径
- 中继:服务端保管密钥,升级浏览器套接字,构建查询字符串,将客户端 JSON 向上转发,并将服务端 JSON 向下转发。为浏览器请求
codec=pcm:原始 PCM16 数据块到达后即可安排播放,而 MP3 数据块无法在没有MediaSource的情况下逐块解码。
- 中继:服务端保管密钥,升级浏览器套接字,构建查询字符串,将客户端 JSON 向上转发,并将服务端 JSON 向下转发。为浏览器请求
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边界拆分单词没有问题;匹配和合成会跨多个增量进行。
- 选项(批处理使用 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) |
| 电话 / IVR | output_format: { codec: "mulaw" | "alaw", sample_rate: 8000 };无法在浏览器中播放 |
| 编辑、后期制作 | codec: "wav"、sample_rate: 44100 或 48000 |
| 更小的文件 | codec: "mp3"、bit_rate: 64000 |
- 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
- 冒烟测试
- 批量测试:
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→ 200audio/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