VoiceDrop 的语音编辑器:在文章详情页按住底部的麦克风说一句话——「把第三行改简洁点」「删掉图二」「标题换一个」——松手,几秒钟后文章原地更新。这页把这条链路完整拆开:从麦克风里的 16kHz PCM,到火山流式听写,到 Cloudflare Durable Object 里的持久队列,到 Claude 的行级定点修改,再到 R2 写回与 WebSocket 推送。
一次语音编辑跨过三层:手机负责采声和显示,Cloudflare 边缘负责听写代理、排队和执行,两家模型服务分别管「听清」和「改对」。手机上没有任何密钥,所有第三方凭证都留在服务端。
录口述备忘和说编辑指令,走的是两条完全不同的音频路。录备忘用 AVAudioRecorder 落成 m4a 文件慢慢传;而编辑指令要的是「边说边出字」,所以 SpeechDictation(VoiceEdit.swift)用 AVAudioEngine 在麦克风上装一个 tap,把每个 buffer 现场重采样成 mono 16kHz Int16 PCM,压根不存文件,直接流出去。
流去哪?手机自己实现了火山引擎的二进制协议(VolcASRProtocol.swift:gzip 分帧 + 序列号),但它连的不是火山,而是自家 Worker 的 wss://jianshuo.dev/agent/asr。这个代理是个「哑中继」——它只做三件事:
openspeech.bytedance.com/api/v3/sauc/bigmodel(流式资源,区别于挖矿用的文件版 ASR)。说话的同时,识别出的字实时回流,屏幕上浮出一个转写气泡;说到「第3行」「图2」这类定位词,气泡里会用赭红色高亮出来(一个正则:第[0-9]+行|图[0-9]+)——你能亲眼确认「它听到的号就是我说的号」。
https:// 而不是 wss://;② CF 把二进制帧给成 Blob,直接转发会被强转成 13 个字节的字符串 "[object Blob]"——音频全毁,「说话和没说一样」。修法是两端 socket 都设 binaryType="arraybuffer" 并显式转换。语音编辑最难的不是「改」,是「指哪」。VoiceDrop 的答案是把行号做成三方共享的坐标系:
手指按住说话时,正文左边距淡入「第N行」,每张图左上角淡入「图N」角标。用 overlay + offset 绝对定位,正文一个像素都不回流。松手号就隐去——平时读文章看不到它们。
正文按真实换行拆成非空行,照片标记单独成行、也消耗一个行号。所以图后面的段落行号是连续累加的,屏幕上标的号和模型拿到的原始正文 1:1 对齐。(早期版本只数文字行、跳过图片,用户一说「第5行」就错位。)
Worker 用和 Swift 完全相同的算法把正文逐行标号,直接把带号版本塞进 prompt——模型被明确告知「严格按标的号定位,别自己数」。靠模型自己数长文章的行数是会数错的,所以干脆不让它数。
行号从不写进保存的正文:模型输出的是「第3行换成这句」这样的操作,服务端把号解析回干净的原始行再落盘。多篇文章各自从第1行重新编号,app 每条指令都带上 articleIndex,指哪篇编哪篇的号。
bodyRows() 和 Worker 的 linenum.js 是同一个算法的两份实现,改其中一个必须同步改另一个(各自都有测试)。编号规则漂移的后果是「用户指第4行、模型改第5行」——这比改错字严重得多。你可以在上一条还在改的时候就说下一条——「正在改…按住继续说」。指令不会打架,因为两端各有一层队列:
articleIndex 先持久化到磁盘(EditQueueStore)——app 被杀也不丢;updated 回来了,才发下一条,保证每次编辑都建立在上一次的结果之上;/agent/edit?stem=…),指令写进 DO 自带的 SQLite 队列表,按 seq FIFO 排干;最后一道保险缝在文章文档本身:每次成功的编辑会把这条指令的 id 盖章成 doc.lastEditId。队列崩溃恢复后重跑某条指令时,先看文档上的章——已经盖过,说明效果早就落盘了,直接返回现状,模型一次都不会重复执行。
// edit-turn.js — 恰好一次的最后一道保险 if (editId && doc.lastEditId === editId) { // 这条指令的效果已经落盘(上次跑完写入后盖了章,队列却没记住) return { ok: true, article: withTopLevelArticles(doc), hadError: false }; }
轮到某条指令时,Worker 组一个 prompt 去驱动 Claude。这个 prompt 的结构本身就说明了设计取向:
事实来源有明确的优先级,写死在系统提示词里:用户当场说的就是最高事实。他让你加「花了 2430」你就加,绝不拿「原始转写里没有」顶回去,绝不反问确认;转写只是底稿参考;唯一底线是不虚构用户根本没说的东西。
然后进 agent loop(最多 8 步),模型按指令挑工具:
| 工具 | 干什么 | 什么时候用 |
|---|---|---|
edit_current_article | 行级定点修改:delete_lines / replace_line / insert_after / set_title,一次可带多个 op,行号一律按改动前的编号 | 默认路径——删行、改行、合并相邻行、删图、插段、改标题,绝不回传全文 |
write_article | 回传完整文章数组,整篇重写当前这篇 | 只有伤筋动骨的大重构、把多篇合并成一篇时 |
list_articles / read_article | 只读:列出、读取别的文章 | 合并 / 参考别篇重写前先看 |
read_style / write_style | 读 / 整体写回用户文风(版本化存储) | 「以后写得再口语一点」这类指令 |
publish_wechat / share_to_community | 发公众号草稿 / 分享到 VD社区 | 说了就直接发 |
edit_photo / new_photo | AI 改图 / 凭空生成一张插入 | 「把图二变成广告」;异步约 1 分钟自动出现 |
行级工具落盘时用展开继承重建文章数组——{...a, title, body}——除了这次改动的字段,其它一切字段(文风版本、公众号 media id…)原样存活。这是一条吃过亏才立下的规矩:白名单重建会在每次编辑时静默丢字段。
每次成功的编辑都写一个新版本进 R2 的文章文档(schema-3:{head, versions:[…]}),而不是覆盖。这带来两个直接的产品能力:
写完,DO 向这篇文章的所有连接广播 {type:"updated", article},app 把正文原地换掉;按住说话时行号会按新正文重新长出来。这次改动的「回声」还会传得更远:
还有一个不显山露水的设计:详情页的「插入照片」按钮走的也是语音编辑这条链——它把照片缩略图打包成一条合成指令(「我刚拍了两张照片,请插到最合适的位置」)排进同一个队列,模型看着图决定插进哪段,写下 [[photo:<key>]] 标记。图文混排和语音改稿共用同一条肌肉。
每条指令在调用 Claude 之前先过计费闸 meteredEditGate:算力余额不足,或这篇已经改满 100 次(按真实编辑次数算——一次编辑是一个 agentic 回合,不管里面调了几次模型),直接拒绝入场,一个 token 都不花。拒绝消息原路推回手机,变成气泡里的「算力不足,无法继续编辑」。
放行的每一次模型调用都写进 llmlogs/(请求、响应、延迟、回合 id,30 天生命周期)。因为「终结型工具成功后不再多调一次模型」,工具执行记录会单独补一条日志——否则管理端就看不到这条指令到底做了什么。
行号在左边距淡入。AVAudioEngine 开始把 16k PCM 流向 /agent/asr,火山的字实时回流,「第3行」「图1」在气泡里被染成赭红。
定稿文字连同 id、articleIndex 持久化进本地队列,WebSocket 发出 instruct。麦克风按钮自己变成「正在改」的呼吸动画。
ArticleEditor DO 把指令写进 SQLite 队列;轮到它时先过计费闸,再组 prompt:缓存好的系统段 + 转写段,加上带行号的正文和这句指令。
Claude 回了一个 edit_current_article:ops:[{op:"replace_line", line:3, text:"我以为这种生活早没了。"}, {op:"delete_lines", lines:[4]}]。两个 op 的行号都按改动前算,服务端一次应用、盖上 lastEditId、写入新版本。
终结型工具成功,回合就地收束。updated 广播到手机,正文原地换新,气泡回一句「改好了」。原来的第5行现在叫第3行——下次按住,新的号自然长出来。
这就是整个语音编辑器:一门三方共讲的行号语言,两条各司其职的 WebSocket,一个恰好执行一次的持久队列,和一个只被允许做定点手术的模型。