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

@@ -105,11 +105,13 @@ static const char *s_web_index =
" </div>\n"
" <div class='small'>Submit prompt only: ESP32-S3 calls DashScope Z-Image, downloads PNG, rasterizes and prints.</div>\n"
"\n"
" <label>Voice Tap2Talk</label>\n"
" <label>Voice 单轮对话</label>\n"
" <div class='row'>\n"
" <button id='btnVoiceTalk' class='talk'>点击开始对话</button>\n"
" <button id='btnVoiceTalk' class='talk'>点击开始单轮对话</button>\n"
" </div>\n"
" <div class='small'>点击一次开始语音输入,再点击一次结束(自动建立语音会话)。</div>\n"
" <div id='voiceState' class='small'>语音状态:初始化中</div>\n"
" <div id='voiceErr' class='small' style='color:#b00020;'></div>\n"
" <div class='small'>单击开始本轮语音输入;识别结束后自动停止上传,等待语音播报和打印完成。</div>\n"
"\n"
" <div class='small'>Tip: connect printer first, then submit print jobs.</div>\n"
" <pre id='out'>Ready.</pre>\n"
@@ -131,9 +133,12 @@ static const char *s_web_index =
" const imgInvertEl = document.getElementById('imgInvert');\n"
" const imgPromptExtendEl = document.getElementById('imgPromptExtend');\n"
" const btnVoiceTalkEl = document.getElementById('btnVoiceTalk');\n"
" const voiceStateEl = document.getElementById('voiceState');\n"
" const voiceErrEl = document.getElementById('voiceErr');\n"
" const thermalPromptSuffix = 'black and white line art, pure white background, high contrast, clean edges, minimal texture, no dark background, no heavy shadows, centered subject, thermal printer friendly';\n"
" let voiceTalkHolding = false;\n"
" let voiceTalkBusy = false;\n"
" let voiceStatusSyncing = false;\n"
"\n"
" function log(obj) {\n"
" if (typeof obj === 'string') {\n"
@@ -163,19 +168,95 @@ static const char *s_web_index =
" return new Promise((resolve) => setTimeout(resolve, ms));\n"
" }\n"
"\n"
" function voiceRoundBusy(st) {\n"
" if (!st) return false;\n"
" const state = (st.voice_dialog_state || '').toLowerCase();\n"
" const voiceBusy = state === 'thinking' || state === 'responding';\n"
" const markerBusy = !!st.voice_marker_image_gen_running;\n"
" const queueDepth = Number(st.queue_depth || 0);\n"
" const printBusy = !!st.printer_busy || queueDepth > 0;\n"
" return voiceBusy || markerBusy || printBusy;\n"
" }\n"
"\n"
" function voiceStatusText(st) {\n"
" if (!st) return '语音状态:未知';\n"
" const state = (st.voice_dialog_state || '').toLowerCase();\n"
" const queueDepth = Number(st.queue_depth || 0);\n"
" if (!st.voice_session_active) {\n"
" return '语音状态:会话未启动';\n"
" }\n"
" if (!st.voice_ws_connected) {\n"
" return '语音状态:会话断开,点击开始将自动重建';\n"
" }\n"
" if (st.voice_tap_active) {\n"
" return '语音状态:录音中';\n"
" }\n"
" if (state === 'thinking' || state === 'responding') {\n"
" return '语音状态:语音处理中';\n"
" }\n"
" if (!!st.voice_marker_image_gen_running) {\n"
" return '语音状态:图片生成中';\n"
" }\n"
" if (!!st.printer_busy || queueDepth > 0) {\n"
" return '语音状态:打印处理中';\n"
" }\n"
" return '语音状态:空闲,可开始下一轮';\n"
" }\n"
"\n"
" function voiceErrorText(st) {\n"
" if (!st || st.voice_last_error === undefined || st.voice_last_error === null) {\n"
" return '';\n"
" }\n"
" const msg = String(st.voice_last_error).trim();\n"
" if (!msg || msg === '-') {\n"
" return '';\n"
" }\n"
" return '最近错误:' + msg;\n"
" }\n"
"\n"
" function voiceRoundBusyText(st) {\n"
" if (!st) return '处理中,请等待';\n"
" const state = (st.voice_dialog_state || '').toLowerCase();\n"
" if (state === 'thinking' || state === 'responding') {\n"
" return '语音处理中,请等待';\n"
" }\n"
" if (!!st.voice_marker_image_gen_running) {\n"
" return '图片生成中,请等待';\n"
" }\n"
" const queueDepth = Number(st.queue_depth || 0);\n"
" if (!!st.printer_busy || queueDepth > 0) {\n"
" return '打印处理中,请等待';\n"
" }\n"
" return '处理中,请等待';\n"
" }\n"
"\n"
" function setVoiceTalkButtonState(active) {\n"
" if (active) {\n"
" btnVoiceTalkEl.classList.add('active');\n"
" btnVoiceTalkEl.textContent = '点击结束对话';\n"
" btnVoiceTalkEl.textContent = '点击取消本轮录音';\n"
" } else {\n"
" btnVoiceTalkEl.classList.remove('active');\n"
" btnVoiceTalkEl.textContent = '点击开始对话';\n"
" btnVoiceTalkEl.textContent = '点击开始单轮对话';\n"
" }\n"
" }\n"
"\n"
" function syncVoiceTalkUi(st) {\n"
" const active = !!(st && st.voice_tap_active);\n"
" const active = !!(st && st.voice_tap_active && st.voice_ws_connected && st.voice_started);\n"
" const busy = voiceRoundBusy(st);\n"
" voiceTalkHolding = active;\n"
" if (voiceStateEl) {\n"
" voiceStateEl.textContent = voiceStatusText(st);\n"
" }\n"
" if (voiceErrEl) {\n"
" voiceErrEl.textContent = voiceErrorText(st);\n"
" }\n"
" if (busy) {\n"
" btnVoiceTalkEl.classList.remove('active');\n"
" btnVoiceTalkEl.disabled = true;\n"
" btnVoiceTalkEl.textContent = voiceRoundBusyText(st);\n"
" return;\n"
" }\n"
" btnVoiceTalkEl.disabled = false;\n"
" setVoiceTalkButtonState(active);\n"
" }\n"
"\n"
@@ -204,6 +285,20 @@ static const char *s_web_index =
" throw new Error('voice session is not ready, please retry');\n"
" }\n"
"\n"
" async function refreshVoiceStatus() {\n"
" if (voiceStatusSyncing) {\n"
" return null;\n"
" }\n"
" voiceStatusSyncing = true;\n"
" try {\n"
" const st = await callApi('/v1/voice/status', 'GET');\n"
" syncVoiceTalkUi(st);\n"
" return st;\n"
" } finally {\n"
" voiceStatusSyncing = false;\n"
" }\n"
" }\n"
"\n"
" async function beginVoiceTalk() {\n"
" if (voiceTalkBusy) {\n"
" const st = await callApi('/v1/voice/status', 'GET');\n"
@@ -327,16 +422,23 @@ static const char *s_web_index =
" });\n"
"\n"
" btnVoiceTalkEl.addEventListener('click', () => run(async () => {\n"
" const st = await callApi('/v1/voice/status', 'GET');\n"
" syncVoiceTalkUi(st);\n"
" const st = await refreshVoiceStatus();\n"
" if (!st) {\n"
" return null;\n"
" }\n"
" if (voiceRoundBusy(st)) {\n"
" return st;\n"
" }\n"
" if (st && st.voice_tap_active) {\n"
" return endVoiceTalk();\n"
" }\n"
" return beginVoiceTalk();\n"
" }));\n"
" window.addEventListener('blur', () => run(async () => {\n"
" const st = await callApi('/v1/voice/status', 'GET');\n"
" syncVoiceTalkUi(st);\n"
" const st = await refreshVoiceStatus();\n"
" if (!st) {\n"
" return null;\n"
" }\n"
" if (st && st.voice_tap_active) {\n"
" return endVoiceTalk();\n"
" }\n"
@@ -345,10 +447,16 @@ static const char *s_web_index =
" setVoiceTalkButtonState(false);\n"
" (async () => {\n"
" try {\n"
" const st = await callApi('/v1/voice/status', 'GET');\n"
" syncVoiceTalkUi(st);\n"
" await refreshVoiceStatus();\n"
" } catch (_) {}\n"
" })();\n"
" setInterval(() => {\n"
" refreshVoiceStatus().catch(() => {\n"
" if (voiceStateEl) {\n"
" voiceStateEl.textContent = '语音状态:状态获取失败';\n"
" }\n"
" });\n"
" }, 1500);\n"
" </script>\n"
"</body>\n"
"</html>\n";

