小智触发 abort 后,为何仍会残留旧声音?

作者:袖梨 2026-09-12

用户打断正在播报的小智后,日志虽然已经出现 Abort speaking,扬声器却仍可能传出一小段旧声音。这个现象不能只归因于服务端取消延迟,因为取消请求、设备状态、解码队列和硬件输出各有独立时序。要确认责任边界,需要沿实际触发入口逐步检查音频从接收到播放的完整路径。

小智发出 abort 后,旧声音为什么还可能继续?

场景:小智(xiaozhi-esp32)设备被打断后仍听到一点旧声音。结论:仅凭 Abort speaking 日志分不清是服务端没取消还是客户端没停播;要按入口、模式、、ResetDecoder、generation、已取输出六步定位责任边界。产出:5 步排查时间线 + 16 行错误速查卡。 适用版本:78/xiaozhi-esp32,固定 commit 6240b777aaa2bc0cad43a4ce25b30de23f36ad00(核验日期 2026-09-10);ESP-IDF v6.0 / 6.0.1 主线。

TL;DR

  • 场景:用户触发小智打断,设备日志出现 Abort speaking,但仍听到一点旧声音,分不清是服务端未取消还是客户端未停播。
  • 结论:小智 Application::AbortSpeaking() 只设标志 + 发请求,不清队列、不切状态;后续是否进入、何时调用 ResetDecoder(),要按 Toggle / StartListening / 唤醒词三种入口分别看。ResetDecoder() 通过递增 playback_generation_ 挡住在途解码结果,但已从 PCM 队列取走的输出任务没有 generation 比较——这是旧声音能继续的真正候选。
  • 产出:5 步排查时间线(入口/模式 → 取消发送 → 启动 → 重置代次 → 到包/输出)、三种症状对应下一步、16 行错误速查卡。

版本矩阵

项目状态说明
78/xiaozhi-esp32 仓库存在✅ 已验证GitHub HTTP 200,29.5k stars
固定 commit 6240b777aaa2bc0cad43a4ce25b30de23f36ad00 可访问✅ 已验证main/audio/audio_service.cc 等 4 个源码文件 blob URL 均 HTTP 200
ESP-IDF 主线 v6.0 / v6.0.1✅ 已验证README 明确
AbortSpeaking 仅设标志 + 发请求✅ 已验证application.cc L1170-1176 源码(贴图忠实摘录)
OnIncomingAudio 按 Speaking 状态接收,不读 aborted_✅ 已验证application.cc L553-557 源码(贴图忠实摘录)
Toggle / StartListening / 唤醒词三个入口后续动作不同✅ 已验证文章引用 application.cc L775-921 / L990-1083 等源码段
GetDefaultListeningMode() 由 AEC 决定✅ 已验证AutoStop / Realtime 模式分支存在;Realtime 不走"关闭语音处理"分支
ResetDecoder() 递增 playback_generation_ 并清队列✅ 已验证audio_service.cc L687-706 引用;generation 比较在解码提交处
输出线程 L324-328 取任务 + L330-340 调用 OutputData✅ 已验证贴图忠实摘录该段;输出路径不再做 generation 比较
仅凭 Abort speaking 日志判定"本地已停播"❌ 已驳斥函数本身不清队列、不切状态;来音入口也不读 aborted_
仅凭"按了按钮"或"说了唤醒词"判定停播时序❌ 已驳斥三个入口在 AbortSpeaking 之后的本地动作不同
把另一个入口的 SetListeningMode() 移植解释当前入口❌ 已驳斥每个入口分支只证明当前路径,不能跨入口套用
generation 是所有旧音频的通用识别器❌ 已驳斥generation 只能挡住在途解码结果;晚到包若入队后保存的是新本地 generation
已取出 PCM 任务的输出路径也用 generation 挡住❌ 已驳斥L324-328 取任务 → 释放锁 → L340 OutputData 路径未做 generation 比较
Realtime 字段当作全双工验收结果❌ 已驳斥仅证明模式分支如何保留收音,不证明 AEC 效果或服务端话权
用客户端发送日志补出"服务端已取消"❌ 已驳斥没有服务端证据时应标明未知,不能用客户端证据倒推
设备事件与未校准的服务端时间戳直接相减❌ 已驳斥跨机未校准不能直接相减;同设备用同一单调时钟
配图"流程图"=有结果❌ 已驳斥流程图为方法示意,无设备/网络/打断延迟实测

设备正在说话,你触发了打断,日志里也出现了 Abort speaking。如果随后仍听见一点旧声音,应该先怀疑服务器没取消,还是客户端没停播?只凭这条日志,还分不清。

