diff --git a/main/xiaozhi-server/test/js/document.js b/main/xiaozhi-server/test/js/document.js new file mode 100644 index 00000000..6fb5b222 --- /dev/null +++ b/main/xiaozhi-server/test/js/document.js @@ -0,0 +1,49 @@ +// DOM元素 +const connectButton = document.getElementById('connectButton'); +const serverUrlInput = document.getElementById('serverUrl'); +const connectionStatus = document.getElementById('connectionStatus'); +const messageInput = document.getElementById('messageInput'); +const sendTextButton = document.getElementById('sendTextButton'); +const recordButton = document.getElementById('recordButton'); +const stopButton = document.getElementById('stopButton'); +// 会话记录 +const conversationDiv = document.getElementById('conversation'); +const logContainer = document.getElementById('logContainer'); +let visualizerCanvas = document.getElementById('audioVisualizer'); + +// ota 是否连接成功,修改成对应的样式 +export function otaStatusStyle (flan) { + if(flan){ + document.getElementById('otaStatus').textContent = 'ota已连接'; + document.getElementById('otaStatus').style.color = 'green'; + }else{ + document.getElementById('otaStatus').textContent = 'ota未连接'; + document.getElementById('otaStatus').style.color = 'red'; + } +} + +// ota 是否连接成功,修改成对应的样式 +export function getLogContainer (flan) { + return logContainer; +} + +// 更新Opus库状态显示 +export function updateScriptStatus(message, type) { + const statusElement = document.getElementById('scriptStatus'); + if (statusElement) { + statusElement.textContent = message; + statusElement.className = `script-status ${type}`; + statusElement.style.display = 'block'; + statusElement.style.width = 'auto'; + } +} + +// 添加消息到会话记录 +export function addMessage(text, isUser = false) { + const messageDiv = document.createElement('div'); + messageDiv.className = `message ${isUser ? 'user' : 'server'}`; + messageDiv.textContent = text; + conversationDiv.appendChild(messageDiv); + conversationDiv.scrollTop = conversationDiv.scrollHeight; +} + diff --git a/main/xiaozhi-server/test/js/opus.js b/main/xiaozhi-server/test/js/opus.js new file mode 100644 index 00000000..67e7ec30 --- /dev/null +++ b/main/xiaozhi-server/test/js/opus.js @@ -0,0 +1,186 @@ +import { log } from './utils/logger.js'; +import { updateScriptStatus } from './document.js' + + +// 检查Opus库是否已加载 +export function checkOpusLoaded() { + try { + // 检查Module是否存在(本地库导出的全局变量) + if (typeof Module === 'undefined') { + throw new Error('Opus库未加载,Module对象不存在'); + } + + // 尝试先使用Module.instance(libopus.js最后一行导出方式) + if (typeof Module.instance !== 'undefined' && typeof Module.instance._opus_decoder_get_size === 'function') { + // 使用Module.instance对象替换全局Module对象 + window.ModuleInstance = Module.instance; + log('Opus库加载成功(使用Module.instance)', 'success'); + updateScriptStatus('Opus库加载成功', 'success'); + + // 3秒后隐藏状态 + const statusElement = document.getElementById('scriptStatus'); + if (statusElement) statusElement.style.display = 'none'; + return; + } + + // 如果没有Module.instance,检查全局Module函数 + if (typeof Module._opus_decoder_get_size === 'function') { + window.ModuleInstance = Module; + log('Opus库加载成功(使用全局Module)', 'success'); + updateScriptStatus('Opus库加载成功', 'success'); + + // 3秒后隐藏状态 + const statusElement = document.getElementById('scriptStatus'); + if (statusElement) statusElement.style.display = 'none'; + return; + } + + throw new Error('Opus解码函数未找到,可能Module结构不正确'); + } catch (err) { + log(`Opus库加载失败,请检查libopus.js文件是否存在且正确: ${err.message}`, 'error'); + updateScriptStatus('Opus库加载失败,请检查libopus.js文件是否存在且正确', 'error'); + } +} + + +// 创建一个Opus编码器 +let opusEncoder = null; +export function initOpusEncoder() { + try { + if (opusEncoder) { + return opusEncoder; // 已经初始化过 + } + + if (!window.ModuleInstance) { + log('无法创建Opus编码器:ModuleInstance不可用', 'error'); + return; + } + + // 初始化一个Opus编码器 + const mod = window.ModuleInstance; + const sampleRate = 16000; // 16kHz采样率 + const channels = 1; // 单声道 + const application = 2048; // OPUS_APPLICATION_VOIP = 2048 + + // 创建编码器 + opusEncoder = { + channels: channels, + sampleRate: sampleRate, + frameSize: 960, // 60ms @ 16kHz = 60 * 16 = 960 samples + maxPacketSize: 4000, // 最大包大小 + module: mod, + + // 初始化编码器 + init: function () { + try { + // 获取编码器大小 + const encoderSize = mod._opus_encoder_get_size(this.channels); + log(`Opus编码器大小: ${encoderSize}字节`, 'info'); + + // 分配内存 + this.encoderPtr = mod._malloc(encoderSize); + if (!this.encoderPtr) { + throw new Error("无法分配编码器内存"); + } + + // 初始化编码器 + const err = mod._opus_encoder_init( + this.encoderPtr, + this.sampleRate, + this.channels, + application + ); + + if (err < 0) { + throw new Error(`Opus编码器初始化失败: ${err}`); + } + + // 设置位率 (16kbps) + mod._opus_encoder_ctl(this.encoderPtr, 4002, 16000); // OPUS_SET_BITRATE + + // 设置复杂度 (0-10, 越高质量越好但CPU使用越多) + mod._opus_encoder_ctl(this.encoderPtr, 4010, 5); // OPUS_SET_COMPLEXITY + + // 设置使用DTX (不传输静音帧) + mod._opus_encoder_ctl(this.encoderPtr, 4016, 1); // OPUS_SET_DTX + + log("Opus编码器初始化成功", 'success'); + return true; + } catch (error) { + if (this.encoderPtr) { + mod._free(this.encoderPtr); + this.encoderPtr = null; + } + log(`Opus编码器初始化失败: ${error.message}`, 'error'); + return false; + } + }, + + // 编码PCM数据为Opus + encode: function (pcmData) { + if (!this.encoderPtr) { + if (!this.init()) { + return null; + } + } + + try { + const mod = this.module; + + // 为PCM数据分配内存 + const pcmPtr = mod._malloc(pcmData.length * 2); // 2字节/int16 + + // 将PCM数据复制到HEAP + for (let i = 0; i < pcmData.length; i++) { + mod.HEAP16[(pcmPtr >> 1) + i] = pcmData[i]; + } + + // 为输出分配内存 + const outPtr = mod._malloc(this.maxPacketSize); + + // 进行编码 + const encodedLen = mod._opus_encode( + this.encoderPtr, + pcmPtr, + this.frameSize, + outPtr, + this.maxPacketSize + ); + + if (encodedLen < 0) { + throw new Error(`Opus编码失败: ${encodedLen}`); + } + + // 复制编码后的数据 + const opusData = new Uint8Array(encodedLen); + for (let i = 0; i < encodedLen; i++) { + opusData[i] = mod.HEAPU8[outPtr + i]; + } + + // 释放内存 + mod._free(pcmPtr); + mod._free(outPtr); + + return opusData; + } catch (error) { + log(`Opus编码出错: ${error.message}`, 'error'); + return null; + } + }, + + // 销毁编码器 + destroy: function () { + if (this.encoderPtr) { + this.module._free(this.encoderPtr); + this.encoderPtr = null; + } + } + }; + + opusEncoder.init(); + return opusEncoder; + } catch (error) { + log(`创建Opus编码器失败: ${error.message}`, 'error'); + return false; + } +} \ No newline at end of file diff --git a/main/xiaozhi-server/test/js/utils/BlockingQueue.js b/main/xiaozhi-server/test/js/utils/BlockingQueue.js new file mode 100644 index 00000000..31a43872 --- /dev/null +++ b/main/xiaozhi-server/test/js/utils/BlockingQueue.js @@ -0,0 +1,98 @@ +export default class BlockingQueue { + #items = []; + #waiters = []; // {resolve, reject, min, timer, onTimeout} + + /* 空队列一次性闸门 */ + #emptyPromise = null; + #emptyResolve = null; + + /* 生产者:把数据塞进去 */ + enqueue(item, ...restItems) { + if (restItems.length === 0) { + this.#items.push(item); + } + // 如果有额外参数,批量处理所有项 + else { + const items = [item, ...restItems].filter(i => i); + if (items.length === 0) return; + this.#items.push(...items); + } + // 若有空队列闸门,一次性放行所有等待者 + if (this.#emptyResolve) { + this.#emptyResolve(); + this.#emptyResolve = null; + this.#emptyPromise = null; + } + + // 唤醒所有正在等的 waiter + this.#wakeWaiters(); + } + + /* 消费者:min 条或 timeout ms 先到谁 */ + async dequeue(min = 1, timeout = Infinity, onTimeout = null) { + // 1. 若空,等第一次数据到达(所有调用共享同一个 promise) + if (this.#items.length === 0) { + await this.#waitForFirstItem(); + } + + // 立即满足 + if (this.#items.length >= min) { + return this.#flush(); + } + + // 需要等待 + return new Promise((resolve, reject) => { + let timer = null; + const waiter = { resolve, reject, min, onTimeout, timer }; + + // 超时逻辑 + if (Number.isFinite(timeout)) { + waiter.timer = setTimeout(() => { + this.#removeWaiter(waiter); + if (onTimeout) onTimeout(this.#items.length); + resolve(this.#flush()); + }, timeout); + } + + this.#waiters.push(waiter); + }); + } + + /* 空队列闸门生成器 */ + #waitForFirstItem() { + if (!this.#emptyPromise) { + this.#emptyPromise = new Promise(r => (this.#emptyResolve = r)); + } + return this.#emptyPromise; + } + + /* 内部:每次数据变动后,检查哪些 waiter 已满足 */ + #wakeWaiters() { + for (let i = this.#waiters.length - 1; i >= 0; i--) { + const w = this.#waiters[i]; + if (this.#items.length >= w.min) { + this.#removeWaiter(w); + w.resolve(this.#flush()); + } + } + } + + #removeWaiter(waiter) { + const idx = this.#waiters.indexOf(waiter); + if (idx !== -1) { + this.#waiters.splice(idx, 1); + if (waiter.timer) clearTimeout(waiter.timer); + } + } + + #flush() { + const snapshot = [...this.#items]; + this.#items.length = 0; + return snapshot; + } + + /* 当前缓存长度(不含等待者) */ + get length() { + return this.#items.length; + } +} \ No newline at end of file diff --git a/main/xiaozhi-server/test/js/utils/logger.js b/main/xiaozhi-server/test/js/utils/logger.js new file mode 100644 index 00000000..913c2c9a --- /dev/null +++ b/main/xiaozhi-server/test/js/utils/logger.js @@ -0,0 +1,37 @@ +import { getLogContainer } from '../document.js' + +const logContainer = getLogContainer(); +// 日志记录函数 +export function log(message, type = 'info') { + // 将消息按换行符分割成多行 + const lines = message.split('\n'); + const now = new Date(); + // const timestamp = `[${now.toLocaleTimeString()}] `; + const timestamp = `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, '0')}] `; + // 为每一行创建日志条目 + lines.forEach((line, index) => { + const logEntry = document.createElement('div'); + logEntry.className = `log-entry log-${type}`; + // 如果是第一条日志,显示时间戳 + const prefix = index === 0 ? timestamp : ' '.repeat(timestamp.length); + logEntry.textContent = `${prefix}${line}`; + // logEntry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; + // logEntry.style 保留起始的空格 + logEntry.style.whiteSpace = 'pre'; + if (type === 'error') { + logEntry.style.color = 'red'; + } else if (type === 'debug') { + logEntry.style.color = 'gray'; + return; + } else if (type === 'warning') { + logEntry.style.color = 'orange'; + } else if (type === 'success') { + logEntry.style.color = 'green'; + } else { + logEntry.style.color = 'black'; + } + logContainer.appendChild(logEntry); + }); + + logContainer.scrollTop = logContainer.scrollHeight; +} \ No newline at end of file diff --git a/main/xiaozhi-server/test/js/xiaoZhiConnect.js b/main/xiaozhi-server/test/js/xiaoZhiConnect.js new file mode 100644 index 00000000..606d615b --- /dev/null +++ b/main/xiaozhi-server/test/js/xiaoZhiConnect.js @@ -0,0 +1,103 @@ +import { log } from './utils/logger.js'; +import { otaStatusStyle } from './document.js' + +// WebSocket 连接 +export async function webSocketConnect(otaUrl,wsUrl,config){ + if (!validateWsUrl(wsUrl)) { + return; // 直接返回,不再往下执行 + } + + if (!validateConfig(config)) { + return; + } + const ok = await sendOTA(otaUrl, config); + if (!ok) return; + + // 使用自定义WebSocket实现以添加认证头信息 + let connUrl = new URL(wsUrl); + // 添加认证参数 + connUrl.searchParams.append('device-id', config.deviceId); + connUrl.searchParams.append('client-id', config.clientId); + log(`正在连接: ${connUrl.toString()}`, 'info'); + + return new WebSocket(connUrl.toString()); +} + +// 验证配置 +function validateConfig(config) { + if (!config.deviceMac) { + log('设备MAC地址不能为空', 'error'); + return false; + } + if (!config.clientId) { + log('客户端ID不能为空', 'error'); + return false; + } + return true; +} + +// 判断wsUrl路径是否存在错误 +function validateWsUrl(wsUrl){ + if (wsUrl === '') return false; + // 检查URL格式 + if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) { + log('URL格式错误,必须以ws://或wss://开头', 'error'); + return false; + } + return true +} + + +// OTA发送请求,验证状态 +async function sendOTA(otaUrl, config) { + try { + const res = await fetch(otaUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Device-Id': config.deviceId, + 'Client-Id': config.clientId + }, + body: JSON.stringify({ + version: 0, + uuid: '', + application: { + name: 'xiaozhi-web-test', + version: '1.0.0', + compile_time: '2025-04-16 10:00:00', + idf_version: '4.4.3', + elf_sha256: '1234567890abcdef1234567890abcdef1234567890abcdef' + }, + ota: { label: 'xiaozhi-web-test' }, + board: { + type: 'xiaozhi-web-test', + ssid: 'xiaozhi-web-test', + rssi: 0, + channel: 0, + ip: '192.168.1.1', + mac: config.deviceMac + }, + flash_size: 0, + minimum_free_heap_size: 0, + mac_address: config.deviceMac, + chip_model_name: '', + chip_info: { model: 0, cores: 0, revision: 0, features: 0 }, + partition_table: [{ label: '', type: 0, subtype: 0, address: 0, size: 0 }] + }) + }); + + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + + const result = await res.json(); + otaStatusStyle(true) + return true; // 成功 + } catch (err) { + otaStatusStyle(false) + return false; // 失败 + } +} + + + + + diff --git a/main/xiaozhi-server/test/test_page.css b/main/xiaozhi-server/test/test_page.css new file mode 100644 index 00000000..c70c9706 --- /dev/null +++ b/main/xiaozhi-server/test/test_page.css @@ -0,0 +1,425 @@ +body { + font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; + margin: 0; + padding: 20px; + background-color: #f5f5f5; +} + +.container { + max-width: 1000px; + 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; +} + +.section { + margin-bottom: 20px; + padding: 15px; + border-radius: 8px; + background-color: #f9f9f9; +} + +.section h2 { + margin-top: 0; + color: #444; + font-size: 18px; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 0; +} + +.section h2 .toggle-button { + margin-left: auto; + padding: 4px 12px; + font-size: 12px; + border-radius: 4px; + cursor: pointer; + height: 28px; + line-height: 20px; +} + +.device-info { + display: flex; + align-items: center; + gap: 20px; + margin-left: 20px; + padding: 0 15px; + background-color: #f9f9f9; + border-radius: 4px; + height: 28px; + line-height: 28px; +} + +.device-info span { + color: #666; + font-size: 13px; +} + +.device-info strong { + color: #333; + font-weight: 500; +} + +.config-panel { + display: none; + transition: all 0.3s ease; + margin-top: 5px; + padding: 5px 0; +} + +.config-panel.expanded { + display: block; +} + +.control-panel { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + +button { + padding: 8px 15px; + border: none; + border-radius: 5px; + background-color: #4285f4; + color: white; + cursor: pointer; + transition: background-color 0.2s; +} + +button:hover { + background-color: #3367d6; +} + +button:disabled { + background-color: #cccccc; + cursor: not-allowed; +} + +#serverUrl, +#otaUrl { + flex-grow: 1; + padding: 8px; + border: 1px solid #ddd; + border-radius: 5px; +} + +.message-input { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +#messageInput { + flex-grow: 1; + padding: 8px; + border: 1px solid #ddd; + border-radius: 5px; +} + +#nfcCardId { + flex-grow: 1; + padding: 8px; + border: 1px solid #ddd; + border-radius: 5px; +} + +.conversation { + max-height: 300px; + overflow-y: auto; + border: 1px solid #ddd; + border-radius: 5px; + padding: 10px; + background-color: white; + flex: 1; + margin-right: 10px; +} + +.message { + margin-bottom: 10px; + padding: 8px 12px; + border-radius: 8px; + max-width: 80%; +} + +.user { + background-color: #e2f2ff; + margin-left: auto; + margin-right: 10px; + text-align: right; +} + +.server { + background-color: #f0f0f0; + margin-right: auto; + margin-left: 10px; +} + +.status { + color: #666; + font-style: italic; + font-size: 14px; + margin: 0; + padding: 0; +} + +.audio-controls { + display: flex; + justify-content: center; + gap: 10px; + margin-top: 20px; +} + +.audio-visualizer { + height: 60px; + width: 100%; + margin-top: 10px; + border: 1px solid #ddd; + border-radius: 5px; + background-color: #fafafa; +} + +.record-button { + background-color: #db4437; +} + +.record-button:hover { + background-color: #c53929; +} + +.record-button.recording { + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0% { + background-color: #db4437; + } + + 50% { + background-color: #ff6659; + } + + 100% { + background-color: #db4437; + } +} + +#logContainer { + margin-top: 0; + padding: 10px; + background-color: #f0f0f0; + border-radius: 5px; + font-family: monospace; + height: 300px; + overflow-y: auto; + flex: 1; + margin-left: 10px; +} + +.log-entry { + margin: 5px 0; + font-size: 12px; +} + +.log-info { + color: #333; +} + +.log-error { + color: #db4437; +} + +.log-success { + color: #0f9d58; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translate(-50%, -60%); + } + + to { + opacity: 1; + transform: translate(-50%, -50%); + } +} + +.script-status { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 5px; +} + +.script-loaded { + background-color: #0f9d58; +} + +.script-loading { + background-color: #f4b400; +} + +.script-error { + background-color: #db4437; +} + +.script-list { + margin: 10px 0; + padding: 10px; + background-color: #f9f9f9; + border-radius: 5px; + font-family: monospace; + font-size: 11px; +} + +#scriptStatus.success { + background-color: #e6f4ea; + color: #0f9d58; + border-left: 4px solid #0f9d58; +} + +#scriptStatus.error { + background-color: #fce8e6; + color: #db4437; + border-left: 4px solid #db4437; +} + +#scriptStatus.warning { + background-color: #fef7e0; + color: #f4b400; + border-left: 4px solid #f4b400; +} + +/* 标签页样式 */ +.tabs { + display: flex; + margin-bottom: 20px; + border-bottom: 2px solid #e0e0e0; +} + +.tab { + padding: 10px 20px; + cursor: pointer; + border: none; + background: none; + font-size: 16px; + color: #666; + position: relative; + transition: all 0.3s ease; +} + +.tab:hover { + color: #4285f4; +} + +.tab.active { + color: #4285f4; + font-weight: bold; +} + +.tab.active::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 100%; + height: 2px; + background-color: #4285f4; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +.flex-container { + display: flex; + gap: 20px; + margin-top: 10px; +} + +.config-item { + display: flex; + align-items: center; + margin-bottom: 8px; + width: 100%; +} + +.config-item label { + width: 100px; + text-align: right; + margin-right: 10px; + color: #666; +} + +.config-item input { + flex-grow: 1; + padding: 6px; + border: 1px solid #ddd; + border-radius: 5px; +} + +.control-panel { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 10px; +} + +.connection-controls { + display: flex; + gap: 10px; + align-items: center; + width: 100%; +} + +.connection-controls input { + flex: 1; + padding: 8px; + border: 1px solid #ddd; + border-radius: 5px; + min-width: 200px; +} + +.connection-controls button { + white-space: nowrap; + padding: 8px 15px; +} + +.connection-status { + display: flex; + align-items: center; + 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 { + color: #333; + font-weight: 500; +} \ 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 567332b8..2dc37fe4 100644 --- a/main/xiaozhi-server/test/test_page.html +++ b/main/xiaozhi-server/test/test_page.html @@ -5,839 +5,341 @@ 小智服务器测试页面 - + - -
-

