mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-21 22:53:56 +08:00
update:前端增加可配置的本地唤醒词运行时
This commit is contained in:
@@ -812,6 +812,20 @@ body {
|
|||||||
font-size: 14px;
|
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 {
|
.model-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 40px 10px 14px;
|
padding: 10px 40px 10px 14px;
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export function loadConfig() {
|
|||||||
const deviceNameInput = document.getElementById('deviceName');
|
const deviceNameInput = document.getElementById('deviceName');
|
||||||
const clientIdInput = document.getElementById('clientId');
|
const clientIdInput = document.getElementById('clientId');
|
||||||
const otaUrlInput = document.getElementById('otaUrl');
|
const otaUrlInput = document.getElementById('otaUrl');
|
||||||
|
const wakewordWsUrlInput = document.getElementById('wakewordWsUrl');
|
||||||
|
const wakewordEnabledInput = document.getElementById('wakewordEnabled');
|
||||||
|
const wakewordListInput = document.getElementById('wakewordList');
|
||||||
|
|
||||||
// 从localStorage加载MAC地址,如果没有则生成新的
|
// 从localStorage加载MAC地址,如果没有则生成新的
|
||||||
let savedMac = localStorage.getItem('xz_tester_deviceMac');
|
let savedMac = localStorage.getItem('xz_tester_deviceMac');
|
||||||
@@ -43,6 +46,21 @@ export function loadConfig() {
|
|||||||
if (savedOtaUrl) {
|
if (savedOtaUrl) {
|
||||||
otaUrlInput.value = 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 deviceMacInput = document.getElementById('deviceMac');
|
||||||
const deviceNameInput = document.getElementById('deviceName');
|
const deviceNameInput = document.getElementById('deviceName');
|
||||||
const clientIdInput = document.getElementById('clientId');
|
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_deviceMac', deviceMacInput.value);
|
||||||
localStorage.setItem('xz_tester_deviceName', deviceNameInput.value);
|
localStorage.setItem('xz_tester_deviceName', deviceNameInput.value);
|
||||||
localStorage.setItem('xz_tester_clientId', clientIdInput.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置值
|
// 获取配置值
|
||||||
|
|||||||
@@ -1,39 +1,56 @@
|
|||||||
import { uiController } from '../../ui/controller.js?v=0205';
|
import { uiController } from '../../ui/controller.js?v=0205';
|
||||||
import { log } from '../../utils/logger.js?v=0205';
|
import { log } from '../../utils/logger.js?v=0205';
|
||||||
|
|
||||||
const BRIDGE_URL_CANDIDATES = [
|
let wakewordSocket = null;
|
||||||
`${window.location.origin}/events`
|
let reconnectTimer = null;
|
||||||
];
|
let reconnectAttempts = 0;
|
||||||
|
let shouldReconnect = true;
|
||||||
|
let wakewordRequestSeq = 0;
|
||||||
|
|
||||||
let wakewordEventSource = null;
|
const pendingWakewordRequests = new Map();
|
||||||
|
|
||||||
export function startWakewordBridgeListener() {
|
export function startWakewordBridgeListener() {
|
||||||
if (wakewordEventSource) {
|
if (wakewordSocket) {
|
||||||
return wakewordEventSource;
|
return wakewordSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shouldReconnect = true;
|
||||||
log('正在连接本地唤醒事件桥...', 'info');
|
log('正在连接本地唤醒事件桥...', 'info');
|
||||||
tryConnect(0);
|
tryConnect();
|
||||||
return wakewordEventSource;
|
return wakewordSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryConnect(index) {
|
function tryConnect() {
|
||||||
if (index >= BRIDGE_URL_CANDIDATES.length) {
|
const bridgeUrl = buildWakewordBridgeUrl();
|
||||||
log('未能连接到本地唤醒事件桥,请确认 test runtime 已启动', 'warning');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bridgeUrl = BRIDGE_URL_CANDIDATES[index];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
wakewordEventSource = new EventSource(bridgeUrl);
|
wakewordSocket = new WebSocket(bridgeUrl);
|
||||||
wakewordEventSource.onopen = () => {
|
wakewordSocket.onopen = () => {
|
||||||
|
reconnectAttempts = 0;
|
||||||
log(`本地唤醒事件桥已连接: ${bridgeUrl}`, 'success');
|
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 {
|
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') {
|
if (message.type === 'bridge_connected') {
|
||||||
log('本地唤醒监听已就绪', 'info');
|
log('本地唤醒监听已就绪', 'info');
|
||||||
return;
|
return;
|
||||||
@@ -44,6 +61,12 @@ function tryConnect(index) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === 'wakeword_config') {
|
||||||
|
uiController.applyWakewordConfig(message.payload || {});
|
||||||
|
log('已同步本地唤醒词配置', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === 'service_stopping') {
|
if (message.type === 'service_stopping') {
|
||||||
log('本地唤醒服务正在停止', 'warning');
|
log('本地唤醒服务正在停止', 'warning');
|
||||||
return;
|
return;
|
||||||
@@ -59,34 +82,126 @@ function tryConnect(index) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
wakewordEventSource.onerror = () => {
|
wakewordSocket.onclose = () => {
|
||||||
log(`本地唤醒事件桥连接异常: ${bridgeUrl}`, 'warning');
|
if (wakewordSocket) {
|
||||||
if (wakewordEventSource) {
|
wakewordSocket = null;
|
||||||
wakewordEventSource.close();
|
|
||||||
wakewordEventSource = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index + 1 < BRIDGE_URL_CANDIDATES.length) {
|
rejectAllWakewordRequests('本地唤醒事件桥已断开');
|
||||||
log(`尝试备用地址: ${BRIDGE_URL_CANDIDATES[index + 1]}`, 'info');
|
|
||||||
tryConnect(index + 1);
|
if (!shouldReconnect) {
|
||||||
return;
|
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) {
|
} catch (error) {
|
||||||
log(`启动本地唤醒监听失败: ${error.message}`, 'error');
|
log(`启动本地唤醒监听失败: ${error.message}`, 'error');
|
||||||
return tryConnect(index + 1);
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopWakewordBridgeListener() {
|
export function stopWakewordBridgeListener() {
|
||||||
if (!wakewordEventSource) {
|
shouldReconnect = false;
|
||||||
|
|
||||||
|
if (reconnectTimer) {
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wakewordSocket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
wakewordEventSource.close();
|
wakewordSocket.close();
|
||||||
wakewordEventSource = null;
|
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();
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
import { loadConfig, saveConfig } from '../config/manager.js?v=0205';
|
import { loadConfig, saveConfig } from '../config/manager.js?v=0205';
|
||||||
import { getAudioPlayer } from '../core/audio/player.js?v=0205';
|
import { getAudioPlayer } from '../core/audio/player.js?v=0205';
|
||||||
import { getAudioRecorder } from '../core/audio/recorder.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 { getWebSocketHandler } from '../core/network/websocket.js?v=0205';
|
||||||
|
import { log } from '../utils/logger.js?v=0205';
|
||||||
|
|
||||||
// UI controller class
|
// UI controller class
|
||||||
class UIController {
|
class UIController {
|
||||||
@@ -14,6 +16,8 @@ class UIController {
|
|||||||
this.currentBackgroundIndex = localStorage.getItem('backgroundIndex') ? parseInt(localStorage.getItem('backgroundIndex')) : 0;
|
this.currentBackgroundIndex = localStorage.getItem('backgroundIndex') ? parseInt(localStorage.getItem('backgroundIndex')) : 0;
|
||||||
this.backgroundImages = ['1.png', '2.png', '3.png'];
|
this.backgroundImages = ['1.png', '2.png', '3.png'];
|
||||||
this.dialBtnDisabled = false;
|
this.dialBtnDisabled = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.lastWakewordDialTime = 0;
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.init = this.init.bind(this);
|
this.init = this.init.bind(this);
|
||||||
@@ -25,6 +29,9 @@ class UIController {
|
|||||||
this.showModal = this.showModal.bind(this);
|
this.showModal = this.showModal.bind(this);
|
||||||
this.hideModal = this.hideModal.bind(this);
|
this.hideModal = this.hideModal.bind(this);
|
||||||
this.switchTab = this.switchTab.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
|
// Initialize
|
||||||
@@ -262,6 +269,11 @@ class UIController {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const applyWakewordBtn = document.getElementById('applyWakewordBtn');
|
||||||
|
if (applyWakewordBtn) {
|
||||||
|
applyWakewordBtn.addEventListener('click', this.handleApplyWakeword);
|
||||||
|
}
|
||||||
|
|
||||||
// 点击模态框背景关闭(仅对特定模态框禁用此功能)
|
// 点击模态框背景关闭(仅对特定模态框禁用此功能)
|
||||||
const modals = document.querySelectorAll('.modal');
|
const modals = document.querySelectorAll('.modal');
|
||||||
modals.forEach(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
|
// Start AI chat session after connection
|
||||||
startAIChatSession() {
|
startAIChatSession() {
|
||||||
this.addChatMessage('连接成功,开始聊天吧~😊', false);
|
this.addChatMessage('连接成功,开始聊天吧~😊', false);
|
||||||
@@ -550,47 +630,51 @@ class UIController {
|
|||||||
|
|
||||||
// Handle connect button click
|
// Handle connect button click
|
||||||
async handleConnect() {
|
async handleConnect() {
|
||||||
console.log('handleConnect called');
|
const wsHandler = getWebSocketHandler();
|
||||||
|
if (this.isConnecting || (wsHandler && wsHandler.isConnected())) {
|
||||||
// Switch to device settings tab
|
log('连接已存在或正在进行,忽略本次拨号请求', 'info');
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const otaUrl = otaUrlInput.value;
|
this.isConnecting = true;
|
||||||
console.log('otaUrl value:', otaUrl);
|
console.log('handleConnect called');
|
||||||
|
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
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
|
// Get WebSocket handler instance
|
||||||
const wsHandler = getWebSocketHandler();
|
|
||||||
|
|
||||||
// Register connection state callback BEFORE connecting
|
// Register connection state callback BEFORE connecting
|
||||||
wsHandler.onConnectionStateChange = (isConnected) => {
|
wsHandler.onConnectionStateChange = (isConnected) => {
|
||||||
this.updateConnectionUI(isConnected);
|
this.updateConnectionUI(isConnected);
|
||||||
@@ -670,9 +754,36 @@ class UIController {
|
|||||||
dialBtn.classList.remove('dial-active');
|
dialBtn.classList.remove('dial-active');
|
||||||
console.log('Dial button state restored successfully');
|
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
|
// Add MCP tool
|
||||||
addMCPTool() {
|
addMCPTool() {
|
||||||
const mcpToolsList = document.getElementById('mcpToolsList');
|
const mcpToolsList = document.getElementById('mcpToolsList');
|
||||||
|
|||||||
@@ -1,30 +1,100 @@
|
|||||||
|
import os
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
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.config import load_config, setup_logging
|
||||||
from wakeword_runtime.runtime import TestRuntimeApplication, TestRuntimeHttpServer
|
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:
|
def main() -> int:
|
||||||
test_root = Path(__file__).resolve().parent
|
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)
|
setup_logging(config.log_file, config.log_level)
|
||||||
http_server = TestRuntimeHttpServer(test_root)
|
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"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(f"wakeword enabled: {config.wakeword_enabled}")
|
||||||
print("press Ctrl+C to stop")
|
print("press Ctrl+C to stop")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app.setup()
|
with app_lock:
|
||||||
app.start()
|
app.setup()
|
||||||
|
app.start()
|
||||||
http_server.serve_forever()
|
http_server.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("test runtime stopped")
|
print("test runtime stopped")
|
||||||
finally:
|
finally:
|
||||||
app.shutdown()
|
with app_lock:
|
||||||
|
app.shutdown()
|
||||||
http_server.shutdown()
|
http_server.shutdown()
|
||||||
|
lock.release()
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>小智服务器测试页面</title>
|
<title>小智服务器测试页面</title>
|
||||||
<link rel="stylesheet" href="css/test_page.css?v=0205">
|
<link rel="stylesheet" href="css/test_page.css?v=0206">
|
||||||
<script>
|
<script>
|
||||||
// 检测是否使用file://协议打开
|
// 检测是否使用file://协议打开
|
||||||
if (window.location.protocol === 'file:') {
|
if (window.location.protocol === 'file:') {
|
||||||
@@ -137,6 +137,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="settings-tabs">
|
<div class="settings-tabs">
|
||||||
<button class="tab-btn active" data-tab="device">设备配置</button>
|
<button class="tab-btn active" data-tab="device">设备配置</button>
|
||||||
|
<button class="tab-btn" data-tab="wakeword">唤醒词</button>
|
||||||
<button class="tab-btn" data-tab="mcp">MCP工具</button>
|
<button class="tab-btn" data-tab="mcp">MCP工具</button>
|
||||||
<button class="tab-btn" data-tab="other">数字人皮肤</button>
|
<button class="tab-btn" data-tab="other">数字人皮肤</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,6 +185,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="wakewordTab">
|
||||||
|
<div class="config-panel">
|
||||||
|
<div class="control-panel">
|
||||||
|
<div class="config-row">
|
||||||
|
<div class="config-item">
|
||||||
|
<label for="wakewordEnabled">启用本地唤醒词:</label>
|
||||||
|
<select id="wakewordEnabled" class="model-select">
|
||||||
|
<option value="true">启用</option>
|
||||||
|
<option value="false">禁用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<div class="config-item">
|
||||||
|
<label for="wakewordWsUrl">唤醒词服务地址:</label>
|
||||||
|
<input type="text" id="wakewordWsUrl" placeholder="ws://127.0.0.1:8006/wakeword-ws">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<div class="config-item">
|
||||||
|
<label for="wakewordList">唤醒词配置 (一行一个):</label>
|
||||||
|
<textarea id="wakewordList" placeholder="例如: 小智小智 你好小智"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-primary" id="applyWakewordBtn">应用唤醒词</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-content" id="mcpTab">
|
<div class="tab-content" id="mcpTab">
|
||||||
<!-- MCP 工具管理区域 -->
|
<!-- MCP 工具管理区域 -->
|
||||||
<div class="mcp-tools-container">
|
<div class="mcp-tools-container">
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# Test 页面语音唤醒
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Test 页面集成了基于 **Sherpa-ONNX** 的高精度语音唤醒功能,支持自定义唤醒词和实时检测。使用轻量级关键词检测模型,提供毫秒级响应速度。
|
||||||
|
|
||||||
|
## 唤醒词模型
|
||||||
|
|
||||||
|
### 模型下载(必需)
|
||||||
|
|
||||||
|
**重要说明**: 项目不包含模型文件,需要提前下载配置。
|
||||||
|
|
||||||
|
### 官方模型下载地址
|
||||||
|
|
||||||
|
- **官方模型列表**: <https://csukuangfj.github.io/sherpa/onnx/kws/pretrained_models/index.html>
|
||||||
|
- **推荐模型**: `sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01`
|
||||||
|
|
||||||
|
### 下载和配置步骤
|
||||||
|
|
||||||
|
#### 1. 下载模型包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方法1:直接下载(推荐)
|
||||||
|
cd main/xiaozhi-server/test
|
||||||
|
wget https://github.com/k2-fsa/sherpa-onnx/releases/download/kws-models/sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01.tar.bz2
|
||||||
|
|
||||||
|
# 解压
|
||||||
|
tar xvf sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01.tar.bz2
|
||||||
|
|
||||||
|
# 方法2:使用ModelScope
|
||||||
|
pip install modelscope
|
||||||
|
python -c "
|
||||||
|
from modelscope import snapshot_download
|
||||||
|
snapshot_download('pkufool/sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01', cache_dir='./models')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 配置模型文件
|
||||||
|
|
||||||
|
模型包下载后包含以下文件:
|
||||||
|
|
||||||
|
```
|
||||||
|
sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01/
|
||||||
|
├── encoder-epoch-12-avg-2-chunk-16-left-64.int8.onnx # 速度优先
|
||||||
|
├── encoder-epoch-12-avg-2-chunk-16-left-64.onnx #
|
||||||
|
├── encoder-epoch-99-avg-1-chunk-16-left-64.int8.onnx # 速度优先
|
||||||
|
├── encoder-epoch-99-avg-1-chunk-16-left-64.onnx # 精度优先
|
||||||
|
├── decoder-epoch-12-avg-2-chunk-16-left-64.onnx #
|
||||||
|
├── decoder-epoch-99-avg-1-chunk-16-left-64.onnx # 精度优先
|
||||||
|
├── joiner-epoch-12-avg-2-chunk-16-left-64.int8.onnx # 速度优先
|
||||||
|
├── joiner-epoch-12-avg-2-chunk-16-left-64.onnx #
|
||||||
|
├── joiner-epoch-99-avg-1-chunk-16-left-64.int8.onnx # 速度优先
|
||||||
|
├── joiner-epoch-99-avg-1-chunk-16-left-64.onnx # 精度优先
|
||||||
|
├── tokens.txt # Token映射表(必需)
|
||||||
|
├── keywords_raw.txt # 模型包里可能附带(可选,runtime 不依赖)
|
||||||
|
├── keywords.txt # 现成的
|
||||||
|
├── test_wavs/ # 测试音频(可选)
|
||||||
|
├── configuration.json # 模型元信息(可选)
|
||||||
|
└── README.md # 说明文档(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 选择配置方案
|
||||||
|
|
||||||
|
**方案一:精度优先(推荐)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01
|
||||||
|
|
||||||
|
# 复制精度优先的epoch-99 fp32三件套
|
||||||
|
cp encoder-epoch-99-avg-1-chunk-16-left-64.onnx ../models/encoder.onnx
|
||||||
|
cp decoder-epoch-99-avg-1-chunk-16-left-64.onnx ../models/decoder.onnx
|
||||||
|
cp joiner-epoch-99-avg-1-chunk-16-left-64.onnx ../models/joiner.onnx
|
||||||
|
|
||||||
|
# 复制配套文件
|
||||||
|
cp tokens.txt ../models/tokens.txt
|
||||||
|
# keywords_raw.txt 如果模型包里附带,可自行保留;test runtime 不依赖它
|
||||||
|
```
|
||||||
|
|
||||||
|
**方案二:速度优先**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01
|
||||||
|
|
||||||
|
# 复制速度优先的epoch-99 int8三件套
|
||||||
|
cp encoder-epoch-99-avg-1-chunk-16-left-64.int8.onnx ../models/encoder.onnx
|
||||||
|
cp decoder-epoch-99-avg-1-chunk-16-left-64.onnx ../models/decoder.onnx
|
||||||
|
cp joiner-epoch-99-avg-1-chunk-16-left-64.int8.onnx ../models/joiner.onnx
|
||||||
|
|
||||||
|
# 复制配套文件
|
||||||
|
cp tokens.txt ../models/tokens.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意事项**:
|
||||||
|
|
||||||
|
- **不要混用fp32与int8**:三个模型文件必须保持一致的精度
|
||||||
|
- **优先选择epoch-99**:比epoch-12训练更充分,精度更高
|
||||||
|
- **必需文件**:`encoder.onnx` + `decoder.onnx` + `joiner.onnx` + `tokens.txt` + `keywords.txt`
|
||||||
|
|
||||||
|
### 最终模型文件结构
|
||||||
|
|
||||||
|
配置完成后,你的models目录应该包含:
|
||||||
|
|
||||||
|
```
|
||||||
|
models/
|
||||||
|
├── encoder.onnx # 编码器模型(重命名后)
|
||||||
|
├── decoder.onnx # 解码器模型(重命名后)
|
||||||
|
├── joiner.onnx # 连接器模型(重命名后)
|
||||||
|
├── tokens.txt # 拼音Token映射表(228行版本)
|
||||||
|
├── keywords.txt # 关键词配置文件(需创建)
|
||||||
|
└── keywords_raw.txt # 可选,runtime 不依赖
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启动方式
|
||||||
|
|
||||||
|
在 `main/xiaozhi-server/test` 目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python start_test_runtime.py
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后默认地址:
|
||||||
|
|
||||||
|
- 页面地址:`http://127.0.0.1:8006/test_page.html`
|
||||||
|
- 事件桥地址:`ws://127.0.0.1:8006/wakeword-ws`
|
||||||
|
- 健康检查:`http://127.0.0.1:8006/health`
|
||||||
|
|
||||||
|
停止方式:
|
||||||
|
|
||||||
|
- 在运行终端按 `Ctrl+C`
|
||||||
|
- 会同时停止静态页面服务、事件桥和唤醒词检测流程
|
||||||
|
|
||||||
|
## 运行依赖
|
||||||
|
|
||||||
|
### 模型文件
|
||||||
|
|
||||||
|
模型目录至少需要这些文件:
|
||||||
|
|
||||||
|
- `encoder.onnx`
|
||||||
|
- `decoder.onnx`
|
||||||
|
- `joiner.onnx`
|
||||||
|
- `tokens.txt`
|
||||||
|
- `keywords.txt`
|
||||||
|
|
||||||
|
### Python 依赖
|
||||||
|
|
||||||
|
唤醒词服务依赖以下 Python 包:
|
||||||
|
|
||||||
|
- `sherpa-onnx`
|
||||||
|
- `sounddevice`
|
||||||
|
- `pypinyin`
|
||||||
|
|
||||||
|
**pip 安装(推荐)**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 conda 环境中
|
||||||
|
conda activate xiaozhi-esp32-server
|
||||||
|
cd main/xiaozhi-server/test/wakeword_runtime/
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
> 如使用独立 conda 环境,可同时安装:
|
||||||
|
> ```bash
|
||||||
|
> conda create -n wakeword python=3.10 -y
|
||||||
|
> conda activate wakeword
|
||||||
|
> cd main/xiaozhi-server/test/wakeword_runtime/
|
||||||
|
> pip install -r requirements.txt
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## 配置文件说明
|
||||||
|
|
||||||
|
配置文件位于 [config.json](./config.json)。
|
||||||
|
|
||||||
|
当前主要配置项:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"wakeword": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"model_dir": "models",
|
||||||
|
"audio": {
|
||||||
|
"input_device": null,
|
||||||
|
"sample_rate": 16000,
|
||||||
|
"channels": 1
|
||||||
|
},
|
||||||
|
"detector": {
|
||||||
|
"num_threads": 4,
|
||||||
|
"provider": "cpu",
|
||||||
|
"max_active_paths": 2,
|
||||||
|
"keywords_score": 1.8,
|
||||||
|
"keywords_threshold": 0.1,
|
||||||
|
"num_trailing_blanks": 1,
|
||||||
|
"cooldown_seconds": 1.5
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO",
|
||||||
|
"dir": "logs",
|
||||||
|
"file": "wakeword-runtime.log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
各字段含义:
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `wakeword.enabled` | 是否启用本地唤醒词检测 |
|
||||||
|
| `model_dir` | 模型和词表所在目录 |
|
||||||
|
| `audio.input_device` | 麦克风输入设备,默认使用系统默认设备 |
|
||||||
|
| `audio.sample_rate` | 采样率,默认 `16000` |
|
||||||
|
| `audio.channels` | 声道数,默认 `1` |
|
||||||
|
| `detector.num_threads` | 检测器线程数 |
|
||||||
|
| `detector.provider` | 推理 provider,当前通常为 `cpu` |
|
||||||
|
| `detector.max_active_paths` | 搜索路径数 |
|
||||||
|
| `detector.keywords_score` | 关键词增强分数 |
|
||||||
|
| `detector.keywords_threshold` | 检测阈值 |
|
||||||
|
| `detector.num_trailing_blanks` | 尾随空白数量 |
|
||||||
|
| `detector.cooldown_seconds` | 连续触发冷却时间 |
|
||||||
|
| `logging.level` | 日志等级 |
|
||||||
|
| `logging.dir` | 日志目录 |
|
||||||
|
| `logging.file` | 日志文件名 |
|
||||||
|
|
||||||
|
## 推荐使用流程
|
||||||
|
|
||||||
|
### 首次使用
|
||||||
|
|
||||||
|
1. 准备 `models/` 目录下的模型文件和 `tokens.txt`
|
||||||
|
2. 确认 `models/keywords.txt` 存在
|
||||||
|
3. 在 `test` 目录运行 `python start_test_runtime.py`
|
||||||
|
4. 浏览器打开 `http://127.0.0.1:8006/test_page.html`
|
||||||
|
5. 进入设置页检查“唤醒词”配置
|
||||||
|
|
||||||
|
### 修改唤醒词
|
||||||
|
|
||||||
|
1. 打开 test 页面设置
|
||||||
|
2. 切到“唤醒词”页签
|
||||||
|
3. 修改启用状态或唤醒词列表
|
||||||
|
4. 点击“应用唤醒词”
|
||||||
|
5. 根据提示决定是否立即重启
|
||||||
|
|
||||||
|
### 禁用唤醒词
|
||||||
|
|
||||||
|
1. 将“启用本地唤醒词”改成禁用
|
||||||
|
2. 点击“应用唤醒词”
|
||||||
|
3. 建议立即重启一次
|
||||||
|
|
||||||
|
禁用后:
|
||||||
|
|
||||||
|
- 页面与事件桥仍然可用
|
||||||
|
- 唤醒词检测不会继续运行
|
||||||
@@ -19,12 +19,9 @@ class WakewordEventBridge:
|
|||||||
return self._running
|
return self._running
|
||||||
|
|
||||||
def build_ready_message(self) -> str:
|
def build_ready_message(self) -> str:
|
||||||
return json.dumps(
|
return self.build_message(
|
||||||
{
|
"bridge_connected",
|
||||||
"type": "bridge_connected",
|
{"status": "ready"},
|
||||||
"payload": {"status": "ready"},
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def publish_detected(self, wake_word: str) -> None:
|
def publish_detected(self, wake_word: str) -> None:
|
||||||
@@ -40,13 +37,7 @@ class WakewordEventBridge:
|
|||||||
if not self._running:
|
if not self._running:
|
||||||
return
|
return
|
||||||
|
|
||||||
message = json.dumps(
|
message = self.build_message(event_type, payload or {})
|
||||||
{
|
|
||||||
"type": event_type,
|
|
||||||
"payload": payload or {},
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
with self._clients_lock:
|
with self._clients_lock:
|
||||||
clients = list(self._clients)
|
clients = list(self._clients)
|
||||||
|
|
||||||
@@ -69,6 +60,24 @@ class WakewordEventBridge:
|
|||||||
self._clients.append(client_queue)
|
self._clients.append(client_queue)
|
||||||
return client_queue
|
return client_queue
|
||||||
|
|
||||||
|
def build_message(
|
||||||
|
self,
|
||||||
|
event_type: str,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
request_id: str | None = None,
|
||||||
|
success: bool = True,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
message: dict[str, Any] = {
|
||||||
|
"type": event_type,
|
||||||
|
"requestId": request_id,
|
||||||
|
"success": success,
|
||||||
|
"payload": payload or {},
|
||||||
|
}
|
||||||
|
if error:
|
||||||
|
message["error"] = error
|
||||||
|
return json.dumps(message, ensure_ascii=False)
|
||||||
|
|
||||||
def remove_client(self, client_queue: queue.Queue[str]) -> None:
|
def remove_client(self, client_queue: queue.Queue[str]) -> None:
|
||||||
with self._clients_lock:
|
with self._clients_lock:
|
||||||
if client_queue in self._clients:
|
if client_queue in self._clients:
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"wakeword": {
|
"wakeword": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"wake_word": "你好小智",
|
|
||||||
"model_dir": "models",
|
"model_dir": "models",
|
||||||
"audio": {
|
"audio": {
|
||||||
"input_device": null,
|
"input_device": null,
|
||||||
@@ -14,7 +13,7 @@
|
|||||||
"provider": "cpu",
|
"provider": "cpu",
|
||||||
"max_active_paths": 2,
|
"max_active_paths": 2,
|
||||||
"keywords_score": 1.8,
|
"keywords_score": 1.8,
|
||||||
"keywords_threshold": 0.05,
|
"keywords_threshold": 0.1,
|
||||||
"num_trailing_blanks": 1,
|
"num_trailing_blanks": 1,
|
||||||
"cooldown_seconds": 1.5
|
"cooldown_seconds": 1.5
|
||||||
},
|
},
|
||||||
@@ -22,8 +21,5 @@
|
|||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"dir": "logs",
|
"dir": "logs",
|
||||||
"file": "wakeword-runtime.log"
|
"file": "wakeword-runtime.log"
|
||||||
},
|
}
|
||||||
"wake_words": [
|
}
|
||||||
"你好小智"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -43,11 +42,10 @@ class RuntimeConfig:
|
|||||||
audio: AudioSettings
|
audio: AudioSettings
|
||||||
detector: DetectorSettings
|
detector: DetectorSettings
|
||||||
logging: LoggingSettings
|
logging: LoggingSettings
|
||||||
raw: dict[str, Any]
|
|
||||||
|
|
||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
if self.wakeword.enabled and not self.wake_words:
|
if self.wakeword.enabled and not self.wake_words:
|
||||||
raise ValueError("wake_word or wake_words cannot be empty when wakeword is enabled")
|
raise ValueError("keywords.txt cannot be empty when wakeword is enabled")
|
||||||
|
|
||||||
if self.audio.sample_rate <= 0:
|
if self.audio.sample_rate <= 0:
|
||||||
raise ValueError("audio.sample_rate must be greater than 0")
|
raise ValueError("audio.sample_rate must be greater than 0")
|
||||||
@@ -85,17 +83,9 @@ def load_config(runtime_root: Path) -> RuntimeConfig:
|
|||||||
config_path = runtime_root / "config.json"
|
config_path = runtime_root / "config.json"
|
||||||
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
wakeword_cfg = dict(raw.get("wakeword", {}))
|
wakeword_cfg = dict(raw.get("wakeword", {}))
|
||||||
raw_words = raw.get("wake_words")
|
|
||||||
wake_words: list[str] = []
|
|
||||||
if isinstance(raw_words, list):
|
|
||||||
wake_words = [str(item).strip() for item in raw_words if str(item).strip()]
|
|
||||||
if not wake_words:
|
|
||||||
wake_word = str(raw.get("wake_word", "")).strip()
|
|
||||||
if wake_word:
|
|
||||||
wake_words = [wake_word]
|
|
||||||
|
|
||||||
raw_model_dir = Path(str(raw.get("model_dir", "models")))
|
raw_model_dir = Path(str(raw.get("model_dir", "models")))
|
||||||
model_dir = raw_model_dir.resolve() if raw_model_dir.is_absolute() else (runtime_root / raw_model_dir).resolve()
|
model_dir = raw_model_dir.resolve() if raw_model_dir.is_absolute() else (runtime_root / raw_model_dir).resolve()
|
||||||
|
wake_words = _load_wake_words_from_keywords_file(model_dir)
|
||||||
|
|
||||||
audio_cfg = dict(raw.get("audio", {}))
|
audio_cfg = dict(raw.get("audio", {}))
|
||||||
detector_cfg = dict(raw.get("detector", {}))
|
detector_cfg = dict(raw.get("detector", {}))
|
||||||
@@ -125,7 +115,24 @@ def load_config(runtime_root: Path) -> RuntimeConfig:
|
|||||||
directory=str(logging_cfg.get("dir", "logs")),
|
directory=str(logging_cfg.get("dir", "logs")),
|
||||||
file_name=str(logging_cfg.get("file", "wakeword-runtime.log")),
|
file_name=str(logging_cfg.get("file", "wakeword-runtime.log")),
|
||||||
),
|
),
|
||||||
raw=raw,
|
|
||||||
)
|
)
|
||||||
config.validate()
|
config.validate()
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _load_wake_words_from_keywords_file(model_dir: Path) -> list[str]:
|
||||||
|
keywords_file = model_dir / "keywords.txt"
|
||||||
|
if not keywords_file.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
wake_words: list[str] = []
|
||||||
|
for line in keywords_file.read_text(encoding="utf-8").splitlines():
|
||||||
|
text = line.strip()
|
||||||
|
if not text or text.startswith("#") or "@" not in text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
wake_word = text.split("@", 1)[1].strip()
|
||||||
|
if wake_word:
|
||||||
|
wake_words.append(wake_word)
|
||||||
|
|
||||||
|
return wake_words
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import queue
|
|||||||
import threading
|
import threading
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -14,14 +12,6 @@ from .detector_assets import DetectorAssetsBuilder
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DetectorBootstrapReport:
|
|
||||||
ready: bool
|
|
||||||
model_root: Path | None
|
|
||||||
keywords_file: Path | None
|
|
||||||
wake_words: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class WakewordDetector:
|
class WakewordDetector:
|
||||||
def __init__(self, config: RuntimeConfig) -> None:
|
def __init__(self, config: RuntimeConfig) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -39,7 +29,7 @@ class WakewordDetector:
|
|||||||
self.last_detection_time = 0.0
|
self.last_detection_time = 0.0
|
||||||
self.detection_cooldown = self.config.detector.cooldown_seconds
|
self.detection_cooldown = self.config.detector.cooldown_seconds
|
||||||
|
|
||||||
def initialize(self) -> DetectorBootstrapReport:
|
def initialize(self) -> None:
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
raise RuntimeError("wakeword detector is disabled")
|
raise RuntimeError("wakeword detector is disabled")
|
||||||
|
|
||||||
@@ -69,17 +59,9 @@ class WakewordDetector:
|
|||||||
provider=detector_cfg.provider,
|
provider=detector_cfg.provider,
|
||||||
)
|
)
|
||||||
self.stream = self.keyword_spotter.create_stream()
|
self.stream = self.keyword_spotter.create_stream()
|
||||||
|
|
||||||
report = DetectorBootstrapReport(
|
|
||||||
ready=True,
|
|
||||||
model_root=assets.model_root,
|
|
||||||
keywords_file=assets.keywords_file,
|
|
||||||
wake_words=self.config.wake_words,
|
|
||||||
)
|
|
||||||
logger.info("detector initialized")
|
logger.info("detector initialized")
|
||||||
logger.info("detector model root: %s", assets.model_root)
|
logger.info("detector model root: %s", assets.model_root)
|
||||||
logger.info("detector keywords file: %s", assets.keywords_file)
|
logger.info("detector keywords file: %s", assets.keywords_file)
|
||||||
return report
|
|
||||||
|
|
||||||
def on_detected(self, callback: Callable[[str, str], None]) -> None:
|
def on_detected(self, callback: Callable[[str, str], None]) -> None:
|
||||||
self.on_detected_callback = callback
|
self.on_detected_callback = callback
|
||||||
@@ -123,6 +105,9 @@ class WakewordDetector:
|
|||||||
logger.info("wakeword detector started")
|
logger.info("wakeword detector started")
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
|
if not self.is_running_flag and self.audio_source is None and self._worker_thread is None:
|
||||||
|
return
|
||||||
|
|
||||||
self.is_running_flag = False
|
self.is_running_flag = False
|
||||||
|
|
||||||
if self.audio_source is not None:
|
if self.audio_source is not None:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class DetectorAssetsBuilder:
|
|||||||
|
|
||||||
wake_words = self.config.wake_words
|
wake_words = self.config.wake_words
|
||||||
if not wake_words:
|
if not wake_words:
|
||||||
raise ValueError("wake_word or wake_words cannot be empty")
|
raise ValueError("keywords.txt cannot be empty")
|
||||||
|
|
||||||
keywords_path = model_root / "keywords.txt"
|
keywords_path = model_root / "keywords.txt"
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ class MicrophoneListener:
|
|||||||
logger.info("microphone block size: %s", self._block_size)
|
logger.info("microphone block size: %s", self._block_size)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
|
if not self._running and self._stream is None:
|
||||||
|
return
|
||||||
|
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._stream is not None:
|
if self._stream is not None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class AudioPlugin(Plugin):
|
|||||||
self.source.stop()
|
self.source.stop()
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
self.stop()
|
|
||||||
if self.app is not None:
|
if self.app is not None:
|
||||||
self.app.audio_source = None
|
self.app.audio_source = None
|
||||||
|
self.source = None
|
||||||
|
self.app = None
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ class WakeWordPlugin(Plugin):
|
|||||||
self.detector.stop()
|
self.detector.stop()
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
self.stop()
|
self.detector = None
|
||||||
|
self.app = None
|
||||||
|
|
||||||
def _on_detected(self, wake_word: str, full_text: str) -> None:
|
def _on_detected(self, wake_word: str, full_text: str) -> None:
|
||||||
if self.app is None:
|
if self.app is None:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
sherpa-onnx==1.12.29
|
||||||
|
sounddevice>=0.4.4
|
||||||
|
pypinyin==0.55.0
|
||||||
@@ -1,17 +1,27 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
from ..config import RuntimeConfig
|
from ..config import RuntimeConfig
|
||||||
from ..plugins import AudioPlugin, PluginManager, WakeWordPlugin
|
from ..plugins import AudioPlugin, PluginManager, WakeWordPlugin
|
||||||
from .http_server import TestRuntimeHttpServer
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EventPublisher(Protocol):
|
||||||
|
def publish_service_ready(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def publish_service_stopping(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def publish_detected(self, wake_word: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
class TestRuntimeApplication:
|
class TestRuntimeApplication:
|
||||||
def __init__(self, config: RuntimeConfig, http_server: TestRuntimeHttpServer) -> None:
|
def __init__(self, config: RuntimeConfig, event_publisher: EventPublisher) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.http_server = http_server
|
self.event_bridge = event_publisher
|
||||||
self.event_bridge = http_server.event_bridge
|
|
||||||
self.plugins = PluginManager()
|
self.plugins = PluginManager()
|
||||||
self.audio_source = None
|
self.audio_source = None
|
||||||
self._is_setup = False
|
self._is_setup = False
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import queue
|
import queue
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from ..bridge import WakewordEventBridge
|
from ..bridge import WakewordEventBridge
|
||||||
|
from ..config.config_loader import load_config
|
||||||
|
|
||||||
|
|
||||||
class TestRuntimeHttpServer:
|
class TestRuntimeHttpServer:
|
||||||
@@ -13,6 +19,8 @@ class TestRuntimeHttpServer:
|
|||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.event_bridge = WakewordEventBridge()
|
self.event_bridge = WakewordEventBridge()
|
||||||
|
self._restart_handler: Callable[[], None] | None = None
|
||||||
|
self._restart_lock = threading.Lock()
|
||||||
self._server = self._build_server()
|
self._server = self._build_server()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -20,8 +28,8 @@ class TestRuntimeHttpServer:
|
|||||||
return f"http://127.0.0.1:{self.port}/test_page.html"
|
return f"http://127.0.0.1:{self.port}/test_page.html"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def events_url(self) -> str:
|
def bridge_url(self) -> str:
|
||||||
return f"http://127.0.0.1:{self.port}/events"
|
return f"ws://127.0.0.1:{self.port}/wakeword-ws"
|
||||||
|
|
||||||
def serve_forever(self) -> None:
|
def serve_forever(self) -> None:
|
||||||
self._server.serve_forever()
|
self._server.serve_forever()
|
||||||
@@ -31,9 +39,32 @@ class TestRuntimeHttpServer:
|
|||||||
self.event_bridge.close()
|
self.event_bridge.close()
|
||||||
self._server.server_close()
|
self._server.server_close()
|
||||||
|
|
||||||
|
def set_restart_handler(self, handler: Callable[[], None]) -> None:
|
||||||
|
self._restart_handler = handler
|
||||||
|
|
||||||
|
def request_runtime_restart(self) -> None:
|
||||||
|
with self._restart_lock:
|
||||||
|
handler = self._restart_handler
|
||||||
|
|
||||||
|
if handler is None:
|
||||||
|
raise RuntimeError("restart handler is not configured")
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=self._run_restart_handler,
|
||||||
|
name="test-runtime-restart",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _run_restart_handler(self) -> None:
|
||||||
|
handler = self._restart_handler
|
||||||
|
if handler is None:
|
||||||
|
return
|
||||||
|
handler()
|
||||||
|
|
||||||
def _build_server(self) -> ThreadingHTTPServer:
|
def _build_server(self) -> ThreadingHTTPServer:
|
||||||
test_root = self.test_root
|
test_root = self.test_root
|
||||||
event_bridge = self.event_bridge
|
event_bridge = self.event_bridge
|
||||||
|
schedule_restart = self.request_runtime_restart
|
||||||
|
|
||||||
class TestRuntimeHandler(SimpleHTTPRequestHandler):
|
class TestRuntimeHandler(SimpleHTTPRequestHandler):
|
||||||
protocol_version = "HTTP/1.1"
|
protocol_version = "HTTP/1.1"
|
||||||
@@ -48,8 +79,8 @@ class TestRuntimeHttpServer:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
if self.path == "/events":
|
if self.path == "/wakeword-ws":
|
||||||
self._handle_events(event_bridge)
|
self._handle_websocket(event_bridge)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.path == "/health":
|
if self.path == "/health":
|
||||||
@@ -67,36 +98,255 @@ class TestRuntimeHttpServer:
|
|||||||
def log_message(self, format: str, *args) -> None:
|
def log_message(self, format: str, *args) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
def _handle_events(self, bridge: WakewordEventBridge) -> None:
|
def _handle_websocket(self, bridge: WakewordEventBridge) -> None:
|
||||||
|
if self.headers.get("Upgrade", "").lower() != "websocket":
|
||||||
|
self.send_error(HTTPStatus.BAD_REQUEST, "expected websocket upgrade")
|
||||||
|
return
|
||||||
|
|
||||||
|
websocket_key = self.headers.get("Sec-WebSocket-Key")
|
||||||
|
if not websocket_key:
|
||||||
|
self.send_error(HTTPStatus.BAD_REQUEST, "missing Sec-WebSocket-Key")
|
||||||
|
return
|
||||||
|
|
||||||
|
accept_source = websocket_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||||
|
accept_value = base64.b64encode(
|
||||||
|
hashlib.sha1(accept_source.encode("utf-8")).digest()
|
||||||
|
).decode("ascii")
|
||||||
|
|
||||||
client_queue = bridge.add_client()
|
client_queue = bridge.add_client()
|
||||||
self.send_response(HTTPStatus.OK)
|
self.send_response(HTTPStatus.SWITCHING_PROTOCOLS)
|
||||||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
self.send_header("Upgrade", "websocket")
|
||||||
self.send_header("Cache-Control", "no-cache")
|
self.send_header("Connection", "Upgrade")
|
||||||
self.send_header("Connection", "keep-alive")
|
self.send_header("Sec-WebSocket-Accept", accept_value)
|
||||||
self.send_header("X-Accel-Buffering", "no")
|
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ready_message = bridge.build_ready_message()
|
self.connection.settimeout(0.2)
|
||||||
self.wfile.write(f"data: {ready_message}\n\n".encode("utf-8"))
|
self._send_websocket_text(bridge.build_ready_message())
|
||||||
self.wfile.flush()
|
self._send_websocket_text(self._build_wakeword_config_message(bridge))
|
||||||
|
|
||||||
while bridge.is_running:
|
while bridge.is_running:
|
||||||
|
inbound_message = self._receive_websocket_message()
|
||||||
|
if inbound_message is not None:
|
||||||
|
response_message = self._handle_bridge_request(bridge, inbound_message)
|
||||||
|
if response_message:
|
||||||
|
self._send_websocket_text(response_message)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
message = client_queue.get(timeout=15)
|
message = client_queue.get(timeout=0.2)
|
||||||
if message == "__bridge_closed__":
|
if message == "__bridge_closed__":
|
||||||
break
|
break
|
||||||
self.wfile.write(f"data: {message}\n\n".encode("utf-8"))
|
self._send_websocket_text(message)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
if not bridge.is_running:
|
if not bridge.is_running:
|
||||||
break
|
break
|
||||||
self.wfile.write(b": keepalive\n\n")
|
continue
|
||||||
self.wfile.flush()
|
except socket.timeout:
|
||||||
|
pass
|
||||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
bridge.remove_client(client_queue)
|
bridge.remove_client(client_queue)
|
||||||
|
|
||||||
|
def _build_wakeword_config_message(self, bridge: WakewordEventBridge) -> str:
|
||||||
|
try:
|
||||||
|
runtime_root = test_root / "wakeword_runtime"
|
||||||
|
config = load_config(runtime_root)
|
||||||
|
payload = {
|
||||||
|
"enabled": config.wakeword_enabled,
|
||||||
|
"wakeWords": config.wake_words,
|
||||||
|
}
|
||||||
|
return bridge.build_message("wakeword_config", payload)
|
||||||
|
except Exception as exc:
|
||||||
|
return bridge.build_message(
|
||||||
|
"wakeword_config",
|
||||||
|
{},
|
||||||
|
success=False,
|
||||||
|
error=f"读取唤醒词配置失败: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_bridge_request(self, bridge: WakewordEventBridge, raw_message: str) -> str | None:
|
||||||
|
try:
|
||||||
|
message = json.loads(raw_message)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message_type = str(message.get("type", "")).strip()
|
||||||
|
request_id = message.get("requestId")
|
||||||
|
payload = message.get("payload") or {}
|
||||||
|
result_type = f"{message_type}_result" if message_type else "bridge_request_result"
|
||||||
|
|
||||||
|
if message_type == "set_wakeword_config":
|
||||||
|
try:
|
||||||
|
result_payload = self._save_wakeword_config(payload)
|
||||||
|
bridge.publish("wakeword_config", result_payload)
|
||||||
|
return bridge.build_message(
|
||||||
|
"set_wakeword_config_result",
|
||||||
|
result_payload,
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return bridge.build_message(
|
||||||
|
"set_wakeword_config_result",
|
||||||
|
{},
|
||||||
|
request_id=request_id,
|
||||||
|
success=False,
|
||||||
|
error=f"保存唤醒词配置失败: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if message_type == "restart_wakeword_service":
|
||||||
|
schedule_restart()
|
||||||
|
return bridge.build_message(
|
||||||
|
"restart_wakeword_service_result",
|
||||||
|
{"restarting": True},
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return bridge.build_message(
|
||||||
|
result_type,
|
||||||
|
{},
|
||||||
|
request_id=request_id,
|
||||||
|
success=False,
|
||||||
|
error=f"unsupported message type: {message_type}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _save_wakeword_config(self, payload: dict) -> dict:
|
||||||
|
runtime_root = test_root / "wakeword_runtime"
|
||||||
|
config_path = runtime_root / "config.json"
|
||||||
|
model_root = runtime_root / "models"
|
||||||
|
keywords_path = model_root / "keywords.txt"
|
||||||
|
|
||||||
|
enabled = bool(payload.get("enabled", True))
|
||||||
|
wake_words = payload.get("wakeWords") or []
|
||||||
|
normalized_wake_words = []
|
||||||
|
for item in wake_words:
|
||||||
|
if not isinstance(item, str):
|
||||||
|
continue
|
||||||
|
text = item.strip()
|
||||||
|
if text and text not in normalized_wake_words:
|
||||||
|
normalized_wake_words.append(text)
|
||||||
|
|
||||||
|
if enabled and not normalized_wake_words:
|
||||||
|
raise ValueError("wakeWords cannot be empty when wakeword is enabled")
|
||||||
|
|
||||||
|
raw_config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
raw_config.setdefault("wakeword", {})["enabled"] = enabled
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(raw_config, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
keywords_lines = [self._build_keyword_line(item) for item in normalized_wake_words]
|
||||||
|
keywords_path.write_text(
|
||||||
|
("\n".join(keywords_lines) + "\n") if keywords_lines else "",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": enabled,
|
||||||
|
"wakeWords": normalized_wake_words,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_keyword_line(self, keyword_text: str) -> str:
|
||||||
|
from pypinyin import Style, pinyin
|
||||||
|
|
||||||
|
initials = pinyin(keyword_text, style=Style.INITIALS, strict=False)
|
||||||
|
finals = pinyin(
|
||||||
|
keyword_text,
|
||||||
|
style=Style.FINALS_TONE,
|
||||||
|
strict=False,
|
||||||
|
neutral_tone_with_five=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
tokens: list[str] = []
|
||||||
|
for initial_parts, final_parts in zip(initials, finals):
|
||||||
|
initial = initial_parts[0].strip()
|
||||||
|
final = final_parts[0].strip()
|
||||||
|
if initial:
|
||||||
|
tokens.append(initial)
|
||||||
|
if final:
|
||||||
|
tokens.append(final)
|
||||||
|
|
||||||
|
if not tokens:
|
||||||
|
raise ValueError(f"failed to generate pinyin tokens for wake word: {keyword_text}")
|
||||||
|
|
||||||
|
return f"{' '.join(tokens)} @{keyword_text}"
|
||||||
|
|
||||||
|
def _receive_websocket_message(self) -> str | None:
|
||||||
|
try:
|
||||||
|
header = self._read_exact(2)
|
||||||
|
except socket.timeout:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not header:
|
||||||
|
return None
|
||||||
|
|
||||||
|
first_byte, second_byte = header[0], header[1]
|
||||||
|
opcode = first_byte & 0x0F
|
||||||
|
masked = (second_byte & 0x80) != 0
|
||||||
|
payload_length = second_byte & 0x7F
|
||||||
|
|
||||||
|
if payload_length == 126:
|
||||||
|
payload_length = int.from_bytes(self._read_exact(2), "big")
|
||||||
|
elif payload_length == 127:
|
||||||
|
payload_length = int.from_bytes(self._read_exact(8), "big")
|
||||||
|
|
||||||
|
masking_key = self._read_exact(4) if masked else b""
|
||||||
|
payload = self._read_exact(payload_length) if payload_length else b""
|
||||||
|
|
||||||
|
if masked and payload:
|
||||||
|
payload = bytes(
|
||||||
|
byte ^ masking_key[index % 4]
|
||||||
|
for index, byte in enumerate(payload)
|
||||||
|
)
|
||||||
|
|
||||||
|
if opcode == 0x8:
|
||||||
|
raise ConnectionAbortedError("websocket closed by client")
|
||||||
|
|
||||||
|
if opcode == 0x9:
|
||||||
|
self._send_websocket_frame(0xA, payload)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if opcode == 0xA:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if opcode != 0x1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return payload.decode("utf-8")
|
||||||
|
|
||||||
|
def _read_exact(self, size: int) -> bytes:
|
||||||
|
if size <= 0:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
chunks = bytearray()
|
||||||
|
while len(chunks) < size:
|
||||||
|
chunk = self.connection.recv(size - len(chunks))
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionResetError("websocket connection closed")
|
||||||
|
chunks.extend(chunk)
|
||||||
|
return bytes(chunks)
|
||||||
|
|
||||||
|
def _send_websocket_text(self, message: str) -> None:
|
||||||
|
self._send_websocket_frame(0x1, message.encode("utf-8"))
|
||||||
|
|
||||||
|
def _send_websocket_frame(self, opcode: int, payload: bytes) -> None:
|
||||||
|
header = bytearray()
|
||||||
|
header.append(0x80 | opcode)
|
||||||
|
|
||||||
|
payload_length = len(payload)
|
||||||
|
if payload_length < 126:
|
||||||
|
header.append(payload_length)
|
||||||
|
elif payload_length < 65536:
|
||||||
|
header.append(126)
|
||||||
|
header.extend(payload_length.to_bytes(2, "big"))
|
||||||
|
else:
|
||||||
|
header.append(127)
|
||||||
|
header.extend(payload_length.to_bytes(8, "big"))
|
||||||
|
|
||||||
|
self.wfile.write(bytes(header) + payload)
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
server = ThreadingHTTPServer((self.host, self.port), TestRuntimeHandler)
|
server = ThreadingHTTPServer((self.host, self.port), TestRuntimeHandler)
|
||||||
server.daemon_threads = True
|
server.daemon_threads = True
|
||||||
return server
|
return server
|
||||||
|
|||||||
Reference in New Issue
Block a user