3082 字
15 分钟
Apple Watch 录音 MVP 一日建成记:研究先行杀掉四个想当然,对抗审查抓出三个测试全绿的必挂 bug

市面上的独立 AI 录音硬件(Plaud 179Limitless179、Limitless 199)定位是 always-on 全天记录。我想验证一个更便宜的替代 Watch 作为按需一键开录的对话记录器,单次 1–2 小时,转写之外做叹气/笑声这类 paralinguistic 事件检测,再和手表自带的心率/运动数据关联——这是没有生理传感器的录音硬件做不到的差异点。

MVP 要回答四个问题:手腕麦克风音质是否可用、每小时真实掉电多少、后台录音存活率、以及我自己到底会不会用。本文记录一天之内把三端(watchOS app、ingest 服务、Mac Mini 双感知管线)全部建完的过程,重点不是”建得快”,而是两个方法论层面的收获:研究阶段杀掉了四个想当然,以及对抗审查抓出了三个测试全绿但上线必挂的 bug

整个流程分四个阶段,全部由并行 agent 完成:8 个研究 agent → 5 个实现 agent → 6 个审查 agent → 3 个修复 agent,共 22 个,外加一个被掐掉的死循环 agent(后述)。

阶段一:研究先行,四个想当然全部被证伪#

动手前放出 8 个并行研究 agent:3 个翻自有平台的代码(对象存储、通知通道、已有 ASR 服务),5 个做 WebSearch(watchOS 后台录音、HealthKit 回查、DeepSeek 音频 API、Gemini 音频理解、叹气检测选型)。每个 agent 强制结构化输出:结论、细节(可直接抄的代码)、坑、来源。这一轮的价值在于,原计划里有四个”看起来理所当然”的技术选型,全部被研究结果推翻:

想当然一 V4 原生多模态,音频直接喂给它。 实况:训练层面多模态 ≠ API 暴露音频端点。官方 chat/completions 的 content 类型只有文本,没有任何 input_audio 字段,官方 voice agent 指南明说”API 不直接接收音频,走 STT → 文本 → TTS 三段式”。感知层只能用 Gemini,DeepSeek(deepseek-v4-flash,0.14/Min0.14/M in、0.28/M out)退守纯文本摘要层。

想当然二audio_timestamp 配置就有音频时间戳。 实况:这个字段在公共 Gemini Developer API 上直接返回 400 “Unknown name ‘audioTimestamp’“,多个 GitHub issue 挂着没修,只有 Vertex AI 企业端点可能支持。可行替代是 prompt 工程:在 response_schema 里强制 start_time/end_time 字段(MM 格式),解析端兼容各种形态。顺带算了账:音频 32 tokens/秒,gemini-2.5-flash 处理 1 小时录音一次约 $0.15。

想当然三:音频事件检测用 YAMNet(教程最多、AudioSet 自带 Sigh 类)。 实况 要 TensorFlow,而 tensorflow-metal 到现在只支持到 TF 2.18 / Python 3.11,在 M4 + Python 3.13 上根本装不起来。换 PANNs CNN14(panns-inference,纯 PyTorch,MPS 直接可用);转写用 mlx-whisper 跑同一个 large-v3-turbo 模型,benchmark 比 whisper.cpp 快约 2 倍。研究还给了清醒剂 里 sigh 样本极少,通用模型对它的 per-class AP 很低,Gemini 对笑声的 F1 也才 39.7%(WESR benchmark)——所以阈值刻意压低(0.22)做高召回,精度靠人工标注复核,这正是双管线对照实验要回答的问题。

想当然四:上传复用平台现成的对象存储 presign 流程。 实况 PUT 的有效期是 900 秒,而 watch 端上传用的是 isDiscretionary=true 的 background URLSession——系统会刻意把传输推迟到充电 + Wi-Fi,可能是几个小时后。两者一拼,URL 必过期。结论:给手表专门建一个单请求直传的 ingest 端点,挂 write-only 静态 token(设备上永远不放完整身份凭证,这条教训上一个服务已经交过学费,见位置追踪服务上线记)。