小智的一个实现细节能把问题拆开:Application::AbortSpeaking() 本身没有清空播放队列,也没有切换设备状态。它设置一个标志,并调用协议层发送取消消息。后面是否进入、何时重置解码器,要沿调用它的那条路径继续看。同一个函数名,并不代表每个入口都执行相同的本地停播动作。

本文核验 78/xiaozhi-esp32 固定提交 6240b777aaa2bc0cad43a4ce25b30de23f36ad00,日期为 2026-09-10。以下是官方客户端源码分析和条件时序推演,没有设备、网络、打断延迟或音质实测。"旧声音继续"是用来定位责任边界的假设情境,不是本次复现的故障。

一条 abort 日志,只证明走到了取消入口

application.cc 中,这个函数很短:

void Application::AbortSpeaking(AbortReason reason) {
    ESP_LOGI(TAG, "Abort speaking");
    aborted_ = true;
    if (protocol_) {
        protocol_->SendAbortSpeaking(reason);
    }
}

协议层的 SendAbortSpeaking() 随后构造包含 session_idtype: abort 的 JSON;唤醒词触发时还带上 reason: wake_word_detected,最后调用 SendText()。这里没有等待服务端取消完成的确认,也没有据返回结果决定下一步。因此,看到入口日志不能直接说"服务端已经停止生成",甚至还需要继续核对协议对象和实际发送结果。

aborted_ = true 也不能单独证明新音频会被丢掉。本文核对的来音回调是:

protocol_->OnIncomingAudio([this](std::unique_ptr<AudioStreamPacket> packet) {
    if (GetDeviceState() == kDeviceStateSpeaking) {
        audio_service_.PushPacketToDecodeQueue(std::move(packet));
    }
});

这个入口检查的是设备是否处于 Speaking,没有读取 aborted_。如果调用取消后状态仍然是 Speaking,后续到达这里的包仍可能进入解码入队流程;入队是否成功,还受队列容量和服务状态等条件约束。这不是在断言服务端一定还会发送,而是在说明:客户端这处判断没有因为刚才那条 abort 日志就自动关闭。

所以,排查的第一个动作是找到触发取消的具体事件。它比继续搜索"哪里有 abort"更有区分力。

图01

都调用 AbortSpeaking,接下来的状态却不同

固定版本里,下面三个事件都可能在 Speaking 状态调用取消,但函数内的后续动作不同。这里说的是应用事件处理器,不替具体开发板推断按钮绑定。

Speaking 时的入口调用取消后的本地动作排查时应继续找什么
HandleToggleChatEvent()这一分支只调用 AbortSpeaking()后续哪个事件改变状态,何时触发播放重置
HandleStartListeningEvent()接着设置 ManualStop 模式,并请求进入 ListeningListening 状态处理器是否需要启动语音处理
HandleWakeWordDetectedEvent()清理待发送包,设置提示音标志,再进入默认模式默认模式是什么,是否等待播放排空,重置何时发生

ManualStop 是手动结束的模式;AutoStop 是自动模式;Realtime 是实时模式。它们会影响后续路径。仅凭"按了按钮"或"又说了唤醒词",不能把整条调用链压缩成一个同义的"停"。

服务端 tts stop 消息也不是设备已经播放完的回执。客户端处理它时,如果当前仍在 Speaking,会根据模式转入 Idle 或 Listening。该消息参与应用状态变化,设备内部可能仍有待播放或正在输出的内容。上一章讨论过队列与在途工作;这里更要紧的是,这些剩余工作会影响什么时候重新开口收音。

例如,对 HandleToggleChatEvent() 的 Speaking 分支,源码只能证明它调用了取消入口。若要说明此后何时离开 Speaking,就必须把后续收到的消息和状态事件一起找出来,不能把另一个入口里的 SetListeningMode() 移植过来解释。

图02

进入 Listening,也不一定立刻重置旧播放

Listening 状态处理器先判断:是否需要播放提示音,或者音频处理器当前没有运行。只有进入这个外层分支后,才判断 AutoStop 模式是否还没播放排空。

如果是 AutoStop 且播放尚未排空,代码设置 pending_listening_start_,暂不调用 StartListeningAudio()。主循环收到播放排空事件后,还会重新检查当前仍在 Listening、确有待启动标志、播放仍为空闲,才启动。代码没有在这里阻塞主循环等待。

这个限定条件不能省略:并非每次切到 AutoStop 的 Listening 都必然等待。外层条件不成立时,代码走的是唤醒词配置分支;状态再次改变也会先使待启动标志失效。把模式名称直接写成固定停播时序,会漏掉这些实际分支。

