VoiceDrop · Architecture Walkthrough

按住说话,
文章自己改好

VoiceDrop 的语音编辑器:在文章详情页按住底部的麦克风说一句话——「把第三行改简洁点」「删掉图二」「标题换一个」——松手,几秒钟后文章原地更新。这页把这条链路完整拆开:从麦克风里的 16kHz PCM,到火山流式听写,到 Cloudflare Durable Object 里的持久队列,到 Claude 的行级定点修改,再到 R2 写回与 WebSocket 推送。

2 条WebSocket
听写一条 · 编辑一条
4 种定点操作
删行 / 改行 / 插段 / 改题
0 次整篇回传
小改绝不重写全文
豫园的早晨
第1行早上七点的豫园没有游客,只有猫。
第2行卖大饼的师傅认识每一只。
第3行我原本以为这种老城厢的生活方式大概已经不存在了,但是……
第4行
图1
第5行九曲桥上的光,是借来的。
🎙 把第3行改简洁一点
松开 发送 · 上滑取消
按住说话时,行号浮现在左边距
00

十秒看懂全景

one round trip, three parties

一次语音编辑跨过三层:手机负责采声和显示,Cloudflare 边缘负责听写代理、排队和执行,两家模型服务分别管「听清」和「改对」。手机上没有任何密钥,所有第三方凭证都留在服务端。

📱 iOS
按住说话PushToTalkBar AVAudioEngine 取麦mono 16k PCM 火山二进制协议分帧VolcASRProtocol · gzip 松手 · 定稿文字SpeechDictation
☁️ CF Worker
/agent/asr 哑中继验 token · 注密钥 火山流式 ASRsauc/bigmodel /agent/edit WebSocket指令入队 ArticleEditor DO每篇文章一个 · SQLite 队列
🤖 执行
计费闸meteredEditGate Claude agent loop≤8 步 · 一组工具 R2 写回新版本articles/<stem>.json 广播 updated文章原地刷新
↺ 全程一个闭环:说话 → 文字 → 队列 → 改写 → 写回 → 推回手机,正文原地更新,行号重新长出来
01

听写:手机自己会说火山话

SpeechDictation → /agent/asr → 火山流式 ASR

录口述备忘和说编辑指令,走的是两条完全不同的音频路。录备忘用 AVAudioRecorder 落成 m4a 文件慢慢传;而编辑指令要的是「边说边出字」,所以 SpeechDictation(VoiceEdit.swift)用 AVAudioEngine 在麦克风上装一个 tap,把每个 buffer 现场重采样成 mono 16kHz Int16 PCM,压根不存文件,直接流出去。

流去哪?手机自己实现了火山引擎的二进制协议(VolcASRProtocol.swift:gzip 分帧 + 序列号),但它连的不是火山,而是自家 Worker 的 wss://jianshuo.dev/agent/asr。这个代理是个「哑中继」——它只做三件事:

说话的同时,识别出的字实时回流,屏幕上浮出一个转写气泡;说到「第3行」「图2」这类定位词,气泡里会用赭红色高亮出来(一个正则:第[0-9]+行|图[0-9]+)——你能亲眼确认「它听到的号就是我说的号」。

两个踩过的坑(都修了):① Cloudflare Workers 出站 WebSocket 必须写 https:// 而不是 wss://;② CF 把二进制帧给成 Blob,直接转发会被强转成 13 个字节的字符串 "[object Blob]"——音频全毁,「说话和没说一样」。修法是两端 socket 都设 binaryType="arraybuffer" 并显式转换。
02

定位:第N行是一种共享语言

用户、屏幕、模型,看的是同一套号

语音编辑最难的不是「改」,是「指哪」。VoiceDrop 的答案是把行号做成三方共享的坐标系

屏幕上:按住才浮现 RecordingDetailView.bodyRows()

手指按住说话时,正文左边距淡入「第N行」,每张图左上角淡入「图N」角标。用 overlay + offset 绝对定位,正文一个像素都不回流。松手号就隐去——平时读文章看不到它们。

计数规则:图片也占一个行号 连续计数器

正文按真实换行拆成非空行,照片标记单独成行、也消耗一个行号。所以图后面的段落行号是连续累加的,屏幕上标的号和模型拿到的原始正文 1:1 对齐。(早期版本只数文字行、跳过图片,用户一说「第5行」就错位。)

模型侧:读号,不数行 linenum.js · inlineNumberedBody

Worker 用和 Swift 完全相同的算法把正文逐行标号,直接把带号版本塞进 prompt——模型被明确告知「严格按标的号定位,别自己数」。靠模型自己数长文章的行数是会数错的,所以干脆不让它数。

号只是坐标,不是内容 applyArticleEdits

行号从不写进保存的正文:模型输出的是「第3行换成这句」这样的操作,服务端把号解析回干净的原始行再落盘。多篇文章各自从第1行重新编号,app 每条指令都带上 articleIndex,指哪篇编哪篇的号。

一条硬契约:iOS 的 bodyRows() 和 Worker 的 linenum.js 是同一个算法的两份实现,改其中一个必须同步改另一个(各自都有测试)。编号规则漂移的后果是「用户指第4行、模型改第5行」——这比改错字严重得多。
03