watchOS 本身的研究结论也值得单独记:

  • 后台录音的声明是 UIBackgroundModes=[audio] 写在 Watch App target——不是 WKBackgroundModes,那个 key 只有 workout-processing 一个合法值。
  • 录音引擎选 AVAudioRecorder 而非 AVAudioEngine tap 上前者更稳,SoC 有硬件 AAC 编码器,5 分钟分块就是 stop → 新文件 → record,接受 100–300ms 间隙,靠每块的绝对时间戳对齐。
  • 后台中断后不可能自动恢复。session 被 Siri/来电打断后,后台 setActive(true) 直接报错,这是 Apple 工程师在论坛明确确认的平台硬限制。能做的只有:发通知请用户点开 app,回到前台再恢复。
  • 静音检测不要事后解码音频算能量(费电),录音时开 isMeteringEnabled,每 5 秒 updateMeters 一次,块轮换时就有现成的 avg/peak dBFS。

阶段二:契约先行,五个 agent 并行实现#

三端由不同 agent 并行写,接缝必然是重灾区,所以动手前先写死两份文档:architecture.md(含一张”原计划 → 修订 → 依据”的决策表)和 contracts.md(文件命名、JSONL 逐字段格式、ingest API、感知层统一 JSON、时间轴映射规则),所有 agent 的 prompt 都以契约为唯一事实源,文件所有权严格不相交。

时间轴映射是契约里最容易错的一条,值得展开:管线输出的时间戳是拼接音频时间轴上的秒数,但块与块之间有间隙、静音块整块被丢弃,拼接时间轴和墙钟之间布满空洞,绝不能线性换算。正确做法是用 manifest 里每块的 started_at 做分段映射:

拼接音频 [offset_k, offset_k + duration_k) → 墙钟 [chunk_k.started_at, +duration_k)

每块的实际时长以 ffprobe 实测为准(AAC 编码器有 priming sample,5.000s 的块实测 5.056s),manifest 声称值只用来对账。

实现轮的结果 app 42 个单测、ingest 服务 77 个、backend 92 个,全绿,xcodebuild 模拟器构建通过。如果故事到这里结束,那就是一篇平平无奇的”agent 写代码真快”。

阶段三:对抗审查,测试全绿不等于能上线#

6 个审查 agent 并行开工:跨组件契约一致性、Swift 深审、ingest 安全审、backend 逻辑审、部署就绪校验,外加一个写 E2E 冒烟脚本并跑通的 agent(本地起真服务、ffmpeg 合成会话、乱序上传、worker 全流程)。抓出来的东西里有三个是”单测全绿但上线必挂”级别的:

一、共享 SQLite 连接的脏事务跨请求污染(已实测复现)。 ingest 服务整个进程共用一个 SQLite 连接,手动 conn.commit()。写路径是 async 的,async for chunk in request.stream() 处会让出事件循环。审查 agent 构造了一个结构合法但 chunk index 重复的 manifest:executemany 插到第二条触发 UNIQUE 约束抛异常,500 返回,但事务还开着。下一个完全无关的会话来 PUT 一个文件,它的 conn.commit() 把上一个失败请求的半截写入一起提交了——跨会话脏数据。为什么 77 个测试抓不到?因为每个测试用例都是独立连接、串行执行,根本不存在”失败事务残留给下一个请求”的场景。修法是根因级的:所有写路径改 with conn: 显式事务,端点最外层异常统一 rollback,并且 manifest 在碰数据库之前先完整 sanitize(去重、范围校验、标量检查、条数上限)。

二、合并窗 3 秒 < 滑窗 hop 5 秒,重复事件永远合并不上。 管线 B 用 10s 窗、5s hop 滑窗推理,同一声叹气必然在相邻两个窗口都触发;后处理”相邻 3 秒内同类事件合并”——但相邻窗口的起点间隔恰好是 5 秒,3 秒的合并窗一次都不会生效。这个参数是研究材料里原样抄来的,研究代码本身就带着这个 bug。修正:合并窗必须 ≥ hop(改 6s),事件时间戳同时从窗口起点改锚到窗口中心,消除相对 Gemini 精确时间戳的系统性偏早。

