repair-agent 0bf556018e
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 3m38s
fix test bug
2026-03-03 16:00:16 +08:00

651 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>hw_service_go 硬件通讯测试</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
padding: 20px 24px;
}
.header h1 { font-size: 20px; font-weight: 600; }
.header p { font-size: 13px; opacity: 0.8; margin-top: 4px; }
.section {
padding: 16px 24px;
border-bottom: 1px solid #eee;
}
.section:last-child { border-bottom: none; }
.section-title {
font-size: 13px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.form-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.form-row:last-child { margin-bottom: 0; }
.form-row label {
min-width: 80px;
font-size: 14px;
font-weight: 500;
color: #555;
}
.form-row input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
font-family: 'SF Mono', Monaco, monospace;
outline: none;
transition: border-color 0.2s;
}
.form-row input:focus { border-color: #667eea; }
.btn {
padding: 8px 18px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #667eea; color: #fff; }
.btn-primary:hover:not(:disabled) { background: #5a6fd6; }
.btn-danger { background: #e74c3c; color: #fff; }
.btn-danger:hover:not(:disabled) { background: #c0392b; }
.btn-success { background: #27ae60; color: #fff; }
.btn-success:hover:not(:disabled) { background: #219a52; }
.btn-secondary { background: #95a5a6; color: #fff; }
.btn-secondary:hover:not(:disabled) { background: #7f8c8d; }
.btn-small { padding: 4px 10px; font-size: 12px; }
.controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #bdc3c7;
transition: background 0.3s;
}
.status-dot.connected { background: #27ae60; }
.status-dot.connecting { background: #f39c12; animation: pulse 1s infinite; }
.status-dot.error { background: #e74c3c; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.log-container {
background: #1e1e1e;
color: #d4d4d4;
border-radius: 8px;
padding: 12px;
height: 400px;
overflow-y: auto;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 12px;
line-height: 1.6;
}
.log-container::-webkit-scrollbar { width: 6px; }
.log-container::-webkit-scrollbar-track { background: transparent; }
.log-container::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
.log-entry { white-space: pre-wrap; word-break: break-all; }
.log-time { color: #858585; }
.log-send { color: #dcdcaa; }
.log-recv { color: #9cdcfe; }
.log-binary { color: #ce9178; }
.log-audio { color: #c586c0; }
.log-error { color: #f44747; }
.log-success { color: #6a9955; }
.log-warning { color: #d7ba7d; }
.stats-bar {
display: flex;
gap: 24px;
padding: 12px 24px;
background: #fafafa;
font-size: 13px;
color: #666;
}
.stats-bar span { font-weight: 600; color: #333; }
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
</style>
<!-- Opus WASM 解码库 -->
<script src="libopus.js"></script>
</head>
<body>
<div class="container">
<!-- 标题 -->
<div class="header">
<h1>hw_service_go 硬件通讯测试</h1>
<p>模拟 ESP32 硬件,测试 WebSocket 故事推送与 Opus 音频播放</p>
</div>
<!-- 连接配置 -->
<div class="section">
<div class="section-title">连接配置</div>
<div class="form-row">
<label>服务地址</label>
<input type="text" id="wsUrl" value="ws://localhost:8888/xiaozhi/v1/">
</div>
<div class="form-row">
<label>device-id</label>
<input type="text" id="deviceId" placeholder="AA:BB:CC:DD:EE:FF">
</div>
<div class="form-row">
<label>client-id</label>
<input type="text" id="clientId" value="">
<button class="btn btn-secondary btn-small" onclick="generateClientId()">随机生成</button>
</div>
</div>
<!-- 控制面板 -->
<div class="section">
<div class="controls">
<button class="btn btn-primary" id="btnConnect" onclick="connect()">连接</button>
<button class="btn btn-danger" id="btnDisconnect" onclick="disconnect()" disabled>断开</button>
<div style="width: 1px; height: 24px; background: #ddd;"></div>
<button class="btn btn-success" id="btnStory" onclick="triggerStory()" disabled>&#9654; 触发故事播放</button>
<button class="btn btn-danger" id="btnStop" onclick="stopPlayback()" disabled>&#9632; 停止</button>
<div style="flex:1"></div>
<div class="status-indicator">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">未连接</span>
</div>
</div>
</div>
<!-- 统计栏 -->
<div class="stats-bar">
<div>Opus 帧: <span id="statFrames">0</span></div>
<div>音频时长: <span id="statDuration">0.0s</span></div>
<div>Opus 库: <span id="statOpus">加载中...</span></div>
</div>
<!-- 消息日志 -->
<div class="section">
<div class="log-header">
<div class="section-title" style="margin-bottom:0">消息日志</div>
<button class="btn btn-secondary btn-small" onclick="clearLog()">清空</button>
</div>
<div class="log-container" id="logContainer"></div>
</div>
</div>
<script>
// ============================================================
// 全局状态
// ============================================================
let ws = null;
let audioCtx = null;
let opusDecoder = null;
let opusReady = false;
let handshaked = false; // hello 握手是否完成
// 播放状态
let opusFrameCount = 0;
let pcmBufferQueue = []; // Float32Array 队列
let isPlaying = false;
let nextPlayTime = 0;
// ============================================================
// 工具函数
// ============================================================
function $(id) { return document.getElementById(id); }
function log(msg, type = 'info') {
const container = $('logContainer');
const now = new Date();
const ts = `${now.toLocaleTimeString()}.${String(now.getMilliseconds()).padStart(3, '0')}`;
const entry = document.createElement('div');
entry.className = 'log-entry';
const typeClass = {
send: 'log-send',
recv: 'log-recv',
binary: 'log-binary',
audio: 'log-audio',
error: 'log-error',
success: 'log-success',
warning: 'log-warning',
}[type] || '';
const arrow = type === 'send' ? '→ ' : type === 'recv' ? '← ' : type === 'binary' ? '← ' : '';
entry.innerHTML = `<span class="log-time">[${ts}]</span> <span class="${typeClass}">${arrow}${escapeHtml(msg)}</span>`;
container.appendChild(entry);
container.scrollTop = container.scrollHeight;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function updateStatus(state, text) {
const dot = $('statusDot');
dot.className = 'status-dot';
if (state) dot.classList.add(state);
$('statusText').textContent = text;
}
function updateStats() {
$('statFrames').textContent = opusFrameCount;
const duration = (opusFrameCount * 60 / 1000).toFixed(1);
$('statDuration').textContent = `${duration}s`;
}
function generateClientId() {
const id = 'test-' + Math.random().toString(36).substring(2, 10);
$('clientId').value = id;
}
function clearLog() {
$('logContainer').innerHTML = '';
}
// ============================================================
// Opus 解码器初始化
// ============================================================
function initOpusDecoder() {
try {
let mod = null;
// 检查 Module.instance 或全局 Module
if (typeof Module !== 'undefined') {
if (Module.instance && typeof Module.instance._opus_decoder_get_size === 'function') {
mod = Module.instance;
} else if (typeof Module._opus_decoder_get_size === 'function') {
mod = Module;
}
}
if (!mod) {
log('Opus 库未就绪,等待加载...', 'warning');
$('statOpus').textContent = '加载失败';
return false;
}
const SAMPLE_RATE = 16000;
const CHANNELS = 1;
const FRAME_SIZE = 960; // 60ms @ 16kHz
// 获取解码器大小并分配内存
const decoderSize = mod._opus_decoder_get_size(CHANNELS);
const decoderPtr = mod._malloc(decoderSize);
if (!decoderPtr) throw new Error('无法分配解码器内存');
// 初始化解码器
const err = mod._opus_decoder_init(decoderPtr, SAMPLE_RATE, CHANNELS);
if (err < 0) throw new Error(`Opus 解码器初始化失败: ${err}`);
opusDecoder = {
mod,
decoderPtr,
frameSize: FRAME_SIZE,
decode(opusData) {
// 为 Opus 数据分配内存
const opusPtr = mod._malloc(opusData.length);
mod.HEAPU8.set(opusData, opusPtr);
// 为 PCM 输出分配内存 (Int16 = 2 bytes)
const pcmPtr = mod._malloc(FRAME_SIZE * 2);
// 解码
const decodedSamples = mod._opus_decode(
decoderPtr, opusPtr, opusData.length,
pcmPtr, FRAME_SIZE, 0
);
if (decodedSamples < 0) {
mod._free(opusPtr);
mod._free(pcmPtr);
throw new Error(`Opus 解码失败: ${decodedSamples}`);
}
// 读取 Int16 并转为 Float32
const float32 = new Float32Array(decodedSamples);
for (let i = 0; i < decodedSamples; i++) {
const sample = mod.HEAP16[(pcmPtr >> 1) + i];
float32[i] = sample / (sample < 0 ? 0x8000 : 0x7FFF);
}
mod._free(opusPtr);
mod._free(pcmPtr);
return float32;
},
destroy() {
if (decoderPtr) mod._free(decoderPtr);
}
};
opusReady = true;
log('Opus 解码器初始化成功 (16kHz, 单声道, 60ms/帧)', 'success');
$('statOpus').textContent = '就绪';
$('statOpus').style.color = '#27ae60';
return true;
} catch (e) {
log(`Opus 初始化失败: ${e.message}`, 'error');
$('statOpus').textContent = '失败';
$('statOpus').style.color = '#e74c3c';
return false;
}
}
// ============================================================
// WebSocket 连接
// ============================================================
function connect() {
if (ws && ws.readyState === WebSocket.OPEN) {
log('已经连接,请先断开', 'warning');
return;
}
const baseUrl = $('wsUrl').value.trim();
const deviceId = $('deviceId').value.trim();
const clientId = $('clientId').value.trim();
if (!deviceId) { log('请输入 device-id (MAC 地址)', 'error'); return; }
if (!clientId) { log('请输入 client-id', 'error'); return; }
// 确保 AudioContext 存在(需要用户交互后创建)
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
}
// 确保 Opus 解码器已初始化
if (!opusReady) {
if (!initOpusDecoder()) {
log('Opus 解码器未就绪,无法连接', 'error');
return;
}
}
// 构建 URL
const url = new URL(baseUrl);
url.searchParams.set('device-id', deviceId);
url.searchParams.set('client-id', clientId);
const connUrl = url.toString();
log(`正在连接: ${connUrl}`, 'info');
updateStatus('connecting', '连接中...');
$('btnConnect').disabled = true;
ws = new WebSocket(connUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
log('WebSocket 连接成功,发送 hello 握手...', 'success');
updateStatus('connecting', '握手中...');
$('btnConnect').disabled = true;
$('btnDisconnect').disabled = false;
handshaked = false;
// 发送 hello 握手消息
const helloMsg = JSON.stringify({ type: 'hello', mac: deviceId });
ws.send(helloMsg);
log(`发送: ${helloMsg}`, 'send');
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
handleBinaryMessage(event.data);
} else {
handleTextMessage(event.data);
}
};
ws.onerror = (err) => {
log('WebSocket 错误', 'error');
updateStatus('error', '连接错误');
};
ws.onclose = (event) => {
log(`WebSocket 已关闭 (code=${event.code}, reason=${event.reason || '无'})`, 'warning');
updateStatus(null, '未连接');
$('btnConnect').disabled = false;
$('btnDisconnect').disabled = true;
$('btnStory').disabled = true;
$('btnStop').disabled = true;
ws = null;
};
}
function disconnect() {
if (ws) {
ws.close();
log('主动断开连接', 'info');
}
handshaked = false;
stopPlayback();
}
// ============================================================
// 消息处理
// ============================================================
function handleTextMessage(data) {
try {
const msg = JSON.parse(data);
log(`收到: ${JSON.stringify(msg)}`, 'recv');
// 处理 hello 握手响应
if (msg.type === 'hello' && msg.status === 'ok') {
handshaked = true;
log(`握手成功session_id=${msg.session_id}`, 'success');
updateStatus('connected', '已连接');
$('btnStory').disabled = false;
return;
}
if (msg.type === 'tts') {
switch (msg.state) {
case 'start':
log('故事推送开始', 'audio');
resetPlaybackState();
$('btnStop').disabled = false;
break;
case 'sentence_start':
if (msg.text) {
log(`故事标题: ${msg.text}`, 'audio');
}
break;
case 'stop':
log('故事推送结束', 'audio');
$('btnStop').disabled = true;
// 标记流结束,等待播放完成
log(`共接收 ${opusFrameCount} 个 Opus 帧,约 ${(opusFrameCount * 60 / 1000).toFixed(1)}s 音频`, 'success');
break;
}
}
} catch (e) {
log(`收到文本: ${data}`, 'recv');
}
}
function handleBinaryMessage(data) {
const frame = new Uint8Array(data);
opusFrameCount++;
updateStats();
// 每 20 帧打印一次,避免刷屏
if (opusFrameCount <= 3 || opusFrameCount % 20 === 0) {
log(`[Binary] Opus 帧 #${opusFrameCount} (${frame.length} bytes)`, 'binary');
}
if (!opusDecoder) {
log('Opus 解码器未初始化,丢弃帧', 'error');
return;
}
try {
const pcmFloat32 = opusDecoder.decode(frame);
if (pcmFloat32 && pcmFloat32.length > 0) {
pcmBufferQueue.push(pcmFloat32);
schedulePlayback();
}
} catch (e) {
log(`解码帧 #${opusFrameCount} 失败: ${e.message}`, 'error');
}
}
// ============================================================
// 音频播放(按时序排队)
// ============================================================
function resetPlaybackState() {
opusFrameCount = 0;
pcmBufferQueue = [];
isPlaying = false;
nextPlayTime = 0;
updateStats();
}
function schedulePlayback() {
// 预缓冲:等待至少 3 帧再开始播放
if (!isPlaying && pcmBufferQueue.length < 3) return;
if (!isPlaying) {
isPlaying = true;
log('开始音频播放...', 'audio');
}
// 如果 AudioContext 被暂停(浏览器策略),恢复它
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
// 直接把队列中所有帧排入播放时间线
while (pcmBufferQueue.length > 0) {
playPcmChunk(pcmBufferQueue.shift());
}
}
function playPcmChunk(pcmFloat32) {
const buffer = audioCtx.createBuffer(1, pcmFloat32.length, 16000);
buffer.copyToChannel(pcmFloat32, 0);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
const now = audioCtx.currentTime;
const startTime = Math.max(now, nextPlayTime);
source.connect(audioCtx.destination);
source.start(startTime);
// 下一帧紧接当前帧播放
nextPlayTime = startTime + buffer.duration;
}
function stopPlayback() {
pcmBufferQueue = [];
isPlaying = false;
nextPlayTime = 0;
if (audioCtx) {
// 创建新的 AudioContext 来停止所有播放
audioCtx.close();
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
}
log('播放已停止', 'audio');
$('btnStop').disabled = true;
}
// ============================================================
// 触发故事
// ============================================================
function triggerStory() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
log('WebSocket 未连接', 'error');
return;
}
if (!handshaked) {
log('握手尚未完成,请等待', 'warning');
return;
}
const msg = JSON.stringify({ type: 'story' });
ws.send(msg);
log(`发送: ${msg}`, 'send');
// 重置统计
resetPlaybackState();
$('btnStop').disabled = false;
}
// ============================================================
// 页面初始化
// ============================================================
window.addEventListener('DOMContentLoaded', () => {
// 生成默认 client-id
generateClientId();
// 延迟初始化 Opus等 WASM 加载完)
const checkOpus = () => {
if (typeof Module !== 'undefined' &&
((Module.instance && typeof Module.instance._opus_decoder_get_size === 'function') ||
typeof Module._opus_decoder_get_size === 'function')) {
initOpusDecoder();
} else {
setTimeout(checkOpus, 200);
}
};
setTimeout(checkOpus, 500);
log('页面加载完成,等待 Opus 库初始化...', 'info');
});
</script>
</body>
</html>