设备正在说话,你触发了打断,日志里也出现了 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_id 和 type: 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”更有区分力。

都调用 AbortSpeaking,接下来的状态却不同
固定版本里,下面三个事件都可能在 Speaking 状态调用取消,但函数内的后续动作不同。这里说的是应用事件处理器,不替具体开发板推断按钮绑定。
| Speaking 时的入口 | 调用取消后的本地动作 | 排查时应继续找什么 |
|---|---|---|
HandleToggleChatEvent() | 这一分支只调用 AbortSpeaking() | 后续哪个事件改变状态,何时触发播放重置 |
HandleStartListeningEvent() | 接着设置 ManualStop 监听模式,并请求进入 Listening | Listening 状态处理器是否需要启动语音处理 |
HandleWakeWordDetectedEvent() | 清理待发送包,设置提示音标志,再进入默认监听模式 | 默认模式是什么,是否等待播放排空,重置何时发生 |
ManualStop 是手动结束监听的模式;AutoStop 是自动模式;Realtime 是实时模式。它们会影响后续路径。仅凭“按了按钮”或“又说了唤醒词”,不能把整条调用链压缩成一个同义的“停”。
服务端 tts stop 消息也不是设备已经播放完的回执。客户端处理它时,如果当前仍在 Speaking,会根据监听模式转入 Idle 或 Listening。该消息参与应用状态变化,设备内部可能仍有待播放或正在输出的内容。上一章讨论过队列与在途工作;这里更要紧的是,这些剩余工作会影响什么时候重新开口收音。
例如,对 HandleToggleChatEvent() 的 Speaking 分支,源码只能证明它调用了取消入口。若要说明此后何时离开 Speaking,就必须把后续收到的消息和状态事件一起找出来,不能把另一个入口里的 SetListeningMode() 移植过来解释。

进入 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 字段当作全双工验收结果。

generation 能挡住哪一份旧结果
即使已经调用了 ResetDecoder(),也还要问:旧任务当时在哪里?
重置函数会递增 playback_generation_,重置 Opus 解码器,并清理接收解码队列、PCM 播放队列等本地积压。与此同时,解码任务取出一个包时,会保存当时的 generation;解码完成、准备把 PCM 放回播放队列时,再比较保存值与当前值。
这可以处理一个具体竞态。假设下面的先后关系成立:
- 解码线程取走旧包 A,保存 generation 为 7,离开队列锁进行解码。
- 另一条路径执行重置,generation 变成 8,并清空等待中的队列。
- A 完成解码,重新取得队列锁,准备提交 PCM。
- 比较发现 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() 之前,同样不能直接当作扬声器已经播到那个位置的确认。

把“打断慢”还原为一段可核对的时间线
沿着前面的路径,调试目标就清楚了:先定位这次取消在哪个阶段停下,再决定改服务器、应用状态还是本地播放。下面是一份待实现的采集建议,不是仓库已经具备的完整监控。
| 同一次打断应记录的事件 | 它能够回答的问题 |
|---|---|
| 触发入口、当时设备状态、监听模式 | 本次应沿 Toggle、StartListening 还是唤醒词路径解释 |
| abort 消息发送结果,以及服务端取消处理证据 | 请求是否送出,服务端是否实际处理;两者分开取证 |
Listening 状态处理、待启动标志、StartListeningAudio() | 是否在等排空,还是已经准备启动语音处理 |
ResetDecoder() 与 generation 变化 | 本地旧队列何时清理,在途解码提交是否被拒绝 |
音频包到达、入队,以及输出取任务和 OutputData() | 剩余内容来自晚到包、在途解码,还是已取出的输出任务 |
| 同条件下的实际音频记录 | 软件动作之后,听到的尾音、误截断和新一轮收音表现如何 |
这些记录应带上可关联的会话信息;设备内事件使用同一单调时钟,不能把未校准的设备与服务端时间戳直接相减。没有服务端证据时,先标明未知,别从客户端的发送日志补出“取消已完成”。
如果重置根本没执行,就回到入口和模式分支;如果重置执行了,旧解码结果也被拒绝,却仍有尾音,就继续检查已经取出的输出任务及实际播放;如果新状态下又接收到了上一轮内容,则需要追踪包的业务归属,而不是继续增减本地 generation。每一种现象对应的下一步不同。
小智这段实现值得借鉴的,是取消被拆在不同责任处:协议层表达停止意图,应用状态决定收音与播放转换,本地 generation 防止跨重置的解码结果回流。调试时沿同一次打断把它们连起来,才能判断“该停的旧声音”究竟停在哪一步。

来源
- 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 类型定义:音频包字段与监听模式。