三、中断恢复链整条挂在一个经常不投递的通知上。 实现版的恢复逻辑:.ended 通知到达时若在后台,设 pendingResume 标志 + 发提醒;回前台时检查标志恢复。问题是 watchOS 后台场景 .ended 经常根本不来——一旦 .began 之后没有下文,标志永远是 false,提醒永远不发,UI 上 paused 状态只有一个”停止”按钮,录音就静默地永久暂停下去。对一个要验证”后台录音存活率”的 MVP,这是致命伤。修复后的恢复链不依赖任何单一信号:.began 时(非前台)立即发 time-sensitive 通知;回前台时只要状态是 paused 就尝试恢复,不看标志;UI 给 paused 态加”继续录音/结束”双按钮兜底。

除此之外还有一批典型问题 的响应缓存键只含 session_id,不绑音频内容——重跑时补齐了缺块、wav 变了,缓存还命中旧响应,时间全错位(修法:缓存里写入音频指纹,不符即作废);坏响应先落盘再解析,解析失败后缓存永久”毒化”,每次重跑都在同一具尸体上失败(修法:解析失败把缓存改名 .bad 隔离,下次视为 miss 重调);launchd 启动的 worker 拿到的 PATH 不含 /opt/homebrew/bin,裸调 ffmpeg 必然 FileNotFoundError(plist 里显式写 PATH)。

修复轮 3 个 agent 又干掉全部 high/medium 项,最终三端 72 + 125 + 122 = 319 个测试,E2E 冒烟全链路 PASS:

PASS: PUT manifest 先到 → 状态保持 uploading(乱序状态机正确)
PASS: chunk 到齐 → uploaded;worker 跳过 silent 块,concat 实测 5.056s
PASS: 双管线事件 ts_wall 均落在 manifest 时间窗内
PASS: comparison events_matched_within_5s=1;telemetry 裸数组 3 条
PASS: stats_line '叹气 1 · 笑声 0 · HR 均值 79' 符合契约格式
E2E PASS — 全链路通过

翻车记录:一个 agent 的死循环#

研究阶段有个插曲:8 个 agent 里 7 个正常返回,第 8 个(平台侦察)卡了 25 分钟不动。翻它的 transcript,最后几十个 tool call 全是同一个动作——反复提交空的结构化输出,schema 校验失败,重试,再提交空的,再失败。378 行 transcript 里它就这么原地转圈。处置:直接掐掉整个 workflow,从执行日志(journal)里收割那 7 个已完成 agent 的结果,缺的那份侦察自己花五分钟读源码补上。教训有二:并行 agent 的产出要落在可恢复的地方(journal 里每条 result 都是完整的),以及给 agent 的结构化输出加一句”绝不能提交空对象”——听起来很蠢,但后续三轮 14 个 agent 再没复发。

另一个值得记的细节 里 result 与 agent 的对应关系,启动顺序 ≠ 任务数组顺序,我第一次按顺序映射,8 份研究报告张冠李戴了 6 份,靠内容核对才纠正。并行系统里任何基于顺序的隐式假设都是 bug 候选——这句话在同一天里应验了两次(另一次是 background URLSession 不保证上传完成顺序,manifest 可能先于 chunk 到达,服务端状态机必须对乱序免疫)。

收尾#

现在的状态:三端代码全部完成并通过测试,ingest 服务在 feature 分支上等部署(平台是 push main 即上线,合并前要先在服务器上初始化服务身份和密钥),手表端等真机装上后跑四个实测场景——息屏、water lock、Siri、来电,每一个都要核对遥测里的 interruption 事件链。然后是一周真实使用:两条管线的叹气召回/误报对比、电量时间序列、触发次数统计。如果一周触发不到 3 次,结论就是需求伪,这个项目最大的产出反而是这篇方法论记录。

回头看,这一天里最值钱的不是 22 个 agent 的并行产能,而是两条流程纪律:研究先行(四个想当然如果带进实现,每一个都是返工半天起步)和对抗审查(三个必挂 bug 没有一个是单元测试能抓到的——它们全部藏在”环境的形状”里:并发的事件循环、滑窗的几何关系、通知系统的不可靠性)。测试全绿只说明代码和你想的一致,审查才能发现你想的本身就错了。

Apple Watch 录音 MVP 一日建成记:研究先行杀掉四个想当然,对抗审查抓出三个测试全绿的必挂 bug
https://blog.lishuyu.app/posts/手表录音mvp一日建成记/
作者
猫猫魔女
发布于
2026-06-11
许可协议
CC BY-NC-SA 4.0