window.addEventListener('DOMContentLoaded', () => { /** 显示模式列表 */ const DISPLAY_MODES = { data: { isDefault: true, label: '数据显示', }, spectrum: { isDefault: false, label: '频谱显示', }, }; /** 分辨率配置选项 */ const RESOLUTION_LIST = { '160x80': { isDefault: true, label: '160x80', lcdX: 160, lcdY: 80, showWidth: 320, showHeight: 240, }, '320x172': { isDefault: false, label: '320x172', lcdX: 320, lcdY: 172, showWidth: 320, showHeight: 240, }, '320x240': { isDefault: false, label: '320x240', lcdX: 320, lcdY: 240, showWidth: 320, showHeight: 240, }, }; /** @type {HTMLButtonElement} */ const connectButton = document.getElementById('connect-button'); /** @type {HTMLButtonElement} */ const disconnectButton = document.getElementById('disconnect-button'); /** @type {HTMLButtonElement} */ const startButton = document.getElementById('start-button'); /** @type {HTMLButtonElement} */ const stopButton = document.getElementById('stop-button'); /** @type {HTMLSelectElement} */ const displayModeSelector = document.getElementById('display-mode-selector'); /** @type {HTMLSelectElement} */ const resolutionSelector = document.getElementById('resolution-selector'); /** @type {HTMLCanvasElement} */ const canvas = document.getElementById('canvas-element'); const ctx = canvas.getContext('2d'); /** * @desc 串口对象 * @type {SerialPort} */ let serialPort = null; /** * @desc 串口读取器 * @type {ReturnType} */ let serialReader = null; /** * @desc 串口写入器 * @type {ReturnType} */ let serialWriter = null; /** 连接状态,0: 未连接, 1: 已连接 */ let deviceState = 0; /** 渲染间隔,毫秒 */ let renderInterval = 100; /** 发送间隔,毫秒 */ let sendInterval = 100; /** 最后一次渲染的时间戳 */ let lastRenderTime = 0; /** 最后一次发送的时间戳 */ let lastSendTime = 0; /** 渲染循环 requestAnimationFrame ID */ let renderAnimationId = null; /** 发送循环 requestAnimationFrame ID */ let sendAnimationId = null; /** * @desc 显示模式 * @type {keyof (typeof DISPLAY_MODES)} */ let displayMode = 'data'; // 音频相关变量 /** @type {AudioContext} */ let audioContext = null; /** @type {ReturnType} */ let audioAnalyser = null; /** @type {number} */ let audioBufferLength = null; /** @type {Uint8Array} */ let audioDataArray = null; /** @type {ReturnType} */ let microphone = null; /** 音频是否已初始化 */ let isAudioReady = false; // 显示参数 let CANVAS_WIDTH = 0; // 画布显示宽度 let CANVAS_HEIGHT = 0; // 画布显示高度 let LCD_WIDTH = 0; // 设备实际宽度 let LCD_HEIGHT = 0; // 设备实际高度 /** 数据文本渲染类 */ class DataTextRender { constructor() { this.monitorData = {}; this.font = '32px Arial'; } updateDataText() { let date = new Date(); this.monitorData = { row_1: { value: `${String(date.getFullYear()).padStart(2, '0')}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`, label: '日期', unit: '', detail: '', color: '#FFFF00', icon: 'I', }, row_2: { value: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`, label: '时间', unit: '', detail: '', color: '#00FFFF', icon: 'I', }, row_3: { value: `${String(date.getFullYear()).padStart(2, '0')}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`, label: '日期', unit: '', detail: '', color: '#FFFF00', icon: 'I', }, row_4: { value: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`, label: '时间', unit: '', detail: '', color: '#00FFFF', icon: 'I', }, }; return this.monitorData; } formatDisplayText(key, showIcon = false) { let item = this.monitorData[key]; if (!item) { return ''; } let icon = showIcon ? `[${item.icon}] ` : ''; let detail = item.detail || ''; if (detail) { return `${icon}${item.label}: ${item.value}${item.unit} ${detail}`; } else { return `${icon}${item.label}: ${item.value}${item.unit}`; } } createDisplayImage() { // 更新数据 this.updateDataText(); // 清空画布 ctx.fillStyle = 'black'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // 绘制网格布局 this.drawGridLayout(); } drawGridLayout() { let layouts = [ { key: 'row_1', position: [10, 10] }, { key: 'row_2', position: [10, 50] }, { key: 'row_3', position: [10, 90] }, { key: 'row_4', position: [10, 130] }, ]; ctx.font = this.font; ctx.textBaseline = 'top'; ctx.textAlign = 'left'; layouts.forEach(layout => { if (this.monitorData[layout.key]) { let item = this.monitorData[layout.key]; let text = this.formatDisplayText(layout.key, false); ctx.fillStyle = item['color']; ctx.fillText(text, layout.position[0], layout.position[1]); } }); } } /** 数据文本渲染实例 */ const dataTextRender = new DataTextRender(); /** 开始音频分析,初始化音频上下文和麦克风 */ async function audioAnalyserStart() { try { // 创建音频上下文 audioContext = new (window.AudioContext || window.webkitAudioContext)(); // 请求麦克风访问 let stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false, }); // 创建麦克风源 microphone = audioContext.createMediaStreamSource(stream); // 创建分析器 audioAnalyser = audioContext.createAnalyser(); audioAnalyser.fftSize = 256; audioAnalyser.smoothingTimeConstant = 0.25; // 连接麦克风到分析器 microphone.connect(audioAnalyser); // 获取分析器数据 audioBufferLength = audioAnalyser.frequencyBinCount; audioDataArray = new Uint8Array(audioBufferLength); // 更新状态 isAudioReady = true; console.info('音频初始化成功,音频分析已开始'); } catch (error) { console.error('音频初始化失败:', error); isAudioReady = false; } } /** 停止音频分析 */ function audioAnalyserStop() { if (audioContext) { audioContext.close(); audioContext = null; } if (microphone) { microphone.disconnect(); microphone = null; } if (audioAnalyser) { audioAnalyser.disconnect(); audioAnalyser = null; } isAudioReady = false; console.info('音频分析已停止'); } /** 将 32 位整数拆分为 4 个字节 */ function convertDigitToInts(di) { return [(di >> 24) & 0xFF, (di >> 16) & 0xFF, (di >> 8) & 0xFF, di & 0xFF]; } /** * @description 将 RGB565 颜色转换为 RGB 颜色 * @param {number} color565 RGB565 颜色值 * @returns {number[]} RGB 颜色值数组,格式:`[r, g, b]` */ function convertRgb565ToRgb(color565) { const r = ((color565 >> 11) & 0x1F) << 3; const g = ((color565 >> 5) & 0x3F) << 2; const b = (color565 & 0x1F) << 3; return [r, g, b]; } /** * @description 将 RGB888 转换为 RGB565 格式 * @param {ImageData} imageData 包含 RGB888 数据的 ImageData 对象 * @returns {number[]} 包含 RGB565 数据的数组 */ function convertRgb888ToRgb565(imageData) { let rgb565Array = []; for (let i = 0; i < imageData.data.length; i += 4) { let r = imageData.data[i]; let g = imageData.data[i + 1]; let b = imageData.data[i + 2]; let rgb565 = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3); rgb565Array.push(rgb565); } return rgb565Array; } /** * 对图像数据进行压缩处理,减少传输数据量 * - 基于 `compaction.c` 中的 `MSN_compaction` 算法移植 */ function convertDisplayData(imageData = []) { let totalDataSize = imageData.length; let dataPerPage = 128; let hexUse = []; let dataIndex = 0; // -- 按页处理数据,每页 128 个像素 -- let totalPages = Math.floor(totalDataSize / dataPerPage); for (let page = 0; page < totalPages; page++) { let i = 0; while (i < 128) { let a = 0; let a_data = imageData[dataIndex]; // 统计第 1 种颜色的连续出现次数(最多 15 次) for (let s = 0; s < 15; s++) { if (i < 128 && a_data === imageData[dataIndex]) { a++; i++; dataIndex++; } else { break; } } let b = 0; let b_data = 0; // 统计第 2 种颜色的连续出现次数(最多 15 次) if (i < 128) { b_data = imageData[dataIndex]; for (let s = 0; s < 15; s++) { if (i < 128 && b_data === imageData[dataIndex]) { b++; i++; dataIndex++; } else { break; } } } // 输出压缩数据: // [9, a*16+b, a_data 高字节, a_data 低字节, b_data 高字节, b_data 低字节] hexUse.push(9); hexUse.push(a * 16 + b); hexUse.push((a_data >> 8) & 0xFF); hexUse.push(a_data & 0xFF); hexUse.push((b_data >> 8) & 0xFF); hexUse.push(b_data & 0xFF); } } // -- 处理剩余数据 -- let remainingDataSize = totalDataSize % dataPerPage; if (remainingDataSize !== 0) { let remainingData = imageData.slice(totalDataSize - remainingDataSize); // 补全数据到 128 字节 while (remainingData.length < dataPerPage) { remainingData.push(0xFFFF); } let i = 0; let dataIndex = 0; while (i < 128) { let a = 0; let a_data = remainingData[dataIndex]; for (let s = 0; s < 15; s++) { if (i < 128 && a_data === remainingData[dataIndex]) { a++; i++; dataIndex++; } else { break; } } let b = 0; let b_data = 0; if (i < 128) { b_data = remainingData[dataIndex]; for (let s = 0; s < 15; s++) { if (i < 128 && b_data === remainingData[dataIndex]) { b++; i++; dataIndex++; } else { break; } } } hexUse.push(9); hexUse.push(a * 16 + b); hexUse.push((a_data >> 8) & 0xFF); hexUse.push(a_data & 0xFF); hexUse.push((b_data >> 8) & 0xFF); hexUse.push(b_data & 0xFF); } } return hexUse; } /** 设置 LCD 显示起始坐标 */ function lcdSetDisplayXY(lcdD0 = 0, lcdD1 = 0) { let hexUse = []; hexUse.push(2); // LCD 多次写入命令 hexUse.push(0); // 设置起始位置指令 hexUse.push(Math.floor(lcdD0 / 256)); // X 坐标高字节 hexUse.push(lcdD0 % 256); // X 坐标低字节 hexUse.push(Math.floor(lcdD1 / 256)); // Y 坐标高字节 hexUse.push(lcdD1 % 256); // Y 坐标低字节 return hexUse; } /** 设置 LCD 显示区域大小 */ function lcdSetDisplaySize(lcdD0 = 0, lcdD1 = 0) { let hexUse = []; hexUse.push(2); // LCD 多次写入命令 hexUse.push(1); // 设置大小指令 hexUse.push(Math.floor(lcdD0 / 256)); // 宽度高字节 hexUse.push(lcdD0 % 256); // 宽度低字节 hexUse.push(Math.floor(lcdD1 / 256)); // 高度高字节 hexUse.push(lcdD1 % 256); // 高度低字节 return hexUse; } /** * @description 设置 LCD 显示区域并准备写入数据 * @param {number} lcdX - 显示区域起始 X 坐标 * @param {number} lcdY - 显示区域起始 Y 坐标 * @param {number} lcdW - 显示区域宽度 * @param {number} lcdH - 显示区域高度 */ async function lcdSetDisplaySend(lcdX = 0, lcdY = 0, lcdW = 0, lcdH = 0) { let hexUse = []; hexUse.push(...lcdSetDisplayXY(lcdX, lcdY)); hexUse.push(...lcdSetDisplaySize(lcdW, lcdH)); hexUse.push(2); // LCD 多次写入命令 hexUse.push(3); // 设置指令 hexUse.push(7); // 载入地址 hexUse.push(0, 0, 0); await sendData(hexUse); } /** * @description 监听串口数据 * @param {typeof serialReader} reader * @param {TextDecoder} decoder */ async function listenSerialData(reader, decoder) { try { while (true) { let { value, done } = await reader.read(); // 端口已关闭 if (done) { console.info('串口已关闭'); deviceState = 0; break; } // 处理接收到的数据 let data = decoder.decode(value); console.debug('接收数据:', data); // 检测是否为 MSN 设备 if (data.length > 5) { for (let n = 0; n < data.length - 5; n++) { if (data.charCodeAt(n) === 0) { if (data.substring(n + 1, n + 4) === 'MSN') { // 确认是 MSN 设备 console.info('检测到 MSN 设备'); deviceState = 1; // 检查是否收到连接确认 if (data.substring(n + 1, n + 6) === 'MSNCN') { console.info('MSN 设备连接完成'); } break; } } } } } } catch (error) { console.error('读取串口数据失败:', error); deviceState = 0; } } /** 循环发送画布内容到设备 */ async function sendCanvas(timestamp = 0) { // 限制发送频率 if (timestamp - lastSendTime >= sendInterval) { // 更新时间戳 lastSendTime = timestamp; // 未连接,结束后续处理 if (!deviceState) { sendAnimationId = requestAnimationFrame(sendCanvas); return; } // 创建一个临时画布用于图像缩放 let tempCanvas = document.createElement('canvas'); let tempCtx = tempCanvas.getContext('2d'); tempCanvas.width = LCD_WIDTH; tempCanvas.height = LCD_HEIGHT; // 将原始画布内容缩放到设备实际分辨率 tempCtx.drawImage(canvas, 0, 0, LCD_WIDTH, LCD_HEIGHT); // 获取缩放后的图像数据 let imageData = tempCtx.getImageData(0, 0, LCD_WIDTH, LCD_HEIGHT); // 转换为 RGB565 格式 let rgb565 = convertRgb888ToRgb565(imageData); // 压缩数据 let hexUse = convertDisplayData(rgb565); // 设置显示区域 await lcdSetDisplaySend(0, 0, LCD_WIDTH, LCD_HEIGHT); // 发送数据到设备 await sendData(hexUse); } // 循环发送 sendAnimationId = requestAnimationFrame(sendCanvas); } /** 发送数据到设备 */ async function sendData(dataArray = []) { if (!serialWriter) { return; } try { let data = new Uint8Array(dataArray); await serialWriter.write(data); console.debug('发送数据成功:', dataArray.length, '字节'); } catch (error) { console.error('发送数据失败:', error); } } /** 发送文本到设备 */ async function sendText(text = '') { if (!serialWriter) { return; } try { // 构建消息 let data = new Uint8Array(text.length + 1); // 添加消息头 data[0] = 0x00; // 处理消息内容 for (let i = 0; i < text.length; i++) { data[i + 1] = text.charCodeAt(i); } await serialWriter.write(data); console.debug('发送文本成功:', text); } catch (error) { console.error('发送文本失败:', error); } } /** 循环渲染画布内容 */ async function renderCanvas(timestamp = 0) { // 限制渲染频率 if (timestamp - lastRenderTime >= renderInterval) { // 更新时间戳 lastRenderTime = timestamp; // 更新画布内容 switch (displayMode) { case 'data': await renderDataText(); break; case 'spectrum': await renderAudioSpectrum(); break; default: break; } } // 循环渲染 renderAnimationId = requestAnimationFrame(renderCanvas); } /** 渲染音频频谱内容 */ async function renderAudioSpectrum() { // 初始化音频 if (!isAudioReady) { await audioAnalyserStart(); } // 清空画布 ctx.fillStyle = 'black'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); if (!audioAnalyser) { ctx.fillStyle = 'white'; ctx.font = '16px Arial'; ctx.textAlign = 'center'; ctx.fillText('音频未初始化', CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2); return; } // 获取频谱数据 audioAnalyser.getByteFrequencyData(audioDataArray); // 计算柱状图宽度和间距 let barWidth = (CANVAS_WIDTH / audioBufferLength) * 2.5; let x = 0; // 绘制频谱柱状图 for (let i = 0; i < audioBufferLength; i++) { // 计算柱状图高度 let barHeight = (audioDataArray[i] / 255) * CANVAS_HEIGHT; // 生成渐变颜色 let r = Math.floor((i / audioBufferLength) * 255); let g = Math.floor((audioDataArray[i] / 255) * 255); let b = 150; ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; // 绘制柱状图 ctx.fillRect(x, CANVAS_HEIGHT - barHeight, barWidth, barHeight); // 移动到下一个柱状图位置 x += barWidth + 1; } } /** 渲染数据文本内容 */ async function renderDataText() { // 更新画布内容 dataTextRender.createDisplayImage(); } // 初始化下拉选项 (function () { let displayModeElements = []; let resolutionElements = []; // 显示模式 for (let key in DISPLAY_MODES) { /** @type {typeof DISPLAY_MODES['data']} */ let item = DISPLAY_MODES[key]; let element = document.createElement('option'); element.value = key; element.textContent = item.label; displayModeElements.push(element); if (item.isDefault) { element.selected = true; } } // 分辨率 for (let key in RESOLUTION_LIST) { /** @type {typeof RESOLUTION_LIST['160x80']} */ let item = RESOLUTION_LIST[key]; let element = document.createElement('option'); element.value = key; element.textContent = item.label; resolutionElements.push(element); if (item.isDefault) { element.selected = true; // 更新画布属性 canvas.width = item.showWidth; canvas.height = item.showHeight; // 更新显示参数 CANVAS_WIDTH = item.showWidth; CANVAS_HEIGHT = item.showHeight; LCD_WIDTH = item.lcdX; LCD_HEIGHT = item.lcdY; } } // 添加到下拉列表 displayModeSelector.append.apply(displayModeSelector, displayModeElements); resolutionSelector.append.apply(resolutionSelector, resolutionElements); })(); // 初始化事件 (function () { // 点击连接设备按钮 connectButton.addEventListener('click', async () => { try { // 请求用户授权访问串口设备 serialPort = await navigator.serial.requestPort(); // 打开串口 await serialPort.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'hardware', }); const encoder = new TextEncoder(); const decoder = new TextDecoder('gbk'); serialReader = serialPort.readable.getReader(); serialWriter = serialPort.writable.getWriter(); // 开始监听串口数据 listenSerialData(serialReader, decoder); console.info('MSN 设备连接中...'); // 发送连接确认消息 await sendText('MSNCN'); // 连接成功后自动开始发送 setTimeout(() => { if (!sendAnimationId) { console.info('自动开始发送'); sendAnimationId = requestAnimationFrame(sendCanvas); } }, 100); } catch (error) { console.error('连接设备失败:', error); } }); // 点击断开连接按钮 disconnectButton.addEventListener('click', async () => { // 断开连接前自动停止发送 if (sendAnimationId) { console.info('自动停止发送'); cancelAnimationFrame(sendAnimationId); sendAnimationId = null; } if (serialReader) { await serialReader.cancel(); serialReader.releaseLock(); serialReader = null; } if (serialWriter) { await serialWriter.close(); serialWriter.releaseLock(); serialWriter = null; } if (serialPort) { await serialPort.close(); serialPort = null; } deviceState = 0; console.info('断开连接成功'); }); // 点击开始渲染按钮 startButton.addEventListener('click', () => { if (!renderAnimationId) { console.info('开始渲染'); renderAnimationId = requestAnimationFrame(renderCanvas); } }); // 点击停止渲染按钮 stopButton.addEventListener('click', () => { // 停止循环渲染 if (renderAnimationId) { console.info('停止渲染'); cancelAnimationFrame(renderAnimationId); renderAnimationId = null; } // 停止音频分析 audioAnalyserStop(); }); // 显示模式选择 displayModeSelector.addEventListener('change', () => { displayMode = displayModeSelector.value; let info = DISPLAY_MODES[displayMode]; if (info) { console.info(`显示模式切换成功: ${info.label}`); } else { console.error('显示模式切换失败:模式无效'); displayMode = ''; return; } }); // 分辨率选择 resolutionSelector.addEventListener('change', () => { let value = resolutionSelector.value; let config = RESOLUTION_LIST[value]; // 更新画布属性 canvas.width = CANVAS_WIDTH; canvas.height = CANVAS_HEIGHT; // 更新显示参数 CANVAS_WIDTH = config.showWidth; CANVAS_HEIGHT = config.showHeight; LCD_WIDTH = config.lcdX; LCD_HEIGHT = config.lcdY; // 显示信息 console.info(`分辨率切换成功:${value}`); }); })(); });