From d5b883817c539ebbf0edc370b89865d93f3c7675 Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Tue, 14 Apr 2026 16:54:37 +0800 Subject: [PATCH] =?UTF-8?q?update=EF=BC=9A=E5=89=8D=E7=AB=AF=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=8F=AF=E9=85=8D=E7=BD=AE=E7=9A=84=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=94=A4=E9=86=92=E8=AF=8D=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/test/css/test_page.css | 14 + main/xiaozhi-server/test/js/config/manager.js | 30 ++ .../test/js/core/network/wakeword-bridge.js | 181 +++++++++-- main/xiaozhi-server/test/js/ui/controller.js | 179 ++++++++--- .../xiaozhi-server/test/start_test_runtime.py | 82 ++++- main/xiaozhi-server/test/test_page.html | 34 ++- .../test/wakeword_runtime/README.md | 250 +++++++++++++++ .../wakeword_runtime/bridge/event_bridge.py | 35 ++- .../test/wakeword_runtime/config.json | 10 +- .../wakeword_runtime/config/config_loader.py | 33 +- .../test/wakeword_runtime/core/detector.py | 23 +- .../wakeword_runtime/core/detector_assets.py | 2 +- .../test/wakeword_runtime/core/microphone.py | 3 + .../test/wakeword_runtime/plugins/audio.py | 3 +- .../wakeword_runtime/plugins/wake_word.py | 3 +- .../test/wakeword_runtime/requirements.txt | 3 + .../test/wakeword_runtime/runtime/app.py | 18 +- .../wakeword_runtime/runtime/http_server.py | 284 ++++++++++++++++-- 18 files changed, 1037 insertions(+), 150 deletions(-) create mode 100644 main/xiaozhi-server/test/wakeword_runtime/README.md create mode 100644 main/xiaozhi-server/test/wakeword_runtime/requirements.txt diff --git a/main/xiaozhi-server/test/css/test_page.css b/main/xiaozhi-server/test/css/test_page.css index 157df9a4..e2e488bf 100644 --- a/main/xiaozhi-server/test/css/test_page.css +++ b/main/xiaozhi-server/test/css/test_page.css @@ -812,6 +812,20 @@ body { font-size: 14px; } +.config-item textarea { + width: 100%; + padding: 12px; + border: 1px solid #40444b; + border-radius: 6px; + background: #40444b; + color: white; + min-height: 100px; + resize: vertical; + box-sizing: border-box; + font-family: inherit; + font-size: 14px; +} + .model-select { width: 100%; padding: 10px 40px 10px 14px; diff --git a/main/xiaozhi-server/test/js/config/manager.js b/main/xiaozhi-server/test/js/config/manager.js index 9bc33122..0c2ef1bf 100644 --- a/main/xiaozhi-server/test/js/config/manager.js +++ b/main/xiaozhi-server/test/js/config/manager.js @@ -19,6 +19,9 @@ export function loadConfig() { const deviceNameInput = document.getElementById('deviceName'); const clientIdInput = document.getElementById('clientId'); const otaUrlInput = document.getElementById('otaUrl'); + const wakewordWsUrlInput = document.getElementById('wakewordWsUrl'); + const wakewordEnabledInput = document.getElementById('wakewordEnabled'); + const wakewordListInput = document.getElementById('wakewordList'); // 从localStorage加载MAC地址,如果没有则生成新的 let savedMac = localStorage.getItem('xz_tester_deviceMac'); @@ -43,6 +46,21 @@ export function loadConfig() { if (savedOtaUrl) { otaUrlInput.value = savedOtaUrl; } + + const savedWakewordWsUrl = localStorage.getItem('xz_tester_wakewordWsUrl'); + if (savedWakewordWsUrl !== null && wakewordWsUrlInput) { + wakewordWsUrlInput.value = savedWakewordWsUrl; + } + + const savedWakewordEnabled = localStorage.getItem('xz_tester_wakewordEnabled'); + if (savedWakewordEnabled !== null && wakewordEnabledInput) { + wakewordEnabledInput.value = savedWakewordEnabled; + } + + const savedWakewordList = localStorage.getItem('xz_tester_wakewordList'); + if (savedWakewordList !== null && wakewordListInput) { + wakewordListInput.value = savedWakewordList; + } } // 保存配置 @@ -50,10 +68,22 @@ export function saveConfig() { const deviceMacInput = document.getElementById('deviceMac'); const deviceNameInput = document.getElementById('deviceName'); const clientIdInput = document.getElementById('clientId'); + const wakewordWsUrlInput = document.getElementById('wakewordWsUrl'); + const wakewordEnabledInput = document.getElementById('wakewordEnabled'); + const wakewordListInput = document.getElementById('wakewordList'); localStorage.setItem('xz_tester_deviceMac', deviceMacInput.value); localStorage.setItem('xz_tester_deviceName', deviceNameInput.value); localStorage.setItem('xz_tester_clientId', clientIdInput.value); + if (wakewordEnabledInput) { + localStorage.setItem('xz_tester_wakewordEnabled', wakewordEnabledInput.value); + } + if (wakewordListInput) { + localStorage.setItem('xz_tester_wakewordList', wakewordListInput.value); + } + if (wakewordWsUrlInput && wakewordWsUrlInput.value.trim()) { + localStorage.setItem('xz_tester_wakewordWsUrl', wakewordWsUrlInput.value.trim()); + } } // 获取配置值 diff --git a/main/xiaozhi-server/test/js/core/network/wakeword-bridge.js b/main/xiaozhi-server/test/js/core/network/wakeword-bridge.js index 7596235e..cf521cad 100644 --- a/main/xiaozhi-server/test/js/core/network/wakeword-bridge.js +++ b/main/xiaozhi-server/test/js/core/network/wakeword-bridge.js @@ -1,39 +1,56 @@ import { uiController } from '../../ui/controller.js?v=0205'; import { log } from '../../utils/logger.js?v=0205'; -const BRIDGE_URL_CANDIDATES = [ - `${window.location.origin}/events` -]; +let wakewordSocket = null; +let reconnectTimer = null; +let reconnectAttempts = 0; +let shouldReconnect = true; +let wakewordRequestSeq = 0; -let wakewordEventSource = null; +const pendingWakewordRequests = new Map(); export function startWakewordBridgeListener() { - if (wakewordEventSource) { - return wakewordEventSource; + if (wakewordSocket) { + return wakewordSocket; } + shouldReconnect = true; log('正在连接本地唤醒事件桥...', 'info'); - tryConnect(0); - return wakewordEventSource; + tryConnect(); + return wakewordSocket; } -function tryConnect(index) { - if (index >= BRIDGE_URL_CANDIDATES.length) { - log('未能连接到本地唤醒事件桥,请确认 test runtime 已启动', 'warning'); - return null; - } - - const bridgeUrl = BRIDGE_URL_CANDIDATES[index]; +function tryConnect() { + const bridgeUrl = buildWakewordBridgeUrl(); try { - wakewordEventSource = new EventSource(bridgeUrl); - wakewordEventSource.onopen = () => { + wakewordSocket = new WebSocket(bridgeUrl); + wakewordSocket.onopen = () => { + reconnectAttempts = 0; log(`本地唤醒事件桥已连接: ${bridgeUrl}`, 'success'); + // 连接成功后自动保存地址,刷新后仍能记住 + localStorage.setItem('xz_tester_wakewordWsUrl', bridgeUrl); + const urlInput = document.getElementById('wakewordWsUrl'); + if (urlInput) urlInput.value = bridgeUrl; }; - wakewordEventSource.onmessage = async (event) => { + wakewordSocket.onerror = () => { + log(`本地唤醒事件桥连接失败: ${bridgeUrl}`, 'error'); + }; + + wakewordSocket.onmessage = async (event) => { try { - const message = JSON.parse(event.data); + const message = parseWakewordBridgeMessage(event.data); + if (message.requestId && pendingWakewordRequests.has(message.requestId)) { + settleWakewordRequest(message); + return; + } + + if (message.success === false) { + log(`本地唤醒事件桥返回错误: ${message.error || '未知错误'}`, 'error'); + return; + } + if (message.type === 'bridge_connected') { log('本地唤醒监听已就绪', 'info'); return; @@ -44,6 +61,12 @@ function tryConnect(index) { return; } + if (message.type === 'wakeword_config') { + uiController.applyWakewordConfig(message.payload || {}); + log('已同步本地唤醒词配置', 'info'); + return; + } + if (message.type === 'service_stopping') { log('本地唤醒服务正在停止', 'warning'); return; @@ -59,34 +82,126 @@ function tryConnect(index) { } }; - wakewordEventSource.onerror = () => { - log(`本地唤醒事件桥连接异常: ${bridgeUrl}`, 'warning'); - if (wakewordEventSource) { - wakewordEventSource.close(); - wakewordEventSource = null; + wakewordSocket.onclose = () => { + if (wakewordSocket) { + wakewordSocket = null; } - if (index + 1 < BRIDGE_URL_CANDIDATES.length) { - log(`尝试备用地址: ${BRIDGE_URL_CANDIDATES[index + 1]}`, 'info'); - tryConnect(index + 1); + rejectAllWakewordRequests('本地唤醒事件桥已断开'); + + if (!shouldReconnect) { return; } - log('请确认当前页面由 test runtime 启动,并且 /events 可访问', 'warning'); + if (reconnectTimer) { + return; + } + + reconnectAttempts += 1; + const delay = Math.min(1000 * reconnectAttempts, 5000); + log(`本地唤醒事件桥将在 ${delay}ms 后重连: ${bridgeUrl}`, 'warning'); + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + tryConnect(); + }, delay); }; - return wakewordEventSource; + return wakewordSocket; } catch (error) { log(`启动本地唤醒监听失败: ${error.message}`, 'error'); - return tryConnect(index + 1); + return null; } } export function stopWakewordBridgeListener() { - if (!wakewordEventSource) { + shouldReconnect = false; + + if (reconnectTimer) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + if (!wakewordSocket) { return; } - wakewordEventSource.close(); - wakewordEventSource = null; + wakewordSocket.close(); + wakewordSocket = null; +} + +export function sendWakewordBridgeMessage(type, payload = {}, requestId = null) { + if (!wakewordSocket || wakewordSocket.readyState !== WebSocket.OPEN) { + log('本地唤醒事件桥未连接,无法发送消息', 'warning'); + return false; + } + + wakewordSocket.send(JSON.stringify({ + type, + requestId, + payload, + })); + return true; +} + +export function requestWakewordBridge(type, payload = {}, timeout = 5000) { + const requestId = `wakeword-${Date.now()}-${++wakewordRequestSeq}`; + + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + pendingWakewordRequests.delete(requestId); + reject(new Error('本地唤醒服务响应超时')); + }, timeout); + + pendingWakewordRequests.set(requestId, { resolve, reject, timer }); + + if (!sendWakewordBridgeMessage(type, payload, requestId)) { + window.clearTimeout(timer); + pendingWakewordRequests.delete(requestId); + reject(new Error('本地唤醒事件桥未连接')); + } + }); +} + +function buildWakewordBridgeUrl() { + const configured = localStorage.getItem('xz_tester_wakewordWsUrl'); + if (configured && configured.trim()) { + return configured.trim(); + } + return 'ws://127.0.0.1:8006/wakeword-ws'; +} + +function parseWakewordBridgeMessage(rawData) { + const message = JSON.parse(rawData); + return { + type: message.type || '', + requestId: message.requestId || null, + success: message.success !== false, + payload: message.payload || {}, + error: message.error || null, + }; +} + +function settleWakewordRequest(message) { + const pendingRequest = pendingWakewordRequests.get(message.requestId); + if (!pendingRequest) { + return; + } + + window.clearTimeout(pendingRequest.timer); + pendingWakewordRequests.delete(message.requestId); + + if (message.success === false) { + pendingRequest.reject(new Error(message.error || '本地唤醒服务返回失败')); + return; + } + + pendingRequest.resolve(message); +} + +function rejectAllWakewordRequests(errorMessage) { + pendingWakewordRequests.forEach((pendingRequest) => { + window.clearTimeout(pendingRequest.timer); + pendingRequest.reject(new Error(errorMessage)); + }); + pendingWakewordRequests.clear(); } \ No newline at end of file diff --git a/main/xiaozhi-server/test/js/ui/controller.js b/main/xiaozhi-server/test/js/ui/controller.js index 81265b1f..42c69d7d 100644 --- a/main/xiaozhi-server/test/js/ui/controller.js +++ b/main/xiaozhi-server/test/js/ui/controller.js @@ -2,7 +2,9 @@ import { loadConfig, saveConfig } from '../config/manager.js?v=0205'; import { getAudioPlayer } from '../core/audio/player.js?v=0205'; import { getAudioRecorder } from '../core/audio/recorder.js?v=0205'; +import { requestWakewordBridge } from '../core/network/wakeword-bridge.js?v=0205'; import { getWebSocketHandler } from '../core/network/websocket.js?v=0205'; +import { log } from '../utils/logger.js?v=0205'; // UI controller class class UIController { @@ -14,6 +16,8 @@ class UIController { this.currentBackgroundIndex = localStorage.getItem('backgroundIndex') ? parseInt(localStorage.getItem('backgroundIndex')) : 0; this.backgroundImages = ['1.png', '2.png', '3.png']; this.dialBtnDisabled = false; + this.isConnecting = false; + this.lastWakewordDialTime = 0; // Bind methods this.init = this.init.bind(this); @@ -25,6 +29,9 @@ class UIController { this.showModal = this.showModal.bind(this); this.hideModal = this.hideModal.bind(this); this.switchTab = this.switchTab.bind(this); + this.applyWakewordConfig = this.applyWakewordConfig.bind(this); + this.handleApplyWakeword = this.handleApplyWakeword.bind(this); + this.triggerWakewordDial = this.triggerWakewordDial.bind(this); } // Initialize @@ -262,6 +269,11 @@ class UIController { }); }); + const applyWakewordBtn = document.getElementById('applyWakewordBtn'); + if (applyWakewordBtn) { + applyWakewordBtn.addEventListener('click', this.handleApplyWakeword); + } + // 点击模态框背景关闭(仅对特定模态框禁用此功能) const modals = document.querySelectorAll('.modal'); modals.forEach(modal => { @@ -512,6 +524,74 @@ class UIController { } } + applyWakewordConfig(config = {}) { + const wakewordEnabledInput = document.getElementById('wakewordEnabled'); + const wakewordListInput = document.getElementById('wakewordList'); + + if (!wakewordEnabledInput || !wakewordListInput) { + return; + } + + const wakeWords = Array.isArray(config.wakeWords) + ? config.wakeWords.filter(item => typeof item === 'string' && item.trim()) + : []; + + wakewordEnabledInput.value = config.enabled === false ? 'false' : 'true'; + wakewordListInput.value = wakeWords.join('\n'); + saveConfig(); + } + + async handleApplyWakeword() { + const wakewordEnabledInput = document.getElementById('wakewordEnabled'); + const wakewordListInput = document.getElementById('wakewordList'); + if (!wakewordEnabledInput || !wakewordListInput) { + return; + } + + const wakeWords = wakewordListInput.value + .split(/\r?\n/u) + .map(item => item.trim()) + .filter(Boolean) + .filter((item, index, items) => items.indexOf(item) === index); + + const payload = { + enabled: wakewordEnabledInput.value !== 'false', + wakeWords, + }; + + if (payload.enabled && payload.wakeWords.length === 0) { + this.addChatMessage('启用唤醒词时,至少需要填写一个唤醒词。', false); + return; + } + + const applyWakewordBtn = document.getElementById('applyWakewordBtn'); + if (applyWakewordBtn) { + applyWakewordBtn.disabled = true; + applyWakewordBtn.textContent = '应用中...'; + } + + try { + const response = await requestWakewordBridge('set_wakeword_config', payload); + this.applyWakewordConfig(response.payload || payload); + + const shouldRestart = window.confirm('唤醒词已保存。是否现在重启唤醒词服务以立即生效?'); + if (!shouldRestart) { + this.addChatMessage('唤醒词配置已保存,可稍后手动重启服务后生效。', false); + return; + } + + await requestWakewordBridge('restart_wakeword_service'); + this.addChatMessage('唤醒词配置已保存,唤醒词服务正在重启。', false); + } catch (error) { + this.addChatMessage(`应用唤醒词失败: ${error.message}`, false); + } finally { + if (applyWakewordBtn) { + applyWakewordBtn.disabled = false; + applyWakewordBtn.textContent = '应用唤醒词'; + } + } + } + // Start AI chat session after connection startAIChatSession() { this.addChatMessage('连接成功,开始聊天吧~😊', false); @@ -550,47 +630,51 @@ class UIController { // Handle connect button click async handleConnect() { - console.log('handleConnect called'); - - // Switch to device settings tab - this.switchTab('device'); - - // Wait for DOM update - await new Promise(resolve => setTimeout(resolve, 50)); - - const otaUrlInput = document.getElementById('otaUrl'); - - console.log('otaUrl element:', otaUrlInput); - - if (!otaUrlInput || !otaUrlInput.value) { - this.addChatMessage('请输入OTA服务器地址', false); + const wsHandler = getWebSocketHandler(); + if (this.isConnecting || (wsHandler && wsHandler.isConnected())) { + log('连接已存在或正在进行,忽略本次拨号请求', 'info'); return; } - const otaUrl = otaUrlInput.value; - console.log('otaUrl value:', otaUrl); - - // Update dial button state to connecting - const dialBtn = document.getElementById('dialBtn'); - if (dialBtn) { - dialBtn.classList.add('dial-active'); - dialBtn.querySelector('.btn-text').textContent = '连接中...'; - dialBtn.disabled = true; - } - - // Show connecting message - this.addChatMessage('正在连接服务器...', false); - - const chatIpt = document.getElementById('chatIpt'); - if (chatIpt) { - chatIpt.style.display = 'flex'; - } + this.isConnecting = true; + console.log('handleConnect called'); try { + // Switch to device settings tab + this.switchTab('device'); + + // Wait for DOM update + await new Promise(resolve => setTimeout(resolve, 50)); + + const otaUrlInput = document.getElementById('otaUrl'); + + console.log('otaUrl element:', otaUrlInput); + + if (!otaUrlInput || !otaUrlInput.value) { + this.addChatMessage('请输入OTA服务器地址', false); + return; + } + + const otaUrl = otaUrlInput.value; + console.log('otaUrl value:', otaUrl); + + // Update dial button state to connecting + const dialBtn = document.getElementById('dialBtn'); + if (dialBtn) { + dialBtn.classList.add('dial-active'); + dialBtn.querySelector('.btn-text').textContent = '连接中...'; + dialBtn.disabled = true; + } + + // Show connecting message + this.addChatMessage('正在连接服务器...', false); + + const chatIpt = document.getElementById('chatIpt'); + if (chatIpt) { + chatIpt.style.display = 'flex'; + } // Get WebSocket handler instance - const wsHandler = getWebSocketHandler(); - // Register connection state callback BEFORE connecting wsHandler.onConnectionStateChange = (isConnected) => { this.updateConnectionUI(isConnected); @@ -670,9 +754,36 @@ class UIController { dialBtn.classList.remove('dial-active'); console.log('Dial button state restored successfully'); } + } finally { + this.isConnecting = false; } } + async triggerWakewordDial(wakeWord = '唤醒词') { + const wsHandler = getWebSocketHandler(); + const now = Date.now(); + + if (wsHandler && wsHandler.isConnected()) { + log('页面已连接,忽略自动拨号', 'info'); + return false; + } + + if (this.isConnecting || this.dialBtnDisabled) { + log('页面正在连接中,忽略重复唤醒', 'info'); + return false; + } + + if (now - this.lastWakewordDialTime < 3000) { + log('唤醒触发过于频繁,忽略本次自动拨号', 'warning'); + return false; + } + + this.lastWakewordDialTime = now; + this.addChatMessage(`检测到唤醒词“${wakeWord}”,准备连接服务器...`, false); + await this.handleConnect(); + return true; + } + // Add MCP tool addMCPTool() { const mcpToolsList = document.getElementById('mcpToolsList'); diff --git a/main/xiaozhi-server/test/start_test_runtime.py b/main/xiaozhi-server/test/start_test_runtime.py index 570f69d2..5a0fdf72 100644 --- a/main/xiaozhi-server/test/start_test_runtime.py +++ b/main/xiaozhi-server/test/start_test_runtime.py @@ -1,30 +1,100 @@ +import os +import threading from pathlib import Path +if os.name == "nt": + import msvcrt +else: + import fcntl + from wakeword_runtime.config import load_config, setup_logging from wakeword_runtime.runtime import TestRuntimeApplication, TestRuntimeHttpServer +class RuntimeInstanceLock: + def __init__(self, lock_path: Path) -> None: + self.lock_path = lock_path + self._handle = None + + def acquire(self) -> bool: + self.lock_path.parent.mkdir(parents=True, exist_ok=True) + self._handle = open(self.lock_path, "a+b") + try: + if os.name == "nt": + self._handle.seek(0) + msvcrt.locking(self._handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + self._handle.seek(0) + self._handle.truncate() + self._handle.write(str(os.getpid()).encode("ascii")) + self._handle.flush() + return True + except OSError: + self.release() + return False + + def release(self) -> None: + if self._handle is None: + return + try: + if os.name == "nt": + self._handle.seek(0) + msvcrt.locking(self._handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(self._handle.fileno(), fcntl.LOCK_UN) + except OSError: + pass + finally: + self._handle.close() + self._handle = None + + def main() -> int: test_root = Path(__file__).resolve().parent - config = load_config(test_root / "wakeword_runtime") + runtime_root = test_root / "wakeword_runtime" + lock = RuntimeInstanceLock(runtime_root / ".runtime.lock") + if not lock.acquire(): + print("failed to start test runtime: another test runtime instance is already running") + print("请先关闭已有的 test runtime 进程,再重新启动。") + return 1 + + config = load_config(runtime_root) setup_logging(config.log_file, config.log_level) http_server = TestRuntimeHttpServer(test_root) - app = TestRuntimeApplication(config, http_server) + app_lock = threading.RLock() + app = TestRuntimeApplication(config, http_server.event_bridge) + + def restart_runtime() -> None: + nonlocal app + with app_lock: + app.shutdown() + next_config = load_config(runtime_root) + setup_logging(next_config.log_file, next_config.log_level) + next_app = TestRuntimeApplication(next_config, http_server.event_bridge) + next_app.setup() + next_app.start() + app = next_app + + http_server.set_restart_handler(restart_runtime) print(f"test runtime started: {http_server.page_url}") - print(f"wakeword events endpoint: {http_server.events_url}") + print(f"wakeword bridge websocket: {http_server.bridge_url}") print(f"wakeword enabled: {config.wakeword_enabled}") print("press Ctrl+C to stop") try: - app.setup() - app.start() + with app_lock: + app.setup() + app.start() http_server.serve_forever() except KeyboardInterrupt: print("test runtime stopped") finally: - app.shutdown() + with app_lock: + app.shutdown() http_server.shutdown() + lock.release() return 0 diff --git a/main/xiaozhi-server/test/test_page.html b/main/xiaozhi-server/test/test_page.html index 5e29e6d4..906d7b2e 100644 --- a/main/xiaozhi-server/test/test_page.html +++ b/main/xiaozhi-server/test/test_page.html @@ -5,7 +5,7 @@ 小智服务器测试页面 - +