All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 3m38s
651 lines
21 KiB
HTML
651 lines
21 KiB
HTML
<!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>▶ 触发故事播放</button>
|
||
<button class="btn btn-danger" id="btnStop" onclick="stopPlayback()" disabled>■ 停止</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>
|