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 ` +
+
+
${tool.name}
+
+ + +
+
+
${tool.description}
+
+
+ 参数数量: + ${paramCount} 个 ${requiredCount > 0 ? `(${requiredCount} 个必填)` : ''} +
+
+ 模拟返回: + ${hasMockResponse ? '✅ 已配置: ' + JSON.stringify(tool.mockResponse) : '⚪ 使用默认'} +
+
+
+ `; + }).join(''); +} + +/** + * 渲染参数列表 + */ +function renderMcpProperties() { + const container = document.getElementById('mcpPropertiesContainer'); + + if (mcpProperties.length === 0) { + container.innerHTML = '
暂无参数,点击下方按钮添加参数
'; + return; + } + + container.innerHTML = mcpProperties.map((prop, index) => ` +
+
+ ${prop.name} + +
+
+
+ + +
+
+ + +
+
+ ${(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 @@ 小智服务器测试页面 - +