这段等待还有一个容易被误判的用途。源码注释说明,它希望在自动模式下让剩余播放排空后再启用语音处理,避免 STOP 因网络抖动较晚到达时造成声音截断。因此,"继续播一点尾音"在这条路径里可能来自保留剩余播放的设计取舍,而不是取消请求失效的充分证据。实际是否走到它,需要事件记录验证。

真正到达 StartListeningAudio() 后,顺序是先发送开始消息,再调用 EnableVoiceProcessing(true);后者在音频引擎初始化成功后,才会调用 ResetDecoder(),随后启用语音处理。这也说明,发出了开始消息和本地重置已经执行,不是同一个观测点。

AEC 模式会改变这段路径的背景。GetDefaultListeningMode() 在 AEC 关闭时选择 AutoStop,否则选择 Realtime;进入 Speaking 时,非 Realtime 模式会关闭语音处理,而 Realtime 不执行这一关闭分支。源码能证明模式分支如何保留收音处理,不能证明实际回声消除效果、服务端话权判断或自然插话体验。本文不把一个 realtime 字段当作全双工验收结果。

图03

generation 能挡住哪一份旧结果

即使已经调用了 ResetDecoder(),也还要问:旧任务当时在哪里?

重置函数会递增 playback_generation_,重置 Opus 解码器,并清理接收解码队列、PCM 播放队列等本地积压。与此同时,解码任务取出一个包时,会保存当时的 generation;解码完成、准备把 PCM 放回播放队列时,再比较保存值与当前值。

这可以处理一个具体竞态。假设下面的先后关系成立:

  1. 解码线程取走旧包 A,保存 generation 为 7,离开队列锁进行解码。
  2. 另一条路径执行重置,generation 变成 8,并清空等待中的队列。
  3. A 完成解码,重新取得队列锁,准备提交 PCM。
  4. 比较发现 7 与 8 不同,A 的结果不会放进播放队列。

这里的 7、8 是作者示意值,不是抓到的设备日志。关键证据是提交条件:解码成功、generation 相同、服务未停止,三者同时成立才允许入播放队列。它解决的是"清空队列时,还有一份旧解码结果正在外面计算"的问题。只清队列而不检查在途结果,会漏掉这一类旧结果回流。

但 generation 不是所有旧音频的通用识别器。一个包如果在重置之后才从网络抵达,而且届时仍通过应用状态检查并成功入队,后续取包解码时保存的会是新的本地 generation。仅靠这次比较,无法判断它在服务端语义上属于上一轮回答。

这不等于整个工程完全没有身份字段。AudioStreamPacket 中还有 playback_id、媒体位置等字段;取消消息也带有 session_id。更窄、更准确的结论是:本文核对的应用来音入口按 Speaking 状态接收,而上述 generation 比较保护的是本地解码重置边界。若要断言晚到包不会串入下一轮,还需要继续核对传输赋值、业务轮次身份和服务端取消实现,不能让这个本地计数承担它没有证明的职责。

还有一种旧任务更靠后:音频输出线程已经从 PCM 队列取出了任务。该线程设置 output_in_flight_,释放队列锁,随后调用 codec_->OutputData(task->pcm);这条已取出任务的输出路径没有再做 generation 比较。此时清空队列,不能从队列里拿回已经被取走的那块 PCM。

这只能证明调用边界。最终还有多少声音能被听到,要看任务进度、codec、驱动及硬件缓冲,本文没有测量,也没有据此给出一个固定"残留毫秒数"。相关播放进度回调位于 OutputData() 之前,同样不能直接当作扬声器已经播到那个位置的确认。

图04

图05

把"打断慢"还原为一段可核对的时间线

沿着前面的路径,调试目标就清楚了:先定位这次取消在哪个阶段停下,再决定改服务器、应用状态还是本地播放。下面是一份待实现的采集建议,不是仓库已经具备的完整坚控。

同一次打断应记录的事件它能够回答的问题
触发入口、当时设备状态、模式本次应沿 Toggle、StartListening 还是唤醒词路径解释
abort 消息发送结果,以及服务端取消处理证据请求是否送出,服务端是否实际处理;两者分开取证
Listening 状态处理、待启动标志、StartListeningAudio()是否在等排空,还是已经准备启动语音处理
ResetDecoder() 与 generation 变化本地旧队列何时清理,在途解码提交是否被拒绝
音频包到达、入队,以及输出取任务和 OutputData()剩余内容来自晚到包、在途解码,还是已取出的输出任务
同条件下的实际音频记录软件动作之后,听到的尾音、误截断和新一轮收音表现如何