排队:每条指令恰好执行一次

ArticleEditor DO · SQLite 队列 · exactly-once

你可以在上一条还在改的时候就说下一条——「正在改…按住继续说」。指令不会打架,因为两端各有一层队列:

📱手机侧:严格串行

  • 每条指令生成一个稳定 id,连同文字、缩略图、articleIndex持久化到磁盘(EditQueueStore)——app 被杀也不丢;
  • 严格一条接一条发:上一条的 updated 回来了,才发下一条,保证每次编辑都建立在上一次的结果之上;
  • 重连时先收服务端 snapshot 对账,发过没回音的指令按 id 重放。

☁️服务端:持久队列

  • 每篇文章一个 Durable Object(/agent/edit?stem=…),指令写进 DO 自带的 SQLite 队列表,按 seq FIFO 排干;
  • 同 id 重复提交 → 直接回放缓存结果,绝不重跑模型;
  • DO 被休眠/驱逐后恢复:把遗留的 running 行重置回 pending 继续排——没有客户端连着也照样跑完

最后一道保险缝在文章文档本身:每次成功的编辑会把这条指令的 id 盖章成 doc.lastEditId。队列崩溃恢复后重跑某条指令时,先看文档上的章——已经盖过,说明效果早就落盘了,直接返回现状,模型一次都不会重复执行

// edit-turn.js — 恰好一次的最后一道保险
if (editId && doc.lastEditId === editId) {
  // 这条指令的效果已经落盘(上次跑完写入后盖了章,队列却没记住)
  return { ok: true, article: withTopLevelArticles(doc), hadError: false };
}
jianshuo.dev/agent/src/edit-turn.js:23
04

改写:一个带工具的小 agent

runEditTurn → runAgentLoop · ≤8 步

轮到某条指令时,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_photoAI 改图 / 凭空生成一张插入「把图二变成广告」;异步约 1 分钟自动出现

行级工具落盘时用展开继承重建文章数组——{...a, title, body}——除了这次改动的字段,其它一切字段(文风版本、公众号 media id…)原样存活。这是一条吃过亏才立下的规矩:白名单重建会在每次编辑时静默丢字段。

省一趟往返的小聪明:定点修改、写回、发布这类「终结型」工具一旦成功,回合立即结束,不再多花一次 Claude 调用去要一句「改好了」的确认——直接拿模型调工具前说的话当回复,没有就默认回「改好了」。
05

写回:新版本、旧回声

R2 versioned doc → broadcast → 原地刷新

每次成功的编辑都写一个新版本进 R2 的文章文档(schema-3:{head, versions:[…]}),而不是覆盖。这带来两个直接的产品能力:

写完,DO 向这篇文章的所有连接广播 {type:"updated", article},app 把正文原地换掉;按住说话时行号会按新正文重新长出来。这次改动的「回声」还会传得更远:

还有一个不显山露水的设计:详情页的「插入照片」按钮走的也是语音编辑这条链——它把照片缩略图打包成一条合成指令(「我刚拍了两张照片,请插到最合适的位置」)排进同一个队列,模型看着图决定插进哪段,写下 [[photo:<key>]] 标记。图文混排和语音改稿共用同一条肌肉。

06

闸门:先验票,再花钱

meteredEditGate · 上限 · 全量日志

每条指令在调用 Claude 之前先过计费闸 meteredEditGate:算力余额不足,或这篇已经改满 100 次(按真实编辑次数算——一次编辑是一个 agentic 回合,不管里面调了几次模型),直接拒绝入场,一个 token 都不花。拒绝消息原路推回手机,变成气泡里的「算力不足,无法继续编辑」。

放行的每一次模型调用都写进 llmlogs/(请求、响应、延迟、回合 id,30 天生命周期)。因为「终结型工具成功后不再多调一次模型」,工具执行记录会单独补一条日志——否则管理端就看不到这条指令到底做了什么。

07

一条指令的完整旅程

「把第3行改简洁点,顺便删掉图1」

0.0s — 按住

行号在左边距淡入。AVAudioEngine 开始把 16k PCM 流向 /agent/asr,火山的字实时回流,「第3行」「图1」在气泡里被染成赭红。

2.8s — 松手

定稿文字连同 id、articleIndex 持久化进本地队列,WebSocket 发出 instruct。麦克风按钮自己变成「正在改」的呼吸动画。

2.9s — 入队 · 验票

ArticleEditor DO 把指令写进 SQLite 队列;轮到它时先过计费闸,再组 prompt:缓存好的系统段 + 转写段,加上带行号的正文和这句指令。

~6s — 一次工具调用

Claude 回了一个 edit_current_articleops:[{op:"replace_line", line:3, text:"我以为这种生活早没了。"}, {op:"delete_lines", lines:[4]}]。两个 op 的行号都按改动前算,服务端一次应用、盖上 lastEditId、写入新版本。

~7s — 回声

终结型工具成功,回合就地收束。updated 广播到手机,正文原地换新,气泡回一句「改好了」。原来的第5行现在叫第3行——下次按住,新的号自然长出来。

这就是整个语音编辑器:一门三方共讲的行号语言,两条各司其职的 WebSocket,一个恰好执行一次的持久队列,和一个只被允许做定点手术的模型。