手机加语音配件,断开连接后谁来保住这段话?

做随身语音产品时,一个很自然的拆法是:配件负责拾音和按钮,手机负责账户、缓存和联网,云端负责转写与总结。这样分工看起来清楚,直到用户把手机留在会议室,戴着配件走进走廊。
如果手机 App 已经支持"离线录音",走廊里的几句话还能回来吗?答案取决于音频有没有到达手机。手机没有网络,和配件没有连上手机,是两种不同的离线。 手机缓存能够保住已经收到的数据,却无法替配件保存尚未传来的声音。
因此,选择手机加配件架构,首先要确定产品承诺的断连场景,再决定哪一端必须先保存原始音频。算力放在哪里,要在这之后讨论。
同样叫离线,保存的位置可能隔着一条蓝牙链路

Plaud 的《Transfer files》说明,在 Plaud Note 上录音后:
"your audio is saved to the device automatically."
配图使用的页面日期为 2026-08-17;2026-10-13 的 web 抽取仍展示这一日期,而 Plaud 官方知识库 API的 article_updated_at 为 2026-10-08。当前 API 正文仍写设备先保存,连接 App 后传输,传到 App 后从设备删除;元数据日期变化不等于这条路径发生了变化。它展示了一条"先由录音设备持有,再交给手机"的路径。文档没有公开底层写盘确认协议,不能据此声称它已经实现本文后面的交接方案。^1^
另一种位置出现在 Omi 2024 年 10 月的 Local Sync 说明中。其缓存地点是:
"directly on your phone"
文档描述的是设备仍连接、手机无互联网时,片段保存在手机上,恢复网络后再同步。该页还把功能标为实验性,并将超过两分钟的本地对话作为恢复网络后可进入同步的条件;设备仍连接、手机没有互联网是这份历史说明的前提,不是任意短片段已经上传或生成总结的保证。这里引用的是一份历史功能说明,用来辨认缓存所处的位置;不能由此推断当前全部 Omi 型号都没有设备存储,或拿它给今天的产品做可靠性排名。^2^
这两处原文值得并排看:它们都涉及断网后的录音,但副本的位置不同。如果声音必须先经过蓝牙才能进入手机缓存,那么蓝牙之前发生的中断,就不在手机缓存的保护范围内。
对新产品来说,"支持离线"应当补全成一句可验收的话:在什么连接断开、持续多久、哪台设备仍有电的条件下,哪一份音频可以恢复。若这些条件不明确,硬件和 App 团队很容易各自实现了缓存,产品仍然留着空档。
把"断连"拆成三个不同的失败位置

下面是架构判断表,不是上述产品的实测结果。假设声音由配件麦克风采集,经数据连接传给手机,再送到云端;它不讨论把配件当系统蓝牙耳机输入的另一条音频路由。
| 中断发生在哪里 | 已经收到的音频由谁保管 | 要恢复中断期间的新声音,还缺什么 |
|---|---|---|
| 手机到云端断网,配件仍连着手机 | 手机持久缓存可以承担 | 手机持续接收、缓存容量和重试规则 |
| 配件离开手机范围或蓝牙断开 | 手机只能保管此前收到的部分 | 配件继续录音并本地保存,或明确停止录音并告知用户 |
| 手机接收 App 的进程终止 | 已持久保存的部分可在重启后读取 | 配件缓冲未交付部分;重连后核对缺口 |
第三行经常被"已经做了后台运行"掩盖。Android 官方 BLE 后台通信指南写得很具体:
"the connection is closed if your process is killed."
它同时给出了前台服务、伴生设备服务等后台通信选择。这里的限制是进程终止会关闭连接,不能扩大成"切到后台就必然断连"。同一文档中的自动重连描述,也是在外设再次可用时尝试连接;恢复连接本身没有交代如何恢复中断时未传输的音频。^3^
因此,后台保活与数据保存需要分别验收。前者争取接收机会,后者决定接收失败后还能找回什么。本文用 Android BLE 数据连接说明这个边界;iOS、系统耳机音频路由以及具体厂商省电策略,需要单独验证,不能套用同一组 API 结论。
连接恢复,只解决重新取得传输机会
作者建议把音频形成、设备保存、手机保存、上传和转写各自看作可观察事实。蓝牙重新连接,说明两端又有机会通信,不能推出此前没传出的片段已经补齐;网络恢复,说明手机又可以尝试上传,也不能推出所有云端结果已完成。UI若只用"已连接"表示录音安全,会把尚未形成副本的区间隐藏起来。
设想配件断连时继续采集,却没有本地持久保存,只在易失缓冲里保留少量数据。手机回来后即使重新连接,早期声音也可能已不在缓冲范围。这个构造例子不指向商业设备,只说明连接状态与保存能力之间的空档。产品若不承诺断连期间记录,可以明确停止并提示;若承诺继续记录,就要为尚未交付部分安排真正的保存责任。
"录音没有丢"和"用户可以得到总结"同样是不同结果。原始片段在设备上,云端暂时不可用,可以如实显示等待;转写完成而摘要失败,则保留音频与文字并说明后续任务。将它们分开,不是给用户展示内部实现,而是让其知道这段话究竟还在不在、接下来是在等待处理还是需要重新说。
先写下音频交给谁,再决定设备要做多重