这些记录应带上可关联的会话信息;设备内事件使用同一单调时钟,不能把未校准的设备与服务端时间戳直接相减。没有服务端证据时,先标明未知,别从客户端的发送日志补出"取消已完成"。

如果重置根本没执行,就回到入口和模式分支;如果重置执行了,旧解码结果也被拒绝,却仍有尾音,就继续检查已经取出的输出任务及实际播放;如果新状态下又接收到了上一轮内容,则需要追踪包的业务归属,而不是继续增减本地 generation。每一种现象对应的下一步不同。

小智这段实现值得借鉴的,是取消被拆在不同责任处:协议层表达停止意图,应用状态决定收音与播放转换,本地 generation 防止跨重置的解码结果回流。调试时沿同一次打断把它们连起来,才能判断"该停的旧声音"究竟停在哪一步。

图06

来源

  • Application:事件入口、TTS 消息与模式:L553–557、L623–638、L775–921、L990–1083、L1170–1185。
  • Protocol:取消与开始消息:L70–102。
  • AudioService:输出、解码提交和重置:L314–340、L380–437、L687–706、L776–801。
  • Protocol 类型定义:音频包字段与模式。

错误速查卡

症状根因定位修复
看到 Abort speaking 日志就判定"本地已停播"AbortSpeaking() 本身不清队列、不切状态看 application.cc L1170–1176 实际函数体继续核对入口与状态变化
aborted_ = true 之后仍能入队新包OnIncomingAudio 检查的是 Speaking 状态看 L553–557 入口判断条件状态切走才能拒绝新包;不能只信标志
仅凭"按了按钮"或"说了唤醒词"判定停播时序三个入口后续动作不同看 Toggle / StartListening / 唤醒词三个分支按入口分别解释,不能跨入口套用
把另一个入口的 SetListeningMode() 用来解释当前入口每个入口只证明当前路径看具体 HandleXxxEvent 的整段逻辑不要跨入口借用后续动作
切到 AutoStop 的 Listening 后立即开始收音还有 pending_listening_start_ 等待播放排空看 L1027、L1032-1039 的外层/内层条件按外层条件是否成立判断是否真在等
模式名称当停播时序模式只决定分支选择GetDefaultListeningMode() 选择结果不要把 AutoStop / Realtime 当作固定时序
以为 generation 能挡所有旧音频generation 只挡在途解码提交看解码提交处保存 generation 与提交比较晚到包、新入队包、走输出路径的旧 PCM 都不被它挡
已取出 PCM 任务仍播放,被 generation 挡住输出路径 L324-340 不做 generation 比较看 L324-340 取出任务与 OutputData调"清队列"无法收回已取出的 PCM
晚到包被新 generation 保存并播放来音入口只查 Speaking 状态看 L553-557 + 解码取包保存 generation还要继续核对 playback_id / 业务轮次身份
用客户端发送日志补出"服务端已取消"没有服务端证据在服务器侧取证据没有证据就标未知
设备事件与未校准的服务端时间戳直接相减跨机未校准在同设备用同一单调时钟记录跨机时间不能直接相减
realtime 字段当全双工验收仅证明模式分支,不证明 AEC 效果GetDefaultListeningMode() 与 Speaking 进入分支不能用一个字段当验收
重置未执行却归咎于服务端入口 / 模式分支未到 ResetDecoderStartListeningAudio 内部顺序回到入口和模式分支排查
重置执行后仍有尾音输出任务已取出在途看 L324-340 输出路径继续看实际播放与 codec / 驱动
新状态又接到上一轮内容本地计数无法判断服务端语义归属playback_id、服务端取消实现不要靠增减 generation 解决
配图流程图当测量结果流程图为方法示意看配图角标"作者示意 / 推演"数据来自实际采集,配图只是方法

作者:武子康的个人博客 发布日期:2026-09-12(核验 commit 日期 2026-09-10) 核查依据:78/xiaozhi-esp32 仓库固定 commit 6240b777aaa2bc0cad43a4ce25b30de23f36ad00(HTTP 200),application.cc / protocol.cc / audio_service.cc / protocol.h 4 个文件 blob 全部可访问;ESP-IDF v6.0 / v6.0.1 主线;仓库 star 数 29.5k,最新 main 提交 2026-08-30。本文无设备、网络、打断延迟或音质实测,所有"残留毫秒数"属于待自测。

相关文章

精彩推荐