From 80c7295784226a7761c25e3a0b95e7de4a883da3 Mon Sep 17 00:00:00 2001
From: hrz <1710360675@qq.com>
Date: Sun, 16 Nov 2025 01:26:10 +0800
Subject: [PATCH] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E9=9F=B3=E9=A2=91?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=A1=B5=E9=9D=A2=E7=9A=84=E6=A0=B7=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../test/abbreviated_version/app.js | 1254 -----------------
.../test/abbreviated_version/test.html | 503 -------
.../test/default-mcp-tools.json | 72 -
.../test/js/StreamingContext.js | 2 +-
main/xiaozhi-server/test/js/document.js | 14 +-
main/xiaozhi-server/test/js/mcpTools.js | 477 +++++++
.../test/{ => js/utils}/libopus.js | 0
main/xiaozhi-server/test/opus_test/app.js | 670 ---------
main/xiaozhi-server/test/opus_test/test.html | 464 ------
main/xiaozhi-server/test/test_page.css | 383 ++++-
main/xiaozhi-server/test/test_page.html | 902 +++---------
11 files changed, 966 insertions(+), 3775 deletions(-)
delete mode 100644 main/xiaozhi-server/test/abbreviated_version/app.js
delete mode 100644 main/xiaozhi-server/test/abbreviated_version/test.html
delete mode 100644 main/xiaozhi-server/test/default-mcp-tools.json
create mode 100644 main/xiaozhi-server/test/js/mcpTools.js
rename main/xiaozhi-server/test/{ => js/utils}/libopus.js (100%)
delete mode 100644 main/xiaozhi-server/test/opus_test/app.js
delete mode 100644 main/xiaozhi-server/test/opus_test/test.html
diff --git a/main/xiaozhi-server/test/abbreviated_version/app.js b/main/xiaozhi-server/test/abbreviated_version/app.js
deleted file mode 100644
index b2286524..00000000
--- a/main/xiaozhi-server/test/abbreviated_version/app.js
+++ /dev/null
@@ -1,1254 +0,0 @@
-const SAMPLE_RATE = 16000;
-const CHANNELS = 1;
-const FRAME_SIZE = 960; // 对应于60ms帧大小 (16000Hz * 0.06s = 960 samples)
-const OPUS_APPLICATION = 2049; // OPUS_APPLICATION_AUDIO
-const BUFFER_SIZE = 4096;
-
-// WebSocket相关变量
-let websocket = null;
-let isConnected = false;
-
-let audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
-let mediaStream, mediaSource, audioProcessor;
-let recordedPcmData = []; // 存储原始PCM数据
-let recordedOpusData = []; // 存储Opus编码后的数据
-let opusEncoder, opusDecoder;
-let isRecording = false;
-
-const startButton = document.getElementById("start");
-const stopButton = document.getElementById("stop");
-const playButton = document.getElementById("play");
-const statusLabel = document.getElementById("status");
-
-// 添加WebSocket界面元素引用
-const connectButton = document.getElementById("connectButton") || document.createElement("button");
-const serverUrlInput = document.getElementById("serverUrl") || document.createElement("input");
-const connectionStatus = document.getElementById("connectionStatus") || document.createElement("span");
-const sendTextButton = document.getElementById("sendTextButton") || document.createElement("button");
-const messageInput = document.getElementById("messageInput") || document.createElement("input");
-const conversationDiv = document.getElementById("conversation") || document.createElement("div");
-
-// 添加连接和发送事件监听
-if(connectButton.id === "connectButton") {
- connectButton.addEventListener("click", connectToServer);
-}
-if(sendTextButton.id === "sendTextButton") {
- sendTextButton.addEventListener("click", sendTextMessage);
-}
-
-startButton.addEventListener("click", startRecording);
-stopButton.addEventListener("click", stopRecording);
-playButton.addEventListener("click", playRecording);
-
-// 音频缓冲和播放管理
-let audioBufferQueue = []; // 存储接收到的音频包
-let isAudioBuffering = false; // 是否正在缓冲音频
-let isAudioPlaying = false; // 是否正在播放音频
-const BUFFER_THRESHOLD = 3; // 缓冲包数量阈值,至少累积5个包再开始播放
-const MIN_AUDIO_DURATION = 0.1; // 最小音频长度(秒),小于这个长度的音频会被合并
-let streamingContext = null; // 音频流上下文
-
-// 初始化Opus编码器与解码器
-async function initOpus() {
- if (typeof window.ModuleInstance === 'undefined') {
- if (typeof Module !== 'undefined') {
- // 尝试使用全局Module
- window.ModuleInstance = Module;
- console.log('使用全局Module作为ModuleInstance');
- } else {
- console.error("Opus库未加载,ModuleInstance和Module对象都不存在");
- return false;
- }
- }
-
- try {
- const mod = window.ModuleInstance;
-
- // 创建编码器
- opusEncoder = {
- channels: CHANNELS,
- sampleRate: SAMPLE_RATE,
- frameSize: FRAME_SIZE,
- maxPacketSize: 4000,
- module: mod,
-
- // 初始化编码器
- init: function() {
- // 获取编码器大小
- const encoderSize = mod._opus_encoder_get_size(this.channels);
- console.log(`Opus编码器大小: ${encoderSize}字节`);
-
- // 分配内存
- this.encoderPtr = mod._malloc(encoderSize);
- if (!this.encoderPtr) {
- throw new Error("无法分配编码器内存");
- }
-
- // 初始化编码器
- const err = mod._opus_encoder_init(
- this.encoderPtr,
- this.sampleRate,
- this.channels,
- OPUS_APPLICATION
- );
-
- if (err < 0) {
- throw new Error(`Opus编码器初始化失败: ${err}`);
- }
-
- return true;
- },
-
- // 编码方法
- encode: function(pcmData) {
- const mod = this.module;
-
- // 为PCM数据分配内存
- const pcmPtr = mod._malloc(pcmData.length * 2); // Int16 = 2字节
-
- // 将数据复制到WASM内存
- for (let i = 0; i < pcmData.length; i++) {
- mod.HEAP16[(pcmPtr >> 1) + i] = pcmData[i];
- }
-
- // 为Opus编码数据分配内存
- const maxEncodedSize = this.maxPacketSize;
- const encodedPtr = mod._malloc(maxEncodedSize);
-
- // 编码
- const encodedBytes = mod._opus_encode(
- this.encoderPtr,
- pcmPtr,
- this.frameSize,
- encodedPtr,
- maxEncodedSize
- );
-
- if (encodedBytes < 0) {
- mod._free(pcmPtr);
- mod._free(encodedPtr);
- throw new Error(`Opus编码失败: ${encodedBytes}`);
- }
-
- // 复制编码后的数据
- const encodedData = new Uint8Array(encodedBytes);
- for (let i = 0; i < encodedBytes; i++) {
- encodedData[i] = mod.HEAPU8[encodedPtr + i];
- }
-
- // 释放内存
- mod._free(pcmPtr);
- mod._free(encodedPtr);
-
- return encodedData;
- },
-
- // 销毁方法
- destroy: function() {
- if (this.encoderPtr) {
- this.module._free(this.encoderPtr);
- this.encoderPtr = null;
- }
- }
- };
-
- // 创建解码器
- opusDecoder = {
- channels: CHANNELS,
- rate: SAMPLE_RATE,
- frameSize: FRAME_SIZE,
- module: mod,
-
- // 初始化解码器
- init: function() {
- // 获取解码器大小
- const decoderSize = mod._opus_decoder_get_size(this.channels);
- console.log(`Opus解码器大小: ${decoderSize}字节`);
-
- // 分配内存
- this.decoderPtr = mod._malloc(decoderSize);
- if (!this.decoderPtr) {
- throw new Error("无法分配解码器内存");
- }
-
- // 初始化解码器
- const err = mod._opus_decoder_init(
- this.decoderPtr,
- this.rate,
- this.channels
- );
-
- if (err < 0) {
- throw new Error(`Opus解码器初始化失败: ${err}`);
- }
-
- return true;
- },
-
- // 解码方法
- decode: function(opusData) {
- const mod = this.module;
-
- // 为Opus数据分配内存
- const opusPtr = mod._malloc(opusData.length);
- mod.HEAPU8.set(opusData, opusPtr);
-
- // 为PCM输出分配内存
- const pcmPtr = mod._malloc(this.frameSize * 2); // Int16 = 2字节
-
- // 解码
- const decodedSamples = mod._opus_decode(
- this.decoderPtr,
- opusPtr,
- opusData.length,
- pcmPtr,
- this.frameSize,
- 0 // 不使用FEC
- );
-
- if (decodedSamples < 0) {
- mod._free(opusPtr);
- mod._free(pcmPtr);
- throw new Error(`Opus解码失败: ${decodedSamples}`);
- }
-
- // 复制解码后的数据
- const decodedData = new Int16Array(decodedSamples);
- for (let i = 0; i < decodedSamples; i++) {
- decodedData[i] = mod.HEAP16[(pcmPtr >> 1) + i];
- }
-
- // 释放内存
- mod._free(opusPtr);
- mod._free(pcmPtr);
-
- return decodedData;
- },
-
- // 销毁方法
- destroy: function() {
- if (this.decoderPtr) {
- this.module._free(this.decoderPtr);
- this.decoderPtr = null;
- }
- }
- };
-
- // 初始化编码器和解码器
- if (opusEncoder.init() && opusDecoder.init()) {
- console.log("Opus 编码器和解码器初始化成功。");
- return true;
- } else {
- console.error("Opus 初始化失败");
- return false;
- }
- } catch (error) {
- console.error("Opus 初始化失败:", error);
- return false;
- }
-}
-
-// 将Float32音频数据转换为Int16音频数据
-function convertFloat32ToInt16(float32Data) {
- const int16Data = new Int16Array(float32Data.length);
- for (let i = 0; i < float32Data.length; i++) {
- // 将[-1,1]范围转换为[-32768,32767]
- const s = Math.max(-1, Math.min(1, float32Data[i]));
- int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
- }
- return int16Data;
-}
-
-// 将Int16音频数据转换为Float32音频数据
-function convertInt16ToFloat32(int16Data) {
- const float32Data = new Float32Array(int16Data.length);
- for (let i = 0; i < int16Data.length; i++) {
- // 将[-32768,32767]范围转换为[-1,1]
- float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7FFF);
- }
- return float32Data;
-}
-
-function startRecording() {
- if (isRecording) return;
-
- // 确保有权限并且AudioContext是活跃的
- if (audioContext.state === 'suspended') {
- audioContext.resume().then(() => {
- console.log("AudioContext已恢复");
- continueStartRecording();
- }).catch(err => {
- console.error("恢复AudioContext失败:", err);
- statusLabel.textContent = "无法激活音频上下文,请再次点击";
- });
- } else {
- continueStartRecording();
- }
-}
-
-// 实际开始录音的逻辑
-function continueStartRecording() {
- // 重置录音数据
- recordedPcmData = [];
- recordedOpusData = [];
- window.audioDataBuffer = new Int16Array(0); // 重置缓冲区
-
- // 初始化Opus
- initOpus().then(success => {
- if (!success) {
- statusLabel.textContent = "Opus初始化失败";
- return;
- }
-
- console.log("开始录音,参数:", {
- sampleRate: SAMPLE_RATE,
- channels: CHANNELS,
- frameSize: FRAME_SIZE,
- bufferSize: BUFFER_SIZE
- });
-
- // 如果WebSocket已连接,发送开始录音信号
- if (isConnected && websocket && websocket.readyState === WebSocket.OPEN) {
- sendVoiceControlMessage('start');
- }
-
- // 请求麦克风权限
- navigator.mediaDevices.getUserMedia({
- audio: {
- sampleRate: SAMPLE_RATE,
- channelCount: CHANNELS,
- echoCancellation: true,
- noiseSuppression: true,
- autoGainControl: true
- }
- })
- .then(stream => {
- console.log("获取到麦克风流,实际参数:", stream.getAudioTracks()[0].getSettings());
-
- // 检查流是否有效
- if (!stream || !stream.getAudioTracks().length || !stream.getAudioTracks()[0].enabled) {
- throw new Error("获取到的音频流无效");
- }
-
- mediaStream = stream;
- mediaSource = audioContext.createMediaStreamSource(stream);
-
- // 创建ScriptProcessor(虽然已弃用,但兼容性好)
- // 在降级到ScriptProcessor之前尝试使用AudioWorklet
- createAudioProcessor().then(processor => {
- if (processor) {
- console.log("使用AudioWorklet处理音频");
- audioProcessor = processor;
- // 连接音频处理链
- mediaSource.connect(audioProcessor);
- audioProcessor.connect(audioContext.destination);
- } else {
- console.log("回退到ScriptProcessor");
- // 创建ScriptProcessor节点
- audioProcessor = audioContext.createScriptProcessor(BUFFER_SIZE, CHANNELS, CHANNELS);
-
- // 处理音频数据
- audioProcessor.onaudioprocess = processAudioData;
-
- // 连接音频处理链
- mediaSource.connect(audioProcessor);
- audioProcessor.connect(audioContext.destination);
- }
-
- // 更新UI
- isRecording = true;
- statusLabel.textContent = "录音中...";
- startButton.disabled = true;
- stopButton.disabled = false;
- playButton.disabled = true;
- }).catch(error => {
- console.error("创建音频处理器失败:", error);
- statusLabel.textContent = "创建音频处理器失败";
- });
- })
- .catch(error => {
- console.error("获取麦克风失败:", error);
- statusLabel.textContent = "获取麦克风失败: " + error.message;
- });
- });
-}
-
-// 创建AudioWorklet处理器
-async function createAudioProcessor() {
- try {
- // 尝试使用更现代的AudioWorklet API
- if ('AudioWorklet' in window && 'AudioWorkletNode' in window) {
- // 定义AudioWorklet处理器代码
- const workletCode = `
- class OpusRecorderProcessor extends AudioWorkletProcessor {
- constructor() {
- super();
- this.buffers = [];
- this.frameSize = ${FRAME_SIZE};
- this.buffer = new Float32Array(this.frameSize);
- this.bufferIndex = 0;
- this.isRecording = false;
-
- this.port.onmessage = (event) => {
- if (event.data.command === 'start') {
- this.isRecording = true;
- } else if (event.data.command === 'stop') {
- this.isRecording = false;
- // 发送最后的缓冲区
- if (this.bufferIndex > 0) {
- const finalBuffer = this.buffer.slice(0, this.bufferIndex);
- this.port.postMessage({ buffer: finalBuffer });
- }
- }
- };
- }
-
- process(inputs, outputs) {
- if (!this.isRecording) return true;
-
- // 获取输入数据
- const input = inputs[0][0]; // mono channel
- if (!input || input.length === 0) return true;
-
- // 将输入数据添加到缓冲区
- for (let i = 0; i < input.length; i++) {
- this.buffer[this.bufferIndex++] = input[i];
-
- // 当缓冲区填满时,发送给主线程
- if (this.bufferIndex >= this.frameSize) {
- this.port.postMessage({ buffer: this.buffer.slice() });
- this.bufferIndex = 0;
- }
- }
-
- return true;
- }
- }
-
- registerProcessor('opus-recorder-processor', OpusRecorderProcessor);
- `;
-
- // 创建Blob URL
- const blob = new Blob([workletCode], { type: 'application/javascript' });
- const url = URL.createObjectURL(blob);
-
- // 加载AudioWorklet模块
- await audioContext.audioWorklet.addModule(url);
-
- // 创建AudioWorkletNode
- const workletNode = new AudioWorkletNode(audioContext, 'opus-recorder-processor');
-
- // 处理从AudioWorklet接收的消息
- workletNode.port.onmessage = (event) => {
- if (event.data.buffer) {
- // 使用与ScriptProcessor相同的处理逻辑
- processAudioData({
- inputBuffer: {
- getChannelData: () => event.data.buffer
- }
- });
- }
- };
-
- // 启动录音
- workletNode.port.postMessage({ command: 'start' });
-
- // 保存停止函数
- workletNode.stopRecording = () => {
- workletNode.port.postMessage({ command: 'stop' });
- };
-
- console.log("AudioWorklet 音频处理器创建成功");
- return workletNode;
- }
- } catch (error) {
- console.error("创建AudioWorklet失败,将使用ScriptProcessor:", error);
- }
-
- // 如果AudioWorklet不可用或失败,返回null以便回退到ScriptProcessor
- return null;
-}
-
-// 处理音频数据
-function processAudioData(e) {
- // 获取输入缓冲区
- const inputBuffer = e.inputBuffer;
-
- // 获取第一个通道的Float32数据
- const inputData = inputBuffer.getChannelData(0);
-
- // 添加调试信息
- const nonZeroCount = Array.from(inputData).filter(x => Math.abs(x) > 0.001).length;
- console.log(`接收到音频数据: ${inputData.length} 个样本, 非零样本数: ${nonZeroCount}`);
-
- // 如果全是0,可能是麦克风没有正确获取声音
- if (nonZeroCount < 5) {
- console.warn("警告: 检测到大量静音样本,请检查麦克风是否正常工作");
- // 继续处理,以防有些样本确实是静音
- }
-
- // 存储PCM数据用于调试
- recordedPcmData.push(new Float32Array(inputData));
-
- // 转换为Int16数据供Opus编码
- const int16Data = convertFloat32ToInt16(inputData);
-
- // 如果收集到的数据不是FRAME_SIZE的整数倍,需要进行处理
- // 创建静态缓冲区来存储不足一帧的数据
- if (!window.audioDataBuffer) {
- window.audioDataBuffer = new Int16Array(0);
- }
-
- // 合并之前缓存的数据和新数据
- const combinedData = new Int16Array(window.audioDataBuffer.length + int16Data.length);
- combinedData.set(window.audioDataBuffer);
- combinedData.set(int16Data, window.audioDataBuffer.length);
-
- // 处理完整帧
- const frameCount = Math.floor(combinedData.length / FRAME_SIZE);
- console.log(`可编码的完整帧数: ${frameCount}, 缓冲区总大小: ${combinedData.length}`);
-
- for (let i = 0; i < frameCount; i++) {
- const frameData = combinedData.subarray(i * FRAME_SIZE, (i + 1) * FRAME_SIZE);
-
- try {
- console.log(`编码第 ${i+1}/${frameCount} 帧, 帧大小: ${frameData.length}`);
- const encodedData = opusEncoder.encode(frameData);
- if (encodedData) {
- console.log(`编码成功: ${encodedData.length} 字节`);
- recordedOpusData.push(encodedData);
-
- // 如果WebSocket已连接,发送编码后的数据
- if (isConnected && websocket && websocket.readyState === WebSocket.OPEN) {
- sendOpusDataToServer(encodedData);
- }
- }
- } catch (error) {
- console.error(`Opus编码帧 ${i+1} 失败:`, error);
- }
- }
-
- // 保存剩余不足一帧的数据
- const remainingSamples = combinedData.length % FRAME_SIZE;
- if (remainingSamples > 0) {
- window.audioDataBuffer = combinedData.subarray(frameCount * FRAME_SIZE);
- console.log(`保留 ${remainingSamples} 个样本到下一次处理`);
- } else {
- window.audioDataBuffer = new Int16Array(0);
- }
-}
-
-function stopRecording() {
- if (!isRecording) return;
-
- // 处理剩余的缓冲数据
- if (window.audioDataBuffer && window.audioDataBuffer.length > 0) {
- console.log(`停止录音,处理剩余的 ${window.audioDataBuffer.length} 个样本`);
- // 如果剩余数据不足一帧,可以通过补零的方式凑成一帧
- if (window.audioDataBuffer.length < FRAME_SIZE) {
- const paddedFrame = new Int16Array(FRAME_SIZE);
- paddedFrame.set(window.audioDataBuffer);
- // 剩余部分填充为0
- for (let i = window.audioDataBuffer.length; i < FRAME_SIZE; i++) {
- paddedFrame[i] = 0;
- }
- try {
- console.log(`编码最后一帧(补零): ${paddedFrame.length} 样本`);
- const encodedData = opusEncoder.encode(paddedFrame);
- if (encodedData) {
- recordedOpusData.push(encodedData);
-
- // 如果WebSocket已连接,发送最后一帧
- if (isConnected && websocket && websocket.readyState === WebSocket.OPEN) {
- sendOpusDataToServer(encodedData);
- }
- }
- } catch (error) {
- console.error("最后一帧Opus编码失败:", error);
- }
- } else {
- // 如果数据超过一帧,按正常流程处理
- processAudioData({
- inputBuffer: {
- getChannelData: () => convertInt16ToFloat32(window.audioDataBuffer)
- }
- });
- }
- window.audioDataBuffer = null;
- }
-
- // 如果WebSocket已连接,发送停止录音信号
- if (isConnected && websocket && websocket.readyState === WebSocket.OPEN) {
- // 发送一个空帧作为结束标记
- const emptyFrame = new Uint8Array(0);
- websocket.send(emptyFrame);
-
- // 发送停止录音控制消息
- sendVoiceControlMessage('stop');
- }
-
- // 如果使用的是AudioWorklet,调用其特定的停止方法
- if (audioProcessor && typeof audioProcessor.stopRecording === 'function') {
- audioProcessor.stopRecording();
- }
-
- // 停止麦克风
- if (mediaStream) {
- mediaStream.getTracks().forEach(track => track.stop());
- }
-
- // 断开音频处理链
- if (audioProcessor) {
- try {
- audioProcessor.disconnect();
- if (mediaSource) mediaSource.disconnect();
- } catch (error) {
- console.warn("断开音频处理链时出错:", error);
- }
- }
-
- // 更新UI
- isRecording = false;
- statusLabel.textContent = "已停止录音,收集了 " + recordedOpusData.length + " 帧Opus数据";
- startButton.disabled = false;
- stopButton.disabled = true;
- playButton.disabled = recordedOpusData.length === 0;
-
- console.log("录制完成:",
- "PCM帧数:", recordedPcmData.length,
- "Opus帧数:", recordedOpusData.length);
-}
-
-function playRecording() {
- if (!recordedOpusData.length) {
- statusLabel.textContent = "没有可播放的录音";
- return;
- }
-
- // 将所有Opus数据解码为PCM
- let allDecodedData = [];
-
- for (const opusData of recordedOpusData) {
- try {
- // 解码为Int16数据
- const decodedData = opusDecoder.decode(opusData);
-
- if (decodedData && decodedData.length > 0) {
- // 将Int16数据转换为Float32
- const float32Data = convertInt16ToFloat32(decodedData);
-
- // 添加到总解码数据中
- allDecodedData.push(...float32Data);
- }
- } catch (error) {
- console.error("Opus解码失败:", error);
- }
- }
-
- // 如果没有解码出数据,返回
- if (allDecodedData.length === 0) {
- statusLabel.textContent = "解码失败,无法播放";
- return;
- }
-
- // 创建音频缓冲区
- const audioBuffer = audioContext.createBuffer(CHANNELS, allDecodedData.length, SAMPLE_RATE);
- audioBuffer.copyToChannel(new Float32Array(allDecodedData), 0);
-
- // 创建音频源并播放
- const source = audioContext.createBufferSource();
- source.buffer = audioBuffer;
- source.connect(audioContext.destination);
- source.start();
-
- // 更新UI
- statusLabel.textContent = "正在播放...";
- playButton.disabled = true;
-
- // 播放结束后恢复UI
- source.onended = () => {
- statusLabel.textContent = "播放完毕";
- playButton.disabled = false;
- };
-}
-
-// 处理二进制消息的修改版本
-async function handleBinaryMessage(data) {
- try {
- let arrayBuffer;
-
- // 根据数据类型进行处理
- if (data instanceof ArrayBuffer) {
- arrayBuffer = data;
- console.log(`收到ArrayBuffer音频数据,大小: ${data.byteLength}字节`);
- } else if (data instanceof Blob) {
- // 如果是Blob类型,转换为ArrayBuffer
- arrayBuffer = await data.arrayBuffer();
- console.log(`收到Blob音频数据,大小: ${arrayBuffer.byteLength}字节`);
- } else {
- console.warn(`收到未知类型的二进制数据: ${typeof data}`);
- return;
- }
-
- // 创建Uint8Array用于处理
- const opusData = new Uint8Array(arrayBuffer);
-
- if (opusData.length > 0) {
- // 将数据添加到缓冲队列
- audioBufferQueue.push(opusData);
-
- // 如果收到的是第一个音频包,开始缓冲过程
- if (audioBufferQueue.length === 1 && !isAudioBuffering && !isAudioPlaying) {
- startAudioBuffering();
- }
- } else {
- console.warn('收到空音频数据帧,可能是结束标志');
-
- // 如果缓冲队列中有数据且没有在播放,立即开始播放
- if (audioBufferQueue.length > 0 && !isAudioPlaying) {
- playBufferedAudio();
- }
-
- // 如果正在播放,发送结束信号
- if (isAudioPlaying && streamingContext) {
- streamingContext.endOfStream = true;
- }
- }
- } catch (error) {
- console.error(`处理二进制消息出错:`, error);
- }
-}
-
-// 开始音频缓冲过程
-function startAudioBuffering() {
- if (isAudioBuffering || isAudioPlaying) return;
-
- isAudioBuffering = true;
- console.log("开始音频缓冲...");
-
- // 设置超时,如果在一定时间内没有收集到足够的音频包,就开始播放
- setTimeout(() => {
- if (isAudioBuffering && audioBufferQueue.length > 0) {
- console.log(`缓冲超时,当前缓冲包数: ${audioBufferQueue.length},开始播放`);
- playBufferedAudio();
- }
- }, 300); // 300ms超时
-
- // 监控缓冲进度
- const bufferCheckInterval = setInterval(() => {
- if (!isAudioBuffering) {
- clearInterval(bufferCheckInterval);
- return;
- }
-
- // 当累积了足够的音频包,开始播放
- if (audioBufferQueue.length >= BUFFER_THRESHOLD) {
- clearInterval(bufferCheckInterval);
- console.log(`已缓冲 ${audioBufferQueue.length} 个音频包,开始播放`);
- playBufferedAudio();
- }
- }, 50);
-}
-
-// 播放已缓冲的音频
-function playBufferedAudio() {
- if (isAudioPlaying || audioBufferQueue.length === 0) return;
-
- isAudioPlaying = true;
- isAudioBuffering = false;
-
- // 创建流式播放上下文
- if (!streamingContext) {
- streamingContext = {
- queue: [], // 已解码的PCM队列
- playing: false, // 是否正在播放
- endOfStream: false, // 是否收到结束信号
- source: null, // 当前音频源
- totalSamples: 0, // 累积的总样本数
- lastPlayTime: 0, // 上次播放的时间戳
- // 将Opus数据解码为PCM
- decodeOpusFrames: async function(opusFrames) {
- let decodedSamples = [];
-
- for (const frame of opusFrames) {
- try {
- // 使用Opus解码器解码
- const frameData = opusDecoder.decode(frame);
- if (frameData && frameData.length > 0) {
- // 转换为Float32
- const floatData = convertInt16ToFloat32(frameData);
- decodedSamples.push(...floatData);
- }
- } catch (error) {
- console.error("Opus解码失败:", error);
- }
- }
-
- if (decodedSamples.length > 0) {
- // 添加到解码队列
- this.queue.push(...decodedSamples);
- this.totalSamples += decodedSamples.length;
-
- // 如果累积了至少0.2秒的音频,开始播放
- const minSamples = SAMPLE_RATE * MIN_AUDIO_DURATION;
- if (!this.playing && this.queue.length >= minSamples) {
- this.startPlaying();
- }
- }
- },
- // 开始播放音频
- startPlaying: function() {
- if (this.playing || this.queue.length === 0) return;
-
- this.playing = true;
-
- // 创建新的音频缓冲区
- const minPlaySamples = Math.min(this.queue.length, SAMPLE_RATE); // 最多播放1秒
- const currentSamples = this.queue.splice(0, minPlaySamples);
-
- const audioBuffer = audioContext.createBuffer(CHANNELS, currentSamples.length, SAMPLE_RATE);
- audioBuffer.copyToChannel(new Float32Array(currentSamples), 0);
-
- // 创建音频源
- this.source = audioContext.createBufferSource();
- this.source.buffer = audioBuffer;
-
- // 创建增益节点用于平滑过渡
- const gainNode = audioContext.createGain();
-
- // 应用淡入淡出效果避免爆音
- const fadeDuration = 0.02; // 20毫秒
- gainNode.gain.setValueAtTime(0, audioContext.currentTime);
- gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + fadeDuration);
-
- const duration = audioBuffer.duration;
- if (duration > fadeDuration * 2) {
- gainNode.gain.setValueAtTime(1, audioContext.currentTime + duration - fadeDuration);
- gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + duration);
- }
-
- // 连接节点并开始播放
- this.source.connect(gainNode);
- gainNode.connect(audioContext.destination);
-
- this.lastPlayTime = audioContext.currentTime;
- console.log(`开始播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / SAMPLE_RATE).toFixed(2)} 秒`);
-
- // 播放结束后的处理
- this.source.onended = () => {
- this.source = null;
- this.playing = false;
-
- // 如果队列中还有数据或者缓冲区有新数据,继续播放
- if (this.queue.length > 0) {
- setTimeout(() => this.startPlaying(), 10);
- } else if (audioBufferQueue.length > 0) {
- // 缓冲区有新数据,进行解码
- const frames = [...audioBufferQueue];
- audioBufferQueue = [];
- this.decodeOpusFrames(frames);
- } else if (this.endOfStream) {
- // 流已结束且没有更多数据
- console.log("音频播放完成");
- isAudioPlaying = false;
- streamingContext = null;
- } else {
- // 等待更多数据
- setTimeout(() => {
- // 如果仍然没有新数据,但有更多的包到达
- if (this.queue.length === 0 && audioBufferQueue.length > 0) {
- const frames = [...audioBufferQueue];
- audioBufferQueue = [];
- this.decodeOpusFrames(frames);
- } else if (this.queue.length === 0 && audioBufferQueue.length === 0) {
- // 真的没有更多数据了
- console.log("音频播放完成 (超时)");
- isAudioPlaying = false;
- streamingContext = null;
- }
- }, 500); // 500ms超时
- }
- };
-
- this.source.start();
- }
- };
- }
-
- // 开始处理缓冲的数据
- const frames = [...audioBufferQueue];
- audioBufferQueue = []; // 清空缓冲队列
-
- // 解码并播放
- streamingContext.decodeOpusFrames(frames);
-}
-
-// 将旧的playOpusFromServer函数保留为备用方法
-function playOpusFromServerOld(opusData) {
- if (!opusDecoder) {
- initOpus().then(success => {
- if (success) {
- decodeAndPlayOpusDataOld(opusData);
- } else {
- statusLabel.textContent = "Opus解码器初始化失败";
- }
- });
- } else {
- decodeAndPlayOpusDataOld(opusData);
- }
-}
-
-// 旧的解码和播放函数作为备用
-function decodeAndPlayOpusDataOld(opusData) {
- let allDecodedData = [];
-
- for (const frame of opusData) {
- try {
- const decodedData = opusDecoder.decode(frame);
- if (decodedData && decodedData.length > 0) {
- const float32Data = convertInt16ToFloat32(decodedData);
- allDecodedData.push(...float32Data);
- }
- } catch (error) {
- console.error("服务端Opus数据解码失败:", error);
- }
- }
-
- if (allDecodedData.length === 0) {
- statusLabel.textContent = "服务端数据解码失败";
- return;
- }
-
- const audioBuffer = audioContext.createBuffer(CHANNELS, allDecodedData.length, SAMPLE_RATE);
- audioBuffer.copyToChannel(new Float32Array(allDecodedData), 0);
-
- const source = audioContext.createBufferSource();
- source.buffer = audioBuffer;
- source.connect(audioContext.destination);
- source.start();
-
- statusLabel.textContent = "正在播放服务端数据...";
- source.onended = () => statusLabel.textContent = "服务端数据播放完毕";
-}
-
-// 更新playOpusFromServer函数为Promise版本
-function playOpusFromServer(opusData) {
- // 为了兼容,我们将opusData添加到audioBufferQueue并触发播放
- if (Array.isArray(opusData) && opusData.length > 0) {
- for (const frame of opusData) {
- audioBufferQueue.push(frame);
- }
-
- // 如果没有在播放和缓冲,启动流程
- if (!isAudioBuffering && !isAudioPlaying) {
- startAudioBuffering();
- }
-
- return new Promise(resolve => {
- // 我们无法准确知道何时播放完成,所以设置一个合理的超时
- setTimeout(resolve, 1000); // 1秒后认为已处理
- });
- } else {
- // 如果不是数组或为空,使用旧方法
- return new Promise(resolve => {
- playOpusFromServerOld(opusData);
- setTimeout(resolve, 1000);
- });
- }
-}
-
-// 连接WebSocket服务器
-function connectToServer() {
- let url = serverUrlInput.value || "ws://127.0.0.1:8000/xiaozhi/v1/";
-
- try {
- // 检查URL格式
- if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
- console.error('URL格式错误,必须以ws://或wss://开头');
- updateStatus('URL格式错误,必须以ws://或wss://开头', 'error');
- return;
- }
-
- // 添加认证参数
- let connUrl = new URL(url);
- connUrl.searchParams.append('device_id', 'web_test_device');
- connUrl.searchParams.append('device_mac', '00:11:22:33:44:55');
-
- console.log(`正在连接: ${connUrl.toString()}`);
- updateStatus(`正在连接: ${connUrl.toString()}`, 'info');
-
- websocket = new WebSocket(connUrl.toString());
-
- // 设置接收二进制数据的类型为ArrayBuffer
- websocket.binaryType = 'arraybuffer';
-
- websocket.onopen = async () => {
- console.log(`已连接到服务器: ${url}`);
- updateStatus(`已连接到服务器: ${url}`, 'success');
- isConnected = true;
-
- // 连接成功后发送hello消息
- await sendHelloMessage();
-
- if(connectButton.id === "connectButton") {
- connectButton.textContent = '断开';
- // connectButton.onclick = disconnectFromServer;
- connectButton.removeEventListener("click", connectToServer);
- connectButton.addEventListener("click", disconnectFromServer);
- }
-
- if(messageInput.id === "messageInput") {
- messageInput.disabled = false;
- }
-
- if(sendTextButton.id === "sendTextButton") {
- sendTextButton.disabled = false;
- }
- };
-
- websocket.onclose = () => {
- console.log('已断开连接');
- updateStatus('已断开连接', 'info');
- isConnected = false;
-
- if(connectButton.id === "connectButton") {
- connectButton.textContent = '连接';
- // connectButton.onclick = connectToServer;
- connectButton.removeEventListener("click", disconnectFromServer);
- connectButton.addEventListener("click", connectToServer);
- }
-
- if(messageInput.id === "messageInput") {
- messageInput.disabled = true;
- }
-
- if(sendTextButton.id === "sendTextButton") {
- sendTextButton.disabled = true;
- }
- };
-
- websocket.onerror = (error) => {
- console.error(`WebSocket错误:`, error);
- updateStatus(`WebSocket错误`, 'error');
- };
-
- websocket.onmessage = function (event) {
- try {
- // 检查是否为文本消息
- if (typeof event.data === 'string') {
- const message = JSON.parse(event.data);
- handleTextMessage(message);
- } else {
- // 处理二进制数据
- handleBinaryMessage(event.data);
- }
- } catch (error) {
- console.error(`WebSocket消息处理错误:`, error);
- // 非JSON格式文本消息直接显示
- if (typeof event.data === 'string') {
- addMessage(event.data);
- }
- }
- };
-
- updateStatus('正在连接...', 'info');
- } catch (error) {
- console.error(`连接错误:`, error);
- updateStatus(`连接失败: ${error.message}`, 'error');
- }
-}
-
-// 断开WebSocket连接
-function disconnectFromServer() {
- if (!websocket) return;
-
- websocket.close();
- if (isRecording) {
- stopRecording();
- }
-}
-
-// 发送hello握手消息
-async function sendHelloMessage() {
- if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
-
- try {
- // 设置设备信息
- const helloMessage = {
- type: 'hello',
- device_id: 'web_test_device',
- device_name: 'Web测试设备',
- device_mac: '00:11:22:33:44:55',
- token: 'your-token1' // 使用config.yaml中配置的token
- };
-
- console.log('发送hello握手消息');
- websocket.send(JSON.stringify(helloMessage));
-
- // 等待服务器响应
- return new Promise(resolve => {
- // 5秒超时
- const timeout = setTimeout(() => {
- console.error('等待hello响应超时');
- resolve(false);
- }, 5000);
-
- // 临时监听一次消息,接收hello响应
- const onMessageHandler = (event) => {
- try {
- const response = JSON.parse(event.data);
- if (response.type === 'hello' && response.session_id) {
- console.log(`服务器握手成功,会话ID: ${response.session_id}`);
- clearTimeout(timeout);
- websocket.removeEventListener('message', onMessageHandler);
- resolve(true);
- }
- } catch (e) {
- // 忽略非JSON消息
- }
- };
-
- websocket.addEventListener('message', onMessageHandler);
- });
- } catch (error) {
- console.error(`发送hello消息错误:`, error);
- return false;
- }
-}
-
-// 发送文本消息
-function sendTextMessage() {
- const message = messageInput ? messageInput.value.trim() : "";
- if (message === '' || !websocket || websocket.readyState !== WebSocket.OPEN) return;
-
- try {
- // 发送listen消息
- const listenMessage = {
- type: 'listen',
- mode: 'manual',
- state: 'detect',
- text: message
- };
-
- websocket.send(JSON.stringify(listenMessage));
- addMessage(message, true);
- console.log(`发送文本消息: ${message}`);
-
- if (messageInput) {
- messageInput.value = '';
- }
- } catch (error) {
- console.error(`发送消息错误:`, error);
- }
-}
-
-// 添加消息到会话记录
-function addMessage(text, isUser = false) {
- if (!conversationDiv) return;
-
- const messageDiv = document.createElement('div');
- messageDiv.className = `message ${isUser ? 'user' : 'server'}`;
- messageDiv.textContent = text;
- conversationDiv.appendChild(messageDiv);
- conversationDiv.scrollTop = conversationDiv.scrollHeight;
-}
-
-// 更新状态信息
-function updateStatus(message, type = 'info') {
- console.log(`[${type}] ${message}`);
- if (statusLabel) {
- statusLabel.textContent = message;
- }
- if (connectionStatus) {
- connectionStatus.textContent = message;
- switch(type) {
- case 'success':
- connectionStatus.style.color = 'green';
- break;
- case 'error':
- connectionStatus.style.color = 'red';
- break;
- case 'info':
- default:
- connectionStatus.style.color = 'black';
- break;
- }
- }
-}
-
-// 处理文本消息
-function handleTextMessage(message) {
- if (message.type === 'hello') {
- console.log(`服务器回应:${JSON.stringify(message, null, 2)}`);
- } else if (message.type === 'tts') {
- // TTS状态消息
- if (message.state === 'start') {
- console.log('服务器开始发送语音');
- } else if (message.state === 'sentence_start') {
- console.log(`服务器发送语音段: ${message.text}`);
- // 添加文本到会话记录
- if (message.text) {
- addMessage(message.text);
- }
- } else if (message.state === 'sentence_end') {
- console.log(`语音段结束: ${message.text}`);
- } else if (message.state === 'stop') {
- console.log('服务器语音传输结束');
- }
- } else if (message.type === 'audio') {
- // 音频控制消息
- console.log(`收到音频控制消息: ${JSON.stringify(message)}`);
- } else if (message.type === 'stt') {
- // 语音识别结果
- console.log(`识别结果: ${message.text}`);
- // 添加识别结果到会话记录
- addMessage(`[语音识别] ${message.text}`, true);
- } else if (message.type === 'llm') {
- // 大模型回复
- console.log(`大模型回复: ${message.text}`);
- // 添加大模型回复到会话记录
- if (message.text && message.text !== '😊') {
- addMessage(message.text);
- }
- } else {
- // 未知消息类型
- console.log(`未知消息类型: ${message.type}`);
- addMessage(JSON.stringify(message, null, 2));
- }
-}
-
-// 发送语音数据到WebSocket
-function sendOpusDataToServer(opusData) {
- if (!websocket || websocket.readyState !== WebSocket.OPEN) {
- console.error('WebSocket未连接,无法发送音频数据');
- return false;
- }
-
- try {
- // 发送二进制数据
- websocket.send(opusData.buffer);
- console.log(`已发送Opus音频数据: ${opusData.length}字节`);
- return true;
- } catch (error) {
- console.error(`发送音频数据失败:`, error);
- return false;
- }
-}
-
-// 发送语音开始和结束信号
-function sendVoiceControlMessage(state) {
- if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
-
- try {
- const message = {
- type: 'listen',
- mode: 'manual',
- state: state // 'start' 或 'stop'
- };
-
- websocket.send(JSON.stringify(message));
- console.log(`发送语音${state === 'start' ? '开始' : '结束'}控制消息`);
- } catch (error) {
- console.error(`发送语音控制消息失败:`, error);
- }
-}
diff --git a/main/xiaozhi-server/test/abbreviated_version/test.html b/main/xiaozhi-server/test/abbreviated_version/test.html
deleted file mode 100644
index 7c1f0324..00000000
--- a/main/xiaozhi-server/test/abbreviated_version/test.html
+++ /dev/null
@@ -1,503 +0,0 @@
-
-
-
-
- 小智语音服务测试
-
-
-
-
-
小智语音服务测试
-
-
正在加载Opus库...
-
-
-
WebSocket连接 未连接
-
-
-
-
-
-
-
-
录音测试
-
-
-
-
-
-
-
录音状态: 待机,正在初始化...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/main/xiaozhi-server/test/default-mcp-tools.json b/main/xiaozhi-server/test/default-mcp-tools.json
deleted file mode 100644
index 2d44f2dc..00000000
--- a/main/xiaozhi-server/test/default-mcp-tools.json
+++ /dev/null
@@ -1,72 +0,0 @@
-[
- {
- "name": "self.get_device_status",
- "description": "Provides the real-time information of the device, including the current status of the audio speaker, screen, battery, network, etc.\nUse this tool for: \n1. Answering questions about current condition (e.g. what is the current volume of the audio speaker?)\n2. As the first step to control the device (e.g. turn up / down the volume of the audio speaker, etc.)",
- "inputSchema": {
- "type": "object",
- "properties": {}
- },
- "mockResponse": {
- "audio_speaker": {
- "volume": 50,
- "muted": false
- },
- "screen": {
- "brightness": 80,
- "theme": "light"
- },
- "battery": {
- "level": 85,
- "charging": false
- },
- "network": {
- "connected": true,
- "type": "wifi"
- }
- }
- },
- {
- "name": "self.audio_speaker.set_volume",
- "description": "Set the volume of the audio speaker. If the current volume is unknown, you must call `self.get_device_status` tool first and then call this tool.",
- "inputSchema": {
- "type": "object",
- "properties": {
- "volume": {
- "type": "integer",
- "minimum": 0,
- "maximum": 100
- }
- },
- "required": [
- "volume"
- ]
- },
- "mockResponse": {
- "success": true,
- "volume": "${volume}",
- "message": "音量已设置为 ${volume}"
- }
- },
- {
- "name": "self.screen.set_brightness",
- "description": "Set the brightness of the screen.",
- "inputSchema": {
- "type": "object",
- "properties": {
- "brightness": {
- "type": "integer",
- "minimum": 0,
- "maximum": 100
- }
- },
- "required": [
- "brightness"
- ]
- },
- "mockResponse": {
- "success": true,
- "brightness": "${brightness}",
- "message": "亮度已设置为 ${brightness}"
- }
- }
-]
\ No newline at end of file
diff --git a/main/xiaozhi-server/test/js/StreamingContext.js b/main/xiaozhi-server/test/js/StreamingContext.js
index 761c0691..690312dd 100644
--- a/main/xiaozhi-server/test/js/StreamingContext.js
+++ b/main/xiaozhi-server/test/js/StreamingContext.js
@@ -1,4 +1,4 @@
-import BlockingQueue from './utils/BlockingQueue.js';
+import BlockingQueue from './utils/blockingQueue.js';
import { log } from './utils/logger.js';
// 音频流播放上下文类
diff --git a/main/xiaozhi-server/test/js/document.js b/main/xiaozhi-server/test/js/document.js
index 6fb5b222..0aad7738 100644
--- a/main/xiaozhi-server/test/js/document.js
+++ b/main/xiaozhi-server/test/js/document.js
@@ -12,19 +12,19 @@ const logContainer = document.getElementById('logContainer');
let visualizerCanvas = document.getElementById('audioVisualizer');
// ota 是否连接成功,修改成对应的样式
-export function otaStatusStyle (flan) {
- if(flan){
- document.getElementById('otaStatus').textContent = 'ota已连接';
+export function otaStatusStyle(flan) {
+ if (flan) {
+ document.getElementById('otaStatus').textContent = 'OTA已连接';
document.getElementById('otaStatus').style.color = 'green';
- }else{
- document.getElementById('otaStatus').textContent = 'ota未连接';
+ } else {
+ document.getElementById('otaStatus').textContent = 'OTA未连接';
document.getElementById('otaStatus').style.color = 'red';
}
}
// ota 是否连接成功,修改成对应的样式
-export function getLogContainer (flan) {
- return logContainer;
+export function getLogContainer(flan) {
+ return logContainer;
}
// 更新Opus库状态显示
diff --git a/main/xiaozhi-server/test/js/mcpTools.js b/main/xiaozhi-server/test/js/mcpTools.js
new file mode 100644
index 00000000..da11d815
--- /dev/null
+++ b/main/xiaozhi-server/test/js/mcpTools.js
@@ -0,0 +1,477 @@
+import { log } from './utils/logger.js';
+
+// ==========================================
+// MCP 工具管理逻辑
+// ==========================================
+
+// 全局变量
+let mcpTools = [];
+let mcpEditingIndex = null;
+let mcpProperties = [];
+let websocket = null; // 将从外部设置
+
+/**
+ * 设置 WebSocket 实例
+ * @param {WebSocket} ws - WebSocket 连接实例
+ */
+export function setWebSocket(ws) {
+ websocket = ws;
+}
+
+/**
+ * 初始化 MCP 工具
+ */
+export async function initMcpTools() {
+ // 加载默认工具数据
+ const defaultMcpTools = await fetch("js/default-mcp-tools.json").then(res => res.json());
+
+ const savedTools = localStorage.getItem('mcpTools');
+ if (savedTools) {
+ try {
+ mcpTools = JSON.parse(savedTools);
+ } catch (e) {
+ log('加载MCP工具失败,使用默认工具', 'warning');
+ mcpTools = [...defaultMcpTools];
+ }
+ } else {
+ mcpTools = [...defaultMcpTools];
+ }
+
+ renderMcpTools();
+ setupMcpEventListeners();
+}
+
+/**
+ * 渲染工具列表
+ */
+function renderMcpTools() {
+ const container = document.getElementById('mcpToolsContainer');
+ const countSpan = document.getElementById('mcpToolsCount');
+
+ countSpan.textContent = `${mcpTools.length} 个工具`;
+
+ if (mcpTools.length === 0) {
+ container.innerHTML = '暂无工具,点击下方按钮添加新工具
';
+ return;
+ }
+
+ container.innerHTML = mcpTools.map((tool, index) => {
+ const paramCount = tool.inputSchema.properties ? Object.keys(tool.inputSchema.properties).length : 0;
+ const requiredCount = tool.inputSchema.required ? tool.inputSchema.required.length : 0;
+ const hasMockResponse = tool.mockResponse && Object.keys(tool.mockResponse).length > 0;
+
+ return `
+
+ `;
+ }).join('');
+}
+
+/**
+ * 渲染参数列表
+ */
+function renderMcpProperties() {
+ const container = document.getElementById('mcpPropertiesContainer');
+
+ if (mcpProperties.length === 0) {
+ container.innerHTML = '暂无参数,点击下方按钮添加参数
';
+ return;
+ }
+
+ container.innerHTML = mcpProperties.map((prop, index) => `
+
+
+
+
+
+
+
+
+
+
+
+
+ ${(prop.type === 'integer' || prop.type === 'number') ? `
+
+ ` : ''}
+
+
+
+
+
+
+ `).join('');
+}
+
+/**
+ * 添加参数
+ */
+function addMcpProperty() {
+ mcpProperties.push({
+ name: `param_${mcpProperties.length + 1}`,
+ type: 'string',
+ required: false,
+ description: ''
+ });
+ renderMcpProperties();
+}
+
+/**
+ * 更新参数
+ */
+function updateMcpProperty(index, field, value) {
+ if (field === 'name') {
+ const isDuplicate = mcpProperties.some((p, i) => i !== index && p.name === value);
+ if (isDuplicate) {
+ alert('参数名称已存在,请使用不同的名称');
+ renderMcpProperties();
+ return;
+ }
+ }
+
+ mcpProperties[index][field] = value;
+
+ if (field === 'type' && value !== 'integer' && value !== 'number') {
+ delete mcpProperties[index].minimum;
+ delete mcpProperties[index].maximum;
+ renderMcpProperties();
+ }
+}
+
+/**
+ * 删除参数
+ */
+function deleteMcpProperty(index) {
+ mcpProperties.splice(index, 1);
+ renderMcpProperties();
+}
+
+/**
+ * 设置事件监听
+ */
+function setupMcpEventListeners() {
+ const toggleBtn = document.getElementById('toggleMcpTools');
+ const panel = document.getElementById('mcpToolsPanel');
+ const addBtn = document.getElementById('addMcpToolBtn');
+ const modal = document.getElementById('mcpToolModal');
+ const closeBtn = document.getElementById('closeMcpModalBtn');
+ const cancelBtn = document.getElementById('cancelMcpBtn');
+ const form = document.getElementById('mcpToolForm');
+ const addPropertyBtn = document.getElementById('addMcpPropertyBtn');
+
+ toggleBtn.addEventListener('click', () => {
+ const isExpanded = panel.classList.contains('expanded');
+ panel.classList.toggle('expanded');
+ toggleBtn.textContent = isExpanded ? '展开' : '收起';
+ });
+
+ addBtn.addEventListener('click', () => openMcpModal());
+ closeBtn.addEventListener('click', closeMcpModal);
+ cancelBtn.addEventListener('click', closeMcpModal);
+ addPropertyBtn.addEventListener('click', addMcpProperty);
+
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) closeMcpModal();
+ });
+
+ form.addEventListener('submit', handleMcpSubmit);
+}
+
+/**
+ * 打开模态框
+ */
+function openMcpModal(index = null) {
+ const isConnected = websocket && websocket.readyState === WebSocket.OPEN;
+ if (isConnected) {
+ alert('WebSocket 已连接,无法编辑工具');
+ return;
+ }
+
+ mcpEditingIndex = index;
+ const errorContainer = document.getElementById('mcpErrorContainer');
+ errorContainer.innerHTML = '';
+
+ if (index !== null) {
+ document.getElementById('mcpModalTitle').textContent = '编辑工具';
+ const tool = mcpTools[index];
+ document.getElementById('mcpToolName').value = tool.name;
+ document.getElementById('mcpToolDescription').value = tool.description;
+ document.getElementById('mcpMockResponse').value = tool.mockResponse ? JSON.stringify(tool.mockResponse, null, 2) : '';
+
+ mcpProperties = [];
+ const schema = tool.inputSchema;
+ if (schema.properties) {
+ Object.keys(schema.properties).forEach(key => {
+ const prop = schema.properties[key];
+ mcpProperties.push({
+ name: key,
+ type: prop.type || 'string',
+ minimum: prop.minimum,
+ maximum: prop.maximum,
+ description: prop.description || '',
+ required: schema.required && schema.required.includes(key)
+ });
+ });
+ }
+ } else {
+ document.getElementById('mcpModalTitle').textContent = '添加工具';
+ document.getElementById('mcpToolForm').reset();
+ mcpProperties = [];
+ }
+
+ renderMcpProperties();
+ document.getElementById('mcpToolModal').style.display = 'block';
+}
+
+/**
+ * 关闭模态框
+ */
+function closeMcpModal() {
+ document.getElementById('mcpToolModal').style.display = 'none';
+ mcpEditingIndex = null;
+ document.getElementById('mcpToolForm').reset();
+ mcpProperties = [];
+ document.getElementById('mcpErrorContainer').innerHTML = '';
+}
+
+/**
+ * 处理表单提交
+ */
+function handleMcpSubmit(e) {
+ e.preventDefault();
+ const errorContainer = document.getElementById('mcpErrorContainer');
+ errorContainer.innerHTML = '';
+
+ const name = document.getElementById('mcpToolName').value.trim();
+ const description = document.getElementById('mcpToolDescription').value.trim();
+ const mockResponseText = document.getElementById('mcpMockResponse').value.trim();
+
+ // 检查名称重复
+ const isDuplicate = mcpTools.some((tool, index) =>
+ tool.name === name && index !== mcpEditingIndex
+ );
+
+ if (isDuplicate) {
+ showMcpError('工具名称已存在,请使用不同的名称');
+ return;
+ }
+
+ // 解析模拟返回结果
+ let mockResponse = null;
+ if (mockResponseText) {
+ try {
+ mockResponse = JSON.parse(mockResponseText);
+ } catch (e) {
+ showMcpError('模拟返回结果不是有效的 JSON 格式: ' + e.message);
+ return;
+ }
+ }
+
+ // 构建 inputSchema
+ const inputSchema = {
+ type: "object",
+ properties: {},
+ required: []
+ };
+
+ mcpProperties.forEach(prop => {
+ const propSchema = { type: prop.type };
+
+ if (prop.description) {
+ propSchema.description = prop.description;
+ }
+
+ if ((prop.type === 'integer' || prop.type === 'number')) {
+ if (prop.minimum !== undefined && prop.minimum !== '') {
+ propSchema.minimum = prop.minimum;
+ }
+ if (prop.maximum !== undefined && prop.maximum !== '') {
+ propSchema.maximum = prop.maximum;
+ }
+ }
+
+ inputSchema.properties[prop.name] = propSchema;
+
+ if (prop.required) {
+ inputSchema.required.push(prop.name);
+ }
+ });
+
+ if (inputSchema.required.length === 0) {
+ delete inputSchema.required;
+ }
+
+ const tool = { name, description, inputSchema, mockResponse };
+
+ if (mcpEditingIndex !== null) {
+ mcpTools[mcpEditingIndex] = tool;
+ log(`已更新工具: ${name}`, 'success');
+ } else {
+ mcpTools.push(tool);
+ log(`已添加工具: ${name}`, 'success');
+ }
+
+ saveMcpTools();
+ renderMcpTools();
+ closeMcpModal();
+}
+
+/**
+ * 显示错误
+ */
+function showMcpError(message) {
+ const errorContainer = document.getElementById('mcpErrorContainer');
+ errorContainer.innerHTML = `${message}
`;
+}
+
+/**
+ * 编辑工具
+ */
+function editMcpTool(index) {
+ openMcpModal(index);
+}
+
+/**
+ * 删除工具
+ */
+function deleteMcpTool(index) {
+ const isConnected = websocket && websocket.readyState === WebSocket.OPEN;
+ if (isConnected) {
+ alert('WebSocket 已连接,无法编辑工具');
+ return;
+ }
+ if (confirm(`确定要删除工具 "${mcpTools[index].name}" 吗?`)) {
+ const toolName = mcpTools[index].name;
+ mcpTools.splice(index, 1);
+ saveMcpTools();
+ renderMcpTools();
+ log(`已删除工具: ${toolName}`, 'info');
+ }
+}
+
+/**
+ * 保存工具
+ */
+function saveMcpTools() {
+ localStorage.setItem('mcpTools', JSON.stringify(mcpTools));
+}
+
+/**
+ * 获取工具列表
+ */
+export function getMcpTools() {
+ return mcpTools.map(tool => ({
+ name: tool.name,
+ description: tool.description,
+ inputSchema: tool.inputSchema
+ }));
+}
+
+/**
+ * 执行工具调用
+ */
+export function executeMcpTool(toolName, toolArgs) {
+ const tool = mcpTools.find(t => t.name === toolName);
+
+ if (!tool) {
+ log(`未找到工具: ${toolName}`, 'error');
+ return {
+ success: false,
+ error: `未知工具: ${toolName}`
+ };
+ }
+
+ // 如果有模拟返回结果,使用它
+ if (tool.mockResponse) {
+ // 替换模板变量
+ let responseStr = JSON.stringify(tool.mockResponse);
+
+ // 替换 ${paramName} 格式的变量
+ if (toolArgs) {
+ Object.keys(toolArgs).forEach(key => {
+ const regex = new RegExp(`\\$\\{${key}\\}`, 'g');
+ responseStr = responseStr.replace(regex, toolArgs[key]);
+ });
+ }
+
+ try {
+ const response = JSON.parse(responseStr);
+ log(`工具 ${toolName} 执行成功,返回模拟结果: ${responseStr}`, 'success');
+ return response;
+ } catch (e) {
+ log(`解析模拟返回结果失败: ${e.message}`, 'error');
+ return tool.mockResponse;
+ }
+ }
+
+ // 没有模拟返回结果,返回默认成功消息
+ log(`工具 ${toolName} 执行成功,返回默认结果`, 'success');
+ return {
+ success: true,
+ message: `工具 ${toolName} 执行成功`,
+ tool: toolName,
+ arguments: toolArgs
+ };
+}
+
+// 暴露全局方法供 HTML 内联事件调用
+window.mcpModule = {
+ updateMcpProperty,
+ deleteMcpProperty,
+ editMcpTool,
+ deleteMcpTool
+};
diff --git a/main/xiaozhi-server/test/libopus.js b/main/xiaozhi-server/test/js/utils/libopus.js
similarity index 100%
rename from main/xiaozhi-server/test/libopus.js
rename to main/xiaozhi-server/test/js/utils/libopus.js
diff --git a/main/xiaozhi-server/test/opus_test/app.js b/main/xiaozhi-server/test/opus_test/app.js
deleted file mode 100644
index 9e56f997..00000000
--- a/main/xiaozhi-server/test/opus_test/app.js
+++ /dev/null
@@ -1,670 +0,0 @@
-const SAMPLE_RATE = 16000;
-const CHANNELS = 1;
-const FRAME_SIZE = 960; // 对应于60ms帧大小 (16000Hz * 0.06s = 960 samples)
-const OPUS_APPLICATION = 2049; // OPUS_APPLICATION_AUDIO
-const BUFFER_SIZE = 4096;
-
-let audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
-let mediaStream, mediaSource, audioProcessor;
-let recordedPcmData = []; // 存储原始PCM数据
-let recordedOpusData = []; // 存储Opus编码后的数据
-let opusEncoder, opusDecoder;
-let isRecording = false;
-
-const startButton = document.getElementById("start");
-const stopButton = document.getElementById("stop");
-const playButton = document.getElementById("play");
-const statusLabel = document.getElementById("status");
-
-startButton.addEventListener("click", startRecording);
-stopButton.addEventListener("click", stopRecording);
-playButton.addEventListener("click", playRecording);
-
-// 初始化Opus编码器与解码器
-async function initOpus() {
- if (typeof window.ModuleInstance === 'undefined') {
- if (typeof Module !== 'undefined') {
- // 尝试使用全局Module
- window.ModuleInstance = Module;
- console.log('使用全局Module作为ModuleInstance');
- } else {
- console.error("Opus库未加载,ModuleInstance和Module对象都不存在");
- return false;
- }
- }
-
- try {
- const mod = window.ModuleInstance;
-
- // 创建编码器
- opusEncoder = {
- channels: CHANNELS,
- sampleRate: SAMPLE_RATE,
- frameSize: FRAME_SIZE,
- maxPacketSize: 4000,
- module: mod,
-
- // 初始化编码器
- init: function() {
- // 获取编码器大小
- const encoderSize = mod._opus_encoder_get_size(this.channels);
- console.log(`Opus编码器大小: ${encoderSize}字节`);
-
- // 分配内存
- this.encoderPtr = mod._malloc(encoderSize);
- if (!this.encoderPtr) {
- throw new Error("无法分配编码器内存");
- }
-
- // 初始化编码器
- const err = mod._opus_encoder_init(
- this.encoderPtr,
- this.sampleRate,
- this.channels,
- OPUS_APPLICATION
- );
-
- if (err < 0) {
- throw new Error(`Opus编码器初始化失败: ${err}`);
- }
-
- return true;
- },
-
- // 编码方法
- encode: function(pcmData) {
- const mod = this.module;
-
- // 为PCM数据分配内存
- const pcmPtr = mod._malloc(pcmData.length * 2); // Int16 = 2字节
-
- // 将数据复制到WASM内存
- for (let i = 0; i < pcmData.length; i++) {
- mod.HEAP16[(pcmPtr >> 1) + i] = pcmData[i];
- }
-
- // 为Opus编码数据分配内存
- const maxEncodedSize = this.maxPacketSize;
- const encodedPtr = mod._malloc(maxEncodedSize);
-
- // 编码
- const encodedBytes = mod._opus_encode(
- this.encoderPtr,
- pcmPtr,
- this.frameSize,
- encodedPtr,
- maxEncodedSize
- );
-
- if (encodedBytes < 0) {
- mod._free(pcmPtr);
- mod._free(encodedPtr);
- throw new Error(`Opus编码失败: ${encodedBytes}`);
- }
-
- // 复制编码后的数据
- const encodedData = new Uint8Array(encodedBytes);
- for (let i = 0; i < encodedBytes; i++) {
- encodedData[i] = mod.HEAPU8[encodedPtr + i];
- }
-
- // 释放内存
- mod._free(pcmPtr);
- mod._free(encodedPtr);
-
- return encodedData;
- },
-
- // 销毁方法
- destroy: function() {
- if (this.encoderPtr) {
- this.module._free(this.encoderPtr);
- this.encoderPtr = null;
- }
- }
- };
-
- // 创建解码器
- opusDecoder = {
- channels: CHANNELS,
- rate: SAMPLE_RATE,
- frameSize: FRAME_SIZE,
- module: mod,
-
- // 初始化解码器
- init: function() {
- // 获取解码器大小
- const decoderSize = mod._opus_decoder_get_size(this.channels);
- console.log(`Opus解码器大小: ${decoderSize}字节`);
-
- // 分配内存
- this.decoderPtr = mod._malloc(decoderSize);
- if (!this.decoderPtr) {
- throw new Error("无法分配解码器内存");
- }
-
- // 初始化解码器
- const err = mod._opus_decoder_init(
- this.decoderPtr,
- this.rate,
- this.channels
- );
-
- if (err < 0) {
- throw new Error(`Opus解码器初始化失败: ${err}`);
- }
-
- return true;
- },
-
- // 解码方法
- decode: function(opusData) {
- const mod = this.module;
-
- // 为Opus数据分配内存
- const opusPtr = mod._malloc(opusData.length);
- mod.HEAPU8.set(opusData, opusPtr);
-
- // 为PCM输出分配内存
- const pcmPtr = mod._malloc(this.frameSize * 2); // Int16 = 2字节
-
- // 解码
- const decodedSamples = mod._opus_decode(
- this.decoderPtr,
- opusPtr,
- opusData.length,
- pcmPtr,
- this.frameSize,
- 0 // 不使用FEC
- );
-
- if (decodedSamples < 0) {
- mod._free(opusPtr);
- mod._free(pcmPtr);
- throw new Error(`Opus解码失败: ${decodedSamples}`);
- }
-
- // 复制解码后的数据
- const decodedData = new Int16Array(decodedSamples);
- for (let i = 0; i < decodedSamples; i++) {
- decodedData[i] = mod.HEAP16[(pcmPtr >> 1) + i];
- }
-
- // 释放内存
- mod._free(opusPtr);
- mod._free(pcmPtr);
-
- return decodedData;
- },
-
- // 销毁方法
- destroy: function() {
- if (this.decoderPtr) {
- this.module._free(this.decoderPtr);
- this.decoderPtr = null;
- }
- }
- };
-
- // 初始化编码器和解码器
- if (opusEncoder.init() && opusDecoder.init()) {
- console.log("Opus 编码器和解码器初始化成功。");
- return true;
- } else {
- console.error("Opus 初始化失败");
- return false;
- }
- } catch (error) {
- console.error("Opus 初始化失败:", error);
- return false;
- }
-}
-
-// 将Float32音频数据转换为Int16音频数据
-function convertFloat32ToInt16(float32Data) {
- const int16Data = new Int16Array(float32Data.length);
- for (let i = 0; i < float32Data.length; i++) {
- // 将[-1,1]范围转换为[-32768,32767]
- const s = Math.max(-1, Math.min(1, float32Data[i]));
- int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
- }
- return int16Data;
-}
-
-// 将Int16音频数据转换为Float32音频数据
-function convertInt16ToFloat32(int16Data) {
- const float32Data = new Float32Array(int16Data.length);
- for (let i = 0; i < int16Data.length; i++) {
- // 将[-32768,32767]范围转换为[-1,1]
- float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7FFF);
- }
- return float32Data;
-}
-
-function startRecording() {
- if (isRecording) return;
-
- // 确保有权限并且AudioContext是活跃的
- if (audioContext.state === 'suspended') {
- audioContext.resume().then(() => {
- console.log("AudioContext已恢复");
- continueStartRecording();
- }).catch(err => {
- console.error("恢复AudioContext失败:", err);
- statusLabel.textContent = "无法激活音频上下文,请再次点击";
- });
- } else {
- continueStartRecording();
- }
-}
-
-// 实际开始录音的逻辑
-function continueStartRecording() {
- // 重置录音数据
- recordedPcmData = [];
- recordedOpusData = [];
- window.audioDataBuffer = new Int16Array(0); // 重置缓冲区
-
- // 初始化Opus
- initOpus().then(success => {
- if (!success) {
- statusLabel.textContent = "Opus初始化失败";
- return;
- }
-
- console.log("开始录音,参数:", {
- sampleRate: SAMPLE_RATE,
- channels: CHANNELS,
- frameSize: FRAME_SIZE,
- bufferSize: BUFFER_SIZE
- });
-
- // 请求麦克风权限
- navigator.mediaDevices.getUserMedia({
- audio: {
- sampleRate: SAMPLE_RATE,
- channelCount: CHANNELS,
- echoCancellation: true,
- noiseSuppression: true,
- autoGainControl: true
- }
- })
- .then(stream => {
- console.log("获取到麦克风流,实际参数:", stream.getAudioTracks()[0].getSettings());
-
- // 检查流是否有效
- if (!stream || !stream.getAudioTracks().length || !stream.getAudioTracks()[0].enabled) {
- throw new Error("获取到的音频流无效");
- }
-
- mediaStream = stream;
- mediaSource = audioContext.createMediaStreamSource(stream);
-
- // 创建ScriptProcessor(虽然已弃用,但兼容性好)
- // 在降级到ScriptProcessor之前尝试使用AudioWorklet
- createAudioProcessor().then(processor => {
- if (processor) {
- console.log("使用AudioWorklet处理音频");
- audioProcessor = processor;
- // 连接音频处理链
- mediaSource.connect(audioProcessor);
- audioProcessor.connect(audioContext.destination);
- } else {
- console.log("回退到ScriptProcessor");
- // 创建ScriptProcessor节点
- audioProcessor = audioContext.createScriptProcessor(BUFFER_SIZE, CHANNELS, CHANNELS);
-
- // 处理音频数据
- audioProcessor.onaudioprocess = processAudioData;
-
- // 连接音频处理链
- mediaSource.connect(audioProcessor);
- audioProcessor.connect(audioContext.destination);
- }
-
- // 更新UI
- isRecording = true;
- statusLabel.textContent = "录音中...";
- startButton.disabled = true;
- stopButton.disabled = false;
- playButton.disabled = true;
- }).catch(error => {
- console.error("创建音频处理器失败:", error);
- statusLabel.textContent = "创建音频处理器失败";
- });
- })
- .catch(error => {
- console.error("获取麦克风失败:", error);
- statusLabel.textContent = "获取麦克风失败: " + error.message;
- });
- });
-}
-
-// 创建AudioWorklet处理器
-async function createAudioProcessor() {
- try {
- // 尝试使用更现代的AudioWorklet API
- if ('AudioWorklet' in window && 'AudioWorkletNode' in window) {
- // 定义AudioWorklet处理器代码
- const workletCode = `
- class OpusRecorderProcessor extends AudioWorkletProcessor {
- constructor() {
- super();
- this.buffers = [];
- this.frameSize = ${FRAME_SIZE};
- this.buffer = new Float32Array(this.frameSize);
- this.bufferIndex = 0;
- this.isRecording = false;
-
- this.port.onmessage = (event) => {
- if (event.data.command === 'start') {
- this.isRecording = true;
- } else if (event.data.command === 'stop') {
- this.isRecording = false;
- // 发送最后的缓冲区
- if (this.bufferIndex > 0) {
- const finalBuffer = this.buffer.slice(0, this.bufferIndex);
- this.port.postMessage({ buffer: finalBuffer });
- }
- }
- };
- }
-
- process(inputs, outputs) {
- if (!this.isRecording) return true;
-
- // 获取输入数据
- const input = inputs[0][0]; // mono channel
- if (!input || input.length === 0) return true;
-
- // 将输入数据添加到缓冲区
- for (let i = 0; i < input.length; i++) {
- this.buffer[this.bufferIndex++] = input[i];
-
- // 当缓冲区填满时,发送给主线程
- if (this.bufferIndex >= this.frameSize) {
- this.port.postMessage({ buffer: this.buffer.slice() });
- this.bufferIndex = 0;
- }
- }
-
- return true;
- }
- }
-
- registerProcessor('opus-recorder-processor', OpusRecorderProcessor);
- `;
-
- // 创建Blob URL
- const blob = new Blob([workletCode], { type: 'application/javascript' });
- const url = URL.createObjectURL(blob);
-
- // 加载AudioWorklet模块
- await audioContext.audioWorklet.addModule(url);
-
- // 创建AudioWorkletNode
- const workletNode = new AudioWorkletNode(audioContext, 'opus-recorder-processor');
-
- // 处理从AudioWorklet接收的消息
- workletNode.port.onmessage = (event) => {
- if (event.data.buffer) {
- // 使用与ScriptProcessor相同的处理逻辑
- processAudioData({
- inputBuffer: {
- getChannelData: () => event.data.buffer
- }
- });
- }
- };
-
- // 启动录音
- workletNode.port.postMessage({ command: 'start' });
-
- // 保存停止函数
- workletNode.stopRecording = () => {
- workletNode.port.postMessage({ command: 'stop' });
- };
-
- console.log("AudioWorklet 音频处理器创建成功");
- return workletNode;
- }
- } catch (error) {
- console.error("创建AudioWorklet失败,将使用ScriptProcessor:", error);
- }
-
- // 如果AudioWorklet不可用或失败,返回null以便回退到ScriptProcessor
- return null;
-}
-
-// 处理音频数据
-function processAudioData(e) {
- // 获取输入缓冲区
- const inputBuffer = e.inputBuffer;
-
- // 获取第一个通道的Float32数据
- const inputData = inputBuffer.getChannelData(0);
-
- // 添加调试信息
- const nonZeroCount = Array.from(inputData).filter(x => Math.abs(x) > 0.001).length;
- console.log(`接收到音频数据: ${inputData.length} 个样本, 非零样本数: ${nonZeroCount}`);
-
- // 如果全是0,可能是麦克风没有正确获取声音
- if (nonZeroCount < 5) {
- console.warn("警告: 检测到大量静音样本,请检查麦克风是否正常工作");
- // 继续处理,以防有些样本确实是静音
- }
-
- // 存储PCM数据用于调试
- recordedPcmData.push(new Float32Array(inputData));
-
- // 转换为Int16数据供Opus编码
- const int16Data = convertFloat32ToInt16(inputData);
-
- // 如果收集到的数据不是FRAME_SIZE的整数倍,需要进行处理
- // 创建静态缓冲区来存储不足一帧的数据
- if (!window.audioDataBuffer) {
- window.audioDataBuffer = new Int16Array(0);
- }
-
- // 合并之前缓存的数据和新数据
- const combinedData = new Int16Array(window.audioDataBuffer.length + int16Data.length);
- combinedData.set(window.audioDataBuffer);
- combinedData.set(int16Data, window.audioDataBuffer.length);
-
- // 处理完整帧
- const frameCount = Math.floor(combinedData.length / FRAME_SIZE);
- console.log(`可编码的完整帧数: ${frameCount}, 缓冲区总大小: ${combinedData.length}`);
-
- for (let i = 0; i < frameCount; i++) {
- const frameData = combinedData.subarray(i * FRAME_SIZE, (i + 1) * FRAME_SIZE);
-
- try {
- console.log(`编码第 ${i+1}/${frameCount} 帧, 帧大小: ${frameData.length}`);
- const encodedData = opusEncoder.encode(frameData);
- if (encodedData) {
- console.log(`编码成功: ${encodedData.length} 字节`);
- recordedOpusData.push(encodedData);
- }
- } catch (error) {
- console.error(`Opus编码帧 ${i+1} 失败:`, error);
- }
- }
-
- // 保存剩余不足一帧的数据
- const remainingSamples = combinedData.length % FRAME_SIZE;
- if (remainingSamples > 0) {
- window.audioDataBuffer = combinedData.subarray(frameCount * FRAME_SIZE);
- console.log(`保留 ${remainingSamples} 个样本到下一次处理`);
- } else {
- window.audioDataBuffer = new Int16Array(0);
- }
-}
-
-function stopRecording() {
- if (!isRecording) return;
-
- // 处理剩余的缓冲数据
- if (window.audioDataBuffer && window.audioDataBuffer.length > 0) {
- console.log(`停止录音,处理剩余的 ${window.audioDataBuffer.length} 个样本`);
- // 如果剩余数据不足一帧,可以通过补零的方式凑成一帧
- if (window.audioDataBuffer.length < FRAME_SIZE) {
- const paddedFrame = new Int16Array(FRAME_SIZE);
- paddedFrame.set(window.audioDataBuffer);
- // 剩余部分填充为0
- for (let i = window.audioDataBuffer.length; i < FRAME_SIZE; i++) {
- paddedFrame[i] = 0;
- }
- try {
- console.log(`编码最后一帧(补零): ${paddedFrame.length} 样本`);
- const encodedData = opusEncoder.encode(paddedFrame);
- if (encodedData) {
- recordedOpusData.push(encodedData);
- }
- } catch (error) {
- console.error("最后一帧Opus编码失败:", error);
- }
- } else {
- // 如果数据超过一帧,按正常流程处理
- processAudioData({
- inputBuffer: {
- getChannelData: () => convertInt16ToFloat32(window.audioDataBuffer)
- }
- });
- }
- window.audioDataBuffer = null;
- }
-
- // 如果使用的是AudioWorklet,调用其特定的停止方法
- if (audioProcessor && typeof audioProcessor.stopRecording === 'function') {
- audioProcessor.stopRecording();
- }
-
- // 停止麦克风
- if (mediaStream) {
- mediaStream.getTracks().forEach(track => track.stop());
- }
-
- // 断开音频处理链
- if (audioProcessor) {
- try {
- audioProcessor.disconnect();
- if (mediaSource) mediaSource.disconnect();
- } catch (error) {
- console.warn("断开音频处理链时出错:", error);
- }
- }
-
- // 更新UI
- isRecording = false;
- statusLabel.textContent = "已停止录音,收集了 " + recordedOpusData.length + " 帧Opus数据";
- startButton.disabled = false;
- stopButton.disabled = true;
- playButton.disabled = recordedOpusData.length === 0;
-
- console.log("录制完成:",
- "PCM帧数:", recordedPcmData.length,
- "Opus帧数:", recordedOpusData.length);
-}
-
-function playRecording() {
- if (!recordedOpusData.length) {
- statusLabel.textContent = "没有可播放的录音";
- return;
- }
-
- // 将所有Opus数据解码为PCM
- let allDecodedData = [];
-
- for (const opusData of recordedOpusData) {
- try {
- // 解码为Int16数据
- const decodedData = opusDecoder.decode(opusData);
-
- if (decodedData && decodedData.length > 0) {
- // 将Int16数据转换为Float32
- const float32Data = convertInt16ToFloat32(decodedData);
-
- // 添加到总解码数据中
- allDecodedData.push(...float32Data);
- }
- } catch (error) {
- console.error("Opus解码失败:", error);
- }
- }
-
- // 如果没有解码出数据,返回
- if (allDecodedData.length === 0) {
- statusLabel.textContent = "解码失败,无法播放";
- return;
- }
-
- // 创建音频缓冲区
- const audioBuffer = audioContext.createBuffer(CHANNELS, allDecodedData.length, SAMPLE_RATE);
- audioBuffer.copyToChannel(new Float32Array(allDecodedData), 0);
-
- // 创建音频源并播放
- const source = audioContext.createBufferSource();
- source.buffer = audioBuffer;
- source.connect(audioContext.destination);
- source.start();
-
- // 更新UI
- statusLabel.textContent = "正在播放...";
- playButton.disabled = true;
-
- // 播放结束后恢复UI
- source.onended = () => {
- statusLabel.textContent = "播放完毕";
- playButton.disabled = false;
- };
-}
-
-// 模拟服务端返回的Opus数据进行解码播放
-function playOpusFromServer(opusData) {
- // 这个函数展示如何处理服务端返回的opus数据
- // opusData应该是一个包含opus帧的数组
-
- if (!opusDecoder) {
- initOpus().then(success => {
- if (success) {
- decodeAndPlayOpusData(opusData);
- } else {
- statusLabel.textContent = "Opus解码器初始化失败";
- }
- });
- } else {
- decodeAndPlayOpusData(opusData);
- }
-}
-
-function decodeAndPlayOpusData(opusData) {
- let allDecodedData = [];
-
- for (const frame of opusData) {
- try {
- const decodedData = opusDecoder.decode(frame);
- if (decodedData && decodedData.length > 0) {
- const float32Data = convertInt16ToFloat32(decodedData);
- allDecodedData.push(...float32Data);
- }
- } catch (error) {
- console.error("服务端Opus数据解码失败:", error);
- }
- }
-
- if (allDecodedData.length === 0) {
- statusLabel.textContent = "服务端数据解码失败";
- return;
- }
-
- const audioBuffer = audioContext.createBuffer(CHANNELS, allDecodedData.length, SAMPLE_RATE);
- audioBuffer.copyToChannel(new Float32Array(allDecodedData), 0);
-
- const source = audioContext.createBufferSource();
- source.buffer = audioBuffer;
- source.connect(audioContext.destination);
- source.start();
-
- statusLabel.textContent = "正在播放服务端数据...";
- source.onended = () => statusLabel.textContent = "服务端数据播放完毕";
-}
diff --git a/main/xiaozhi-server/test/opus_test/test.html b/main/xiaozhi-server/test/opus_test/test.html
deleted file mode 100644
index 77d5c73b..00000000
--- a/main/xiaozhi-server/test/opus_test/test.html
+++ /dev/null
@@ -1,464 +0,0 @@
-
-
-
-
- Opus 编解码测试
-
-
-
-
-
Opus 编解码录音播放测试
-
-
正在加载Opus库...
-
-
-
-
-
-
-
-
-
录音状态: 待机,正在初始化...
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/main/xiaozhi-server/test/test_page.css b/main/xiaozhi-server/test/test_page.css
index bea04994..5a97063e 100644
--- a/main/xiaozhi-server/test/test_page.css
+++ b/main/xiaozhi-server/test/test_page.css
@@ -1,52 +1,58 @@
body {
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
margin: 0;
- padding: 20px;
- background-color: #f5f5f5;
+ padding: 0px;
+ background-color: #f7f8fb;
}
.container {
- max-width: 1000px;
+ max-width: 80%;
margin: 0 auto;
- background-color: white;
border-radius: 10px;
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
- padding: 10px 20px 10px 20px;
-}
-
-h1 {
- color: #333;
- text-align: center;
- margin-bottom: 30px;
+ padding: 0px;
}
.section {
- margin-bottom: 5px;
+ margin-top: 10px;
+ margin-bottom: 10px;
padding: 10px;
- border-radius: 10px;
- background-color: #f9f9f9;
+ border-radius: 15px;
+ background-color: #fff;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.section h2 {
- margin-top: 0;
+ margin: 0;
color: #444;
- font-size: 18px;
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 10px;
- padding: 10px 0;
}
-.section h2 .toggle-button {
+.toggle-button,
+.connect-button {
margin-left: auto;
padding: 4px 12px;
- font-size: 12px;
- border-radius: 4px;
+ font-size: 14px;
+ border-radius: 20px;
cursor: pointer;
height: 28px;
line-height: 20px;
}
+.toggle-button {
+ border: 1px solid #007aff;
+ background-color: #fff;
+ color: #007aff;
+}
+
+.connect-button {
+ box-shadow: 0px 0px 10px 2px rgba(0, 122, 255, 0.28);
+}
+
.device-info {
display: flex;
align-items: center;
@@ -70,37 +76,56 @@ h1 {
}
.config-panel {
- display: none;
+ display: block;
transition: all 0.3s ease;
- margin-top: 5px;
- padding: 5px 0;
}
-.config-panel.expanded {
+/* 只为MCP工具面板设置默认隐藏 */
+#mcpToolsPanel {
+ display: none;
+}
+
+#mcpToolsPanel.expanded {
display: block;
}
.control-panel {
display: flex;
- flex-wrap: wrap;
+ flex-direction: column;
gap: 10px;
margin-top: 10px;
}
+.config-row {
+ display: flex;
+ gap: 10px;
+ width: 100%;
+}
+
+.config-item {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.config-item input {
+ flex: 1;
+ width: 0;
+ /* 确保input能自动填充剩余空间 */
+}
+
button {
padding: 8px 15px;
border: none;
border-radius: 5px;
- background-color: #4285f4;
+ background-color: #007aff;
color: white;
cursor: pointer;
transition: background-color 0.2s;
}
-button:hover {
- background-color: #3367d6;
-}
-
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
@@ -124,53 +149,45 @@ button:disabled {
flex-grow: 1;
padding: 8px;
border: 1px solid #ddd;
- border-radius: 5px;
+ border-radius: 10px;
}
-#nfcCardId {
- flex-grow: 1;
- padding: 8px;
- border: 1px solid #ddd;
- border-radius: 5px;
+#sendTextButton,
+#recordButton {
+ border-radius: 20px;
}
.conversation {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
- border-radius: 5px;
+ border-right: none;
+ border-radius: 5px 0px 0px 5px;
padding: 10px;
- background-color: white;
flex: 1;
- margin-right: 10px;
}
.message {
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 8px;
+ width: fit-content;
max-width: 80%;
}
.user {
- background-color: #e2f2ff;
+ background-color: #95ec69;
margin-left: auto;
margin-right: 10px;
text-align: right;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.server {
- background-color: #f0f0f0;
+ background-color: #fff;
margin-right: auto;
margin-left: 10px;
-}
-
-.status {
- color: #666;
- font-style: italic;
- font-size: 14px;
- margin: 0;
- padding: 0;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.audio-controls {
@@ -218,13 +235,12 @@ button:disabled {
#logContainer {
margin-top: 0;
padding: 10px;
- background-color: #f0f0f0;
- border-radius: 5px;
+ border-radius: 0px 5px 5px 0px;
font-family: monospace;
height: 300px;
overflow-y: auto;
flex: 1;
- margin-left: 10px;
+ border: 1px solid #ddd;
}
.log-entry {
@@ -322,11 +338,11 @@ button:disabled {
}
.tab:hover {
- color: #4285f4;
+ color: #007aff;
}
.tab.active {
- color: #4285f4;
+ color: #007aff;
font-weight: bold;
}
@@ -337,7 +353,7 @@ button:disabled {
left: 0;
width: 100%;
height: 2px;
- background-color: #4285f4;
+ background-color: #007aff;
}
.tab-content {
@@ -350,8 +366,8 @@ button:disabled {
.flex-container {
display: flex;
- gap: 20px;
margin-top: 10px;
+ background-color: #f9fafb;
}
.config-item {
@@ -362,10 +378,11 @@ button:disabled {
}
.config-item label {
- width: 100px;
- text-align: right;
- margin-right: 10px;
+ white-space: nowrap;
+ min-width: 70px;
+ text-align: left;
color: #666;
+ font-size: 14px;
}
.config-item input {
@@ -375,6 +392,24 @@ button:disabled {
border-radius: 5px;
}
+.config-item input:disabled {
+ background-color: #f9f9f9;
+ border-color: #e0e0e0;
+ cursor: default;
+}
+
+/* 两栏布局样式 */
+.two-column-layout {
+ display: flex;
+ gap: 15px;
+ align-items: stretch;
+}
+
+.two-column-layout>.section {
+ flex: 1;
+ margin-bottom: 5px;
+}
+
.control-panel {
display: flex;
flex-direction: column;
@@ -384,17 +419,18 @@ button:disabled {
.connection-controls {
display: flex;
+ flex-direction: column;
gap: 10px;
- align-items: center;
width: 100%;
+ margin-top: 10px;
}
.connection-controls input {
- flex: 1;
+ width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 5px;
- min-width: 200px;
+ box-sizing: border-box;
}
.connection-controls button {
@@ -408,18 +444,233 @@ button:disabled {
gap: 20px;
margin-left: 20px;
padding: 0 15px;
- background-color: #f9f9f9;
- border-radius: 4px;
height: 28px;
line-height: 28px;
}
.connection-status span {
- color: #666;
font-size: 13px;
}
.connection-status .status {
+ /* 默认未连接状态 */
+ background-color: #fef2f2;
+ color: #b91c1c;
+ font-size: 12px;
+ padding: 0px 8px;
+ border-radius: 20px;
+ transition: all 0.3s ease;
+}
+
+/* 已连接状态样式 */
+.connection-status .status.connected {
+ background-color: #ecfdf3;
+ color: #15803d;
+}
+
+#fileProtocolWarning {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.8);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ z-index: 9999;
+ color: white;
+ padding: 20px;
+ box-sizing: border-box;
+}
+
+#fileProtocolWarning h2 {
+ color: #ff4d4d;
+ margin-bottom: 20px;
+}
+
+#fileProtocolWarning pre {
+ background-color: green;
+ font-size: 18px;
+ padding: 15px;
+ border-radius: 5px;
+ font-family: monospace;
+ overflow-x: auto;
+ margin: 15px 0;
+}
+
+#fileProtocolWarning button {
+ background-color: #4CAF50;
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ text-align: center;
+ text-decoration: none;
+ display: inline-block;
+ font-size: 16px;
+ margin: 10px 2px;
+ cursor: pointer;
+ border-radius: 4px;
+}
+
+#fileProtocolWarning button:hover {
+ background-color: #45a049;
+}
+
+/* MCP 工具管理样式 */
+.mcp-tools-container {
+ display: grid;
+ gap: 12px;
+ margin-top: 10px;
+}
+
+.mcp-tool-card {
+ background-color: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ padding: 15px;
+ transition: all 0.2s;
+}
+
+.mcp-tool-card:hover {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.mcp-tool-card.disabled {
+ opacity: 0.6;
+ pointer-events: none;
+}
+
+.mcp-tool-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.mcp-tool-name {
+ font-size: 15px;
+ font-weight: 600;
color: #333;
+ flex: 1;
+}
+
+.mcp-tool-actions {
+ display: flex;
+ gap: 6px;
+}
+
+.mcp-tool-description {
+ color: #666;
+ font-size: 13px;
+ line-height: 1.5;
+ margin-bottom: 8px;
+}
+
+.mcp-tool-info {
+ background-color: #f9f9f9;
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ padding: 8px;
+ font-size: 12px;
+}
+
+.mcp-tool-info-row {
+ display: flex;
+ gap: 15px;
+ margin-bottom: 4px;
+}
+
+.mcp-tool-info-label {
+ color: #999;
+ min-width: 60px;
+}
+
+.mcp-tool-info-value {
+ color: #333;
+ font-family: 'Courier New', monospace;
+}
+
+.mcp-property-item {
+ background-color: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 5px;
+ padding: 12px;
+ margin-bottom: 10px;
+}
+
+.mcp-property-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.mcp-property-name {
+ font-weight: 600;
+ color: #333;
+}
+
+.mcp-property-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+
+.mcp-property-row-full {
+ margin-bottom: 8px;
+}
+
+.mcp-small-label {
+ display: block;
+ margin-bottom: 4px;
+ font-size: 12px;
+ color: #666;
+}
+
+.mcp-small-input {
+ width: 100%;
+ padding: 6px 8px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 13px;
+}
+
+.mcp-checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: #666;
+ cursor: pointer;
+}
+
+.mcp-error {
+ background-color: #ffebee;
+ color: #c62828;
+ padding: 10px;
+ border-radius: 5px;
+ margin-bottom: 15px;
+ font-size: 14px;
+}
+
+.mcp-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 11px;
font-weight: 500;
+ margin-left: 8px;
+}
+
+.mcp-badge-required {
+ background-color: #ffebee;
+ color: #c62828;
+}
+
+.mcp-badge-optional {
+ background-color: #e3f2fd;
+ color: #1976d2;
}
\ No newline at end of file
diff --git a/main/xiaozhi-server/test/test_page.html b/main/xiaozhi-server/test/test_page.html
index 88f182c4..dff8b139 100644
--- a/main/xiaozhi-server/test/test_page.html
+++ b/main/xiaozhi-server/test/test_page.html
@@ -6,214 +6,6 @@
小智服务器测试页面
-
+