若产品承诺"离开手机也能继续记下这一段",配件就需要承担一项不可转交给手机的工作:在无法交付时保留声音。这并不自动要求在配件上运行转写或大模型,却要求它有与承诺相匹配的录音、存储、掉电处理和容量反馈。
可以把一次交接设计成下面的过程。这是作者提出的实现方案,未验证于上述商业设备。
- 配件把录音切成有会话号和递增序号的片段;片段形成可恢复的本地记录后,才标成"设备已保存"。尚未封口的尾段如何处理,要单独规定。
- 手机收到片段后先持久保存,再向配件确认接收结果。确认应绑定片段标识、长度或校验值,避免把"收到蓝牙包"误当作"重启后还能读出来"。
- 配件根据约定的保留策略清理已确认片段;未确认部分仍在本地等待。若用户需要手机丢失后的额外恢复能力,清理条件还要更严格,这会增加存储占用。
- 手机恢复网络后提交云端处理。音频上传、转写完成、摘要生成分别记录,不能用一枚"同步成功"勾号替代所有状态。

例如,配件发完第 41 段,手机保存成功,但回执在断连时丢失。重连后配件可能再发第 41 段。手机应凭片段标识识别重复,确认已有副本,然后继续第 42 段。这个构造例子说明:只做重试还不够;如果接收方不能识别同一段,补传也可能造成重复录音或重复处理。
反过来,如果手机先确认、后保存,恰好在两步之间退出,而配件已经清理了源文件,两边就可能都没有完整副本。这也是构造失败轨迹,并非本文发现的某款产品缺陷。它给工程评审的具体问题是:确认发生在何时,源副本又在何时允许消失?
设备存储不是免费的保险。缓存写满时,是停止、覆盖最旧片段,还是提醒用户立即同步,需要产品先选定。对声称"重要对话不丢"的记录设备,静默覆盖会直接违背承诺;对只服务当前一次实时对话的按钮,提示连接断开并停止采集,可能就是合理边界。
最小例:接收、可恢复记录声明、确认与清理分开
下面仅用标准库检查构造交接记录,没有真实声音、BLE、磁盘或数据库。Segment 的身份是 session 加 seq,内容指纹为字节长度加 SHA-256;两段序号相同但会话不同,不能共用一枚确认。示例中的 payload 是明确标注的字节标签,不是录音。digest 用于核内容,不提供确认来源认证,真实系统仍需确认 ACK 来自约定接收方。
phone 字典只代表这份演示记录。evidence=True 是作者人为注入的"假设已取得恢复证据"事件,不会触发 fsync、文件关闭、事务提交或重启读取;MODEL_SOURCE_REMOVED 也只是从内存字典删一个条目,不能称为设备已擦除或已证明掉电安全。这样的小例能暴露确认条件次序,却不能实现所需的持久化协议。
保存为 examples/segment_handoff.py,Python 3.7 及以上只需标准库,从仓库根目录运行:
bash
python3 08-publishing-pipeline/generated-packages/PKG-20261001-0003-phone-voice-accessory/examples/segment_handoff.py
python
"""Constructed handoff records only: no audio, filesystem, fsync or power test."""
from dataclasses import dataclass, replace
from hashlib import sha256
@dataclass(frozen=True)
class Segment:
session: str
seq: int
payload: bytes
sealed: bool = True
@property
def key(self):
return self.session, self.seq
@property
def fingerprint(self):
return len(self.payload), sha256(self.payload).hexdigest()
def receive(phone, segment):
if not segment.sealed:
return "TAIL_UNSEALED"
if segment.key in phone:
return ("DUPLICATE" if phone[segment.key]["fingerprint"] == segment.fingerprint
else "CONTENT_CONFLICT")
phone[segment.key] = {"payload": segment.payload,
"fingerprint": segment.fingerprint, "evidence": False}
return "RECEIVED_ONLY"
def make_ack(phone, segment):
row = phone.get(segment.key)
if not row or not row["evidence"] or row["payload"] is None or not segment.sealed:
return None
actual = len(row["payload"]), sha256(row["payload"]).hexdigest()
if actual != row["fingerprint"] or actual != segment.fingerprint:
return None
return segment.key, actual
def clear_source(source, target, ack, allow_clear=True):
if ack is None:
return "WAIT_ACK"
if ack[0] != target:
return "ACK_ID_MISMATCH"
if target not in source:
return "ALREADY_ABSENT"
segment = source[target]
if not segment.sealed:
return "TAIL_NOT_CLEARABLE"
if ack[1] != segment.fingerprint:
return "ACK_CONTENT_MISMATCH"
if not allow_clear:
return "HELD_BY_POLICY"
del source[target]
return "MODEL_SOURCE_REMOVED"
def expect(label, actual, wanted):
assert actual == wanted, label
print(f"{label}: {actual}")
def main():
phone = {}
part = Segment("session-A", 41, b"constructed segment 41; not audio")
source = {part.key: part}
expect("receive", receive(phone, part), "RECEIVED_ONLY")
expect("before_evidence_ack", make_ack(phone, part), None)
expect("before_evidence_clear", clear_source(source, part.key, None), "WAIT_ACK")
# A declared fixture event, not storage I/O or measured recovery evidence.
phone[part.key]["evidence"] = True
ack = make_ack(phone, part)
assert ack == (part.key, part.fingerprint)
assert part.key in source # This first ACK is intentionally not delivered.
expect("lost_ack_retry", receive(phone, part), "DUPLICATE")
assert len(phone) == 1 and make_ack(phone, part) == ack
changed = replace(part, payload=b"other constructed contents")
expect("same_id_changed_contents", receive(phone, changed), "CONTENT_CONFLICT")
assert phone[part.key]["payload"] == part.payload
expect("retain_extra_copy", clear_source(source, part.key, ack, False), "HELD_BY_POLICY")
expect("ack_delivered", clear_source(source, part.key, ack), "MODEL_SOURCE_REMOVED")
expect("duplicate_ack", clear_source(source, part.key, ack), "ALREADY_ABSENT")
newer = replace(part, session="session-B", payload=b"new session same seq")
source[newer.key] = newer
expect("old_ack_new_session", clear_source(source, newer.key, ack), "ACK_ID_MISMATCH")
bad_ack = newer.key, part.fingerprint
expect("wrong_digest", clear_source(source, newer.key, bad_ack), "ACK_CONTENT_MISMATCH")
assert newer.key in source
phone[part.key]["payload"] = b"corrupted fixture payload"
expect("payload_digest_changed", make_ack(phone, part), None)
phone[part.key]["payload"] = None
expect("index_without_payload", make_ack(phone, part), None)
tail = Segment("session-A", 44, b"open tail", sealed=False)
source[tail.key] = tail
expect("open_tail_receive", receive(phone, tail), "TAIL_UNSEALED")
expect("open_tail_clear", clear_source(source, tail.key, (tail.key, tail.fingerprint)), "TAIL_NOT_CLEARABLE")
phone[part.key]["payload"] = part.payload
later = replace(part, seq=43, payload=b"constructed segment 43")
assert receive(phone, later) == "RECEIVED_ONLY"
phone[later.key]["evidence"] = True
expected = {41, 42, 43}
covered = {s.seq for s in (part, later) if make_ack(phone, s) is not None}
expect("declared_range_missing", sorted(expected - covered), [42])
assert tail.key not in phone and tail.key in source
print("covered=[41,43]; open_tail=44; unknown expected range cannot imply complete recovery")
print("self_check=handoff ordering/retry/conflict/session/policy/tail/gap assertions passed")
print("scope=constructed records; durable_storage/BLE/power_loss/audio=NOT_MEASURED")
if __name__ == "__main__":
main()
Python 3.11.6 / macOS arm64 的实际退出码为 0,stderr 为空,stdout 原样如下:
text
receive: RECEIVED_ONLY
before_evidence_ack: None
before_evidence_clear: WAIT_ACK
lost_ack_retry: DUPLICATE
same_id_changed_contents: CONTENT_CONFLICT
retain_extra_copy: HELD_BY_POLICY
ack_delivered: MODEL_SOURCE_REMOVED
duplicate_ack: ALREADY_ABSENT
old_ack_new_session: ACK_ID_MISMATCH
wrong_digest: ACK_CONTENT_MISMATCH
payload_digest_changed: None
index_without_payload: None
open_tail_receive: TAIL_UNSEALED
open_tail_clear: TAIL_NOT_CLEARABLE
declared_range_missing: [42]
covered=[41,43]; open_tail=44; unknown expected range cannot imply complete recovery
self_check=handoff ordering/retry/conflict/session/policy/tail/gap assertions passed
scope=constructed records; durable_storage/BLE/power_loss/audio=NOT_MEASURED
stdout 中的 None 表示 make_ack 没有返回确认记录,不是"已经确认但省略显示",也不是一次真实存储错误。receive 之后还没有构造 evidence 事件,不能生成 ACK,也不允许据此清源。之后第一枚 ACK 假设没有送达,source 仍在;lost_ack_retry 返回 DUPLICATE,phone 仍只有一个身份记录,再发的 ACK 绑定同会话、同序号、同长度与 digest。重复传输和重复音频记录是两件事,这也没有证明云端处理"恰好一次"。真实回执丢失演练须独立检查手机可读取的完整载荷,不能只看到重传次数就宣布交接正确。
same_id_changed_contents 保留原载荷并报冲突,不把新内容覆盖成"同段重试"。payload_digest_changed 和 index_without_payload 则分别表示载荷与记录指纹不符、索引存在而载荷缺失;即使 evidence 还是真,也不能生成 ACK。实际实现应检查载荷、索引、片段身份和保存完成条件的一致性,并在目标退出、重启或掉电边界重新验证;本例改变字典字段没有制造真实磁盘损坏或掉电,也没有通过这些存储验收。
old_ack_new_session 中 session-A 的 41 不能清掉 session-B 的 41;wrong_digest 中身份相同而内容指纹不同也会拒绝清理。ALREADY_ABSENT 仅表示本例源条目已不存在,不能当作对端还永久持有或整会话完整的保证。HELD_BY_POLICY 展示另一个条件:即使有相符 ACK,产品若要求额外恢复副本,仍可继续保留;具体容量、保留时长与手机丢失后的承诺需要另行设计。
open_tail_receive 不把未封口 44 填成完整片段,open_tail_clear 也拒绝按完整片段确认清理它。作者在这里选了"仅对已封口片段确认"的有限规则,并没有实现尾段落盘、部分恢复或继续录音策略。产品如果保不住尾段,必须说明恢复范围;前面几十段被确认,不能替刚说完的最后一句通过。
最后的 expected={41,42,43} 是构造的一个待核片段范围,覆盖 41 和 43 仍缺 42;它不是推断整个会话从 1 到 43 都已恢复。重连时可以交换两端持有范围与已知尾段,再以会话身份找缺口。乱序到达并不授权直接按接收时间拼接,序号不连续也不能凭空填静音。若连预期范围都不知道,应显示已找回的范围和未知项,不能让"已连接"代替"全部安全恢复"。
用户主动删除的意图与这些 ACK 清理条件不同:清掉配件交付副本不是用户要求所有副本删除,旧副本重试也不得悄悄恢复已删除内容。这个脚本没有删除意图传播、容量覆盖策略或真实保留期限,后续必须分别设计并测试。它交付的是几个可复跑的反例,帮助评审准确说出哪个确认条件还缺证据。
尾段、乱序与回执重发,是交接的不同边界
录音的最后一段往往尚未封口。作者建议单独定义停止录音、突然断电和连接中断时它如何落盘或标记不完整。已封口片段可恢复,并不能证明尾段也同样安全;如果产品只能保住已完成部分,就把这项范围写入验收。不能用前面几十段成功传输盖住用户刚说完的最后一句。
重连补传还可能与实时新音频同时发生。旧片段先后顺序与到达顺序未必相同,手机应依会话与片段身份重建记录,而不是仅按接收时间拼接。本文不规定必须串行或并行发送,具体选择取决于资源与延迟目标;但不管怎样发送,都需要能辨认缺失、重复和尾段状态,否则恢复连接后听起来可能有遗漏或次序变化。
清理策略还应与用户的恢复范围一致。手机已保存后删配件副本,可以释放空间,却也缩小手机丢失后的恢复能力;云端已有音频后再清理,可能保留更长时间,但增加存储压力。没有一种顺序免费覆盖所有故障。先声明承诺,再测试选择的顺序,不能把不同副本的存在一概称作永久保险。
容量满额时也要让状态可见。若停止采集,就及时告诉用户;若允许覆盖,就说明覆盖哪一范围并验证记录没有误指向已被覆盖数据。容量判断应来自编码与实际写入条件,而不是存储芯片标称大小。本稿不估算续航或可录小时数,仍将它们列为目标设备需要实测的约束。
用一次断连演练,验证这套分工

