Grok Voice 插件 add-dictation 技能:为应用接入 Grok 语音转文字(STT)的 Batch 与 Streaming 双路径实践
【免费下载链接】pluginsCursor plugin specification and official plugins项目地址: https://gitcode.com/GitHub_Trending/plugins125/plugins
本文围绕 Grok Voice 插件 中的add-dictation技能(grok-voice/skills/add-dictation/SKILL.md)展开,讲清楚如何把 xAI Grok Speech-to-Text 能力集成到一个已有应用里:从 Batch(POST /v1/stt)与 Streaming(wss://api.x.ai/v1/stt)两条路径的选型,到服务端转发、浏览器端麦克风采集、流式 PCM 分帧与事件状态机的完整实现,再到全部可选参数、Python 服务端的等价写法和上线前的冒烟验证清单。读完你可以按文档步骤直接落地一个"点按说话、文字进输入框"的听写功能,或者实时字幕与录音转写场景。
一、技能定位:它做什么、何时触发
add-dictation是一个 Cursor 插件技能(SKILL),其 frontmatter 的name为add-dictation,触发方式是用户输入/add-dictation、键入Dictate,或表达明确的 "transcribe"(转写)意图。技能的核心目标原文如此:
为现有应用添加 Grok Speech to Text:一个把语音打进 composer(输入框)的麦克风按钮、实时字幕,或对已录制音频的转写。在
/add-dictation、键入Dictate或明确的"转写"意图时运行。Cursor 本身没有麦克风,要接线的是你的应用(app),而不是 IDE。
这里有一个关键前提值得强调:该技能面向的是业务应用而非编辑器本身。Cursor 不提供麦克风采集能力,所有采集逻辑都必须写在应用自己的客户端里,服务端只做鉴权与转发。
在插件层面,grok-voice是整个 plugins 多插件市场仓库 中的一个独立插件目录,清单见 grok-voice/.cursor-plugin/plugin.json(name: grok-voice,skills指向./skills/)。它包含四个互相配合的技能:
| 技能 | 能力 | 图标约定 |
|---|---|---|
| add-voice | 实时语音对语音(speech-to-speech),composer 上放波形按钮 | 波形 = Voice Mode |
| add-dictation | 语音转文字(本文主题) | 麦克风 = 听写 |
| add-read-aloud | 文字转语音朗读 | 喇叭 = 朗读 |
| debug-voice | 语音会话的日志驱动排障闭环 | — |
Grok Voice 插件 README 明确了图标规范:"waveform = voice mode, microphone = dictation, speaker = read aloud"。这条规范直接约束本文第五节中麦克风按钮的 UI 落位:如果add-voice已安装,它的波形主按钮保持不动,麦克风只作为次级 ghost 按钮加在旁边,绝不抢占主按钮。
参考文档方面,技能正文指向 xAI 官方文档的 Speech-to-Text 模型能力章节与定价页(价格信息仅引用官方文档,本文不复述)。
二、路径选型:Batch 还是 Streaming
技能给出了明确的选型表,这是整个集成的第一决策:
| 需求 | 路径 |
|---|---|
| 点按 → 说话 → 再点按 → 出现文字;上传文件;URL | BatchPOST https://api.x.ai/v1/stt(默认) |
| 说话的同时文字就出现:字幕、长听写、push-to-talk | Streamingwss://api.x.ai/v1/stt,且必须经过后端 relay |
技能给出的默认策略是:composer 上的麦克风听写按钮优先用 Batch——一次 HTTP 请求、不维护 socket、API key 永远留在服务端。只有当 UX 明确需要"边说边出字"(interim text)时才上 Streaming。
鉴权:只有一种正确姿势
- 服务端使用 Bearer
XAI_API_KEY,仅服务端持有。 - STT 文档没有ephemeral token(临时令牌)流程;又因为浏览器端 WebSocket 无法自定义 header,所以浏览器流式场景必须走你自己的后端 relay 升级连接。技能特别警告:"Do not invent a token flow"(不要自造令牌流程)。对比之下,同插件的 add-voice 面向 speech-to-speech 场景,是存在
client_secrets临时令牌机制的,两者不可混用。 - 两条铁律:API key 绝不进客户端 bundle;绝不把 key 粘贴进聊天。
三、第 1 步:先给应用"画地图"
动手前先摸清应用的四个要素:
- composer / 输入组件在哪,文字落点如何:插入光标处(insert at cursor)还是整体替换(replace);
- 服务端框架与包管理器;
- 麦克风图标归属:麦克风图标归听写所有。若 add-voice 已安装,它的波形主按钮保持原样,麦克风按钮以次级 ghost 按钮形式加在旁边;
- 是否已有麦克风采集:
/add-voice跑过的话,它的 PCM 采集可以直接喂给 Streaming STT,只需把采集采样率作为sample_rate传入。16 kHz 是模型的原生采样率;其余受支持的 8000、16000、22050、24000、44100、48000 会在服务端被重采样。
从源码结构看,技能把"复用 add-voice 的采集链路"写成了一条显式分支,意味着这两个技能被设计为可组合叠加:同一个AudioContext采集图,既可以上行 realtime 会话,也可以切换成 STT 流,避免应用里出现两套音频图。
四、Batch 路径(默认):一次请求完成转写
4.1 客户端:MediaRecorder → Blob → 自己的路由
- 用
MediaRecorder录制成Blob,POST 到你自己的后端路由(不是直连 xAI)。 - 服务端端点自动探测容器格式:WAV、MP3、OGG、Opus、FLAC、AAC、MP4、M4A、MKV、WebM。因此
MediaRecorder产出什么容器就发什么容器,无需客户端转码。
4.2 服务端:multipart 转发,file 必须最后
转发时以multipart/form-data提交。两个硬性细节:
- 选项字段在前,
file字段在最后——file之后的字段可能被服务端忽略; file与url二选一,单文件最大500 MB。
技能给出的 TypeScript 服务端参考实现(适用于任何带fetch+FormData的运行时):
// server (any runtime with fetch + FormData) export async function transcribe(blob: Blob, filename: string) { const form = new FormData(); form.append("format", "true"); // 数字/货币写成书写形式;要求同时传 language form.append("language", "en"); // form.append("keyterm", "Acme"); // 每个词重复 append,≤100 词 × 50 字符 form.append("file", blob, filename); // 必须放在最后 const res = await fetch("https://api.x.ai/v1/stt", { method: "POST", headers: { Authorization: `Bearer ${process.env.XAI_API_KEY}` }, body: form, }); if (!res.ok) throw new Error(`STT ${res.status}`); // 400 输入非法,413 超过 500 MB,429 退避,502 url 抓取失败,503 重试 return (await res.json()) as { text: string; language: string; duration: number; words?: { text: string; start: number; end: number; speaker?: number }[]; channels?: { index: number; text: string; words: unknown[] }[]; }; }响应结构值得注意:words[]携带逐词时间戳(start/end)与speaker(开启 diarization 时);channels[]是多声道转写(会议/客服双声道场景)。这两个字段正是第七节diarize/multichannel选项的落点,也是做字幕、会议纪要的基础。
客户端采集与提交:
// client const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/mp4"; const rec = new MediaRecorder(stream, { mimeType: mime }); const parts: BlobPart[] = []; rec.ondataavailable = (e) => parts.push(e.data); rec.onstop = async () => { const fd = new FormData(); fd.append("file", new Blob(parts, { type: mime }), "dictation"); const { text } = await (await fetch("/api/dictation", { method: "POST", body: fd })).json(); insertAtCursor(text); }; rec.start(); // 第二次点击:rec.stop()实现要点:优先探测audio/webm;codecs=opus,不支持则回退audio/mp4;ondataavailable里累积BlobPart,停止时合成单个 Blob 提交;转写文本通过insertAtCursor落到 composer 光标处——这与第三节"先确认插入策略"的地图信息呼应。
五、Streaming 路径:后端 relay + 原始 PCM 分帧
只有当 UX 需要"边说边出字"时才走这条路。架构上分两半:服务端 relay 与浏览器采集端。
5.1 服务端 relay:持有 key,双向转发
服务端持有XAI_API_KEY,负责把浏览器 socket 升级并转发到 xAI:二进制帧与客户端控制消息向上,JSON 事件向下;query string 在服务端拼接(key 与参数不出服务端):
import { WebSocketServer, WebSocket } from "ws"; new WebSocketServer({ port: 8788 }).on("connection", (client) => { const q = new URLSearchParams({ sample_rate: "16000", encoding: "pcm", interim_results: "true", language: "en" }); const up = new WebSocket(`wss://api.x.ai/v1/stt?${q}`, { headers: { Authorization: `Bearer ${process.env.XAI_API_KEY}` } }); up.on("message", (d) => client.send(d.toString())); // transcript.* 与 error 事件 client.on("message", (d, isBinary) => up.readyState === WebSocket.OPEN && up.send(d, { binary: isBinary })); // 音频 + finalize/audio.done const end = () => { client.close(); up.close(); }; up.on("close", end); up.on("error", end); client.on("close", end); });5.2 浏览器采集:PCM16 裸帧,100 ms 一帧
这是最容易踩坑的部分,技能给出了精确约束:
- 格式:PCM16 小端、单声道、16 kHz;
- 分帧:100 ms 一帧 = 3,200 字节,原始二进制发送,不做 base64;
- 时序:收到
transcript.created之前不许发音频; MediaRecorder的产物是容器,不是裸帧,不要拿它直接 stream。
const ws = new WebSocket(relayUrl); ws.binaryType = "arraybuffer"; const ctx = new AudioContext({ sampleRate: 16000 }); // 若 ctx.sampleRate !== 16000,在 worklet 里降采样 await ctx.audioWorklet.addModule("/pcm16-worklet.js"); // Float32 → Int16LE,每 100 ms post 一帧 3,200 字节 const node = new AudioWorkletNode(ctx, "pcm16"); ctx.createMediaStreamSource(stream).connect(node); let ready = false; node.port.onmessage = (e) => ready && ws.readyState === WebSocket.OPEN && ws.send(e.data); let committed = "", locked = "", live = ""; ws.addEventListener("message", (e) => { const ev = JSON.parse(e.data); if (ev.type === "transcript.created") ready = true; else if (ev.type === "transcript.partial") { if (ev.speech_final) { committed += ev.text + " "; locked = ""; live = ""; } // 完整拼接好的一句话 else if (ev.is_final) { locked += ev.text + " "; live = ""; } // 块级 final:文本不会再变 else live = ev.text; // 中间结果:可能变化 render(committed + locked + live); } else if (ev.type === "transcript.done") ws.close(); // 在 audio.done 之后 else if (ev.type === "error") showError(ev.message); // 多数 error 会直接关 socket }); // 停止:ws.send(JSON.stringify({ type: "audio.done" })) // push-to-talk 松手:ws.send(JSON.stringify({ type: "Finalize" })) 之后继续流式(文档中 finalize/Finalize 均出现过,示例采用 Finalize)事件处理里最值得展开的是那个三段式文本状态机committed / locked / live:
committed:已经以speech_final收尾的完整语句,永不再变;locked:当前语句内、已标记is_final的块,文本不再变化,但语句还没结束;live:当前 interim 结果,随时可能被覆盖。
渲染永远是committed + locked + live的拼接。这个结构直接对应第九节冒烟测试里最典型的一类缺陷:"句子边界的词消失或重复"——若拼接的speech_final文本没有包含此前 chunk final,就必须追加而非替换locked。
另外两点实现细节:
AudioContext({ sampleRate: 16000 })在部分浏览器上拿不到目标采样率,此时必须在 AudioWorklet 内做降采样,而不是指望系统帮你重采样;- 控制消息只有两种:
audio.done(结束本次转写)与Finalize(push-to-talk 松手但会话继续)。文档里finalize与Finalize两种写法都出现过,技能示例统一采用Finalize。
六、全部选项速查表:streaming 用 query 参数,batch 用表单字段
技能第 4 步的选项表必须完整保留,它是调优 STT 行为的唯一权威清单(左列是目标,右列是设置方式):
| 想要的效果 | 设置 |
|---|---|
| 说话时就有文字 | interim_results=true |
"one hundred dollars" →$100 | streaming:language=en;batch:format=true+language=en |
| 产品名、行业术语 | 重复 appendkeyterm= |
| 听写数字时别在半句被切断 | smart_turn=0.7&smart_turn_timeout=3000 |
| 句尾判定更快/更慢 | endpointing=(毫秒),默认 400 |
| 谁说了什么(会议) | diarize=true→ 落到words[].speaker |
| Agent 与客户分声道 | multichannel=true&channels=2(仅 PCM,不支持 Opus) |
| 保留"嗯""呃" | filler_words=true(默认会被去除) |
| 低带宽或移动端 | encoding=opus,每帧恰好一个裸 Opus 包,且省略sample_rate |
| 裸音频走 batch | audio_format=pcm\|mulaw\|alaw+sample_rate |
| 安静或电话音频 | 调低vad_threshold(streaming 默认 0.08,batch 默认 0.5) |
几个容易忽略的耦合关系,结合技能正文逐条说明:
format=true依赖language:冒烟一节明确验证了"带format=true但不传language会返回 400"。这不是建议,是硬约束。keyterm有配额:batch 侧每个词重复append("keyterm", ...),上限 100 个词、每词 50 字符。multichannel与编码互斥:多声道只支持 PCM,Opus 走不了这条路——与"低带宽用 Opus"的选项形成天然取舍。- Opus 模式下必须省略
sample_rate:裸 Opus 包自带速率信息,两者同时给会自相矛盾。 - VAD 阈值两条路径默认值不同(0.08 vs 0.5),跨路径迁移配置时不能照抄。
七、Python 服务端等价实现(仅当服务端是 Python)
技能第 5 步给出 Python twin,前提是"只有当服务端是 Python 时才用",避免一个应用里混入第二套工具链:
import os, requests r = requests.post( "https://api.x.ai/v1/stt", headers={"Authorization": f"Bearer {os.environ['XAI_API_KEY']}"}, data=[("format", "true"), ("language", "en")], files={"file": ("dictation.webm", blob, "audio/webm")}, # requests 会把 data 字段排在 files 之前 ) r.raise_for_status(); text = r.json()["text"] # streaming:websockets.connect(url, additional_headers={"Authorization": f"Bearer {key}"});await ws.send(pcm_bytes)注意注释里那句"requests 会把data字段排在files之前"——这恰好满足第四节"file必须最后"的 multipart 顺序要求,是requests库行为与 API 契约的一次良性巧合,换其他 HTTP 库时要自行保证顺序。
八、冒烟验证:四条检查项
技能第 6 步的 Smoke 清单是验收标准,原样继承并说明各自验证的契约:
Batch 正例:
curl -X POST https://api.x.ai/v1/stt -H "Authorization: Bearer $XAI_API_KEY" -F language=en -F file=@short.wav期望 200 且返回
text字段。Batch 负例:同一调用加
-F format=true但不传language→ 必须返回 400。这条把第六节"format依赖language"的约束变成了可执行断言。Streaming 行为测试:说两句、中间停顿,期望看到 interim 文本随后变 final,且语句边界没有重复或消失的词。若词消失,说明拼接的
speech_final文本没有包含 chunk final,修复方向是追加而不是替换locked。随后audio.done→transcript.done,socket 关闭。密钥审计:在客户端 bundle 里搜索
XAI_API_KEY,必须搜不到。这一步与同插件 add-voice、add-read-aloud 冒烟清单里的同款检查保持一致——整个 grok-voice 插件把"key 不进客户端"当成跨技能的统一红线。
排障入口:技能指向/debug-voice(debug-voice 技能)。该技能会先出计划、经用户确认后再安装开发期日志管道(客户端 logger →POST /api/voice/log→.voice-logs/<sessionId>.ndjson,gitignored),然后进入"用户报告 → 日志特征 → 单点修复 → 复测"的闭环。用于 STT 时,把它的钩子点换成transcript.*事件即可——这正是第四节 relay 向下转发的三类事件(transcript.created/transcript.partial/transcript.done)。
九、边界与技能协作
add-dictation的 Out of scope 划得很清楚:
- 会回话的语音(
speech-to-speech)→ 用 add-voice; - 朗读文本(TTS)→ 用 add-read-aloud;
- 不发明文档里没有的 STT 令牌流程、端点或事件名——技能明确写了 "Do not invent a token flow"。
从技能组合的视角看,grok-voice插件内的四个技能共享两套契约,集成add-dictation时应一并遵守:
- 图标契约:波形 = Voice Mode、麦克风 = 听写、喇叭 = 朗读(grok-voice 插件 README)。麦克风按钮永远是听写的专属,不占 Voice Mode 的主按钮位。
- 音频资产复用:
add-voice已建立AudioContext与 PCM 采集/播放链路时,add-dictation的 Streaming 路径直接复用其 PCM 采集并以sample_rate声明采样率(模型原生 16 kHz,其余受支持速率服务端重采样);add-read-aloud的流式播放也复用同一音频图,不加第二套。 - 排障复用:任何路径上线前,按第八节跑冒烟;出现行为问题再进入
/debug-voice的日志闭环,一次只修一处,让新日志能证明是哪个改动生效。
十、小结:关键约束一页速记
| 约束 | 取值 | 出处 |
|---|---|---|
| 默认路径 | BatchPOST https://api.x.ai/v1/stt | 技能"Pick the path" |
| API key 位置 | 仅服务端;STT 无 ephemeral token 流程 | 技能"Auth" |
| multipart 顺序 | 选项字段在前,file最后;file/url二选一,≤ 500 MB | 技能"Steps 2" |
| 容器格式 | WAV/MP3/OGG/Opus/FLAC/AAC/MP4/M4A/MKV/WebM 自动探测 | 技能"Steps 2" |
| 流式帧 | PCM16LE、mono、16 kHz、100 ms = 3,200 字节,禁 base64 | 技能"Steps 3" |
| 流式前置 | 收到transcript.created才发音频;MediaRecorder容器不得直接 stream | 技能"Steps 3" |
format=true | 必须同时有language,否则 400 | 技能"Steps 6" 冒烟 |
keyterm配额 | ≤ 100 词 × 50 字符 | 技能"Steps 2" |
| VAD 默认 | streaming 0.08 / batch 0.5 | 技能"Steps 4" |
| 验收底线 | bundle 中搜不到XAI_API_KEY | 技能"Steps 6" |
按 grok-voice/skills/add-dictation/SKILL.md 的六步流程走一遍,配合本仓库 add-voice 与 debug-voice 两个姊妹技能,可以覆盖从"composer 里的听写按钮"到"会议双声道转写 + 说话人分离"的完整谱系,且每一步都有仓库内文档与代码示例可对照。
【免费下载链接】pluginsCursor plugin specification and official plugins项目地址: https://gitcode.com/GitHub_Trending/plugins125/plugins
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考