# 网络异常语音反馈与业务快速失败改造说明 > 本文中的提交、构建容量和测试结果是历史记录;资源路径已更新为现行应用内嵌素材名称。现行添加流程见 [应用内嵌素材使用与添加指南](应用内嵌素材使用与添加指南.md)。 ## 1. 提交概况 ```text 提交:ceb07804254a257b17db291c8198567f5d265c04 标题:feat(control-plane): add network outage voice feedback 日期:2026-08-25 ``` 本次提交为设备增加网络异常语音反馈,并调整断网时的对话和云端抽签行为。改造后的核心原则是: 1. 被动断网时只提示一次,避免网络抖动造成连续播报。 2. 用户在网络不可用时发起对话或抽签,立即得到语音反馈,不再无提示等待。 3. Wi-Fi 恢复不等于业务恢复,只有服务器连接恢复后才播放业务恢复提示。 4. 保留后台自动重连、Wi-Fi 多凭据遍历以及空闲 WebSocket 唤醒机制。 ## 2. 问题原因 ### 2.1 Wi-Fi 断开后只有视觉反馈 原有运行时能够检测 Wi-Fi STA 断开,并在断开持续超过 3 秒后显示 Wi-Fi 断开表情、停止旧多模态客户端并启动重连。 但该路径没有接入提示音。用户没有看屏幕时,无法知道设备已经断网以及设备正在自动修复网络。 ### 2.2 离线对话会进入无效等待 原有对话入口会先进入聆听状态,再等待 Wi-Fi 和 WebSocket 可用。在点击对话模式下,整个捕获窗口约为 8 秒;如果网络始终不可用,操作最终结束,但没有明确的“网络不可用”语音反馈。 用户感知是:设备似乎开始聆听,但没有回复,也不知道失败原因。 ### 2.3 离线抽签会排队后静默失败 原有云端抽签请求可以在服务器未连接时先写入待处理状态,然后等待多模态会话建立。准备阶段最多等待约 10 秒,超时后清除请求。 如果设备已经明确断网,继续排队没有实际价值,而且请求被清除时没有用户可感知的反馈。 ### 2.4 Wi-Fi 断开和服务器断开没有统一反馈状态 项目中存在以下不同状态: - Wi-Fi 与路由器断开; - Wi-Fi 已关联,但公网、DNS、HTTPS 或服务器不可用; - WebSocket 空闲超时后主动关闭; - WebSocket 异常断开; - Wi-Fi 已恢复,但服务器尚未完成重新鉴权; - Wi-Fi 与服务器均已恢复。 原有代码分别处理这些状态,但没有统一记录“是否发生过故障、是否已经播报、是否允许播放恢复提示”。因此直接在多个回调中增加提示音,容易出现重复播报、恢复音过早播放或把正常节能关闭误判为故障。 ### 2.5 提示音播放是同步阻塞操作 本地 Opus 提示音接口会读取文件、提交音频帧并等待播放完成。如果直接在 WebSocket 回调中播放,会阻塞网络事件处理线程,扩大断线期间的并发风险。 因此服务器回调只能记录状态,提示音需要由现有运行时任务在安全上下文中消费。 ## 3. 解决方式 ### 3.1 增加统一网络反馈状态机 新增控制层网络反馈模块,集中维护网络服务状态、待播放提示、故障是否已经播报、提示冷却时间和服务器断开时间。 ```c typedef enum { NETWORK_FEEDBACK_STATE_BOOTING = 0, NETWORK_FEEDBACK_STATE_READY, NETWORK_FEEDBACK_STATE_WIFI_DOWN, NETWORK_FEEDBACK_STATE_SERVER_DOWN, } network_feedback_state_t; typedef enum { NETWORK_FEEDBACK_PROMPT_NONE = 0, NETWORK_FEEDBACK_PROMPT_WIFI_INTERRUPTED, NETWORK_FEEDBACK_PROMPT_SERVER_INTERRUPTED, NETWORK_FEEDBACK_PROMPT_RESTORED, } network_feedback_prompt_t; typedef struct { network_feedback_state_t state; network_feedback_prompt_t pending_prompt; bool service_ever_ready; bool outage_active; bool outage_announced; bool passive_prompt_attempted; bool prompt_in_progress; int64_t server_down_since_us; int64_t last_fault_prompt_us; } network_feedback_runtime_t; static network_feedback_runtime_t s_feedback; static portMUX_TYPE s_feedback_lock = portMUX_INITIALIZER_UNLOCKED; ``` 状态由自旋锁保护,可以安全地在 Wi-Fi 监控任务、按键任务和多模态回调之间共享。模块没有增加新的后台任务,而是复用现有 Wi-Fi 状态监控任务定期处理待播放提示。 ### 3.2 复用现有音频资源 本次没有新增录音文件,直接复用应用镜像中已有的五个 Opus 资源: ```c #define CONTROL_PLANE_WIFI_INTERRUPTED_PROMPT_FILE \ "audio_prompts/network_interrupted_repairing.opus" #define CONTROL_PLANE_WIFI_ACTION_BLOCKED_PROMPT_FILE \ "audio_prompts/network_unstable.opus" #define CONTROL_PLANE_SERVER_INTERRUPTED_PROMPT_FILE \ "audio_prompts/server_abnormal_interrupted.opus" #define CONTROL_PLANE_SERVER_ACTION_BLOCKED_PROMPT_FILE \ "audio_prompts/server_repair_wait_reopen.opus" #define CONTROL_PLANE_NETWORK_RESTORED_PROMPT_FILE \ "audio_prompts/signal_restored_business_resume.opus" ``` 对应关系如下: | 场景 | 提示音资源 | | --- | --- | | Wi-Fi 持续断开 | `network_interrupted_repairing.opus` | | 用户在 Wi-Fi 断开时操作 | `network_unstable.opus` | | 服务器持续断开 | `server_abnormal_interrupted.opus` | | 用户在服务器断开时操作 | `server_repair_wait_reopen.opus` | | 网络与服务器均恢复 | `signal_restored_business_resume.opus` | ### 3.3 Wi-Fi 持续断开时播放一次提示 Wi-Fi 原有 3 秒断开保护期保持不变。只有断开持续超过保护期、准备停止旧业务客户端时,才将反馈状态标记为 `WIFI_DOWN`。 ```c if (!disconnected_expression_active) { ESP_LOGI(TAG, "Wi-Fi STA disconnect persisted; stopping multimodal client"); control_plane_network_feedback_mark_wifi_down(); (void)control_plane_stop_multimodal_client(); control_plane_stop_user_config_server(); control_plane_network_feedback_service(); } ``` 标记 Wi-Fi 断开时,只在本次故障尚未播报的情况下生成一个待播放提示: ```c void control_plane_network_feedback_mark_wifi_down(void) { portENTER_CRITICAL(&s_feedback_lock); control_plane_network_feedback_begin_outage_locked(); s_feedback.state = NETWORK_FEEDBACK_STATE_WIFI_DOWN; s_feedback.server_down_since_us = 0; if (!s_feedback.outage_announced) { s_feedback.pending_prompt = NETWORK_FEEDBACK_PROMPT_WIFI_INTERRUPTED; s_feedback.passive_prompt_attempted = true; } portEXIT_CRITICAL(&s_feedback_lock); } ``` Wi-Fi 断开表情仍可按原逻辑刷新,但提示音不会随表情刷新重复播放。 ### 3.4 服务器异常保留 3 秒自恢复时间 WebSocket 连接成功、断开和错误事件接入统一反馈状态: ```c switch (event) { case CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_CONNECTED: control_plane_network_feedback_mark_service_ready(); control_plane_schedule_server_success_prompt(); break; case CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_DISCONNECTED: control_plane_network_feedback_mark_server_down(); control_plane_clear_downstream_stall_recovery(); control_plane_clear_cloud_chouqian_pending("transport disconnected"); control_plane_multimodal_stop_uplink(); (void)control_plane_schedule_websocket_wait_task(); break; case CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_ERROR: control_plane_network_feedback_mark_server_down(); control_plane_clear_downstream_stall_recovery(); control_plane_clear_cloud_chouqian_pending("transport error"); control_plane_multimodal_stop_uplink(); (void)control_plane_schedule_websocket_wait_task(); break; } ``` 服务器断开不会立即播报,而是记录断开时间。超过 3 秒仍未恢复时,才安排服务器异常提示: ```c #define CONTROL_PLANE_SERVER_DISCONNECT_GRACE_MS 3000U if (s_feedback.state == NETWORK_FEEDBACK_STATE_SERVER_DOWN && s_feedback.service_ever_ready && s_feedback.outage_active && !s_feedback.outage_announced && !s_feedback.passive_prompt_attempted && s_feedback.server_down_since_us > 0 && now_us - s_feedback.server_down_since_us >= (int64_t)CONTROL_PLANE_SERVER_DISCONNECT_GRACE_MS * 1000LL) { s_feedback.pending_prompt = NETWORK_FEEDBACK_PROMPT_SERVER_INTERRUPTED; s_feedback.passive_prompt_attempted = true; } ``` 这样可以过滤短暂 WebSocket 抖动,避免服务器在几秒内自行恢复时仍播放异常提示。 ### 3.5 对话和抽签统一执行网络可用性检查 新增两类需要网络的用户操作: ```c typedef enum { CONTROL_PLANE_NETWORK_ACTION_CONVERSATION = 0, CONTROL_PLANE_NETWORK_ACTION_CLOUD_CHOUQIAN, } control_plane_network_action_t; ``` 对话入口在显示聆听表情和创建语音会话之前检查网络: ```c control_plane_local_wake_pause_for_foreground(); if (control_plane_network_feedback_require_ready( CONTROL_PLANE_NETWORK_ACTION_CONVERSATION) != ESP_OK) { ESP_LOGI(TAG, "PTT activation rejected while network service is unavailable"); control_plane_local_wake_resume_after_foreground(); return; } ``` 云端抽签入口在写入待处理请求之前检查网络: ```c esp_err_t network_err = control_plane_network_feedback_require_ready( CONTROL_PLANE_NETWORK_ACTION_CLOUD_CHOUQIAN); if (network_err != ESP_OK) { return network_err; } ``` 统一检查函数根据故障层级选择提示音,并决定触发 Wi-Fi 重连还是服务器重连: ```c bool wifi_connected = control_plane_is_sta_connected(); bool server_connected = wifi_connected && control_plane_multimodal_is_connected(); if (server_connected) { return ESP_OK; } portENTER_CRITICAL(&s_feedback_lock); bool confirmed_outage = s_feedback.state == NETWORK_FEEDBACK_STATE_WIFI_DOWN || s_feedback.state == NETWORK_FEEDBACK_STATE_SERVER_DOWN; portEXIT_CRITICAL(&s_feedback_lock); if (wifi_connected && !confirmed_outage) { return ESP_OK; } if (!wifi_connected) { prompt_path = CONTROL_PLANE_WIFI_ACTION_BLOCKED_PROMPT_FILE; } else { prompt_path = CONTROL_PLANE_SERVER_ACTION_BLOCKED_PROMPT_FILE; } if (should_play) { (void)control_plane_network_feedback_play(prompt_path, control_plane_network_action_name(action)); } if (wifi_connected) { (void)control_plane_schedule_websocket_wait_task(); } else { control_plane_request_wifi_reconnect_now(); } return ESP_ERR_INVALID_STATE; ``` 本次操作会明确结束,不会继续排队。用户需要在网络恢复后重新发起对话或抽签。 ### 3.6 用户操作可加速 Wi-Fi 重连 新增一个受锁保护的立即重连请求标志。离线操作提示播放完成后,唤醒 Wi-Fi 状态任务: ```c void control_plane_request_wifi_reconnect_now(void) { portENTER_CRITICAL(&s_runtime_lock); if (!s_runtime_stop_requested) { s_wifi_reconnect_now_requested = true; } portEXIT_CRITICAL(&s_runtime_lock); control_plane_notify_wifi_state_task(); } ``` Wi-Fi 状态任务消费该标志,并允许本轮跳过剩余保护期或退避等待: ```c bool reconnect_now_requested = control_plane_consume_wifi_reconnect_now_request(); bool disconnect_grace_elapsed = reconnect_now_requested || ptt_foreground_disconnect || disconnected_expression_active || (now_tick - disconnect_started_tick) >= disconnect_grace_ticks; bool reconnect_due = ptt_foreground || reconnect_now_requested || !reconnect_attempted || (now_tick - last_reconnect_attempt_tick) >= reconnect_delay_ticks; ``` 这里的“立即”表示立即唤醒并允许开始下一轮连接,不代表 Wi-Fi 可以瞬间恢复。扫描、认证、关联和 DHCP 仍受原有 20 秒连接周期约束。 ### 3.7 增加 5 秒提示冷却和播放互斥 所有网络故障提示共享 5 秒冷却时间,并通过 `prompt_in_progress` 防止并发播放: ```c #define CONTROL_PLANE_NETWORK_PROMPT_COOLDOWN_MS 5000U static bool control_plane_network_feedback_begin_prompt_locked(int64_t now_us) { if (s_feedback.prompt_in_progress) { return false; } if (s_feedback.last_fault_prompt_us > 0 && now_us - s_feedback.last_fault_prompt_us < (int64_t)CONTROL_PLANE_NETWORK_PROMPT_COOLDOWN_MS * 1000LL) { return false; } s_feedback.prompt_in_progress = true; s_feedback.last_fault_prompt_us = now_us; s_feedback.outage_announced = true; return true; } ``` 用户连续点击对话和抽签时,第一次操作可以播放提示,冷却期内后续操作仍会被快速拒绝和触发恢复,但不会重复播音。 ### 3.8 只有业务真正恢复后才播放恢复提示 Wi-Fi 获得 IP 后,只把状态从 `WIFI_DOWN` 转换为 `SERVER_DOWN`,不会立刻播放恢复音: ```c void control_plane_network_feedback_mark_wifi_recovered(void) { int64_t now_us = control_plane_network_feedback_now_us(); portENTER_CRITICAL(&s_feedback_lock); if (s_feedback.state == NETWORK_FEEDBACK_STATE_WIFI_DOWN) { s_feedback.state = NETWORK_FEEDBACK_STATE_SERVER_DOWN; s_feedback.server_down_since_us = now_us; s_feedback.pending_prompt = NETWORK_FEEDBACK_PROMPT_NONE; } portEXIT_CRITICAL(&s_feedback_lock); } ``` 只有 WebSocket 再次连接成功,并且设备此前确实完成过服务器连接、当前故障也已经对用户播报,才安排业务恢复提示: ```c void control_plane_network_feedback_mark_service_ready(void) { portENTER_CRITICAL(&s_feedback_lock); bool should_announce_recovery = s_feedback.service_ever_ready && s_feedback.outage_active && s_feedback.outage_announced; s_feedback.service_ever_ready = true; s_feedback.state = NETWORK_FEEDBACK_STATE_READY; s_feedback.outage_active = false; s_feedback.passive_prompt_attempted = false; s_feedback.server_down_since_us = 0; s_feedback.pending_prompt = should_announce_recovery ? NETWORK_FEEDBACK_PROMPT_RESTORED : NETWORK_FEEDBACK_PROMPT_NONE; portEXIT_CRITICAL(&s_feedback_lock); } ``` 开机首次联网不会播放“业务恢复”,仍使用原有服务器连接成功提示。 ### 3.9 保留空闲 WebSocket 唤醒行为 设备会在多模态连接空闲约 30 秒后主动关闭 WebSocket以节省资源。这属于正常状态,不是服务器故障。 网络检查只在状态机已经确认 `WIFI_DOWN` 或 `SERVER_DOWN` 时快速拒绝操作。如果 Wi-Fi 已连接、WebSocket 只是正常空闲关闭,则允许原有对话流程继续,并由首次操作唤醒服务器连接: ```c if (wifi_connected && !confirmed_outage) { return ESP_OK; } ``` 该判断避免把正常节能行为误判为服务器异常。 ### 3.10 OTA 和 BLE 配网期间抑制被动网络提示 网络反馈服务在 OTA 不允许语音服务或小程序 BLE 配网运行时直接返回: ```c if (!control_plane_ota_voice_services_allowed() || control_plane_is_mini_program_provisioning_running()) { return; } ``` 这样可以避免网络提示音打断系统升级、配网引导和相应的专用提示音。 ## 4. 解决后的完整体验 ### 4.1 Wi-Fi 真正断开 ```text Wi-Fi 链路断开 -> 等待原有 3 秒保护期 -> 3 秒内恢复:不显示断网状态,不播放提示 -> 超过 3 秒:停止旧业务连接并显示断网表情 -> 播放一次“网络中断,正在修复” -> 遍历可见的已保存 Wi-Fi 并自动重连 ``` ### 4.2 用户在 Wi-Fi 断开时操作 ```text 用户发起对话或云端抽签 -> 播放“网络不稳定”提示,5 秒内不重复播放 -> 不进入聆听或抽签排队状态 -> 本次操作立即结束 -> 唤醒 Wi-Fi 状态任务并加速下一轮重连 ``` ### 4.3 Wi-Fi 正常但服务器异常 ```text WebSocket 断开或服务器连接失败 -> 记录服务器异常并重新鉴权 -> 3 秒内恢复:不播放异常提示 -> 超过 3 秒:播放一次服务器异常提示 -> 用户继续操作:播放服务器修复提示并结束本次操作 -> 立即安排新的服务器连接 ``` ### 4.4 网络恢复 ```text Wi-Fi 获得 IP -> 恢复局域网状态,但暂不播放业务恢复提示 -> 重新鉴权并建立 WebSocket -> WebSocket 连接成功 -> 若本次故障此前已经播报,则播放一次“信号恢复,业务恢复” -> 用户可以重新发起对话或抽签 ``` ## 5. 解决成果 ### 5.1 用户体验成果 - 持续断网不再只有屏幕表情,用户可以通过语音获知设备正在修复网络。 - 离线对话不再先进入假聆听状态后静默结束。 - 离线云端抽签不再排队约 10 秒后静默失败。 - Wi-Fi 故障和服务器故障使用不同语音,用户能区分问题层级。 - 连续点击不会重复播音,避免提示音轰炸。 - Wi-Fi 恢复后不会提前宣布业务恢复,恢复提示以 WebSocket 真正可用为准。 - 正常的空闲 WebSocket 关闭仍能由首次业务操作唤醒,不受快速失败策略影响。 ### 5.2 工程成果 - 网络反馈策略集中在控制层单一模块中,没有把状态分散到按键、Wi-Fi 和 WebSocket 实现。 - 多任务共享状态由自旋锁保护。 - WebSocket 回调只更新状态,不执行同步音频播放。 - 没有新增长期运行任务、定时器或跨层依赖。 - 没有新增音频资产,直接复用已打包资源。 - 原有 Wi-Fi 多凭据、20 秒连接周期、指数退避、BLE 重新配网和服务器鉴权机制保持不变。 ### 5.3 构建验证 使用 ESP-IDF 5.5.4 完成完整固件构建: ```text Project build complete TQ_Cat_Code.bin binary size 0x217d50 bytes Smallest app partition: 0x380000 bytes Free space: 0x1682b0 bytes(40%) ``` 构建阶段确认: - 新增网络反馈模块编译通过; - 固件链接通过; - 五个提示音均进入应用镜像中的素材包; - 应用分区容量检查通过; - Markdown 格式和空白错误检查通过。 ## 6. 后续影响与限制 ### 6.1 提示音会推迟重连开始时间 提示音接口会同步等待播放完成。Wi-Fi 被动断开和用户主动离线操作路径会先完成提示播放,再继续或加速重连。 这样可以避免提示音与多模态会话销毁、音频初始化同时运行,但相应重连会比完全静默时晚一个提示音时长开始。该延迟不计入 Wi-Fi 单轮 20 秒连接预算。 ### 6.2 离线操作不会自动补执行 断网时发起的对话和云端抽签会立即结束,不会在网络恢复后自动执行。 这是有意设计:自动补执行可能在几十秒后突然触发已经失去上下文的抽签;对话音频也无法在长时间断网后可靠重放。恢复后需要用户重新操作。 ### 6.3 所有网络故障提示共享冷却时间 Wi-Fi 异常、服务器异常、离线对话和离线抽签共用 5 秒故障提示冷却。冷却期内状态处理和重连仍会执行,但不会播放第二条提示。 因此,如果 Wi-Fi 故障刚播报后立即转变为服务器故障,服务器提示可能被冷却时间抑制。这是为了优先保证设备安静和避免连续播报。 ### 6.4 恢复提示有严格前置条件 只有同时满足以下条件才会播放业务恢复提示: 1. 设备本次运行中曾成功连接过服务器。 2. 当前故障已经对用户播放过异常提示。 3. WebSocket 已重新连接成功。 如果故障很短、异常提示被 OTA 或 BLE 配网抑制,恢复后不会额外播放恢复音。这可以避免用户只听到“恢复”却不知道之前发生过什么。 ### 6.5 服务器异常识别依赖现有事件 服务器故障主要由 WebSocket 断开、WebSocket 错误或多模态客户端启动失败进行标记。如果某种网络异常没有触发这些事件,也没有产生现有 HTTPS 网络错误,则可能延迟进入 `SERVER_DOWN` 状态。 后续可以结合心跳超时、DNS 错误分类和 HTTPS 错误统计进一步增强服务器可用性判定,但不建议仅凭 Wi-Fi 已关联状态判断业务可用。 ### 6.6 尚需真机验证音频与时序 当前已经完成编译、链接、资源打包和静态检查,但仍需要真机覆盖以下场景: 1. 路由器断电超过 3 秒,确认断网提示只播放一次。 2. Wi-Fi 在 3 秒内恢复,确认不播放断网提示。 3. 断网时连续点击对话和抽签,确认立即结束且 5 秒内不重复播音。 4. Wi-Fi 恢复但 WebSocket 尚未恢复,确认不提前播放业务恢复提示。 5. WebSocket 短暂断开少于 3 秒,确认不播放服务器异常提示。 6. WebSocket 持续断开超过 3 秒,确认播放一次服务器异常提示。 7. 服务器异常时发起业务操作,确认播放服务器修复提示并重新鉴权。 8. WebSocket 最终恢复,确认业务恢复提示只播放一次。 9. 空闲 WebSocket 主动关闭后发起对话,确认正常唤醒连接而不是被拒绝。 10. OTA 和 BLE 配网过程中制造网络变化,确认不会插入普通网络提示音。 11. 提示音播放过程中恢复 Wi-Fi,确认不会出现音频冲突或重复恢复播报。 ### 6.7 固件容量变化 新增的是状态管理和接线代码,没有新增音频文件。相比前一版构建,应用二进制由 `0x217430` 增长到 `0x217d50`,增加 `0x920` 字节,即 2336 字节。 当前最小应用分区仍有 `0x1682b0` 字节可用,约占分区的 40%,不存在近期容量风险。 ## 7. 结论 本次提交解决了网络异常状态下“用户不知道设备发生了什么”和“网络业务静默等待后失败”两个主要体验问题。 设备现在能够区分 Wi-Fi 断开与服务器断开,在合适的时机播放一次提示,并在用户主动操作时快速反馈和触发恢复。恢复提示以服务器业务连接真正可用为准,同时保留正常的空闲 WebSocket 唤醒、Wi-Fi 多凭据重连、OTA 和 BLE 配网行为。 后续主要风险不在编译或容量,而在真实网络抖动、音频播放时长和多种恢复时序组合。应通过上述真机测试确认提示音频率、恢复延迟和用户感知是否符合产品预期。