采购硬件或决定自研前,先确认测试参与者的录音授权与隐私处理;独立参考录音留在授权范围,公开报告只放脱敏片段身份与结构化结果。随后可以让原型先通过一段带序号的测试语音:每隔几秒读出新的编号,并保留独立参考录音。分别制造三种故障,不要一次全部关闭,否则很难判断缺口出在哪层。
先只关闭手机互联网,保持配件连接;再只让配件与手机断开;最后在确认目标进程状态的测试环境里终止接收进程。每次恢复后,把原始音频片段与参考录音对齐,记录缺失编号、重复编号、恢复耗时,以及各端最后确认的片段。转写漏字可能来自识别问题,不能仅凭文字稿判断音频是否丢失。
这些是待执行的验证步骤,本文没有硬件实跑数据。不同系统、配件固件、App 版本、实际安装构建与配置身份,以及测试日志和中断方式应分别记录;修订后绑定各自构建,不要求两轮二进制相同。划掉最近任务、系统回收进程、用户强行停止应用并不等价。测试还应覆盖录音尾段未完成、手机收到但回执未到、缓存接近满额等交接位置;容量承诺则要按所用编码格式与最大离线时长实测,不能凭"有存储芯片"直接通过。
结果最好落到一张具体记录里:故障条件、丢失或重复片段、当前持有者、恢复动作、用户看到的状态。若音频还在设备、云端暂时不可用,界面可以显示"设备已保存,等待同步";若采集已停止,就要让用户看到停止。这样的提示让等待与丢失可以被分辨,也让客服有证据可查。
演练记录应能分辨丢失、重复与处理失败
测试语音中的编号只是帮助对齐,不是唯一真值。保留独立参考录音,并核对原始音频的时间与内容,能区分编号被ASR识别错和片段实际缺失。假设原始音频里有第42号,文字稿没有,该问题先进入识别或文本处理;若两端原始音频都没有,就回到采集和交接。只看总结是否完整,会把这些故障混在一起。
每次只制造一个明确故障,并记录发生在交接哪一步。例如手机网络断开时配件仍连接,手机进程终止时网络保持,配件离开范围时手机进程仍在。若多项同时发生,可以作为综合场景,但不能再从单次结果解释每个环节贡献。原型先通过可定位的故障,再扩大组合条件,更容易知道下一处该修什么。
用户看到的提示也需要与这些事实核对。显示"设备已保存"时,断连再重启是否还能找到该段;显示"手机已接收"时,接收进程重启后是否可读取;显示"同步完成"时,究竟指上传还是所有处理已完成。测试不必把内部字段暴露给用户,但每句提示都应有实现证据支撑,防止界面比音频副本先一步宣布成功。
若一次演练存在缺口,不急着给架构整体贴失败标签。先指出哪份副本在哪个边界没有形成,收缩到可重做的一次交接;若当前硬件确实不能保存断连音频,则让产品承诺与能力一致,而不是增加云端算力来补蓝牙前的数据缺失。正确的任务拆分可以节省实现工作,也能让硬件选择直接对应那句"刚说完的话在哪里"。
恢复完成还应有明确的核对出口:这次会话预期有哪些片段,各端实际持有哪一些,哪些仍待补传,哪些因尾段不完整只能恢复部分。若无法确定完整范围,就不要显示整段已安全恢复。可以告诉用户已经找回哪一部分,并保留缺口,让后续同步继续围绕同一身份处理;这比用一个总勾号隐藏未知范围更可核查。
同样,用户主动删除音频与设备因容量策略清理不是一回事。作者方案需要让清理遵守已声明的副本责任,也让用户删除意图不会在重试时被旧副本悄悄恢复。具体删除与同步规则须由产品单独设计并测试,本文没有替商业设备声称实现。把保留、交接和用户动作的边界写清,才能使"不丢音频"的承诺与用户控制共同成立。
最终的选择会更清楚:只要求手机在身边发起短会话,可以让配件保持轻量,并清楚提示断连;要求离开手机后继续记录,就把本地保存与补传列为配件能力;要求立即得到回答,则还需另行评估网络和推理时延,不能让"稍后可找回录音"代替实时可用性。
手机加配件的架构评审,可以从一个很小的问题开始:此刻断开这条连接,刚说完的那句话在哪儿? 只有每一次交接都能回答,手机承担更多功能才会成为明确的分工。
来源与边界
原配图核对日期:2026-10-01;正文关键路径于 2026-10-13 复核。Plaud 页面展示日期与官方 API 更新元数据的差异已在首节区分,Omi 保持 2024 年历史功能范围。本文基于官方文档分析与作者方案,未对商业设备做断连、掉电或续航测试;未采用原研究包的架构样本占比、销量、成本和市场推断。
Footnotes
-
Plaud --- Transfer files,配图页面日期 2026-08-17;2026-10-13 核官方 API 元数据更新于 2026-10-08,正文仍支持设备先保存、随后传输的说明;不支持推定底层确认协议。 ↩
-
Omi --- Introducing Local Sync,发布 2024-10-17,历史实验性功能说明,仅用于解释手机缓存位置。 ↩
-
Android Developers --- Communicate in the background,页面更新 2026-02-26。适用 BLE 数据连接的后台通信限制,不是各平台录音保证。 ↩