fix test bug
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 3m38s

This commit is contained in:
repair-agent 2026-03-03 16:00:16 +08:00
parent 5e3f0653c9
commit 0bf556018e
3 changed files with 917 additions and 1 deletions

View File

@ -39,7 +39,7 @@ spec:
value: "8888"
- name: HW_RTC_BACKEND_URL
# 集群内部直接访问 rtc-backend Service不走公网
value: "http://rtc-backend-svc:8000"
value: "http://rtc-backend:8000"
lifecycle:
preStop:

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,650 @@
<!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>