小智服务器测试页面

+
+

小智服务器测试页面

-
- 正在加载Opus库...
+
+ 正在加载Opus库... +
- -
-

- 设备配置 - + +
+

+ 设备配置 + MAC: 客户端: web_test_client - -

-
-
-
- - -
-
- - -
-
- - -
-
- - -
+ +

+
+
+
+ +
-
-
- -
-

- 连接信息 - - OTA: ota未连接 - WS: ws未连接 - -

-
- - - - -
-
- -
-
- - -
- -
-
- - +
+ +
-
- -
-
- +
+ +
- -
-
- -
-

会话记录

-
-
-
-
准备就绪,请连接服务器开始测试...
+
+ +
- - +
+

+ 连接信息 + + OTA: ota未连接 + WS: ws未连接 + +

+
+ + + + +
+
- - // 如果没有Module.instance,检查全局Module函数 - if (typeof Module._opus_decoder_get_size === 'function') { - window.ModuleInstance = Module; - log('Opus库加载成功(使用全局Module)', 'success'); - updateScriptStatus('Opus库加载成功', 'success'); + + // 停止直接从PCM数据录音 + function stopDirectRecording() { + if (!isRecording) return; + + try { + // 停止录音 + isRecording = false; + + // 停止音频处理器的录音 + if (audioProcessor) { + // 只有AudioWorklet才需要发送停止消息 + if (audioProcessorType === 'worklet' && audioProcessor.port) { + audioProcessor.port.postMessage({command: 'stop'}); + } + + audioProcessor.disconnect(); + audioProcessor = null; + } + + // 断开音频连接 + if (audioSource) { + audioSource.disconnect(); + audioSource = null; + } + + // 停止可视化 + if (visualizationRequest) { + cancelAnimationFrame(visualizationRequest); + visualizationRequest = null; + } + + // 清除录音计时器 + if (window.recordingTimer) { + clearInterval(window.recordingTimer); + window.recordingTimer = null; + } + + // 编码并发送剩余的数据 + encodeAndSendOpus(); + + // 发送一个空的消息作为结束标志(模拟接收到空音频数据的情况) + if (websocket && websocket.readyState === WebSocket.OPEN) { + // 使用空的Uint8Array发送最后一个空帧 + const emptyOpusFrame = new Uint8Array(0); + websocket.send(emptyOpusFrame); + + // 发送监听结束消息 + const stopMessage = { + type: 'listen', + mode: 'manual', + state: 'stop' + }; + + websocket.send(JSON.stringify(stopMessage)); + log('已发送录音停止信号', 'info'); + } + + // 重置UI + recordButton.textContent = '开始录音'; + recordButton.classList.remove('recording'); + recordButton.disabled = false; + + log('停止PCM直接录音', 'success'); + return true; + } catch (error) { + log(`直接录音停止错误: ${error.message}`, 'error'); + return false; + } + } + + async function handleBinaryMessage(data) { + try { + let arrayBuffer; + // 根据数据类型进行处理 + if (data instanceof ArrayBuffer) { + arrayBuffer = data; + log(`收到ArrayBuffer音频数据,大小: ${data.byteLength}字节`, 'debug'); + } else if (data instanceof Blob) { + // 如果是Blob类型,转换为ArrayBuffer + arrayBuffer = await data.arrayBuffer(); + log(`收到Blob音频数据,大小: ${arrayBuffer.byteLength}字节`, 'debug'); + } else { + log(`收到未知类型的二进制数据: ${typeof data}`, 'warning'); + return; + } + // 创建Uint8Array用于处理 + const opusData = new Uint8Array(arrayBuffer); + if (opusData.length > 0) { + // 将数据添加到缓冲队列 + queue.enqueue(opusData); + } else { + log('收到空音频数据帧,可能是结束标志', 'warning'); + // 如果正在播放,发送结束信号 + if (isAudioPlaying && streamingContext) { + streamingContext.endOfStream = true; + } + } + } catch (error) { + log(`处理二进制消息出错: ${error.message}`, 'error'); + } + } + + // 获取配置值 + function getConfig() { + const deviceMac = document.getElementById('deviceMac').value.trim(); + return { + deviceId: deviceMac, // 使用MAC地址作为deviceId + deviceName: document.getElementById('deviceName').value.trim(), + deviceMac: deviceMac, + clientId: document.getElementById('clientId').value.trim(), + token: document.getElementById('token').value.trim() + }; + } + + initApp(); + \ No newline at end of file