add:添加 Server 测试网页

This commit is contained in:
安稳
2025-03-31 09:35:35 +08:00
parent 330d7ed95c
commit ee2e1ed5fc
5 changed files with 464 additions and 0 deletions
+42
View File
@@ -11,6 +11,8 @@
"element-ui": "^2.15.14",
"flyio": "^0.6.14",
"normalize.css": "^8.0.1",
"opus-decoder": "^0.7.7",
"opus-recorder": "^8.0.5",
"vue": "^2.6.14",
"vue-axios": "^3.5.2",
"vue-router": "^3.6.5",
@@ -149,6 +151,11 @@
"node": ">=10.0.0"
}
},
"node_modules/@eshaz/web-worker": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@eshaz/web-worker/-/web-worker-1.2.2.tgz",
"integrity": "sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw=="
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmmirror.com/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -1004,6 +1011,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@wasm-audio-decoders/common": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/@wasm-audio-decoders/common/-/common-9.0.5.tgz",
"integrity": "sha512-b9JNh9sPAvn8PVIizNh9D60WkfQong/u9ea873H47u7zvVDLctxYIp2aZw9CQqXaQdk7JB3MoU5UHiseO40swg==",
"dependencies": {
"@eshaz/web-worker": "1.2.2",
"simple-yenc": "^1.0.4"
}
},
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmmirror.com/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -5731,6 +5747,23 @@
"opener": "bin/opener-bin.js"
}
},
"node_modules/opus-decoder": {
"version": "0.7.7",
"resolved": "https://registry.npmjs.org/opus-decoder/-/opus-decoder-0.7.7.tgz",
"integrity": "sha512-KWDyCi/9aXnNN+jrjs+aaVdwiwzDdac81S9ul0iv1CTs4+5K4VDZKuJjIImrYOBA2oSNHDjVq4xzn6BE+XbI1A==",
"dependencies": {
"@wasm-audio-decoders/common": "9.0.5"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/opus-recorder": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/opus-recorder/-/opus-recorder-8.0.5.tgz",
"integrity": "sha512-tBRXc9Btds7i3bVfA7d5rekAlyOcfsivt5vSIXHxRV1Oa+s6iXFW8omZ0Lm3ABWotVcEyKt96iIIUcgbV07YOw=="
},
"node_modules/ora": {
"version": "5.4.1",
"resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz",
@@ -7619,6 +7652,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/simple-yenc": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/simple-yenc/-/simple-yenc-1.0.4.tgz",
"integrity": "sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/sirv": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/sirv/-/sirv-2.0.4.tgz",
+2
View File
@@ -10,6 +10,8 @@
"element-ui": "^2.15.14",
"flyio": "^0.6.14",
"normalize.css": "^8.0.1",
"opus-decoder": "^0.7.7",
"opus-recorder": "^8.0.5",
"vue": "^2.6.14",
"vue-axios": "^3.5.2",
"vue-router": "^3.6.5",
File diff suppressed because one or more lines are too long
+7
View File
@@ -62,6 +62,13 @@ const routes = [
return import('../views/ModelConfig.vue')
}
},
{
path: '/test',
name: 'TestServer',
component: function () {
return import('../views/test.vue')
}
},
]
+412
View File
@@ -0,0 +1,412 @@
<template>
<div class="container">
<h1 class="title">XiaoZhi ESP32 Server 测试助手</h1>
<div class="chat-container" ref="chatContainer">
<div v-for="(message, index) in messages" :key="index"
:class="['message', message.role === 'user' ? 'user' : 'assistant']">
<span>{{ message.content }}</span>
</div>
</div>
<div class="controls">
<button @click="toggleRecording" :disabled="wsStatus !== 'connected'"
:class="{ recording: isRecording }">
{{ isRecording ? '停止录音' : '开始录音' }}
</button>
<p>WebSocket: {{ wsStatus }}</p>
</div>
</div>
</template>
<script>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import Recorder from 'opus-recorder';
import { OpusDecoder } from 'opus-decoder';
export default {
name: 'TestPage',
setup() {
const messages = ref([]);
const chatContainer = ref(null);
const wsStatus = ref('disconnected');
const isRecording = ref(false);
let ws = null;
let recorder = null;
let stream = null;
let audioContext = null;
let sourceNode = null;
let audioDecoder = null;
let audioBufferQueue = []; // 当前句子的音频缓冲区
let playbackQueue = []; // 播放队列,存储待播放的句子
let isPlaying = false;
const connectWebSocket = () => {
ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
wsStatus.value = 'connected';
console.log('WebSocket 连接成功');
ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
};
ws.onmessage = async (event) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
console.log('收到文本消息:', msg);
if (msg.type === 'stt') {
messages.value.push({ role: 'user', content: msg.text });
scrollToBottom();
} else if (msg.type === 'llm') {
messages.value.push({ role: 'assistant', content: msg.text });
scrollToBottom();
} else if (msg.type === 'tts') {
if (msg.state === 'sentence_start') {
// 开始新句子,清空当前缓冲区
audioBufferQueue = [];
const lastMessage = messages.value[messages.value.length - 1];
if (!lastMessage || lastMessage.content !== msg.text) {
messages.value.push({ role: 'assistant', content: msg.text });
scrollToBottom();
}
} else if (msg.state === 'sentence_end') {
// 句子结束,将当前缓冲区加入播放队列
if (audioBufferQueue.length > 0) {
playbackQueue.push([...audioBufferQueue]);
audioBufferQueue = [];
playNextInQueue(); // 尝试播放队列中的下一句
}
} else if (msg.state === 'stop') {
console.log('TTS 任务结束');
// 确保所有剩余音频播放
if (audioBufferQueue.length > 0) {
playbackQueue.push([...audioBufferQueue]);
audioBufferQueue = [];
playNextInQueue();
}
}
} else if (msg.type === 'hello') {
console.log('收到服务器初始化消息:', msg);
}
} else if (event.data instanceof ArrayBuffer) {
console.log('收到音频帧,大小:', event.data.byteLength, '字节');
const opusFrame = new Uint8Array(event.data);
const frameHead = Array.from(opusFrame.slice(0, 8))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
console.log('音频帧前8字节:', frameHead);
try {
const decoded = audioDecoder.decodeFrame(opusFrame);
console.log('解码结果:', decoded);
if (decoded && decoded.channelData && decoded.channelData[0]) {
const pcmData = decoded.channelData[0];
if (pcmData.length > 0) {
audioBufferQueue.push(pcmData);
console.log('解码音频帧,PCM 数据长度:', pcmData.length);
// 如果缓冲区达到一定长度(例如 5 帧,180ms),立即播放
if (audioBufferQueue.length >= 5 && playbackQueue.length === 0 && !isPlaying) {
playbackQueue.push([...audioBufferQueue]);
audioBufferQueue = [];
playNextInQueue();
}
} else {
console.warn('解码成功,但 PCM 数据长度为 0');
}
} else {
console.warn('解码结果无效:', decoded);
}
} catch (e) {
console.error('Opus 解码错误:', e);
}
}
};
ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
wsStatus.value = 'error';
};
ws.onclose = () => {
wsStatus.value = 'disconnected';
console.log('WebSocket 断开');
setTimeout(connectWebSocket, 1000);
};
};
const scrollToBottom = () => {
nextTick(() => {
if (chatContainer.value) chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
});
};
const playNextInQueue = async () => {
if (isPlaying || playbackQueue.length === 0) {
return; // 正在播放或队列为空,等待下次触发
}
isPlaying = true;
if (!audioContext || audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
}
const pcmBuffers = playbackQueue.shift(); // 取出队列中的第一句
const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0);
if (totalLength === 0) {
console.error('音频缓冲区总长度为 0,无法播放');
isPlaying = false;
playNextInQueue(); // 尝试播放下一句
return;
}
const mergedPcm = new Float32Array(totalLength);
let offset = 0;
for (const pcm of pcmBuffers) {
mergedPcm.set(pcm, offset);
offset += pcm.length;
}
const audioBuffer = audioContext.createBuffer(1, totalLength, 16000);
audioBuffer.getChannelData(0).set(mergedPcm);
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
source.onended = () => {
isPlaying = false;
console.log('音频播放结束');
playNextInQueue(); // 播放结束后继续下一句
};
source.start();
console.log('开始播放音频,总长度:', totalLength);
};
const stripOggContainer = (data) => {
let arrayBuffer;
if (data instanceof ArrayBuffer) {
arrayBuffer = data;
} else if (data.buffer instanceof ArrayBuffer) {
arrayBuffer = data.buffer;
} else {
console.error('输入数据类型不支持:', data);
return [data];
}
const dataView = new DataView(arrayBuffer);
const frames = [];
let offset = 0;
while (offset < arrayBuffer.byteLength) {
if (dataView.getUint32(offset) === 0x4F676753) { // 'OggS'
const segmentTableLength = dataView.getUint8(offset + 26);
const segmentTableOffset = offset + 27;
let segmentOffset = segmentTableOffset + segmentTableLength;
for (let i = 0; i < segmentTableLength; i++) {
const segmentLength = dataView.getUint8(segmentTableOffset + i);
const segmentData = arrayBuffer.slice(segmentOffset, segmentOffset + segmentLength);
segmentOffset += segmentLength;
const header = new TextDecoder().decode(segmentData.slice(0, 8));
if (header === 'OpusHead' || header === 'OpusTags') {
console.log(`跳过 ${header},大小: ${segmentLength} 字节`);
} else if (segmentLength >= 50 && segmentLength <= 300) {
frames.push(segmentData);
}
}
offset = segmentOffset;
} else {
const remainingLength = arrayBuffer.byteLength - offset;
if (remainingLength >= 50 && remainingLength <= 300) {
frames.push(arrayBuffer.slice(offset));
}
break;
}
}
if (frames.length === 0) {
console.warn('未找到有效裸 Opus 帧,返回原始数据');
return [arrayBuffer];
}
console.log('剥离后找到', frames.length, '个裸 Opus 帧');
return frames;
};
const initRecorder = async () => {
console.log('开始初始化录音');
try {
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('获取麦克风权限成功,stream:', stream);
if (!audioContext || audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
}
sourceNode = audioContext.createMediaStreamSource(stream);
recorder = new Recorder({
encoderPath: '/encoderWorker.min.js',
sampleRate: 16000,
numberOfChannels: 1,
frameSize: 960,
encoderApplication: 2048,
encoderFrameSize: 60,
encoderBitRate: 24000,
monitorGain: 0,
sourceNode: sourceNode,
ogg: false,
streamPages: false,
maxFramesPerPage: 1,
});
recorder.ondataavailable = (data) => {
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
const frames = stripOggContainer(data);
frames.forEach((frame, index) => {
const frameView = new DataView(frame);
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
console.log(`${index} 大小: ${frame.byteLength} 字节,前8字节: ${frameHead}`);
if (frame.byteLength < 50 || frame.byteLength > 300) {
console.warn(`${index} 大小异常: ${frame.byteLength} 字节,预期 50-300 字节`);
return;
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(frame);
console.log('发送裸 Opus 帧:', frame.byteLength);
} else {
console.warn('WebSocket 未连接,跳过发送');
}
});
};
recorder.onstart = () => {
console.log('录音已启动');
};
recorder.onstop = () => {
console.log('录音停止');
stream.getTracks().forEach(track => track.stop());
stream = null;
sourceNode = null;
scrollToBottom();
};
console.log('Recorder 初始化成功');
} catch (err) {
console.error('初始化录音失败:', err);
alert('无法访问麦克风或录音初始化失败,请检查权限');
}
};
const toggleRecording = async () => {
console.log('点击 toggleRecording,当前状态:', isRecording.value, 'WebSocket 状态:', wsStatus.value);
if (!recorder) {
await initRecorder();
if (!recorder) {
console.error('recorder 初始化失败');
return;
}
}
if (isRecording.value) {
console.log('停止录音');
recorder.stop();
isRecording.value = false;
} else {
try {
console.log('开始录音');
await initRecorder();
await recorder.start();
console.log('录音开始后,状态:', recorder.state);
isRecording.value = true;
} catch (err) {
console.error('录音启动失败:', err);
}
}
};
onMounted(() => {
console.log('组件挂载,初始化 WebSocket');
connectWebSocket();
});
onUnmounted(() => {
if (ws) ws.close();
if (stream) stream.getTracks().forEach(track => track.stop());
if (recorder) recorder.stop();
if (audioContext) audioContext.close();
if (audioDecoder) audioDecoder.destroy();
});
return { messages, chatContainer, wsStatus, isRecording, toggleRecording };
}
};
</script>
<style scoped>
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: Arial, sans-serif;
}
.title {
text-align: center;
font-size: 24px;
margin-bottom: 20px;
}
.chat-container {
height: 60vh;
overflow-y: auto;
border: 1px solid #ccc;
padding: 10px;
background: #f9f9f9;
border-radius: 5px;
}
.message {
margin: 10px 0;
padding: 8px 12px;
border-radius: 5px;
max-width: 70%;
}
.user {
background: #007bff;
color: white;
margin-left: auto;
}
.assistant {
background: #e9ecef;
color: black;
}
.controls {
text-align: center;
margin-top: 20px;
}
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background: #007bff;
color: white;
border: none;
border-radius: 5px;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
button.recording {
background: #dc3545;
}
</style>