View File

@@ -7,12 +7,15 @@
static void rest_server_add_voice_status(cJSON *root) {
voice_interaction_status_t st = {0};
voice_interaction_get_status(&st);
printer_runtime_status_t printer = {0};
printer_protocol_get_runtime_status(&printer);
cJSON_AddBoolToObject(root, "voice_ready", st.ready);
cJSON_AddBoolToObject(root, "voice_session_active", st.session_active);
cJSON_AddBoolToObject(root, "voice_ws_connected", st.ws_connected);
cJSON_AddBoolToObject(root, "voice_started", st.started);
cJSON_AddBoolToObject(root, "voice_tap_active", st.tap_active);
cJSON_AddBoolToObject(root, "voice_marker_image_gen_running", st.marker_image_gen_running);
cJSON_AddStringToObject(root,
"voice_dialog_state",
voice_interaction_dialog_state_str(st.dialog_state));
@@ -22,6 +25,8 @@ static void rest_server_add_voice_status(cJSON *root) {
cJSON_AddNumberToObject(root, "voice_last_event_ms", (double)st.last_event_ms);
cJSON_AddNumberToObject(root, "voice_upstream_packets", (double)st.upstream_packets);
cJSON_AddNumberToObject(root, "voice_downstream_packets", (double)st.downstream_packets);
cJSON_AddBoolToObject(root, "printer_busy", printer.busy);
cJSON_AddNumberToObject(root, "queue_depth", (double)printer.queue_depth);
}
esp_err_t rest_server_voice_status_get(httpd_req_t *req) {