fix(voice): stabilize single-round flow and sync web UI state

This commit is contained in:
admin
2026-02-26 14:33:02 +08:00
parent e6a6b69ced
commit dde759d0a4
10 changed files with 251 additions and 39 deletions

View File

@@ -203,6 +203,7 @@ typedef struct {
bool ws_connected;
bool started;
bool tap_active;
bool marker_image_gen_running;
voice_dialog_state_t dialog_state;
char task_id[40];
char dialog_id[40];

View File

@@ -120,6 +120,7 @@ esp_err_t voice_send_directive(const char *action,
void voice_ws_protocol_handle_text_message(const char *text, size_t len);
void voice_ws_audio_handle_downstream_packet(const uint8_t *data, size_t len);
void voice_marker_try_trigger_image_generation(const char *dialog_id, const char *final_text);
bool voice_marker_image_generation_is_running(void);
void voice_websocket_event_handler(void *handler_args,
esp_event_base_t base,

View File

@@ -9,6 +9,32 @@
static const char *TAG = "voice_interaction";
static void voice_wait_worker_tasks_exit(uint32_t timeout_ms) {
int64_t deadline_ms = voice_now_ms() + (int64_t)timeout_ms;
while (voice_now_ms() < deadline_ms) {
bool uplink_alive = false;
bool heartbeat_alive = false;
if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) {
uplink_alive = (s_voice.uplink_task != NULL);
heartbeat_alive = (s_voice.heartbeat_task != NULL);
voice_unlock();
}
if (!uplink_alive && !heartbeat_alive) {
return;
}
vTaskDelay(pdMS_TO_TICKS(20));
}
if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG,
"[stage] worker_task_exit_timeout: uplink_alive=%d heartbeat_alive=%d",
s_voice.uplink_task != NULL,
s_voice.heartbeat_task != NULL);
voice_unlock();
}
}
const char *voice_interaction_dialog_state_str(voice_dialog_state_t state) {
switch (state) {
case VOICE_DIALOG_STATE_IDLE:
@@ -71,12 +97,23 @@ esp_err_t voice_interaction_start(char *err, size_t err_len) {
return ESP_ERR_TIMEOUT;
}
bool already_active = s_voice.session_active;
bool ws_connected = s_voice.ws_connected;
bool started = s_voice.started;
voice_unlock();
if (already_active) {
ESP_LOGI(TAG, "[stage] session_start_skip: already active");
voice_log_status_snapshot("already_active");
return ESP_OK;
if (ws_connected && started) {
ESP_LOGI(TAG, "[stage] session_start_skip: already active");
voice_log_status_snapshot("already_active");
return ESP_OK;
}
ESP_LOGW(TAG,
"[stage] session_start_recover: active=%d ws_connected=%d started=%d",
already_active,
ws_connected,
started);
(void)voice_interaction_stop(NULL, 0);
}
voice_interaction_ws_init_cjson_hooks();
@@ -290,8 +327,6 @@ esp_err_t voice_interaction_stop(char *err, size_t err_len) {
bool ws_connected = s_voice.ws_connected;
TaskHandle_t uplink_task = s_voice.uplink_task;
TaskHandle_t heartbeat_task = s_voice.heartbeat_task;
bool uplink_task_with_caps = s_voice.uplink_task_with_caps;
bool heartbeat_task_with_caps = s_voice.heartbeat_task_with_caps;
esp_websocket_client_handle_t ws = s_voice.ws;
s_voice.tap_active = false;
@@ -302,9 +337,13 @@ esp_err_t voice_interaction_stop(char *err, size_t err_len) {
(void)voice_send_directive("finish-task", "Stop", true);
}
voice_close_audio_with_drain(VOICE_AUDIO_DRAIN_WAIT_ON_STOP_MS, "session_stop_close");
voice_delete_task(uplink_task, uplink_task_with_caps);
voice_delete_task(heartbeat_task, heartbeat_task_with_caps);
if (uplink_task != NULL) {
(void)xTaskAbortDelay(uplink_task);
}
if (heartbeat_task != NULL) {
(void)xTaskAbortDelay(heartbeat_task);
}
voice_wait_worker_tasks_exit(1500);
if (ws != NULL) {
ESP_LOGI(TAG, "[stage] ws_stop_destroy_begin");
@@ -444,6 +483,7 @@ void voice_interaction_get_status(voice_interaction_status_t *out_status) {
out_status->ws_connected = s_voice.ws_connected;
out_status->started = s_voice.started;
out_status->tap_active = s_voice.tap_active;
out_status->marker_image_gen_running = voice_marker_image_generation_is_running();
out_status->dialog_state = s_voice.dialog_state;
out_status->last_event_ms = s_voice.last_event_ms;
out_status->upstream_packets = s_voice.upstream_packets;

View File

@@ -472,3 +472,11 @@ void voice_marker_try_trigger_image_generation(const char *dialog_id, const char
voice_log_text_preview("marker_image_gen_prompt_templated", templated_prompt);
voice_schedule_marker_image_generation(templated_prompt, dialog_id);
}
bool voice_marker_image_generation_is_running(void) {
bool running = false;
portENTER_CRITICAL(&s_marker_image_gen_lock);
running = s_marker_image_gen_running;
portEXIT_CRITICAL(&s_marker_image_gen_lock);
return running;
}

View File

@@ -10,7 +10,6 @@ static const char *TAG = "voice_interaction";
void voice_uplink_task(void *arg) {
(void)arg;
const int frame_ms = CONFIG_TQ_VOICE_OPUS_FRAME_MS;
bool low_stack_warned = false;
UBaseType_t start_hwm = uxTaskGetStackHighWaterMark(NULL);
ESP_LOGI(TAG, "[stage] uplink_task_started: stack_hwm_words=%u", (unsigned)start_hwm);
@@ -43,24 +42,19 @@ void voice_uplink_task(void *arg) {
break;
}
if (!(connected && started && listening)) {
if (!(connected && started && listening && tap_active)) {
vTaskDelay(pdMS_TO_TICKS(20));
continue;
}
if (tap_active) {
char read_err[64] = {0};
esp_err_t rc = voice_audio_read_pcm(s_voice.pcm_tx_buf,
(size_t)s_voice.opus_enc_in_size / VOICE_SAMPLE_BYTES,
VOICE_AUDIO_IO_TIMEOUT_MS,
read_err,
sizeof(read_err));
if (rc != ESP_OK) {
memset(s_voice.pcm_tx_buf, 0, (size_t)s_voice.opus_enc_in_size);
}
} else {
char read_err[64] = {0};
esp_err_t rc = voice_audio_read_pcm(s_voice.pcm_tx_buf,
(size_t)s_voice.opus_enc_in_size / VOICE_SAMPLE_BYTES,
VOICE_AUDIO_IO_TIMEOUT_MS,
read_err,
sizeof(read_err));
if (rc != ESP_OK) {
memset(s_voice.pcm_tx_buf, 0, (size_t)s_voice.opus_enc_in_size);
vTaskDelay(pdMS_TO_TICKS(frame_ms));
}
esp_audio_enc_in_frame_t in_frame = {
@@ -80,10 +74,23 @@ void voice_uplink_task(void *arg) {
if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) {
esp_websocket_client_handle_t ws = s_voice.ws;
bool can_send = s_voice.ws_connected;
bool ws_connected = s_voice.ws_connected;
voice_unlock();
if (ws != NULL && can_send) {
bool transport_connected = (ws != NULL) && esp_websocket_client_is_connected(ws);
if (ws_connected && !transport_connected && voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) {
if (s_voice.ws_connected) {
s_voice.ws_connected = false;
s_voice.started = false;
s_voice.tap_active = false;
s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE;
s_voice.last_event_ms = voice_now_ms();
ESP_LOGW(TAG, "[stage] uplink_detected_ws_disconnected");
}
voice_unlock();
}
if (ws != NULL && ws_connected && transport_connected) {
int ret = esp_websocket_client_send_bin(ws,
(const char *)s_voice.opus_tx_buf,
(int)out_frame.encoded_bytes,

View File

@@ -126,7 +126,20 @@ static esp_err_t voice_send_text(const char *text, int len) {
bool connected = s_voice.ws_connected;
voice_unlock();
if (ws == NULL || !connected) {
bool transport_connected = (ws != NULL) && esp_websocket_client_is_connected(ws);
if (connected && !transport_connected && voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) {
if (s_voice.ws_connected) {
s_voice.ws_connected = false;
s_voice.started = false;
s_voice.tap_active = false;
s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE;
s_voice.last_event_ms = voice_now_ms();
ESP_LOGW(TAG, "[stage] ws_state_reconciled: transport disconnected");
}
voice_unlock();
}
if (ws == NULL || !connected || !transport_connected) {
ESP_LOGW(TAG, "[stage] ws_send_text skipped: ws=%p connected=%d len=%d", (void *)ws, connected, len);
return ESP_ERR_INVALID_STATE;
}

View File

@@ -131,7 +131,11 @@ static void voice_handle_output_event(cJSON *output) {
state != NULL ? state : "-",
voice_interaction_dialog_state_str(s_voice.dialog_state));
} else if (strcmp(event_name, "SpeechEnded") == 0) {
ESP_LOGI(TAG, "[stage] speech_ended: keep tap_active=%d for continuous rounds", s_voice.tap_active);
s_voice.tap_active = false;
ESP_LOGI(TAG, "[stage] speech_ended: mark single_round_input_done");
} else if (strcmp(event_name, "RespondingEnded") == 0) {
s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE;
ESP_LOGI(TAG, "[stage] responding_ended: wait_local_playback_before_ack");
} else if (strcmp(event_name, "Stopped") == 0) {
s_voice.started = false;
s_voice.tap_active = false;
@@ -164,7 +168,28 @@ static void voice_handle_output_event(cJSON *output) {
if (strcmp(event_name, "RespondingStarted") == 0) {
(void)voice_send_directive("continue-task", "LocalRespondingStarted", true);
} else if (strcmp(event_name, "RespondingEnded") == 0) {
uint32_t playback_wait_ms = VOICE_AUDIO_DRAIN_WAIT_MS;
if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) {
int64_t now_ms = voice_now_ms();
int64_t deadline_ms = s_voice.playback_deadline_ms;
voice_unlock();
if (deadline_ms > now_ms) {
int64_t remain_ms = (deadline_ms - now_ms) + VOICE_AUDIO_DRAIN_QUIET_MS + 120;
if (remain_ms > (int64_t)playback_wait_ms) {
if (remain_ms > 12000) {
playback_wait_ms = 12000;
} else {
playback_wait_ms = (uint32_t)remain_ms;
}
}
}
}
ESP_LOGI(TAG, "[stage] responding_ended_local_playback_wait: timeout_ms=%u", (unsigned)playback_wait_ms);
voice_wait_audio_playback_done(playback_wait_ms,
"responding_ended_local_playback");
(void)voice_send_directive("continue-task", "LocalRespondingEnded", true);
(void)voice_send_directive("finish-task", "Stop", true);
}
}
@@ -216,6 +241,9 @@ void voice_ws_protocol_handle_text_message(const char *text, size_t len) {
ESP_LOGW(TAG, "[stage] ws_task_failed: %s", error_msg != NULL ? error_msg : "task failed");
if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) {
voice_set_last_error_locked(error_msg != NULL ? error_msg : "task failed");
s_voice.started = false;
s_voice.tap_active = false;
s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE;
voice_unlock();
}
}