diff --git a/main/xiaozhi-server/test/js/app.js b/main/xiaozhi-server/test/js/app.js index 1b70080e..9f71b39a 100644 --- a/main/xiaozhi-server/test/js/app.js +++ b/main/xiaozhi-server/test/js/app.js @@ -17,33 +17,24 @@ class App { // 初始化应用 async init() { log('正在初始化应用...', 'info'); - // 初始化UI控制器 this.uiController = uiController; this.uiController.init(); - // 检查Opus库 checkOpusLoaded(); - // 初始化Opus编码器 initOpusEncoder(); - // 初始化音频播放器 this.audioPlayer = getAudioPlayer(); await this.audioPlayer.start(); - // 初始化MCP工具 initMcpTools(); - // 检查麦克风可用性 await this.checkMicrophoneAvailability(); - // 初始化Live2D await this.initLive2D(); - // 关闭加载loading this.setModelLoadingStatus(false); - log('应用初始化完成', 'success'); } @@ -54,21 +45,17 @@ class App { if (typeof window.Live2DManager === 'undefined') { throw new Error('Live2DManager未加载,请检查脚本引入顺序'); } - this.live2dManager = new window.Live2DManager(); await this.live2dManager.initializeLive2D(); - // 更新UI状态 const live2dStatus = document.getElementById('live2dStatus'); if (live2dStatus) { live2dStatus.textContent = '● 已加载'; live2dStatus.className = 'status loaded'; } - log('Live2D初始化完成', 'success'); } catch (error) { log(`Live2D初始化失败: ${error.message}`, 'error'); - // 更新UI状态 const live2dStatus = document.getElementById('live2dStatus'); if (live2dStatus) { @@ -94,16 +81,13 @@ class App { try { const isAvailable = await checkMicrophoneAvailability(); const isHttp = isHttpNonLocalhost(); - // 保存可用性状态到全局变量 window.microphoneAvailable = isAvailable; window.isHttpNonLocalhost = isHttp; - // 更新UI if (this.uiController) { this.uiController.updateMicrophoneAvailability(isAvailable, isHttp); } - log(`麦克风可用性检查完成: ${isAvailable ? '可用' : '不可用'}`, isAvailable ? 'success' : 'warning'); } catch (error) { log(`检查麦克风可用性失败: ${error.message}`, 'error'); @@ -119,14 +103,10 @@ class App { // 创建并启动应用 const app = new App(); - // 将应用实例暴露到全局,供其他模块访问 window.chatApp = app; - document.addEventListener('DOMContentLoaded', () => { // 初始化应用 app.init(); }); - - -export default app; \ No newline at end of file +export default app; diff --git a/main/xiaozhi-server/test/js/core/audio/recorder.js b/main/xiaozhi-server/test/js/core/audio/recorder.js index 35cc7630..b4173807 100644 --- a/main/xiaozhi-server/test/js/core/audio/recorder.js +++ b/main/xiaozhi-server/test/js/core/audio/recorder.js @@ -19,7 +19,6 @@ export class AudioRecorder { this.visualizationRequest = null; this.recordingTimer = null; this.websocket = null; - // Callback functions this.onRecordingStart = null; this.onRecordingStop = null; @@ -33,8 +32,7 @@ export class AudioRecorder { // Get AudioContext instance getAudioContext() { - const audioPlayer = getAudioPlayer(); - return audioPlayer.getAudioContext(); + return getAudioPlayer().getAudioContext(); } // Initialize encoder @@ -56,50 +54,35 @@ export class AudioRecorder { this.buffer = new Int16Array(this.frameSize); this.bufferIndex = 0; this.isRecording = false; - this.port.onmessage = (event) => { if (event.data.command === 'start') { this.isRecording = true; this.port.postMessage({ type: 'status', status: 'started' }); } else if (event.data.command === 'stop') { this.isRecording = false; - if (this.bufferIndex > 0) { const finalBuffer = this.buffer.slice(0, this.bufferIndex); - this.port.postMessage({ - type: 'buffer', - buffer: finalBuffer - }); + this.port.postMessage({ type: 'buffer', buffer: finalBuffer }); this.bufferIndex = 0; } - this.port.postMessage({ type: 'status', status: 'stopped' }); } }; } - process(inputs, outputs, parameters) { if (!this.isRecording) return true; - const input = inputs[0][0]; if (!input) return true; - for (let i = 0; i < input.length; i++) { if (this.bufferIndex >= this.frameSize) { - this.port.postMessage({ - type: 'buffer', - buffer: this.buffer.slice(0) - }); + this.port.postMessage({ type: 'buffer', buffer: this.buffer.slice(0) }); this.bufferIndex = 0; } - this.buffer[this.bufferIndex++] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767))); } - return true; } } - registerProcessor('audio-recorder-processor', AudioRecorderProcessor); `; } @@ -107,24 +90,19 @@ export class AudioRecorder { // Create audio processor async createAudioProcessor() { this.audioContext = this.getAudioContext(); - try { if (this.audioContext.audioWorklet) { const blob = new Blob([this.getAudioProcessorCode()], { type: 'application/javascript' }); const url = URL.createObjectURL(blob); await this.audioContext.audioWorklet.addModule(url); URL.revokeObjectURL(url); - const audioProcessor = new AudioWorkletNode(this.audioContext, 'audio-recorder-processor'); - audioProcessor.port.onmessage = (event) => { if (event.data.type === 'buffer') { this.processPCMBuffer(event.data.buffer); } }; - log('Using AudioWorklet to process audio', 'success'); - const silent = this.audioContext.createGain(); silent.gain.value = 0; audioProcessor.connect(silent); @@ -145,25 +123,19 @@ export class AudioRecorder { try { const frameSize = 4096; const scriptProcessor = this.audioContext.createScriptProcessor(frameSize, 1, 1); - scriptProcessor.onaudioprocess = (event) => { if (!this.isRecording) return; - const input = event.inputBuffer.getChannelData(0); const buffer = new Int16Array(input.length); - for (let i = 0; i < input.length; i++) { buffer[i] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767))); } - this.processPCMBuffer(buffer); }; - const silent = this.audioContext.createGain(); silent.gain.value = 0; scriptProcessor.connect(silent); silent.connect(this.audioContext.destination); - log('Using ScriptProcessorNode as fallback successfully', 'warning'); return { node: scriptProcessor, type: 'processor' }; } catch (fallbackError) { @@ -175,18 +147,14 @@ export class AudioRecorder { // Process PCM buffer data processPCMBuffer(buffer) { if (!this.isRecording) return; - const newBuffer = new Int16Array(this.pcmDataBuffer.length + buffer.length); newBuffer.set(this.pcmDataBuffer); newBuffer.set(buffer, this.pcmDataBuffer.length); this.pcmDataBuffer = newBuffer; - const samplesPerFrame = 960; - while (this.pcmDataBuffer.length >= samplesPerFrame) { const frameData = this.pcmDataBuffer.slice(0, samplesPerFrame); this.pcmDataBuffer = this.pcmDataBuffer.slice(samplesPerFrame); - this.encodeAndSendOpus(frameData); } } @@ -197,15 +165,12 @@ export class AudioRecorder { log('Opus encoder not initialized', 'error'); return; } - try { if (pcmData) { const opusData = this.opusEncoder.encode(pcmData); - if (opusData && opusData.length > 0) { this.audioBuffers.push(opusData.buffer); this.totalAudioSize += opusData.length; - if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { try { this.websocket.send(opusData.buffer); @@ -238,73 +203,47 @@ export class AudioRecorder { // Start recording async start() { if (this.isRecording) return false; - try { // Check if WebSocketHandler instance exists const { getWebSocketHandler } = await import('../network/websocket.js'); const wsHandler = getWebSocketHandler(); - // If machine is speaking, send abort message if (wsHandler && wsHandler.isRemoteSpeaking && wsHandler.currentSessionId) { - const abortMessage = { - session_id: wsHandler.currentSessionId, - type: 'abort', - reason: 'wake_word_detected' - }; - + const abortMessage = { session_id: wsHandler.currentSessionId, type: 'abort', reason: 'wake_word_detected' }; if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { this.websocket.send(JSON.stringify(abortMessage)); log('Sent abort message', 'info'); } } - if (!this.initEncoder()) { log('Unable to start recording: Opus encoder initialization failed', 'error'); return false; } - log('Please record at least 1-2 seconds of audio to ensure sufficient data is collected', 'info'); - - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - sampleRate: 16000, - channelCount: 1 - } - }); - + const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, channelCount: 1 } }); this.audioContext = this.getAudioContext(); - if (this.audioContext.state === 'suspended') { await this.audioContext.resume(); } - const processorResult = await this.createAudioProcessor(); if (!processorResult) { log('Unable to create audio processor', 'error'); return false; } - this.audioProcessor = processorResult.node; this.audioProcessorType = processorResult.type; - this.audioSource = this.audioContext.createMediaStreamSource(stream); this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 2048; - this.audioSource.connect(this.analyser); this.audioSource.connect(this.audioProcessor); - this.pcmDataBuffer = new Int16Array(); this.audioBuffers = []; this.totalAudioSize = 0; this.isRecording = true; - if (this.audioProcessorType === 'worklet' && this.audioProcessor.port) { this.audioProcessor.port.postMessage({ command: 'start' }); } - // Send listening start message if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { log(`Sent recording start message`, 'info'); @@ -312,18 +251,15 @@ export class AudioRecorder { log('WebSocket not connected, unable to send start message', 'error'); return false; } - // Start visualization if (this.onVisualizerUpdate) { const dataArray = new Uint8Array(this.analyser.frequencyBinCount); this.startVisualization(dataArray); } - // Immediately notify recording start, update button state if (this.onRecordingStart) { this.onRecordingStart(0); } - // Start recording timer let recordingSeconds = 0; this.recordingTimer = setInterval(() => { @@ -332,7 +268,6 @@ export class AudioRecorder { this.onRecordingStart(recordingSeconds); } }, 100); - log('Started PCM direct recording', 'success'); return true; } catch (error) { @@ -346,11 +281,8 @@ export class AudioRecorder { startVisualization(dataArray) { const draw = () => { this.visualizationRequest = requestAnimationFrame(() => draw()); - if (!this.isRecording) return; - this.analyser.getByteFrequencyData(dataArray); - if (this.onVisualizerUpdate) { this.onVisualizerUpdate(dataArray); } @@ -361,48 +293,38 @@ export class AudioRecorder { // Stop recording stop() { if (!this.isRecording) return false; - try { this.isRecording = false; - if (this.audioProcessor) { if (this.audioProcessorType === 'worklet' && this.audioProcessor.port) { this.audioProcessor.port.postMessage({ command: 'stop' }); } - this.audioProcessor.disconnect(); this.audioProcessor = null; } - if (this.audioSource) { this.audioSource.disconnect(); this.audioSource = null; } - if (this.visualizationRequest) { cancelAnimationFrame(this.visualizationRequest); this.visualizationRequest = null; } - if (this.recordingTimer) { clearInterval(this.recordingTimer); this.recordingTimer = null; } - // Encode and send remaining data this.encodeAndSendOpus(); - // Send end signal if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { const emptyOpusFrame = new Uint8Array(0); this.websocket.send(emptyOpusFrame); log('Sent recording stop signal', 'info'); } - if (this.onRecordingStop) { this.onRecordingStop(); } - log('Stopped PCM direct recording', 'success'); return true; } catch (error) { @@ -437,21 +359,11 @@ export async function checkMicrophoneAvailability() { log('Browser does not support getUserMedia API', 'warning'); return false; } - try { // Try to access microphone - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - sampleRate: 16000, - channelCount: 1 - } - }); - + const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, channelCount: 1 } }); // Immediately stop all tracks to release microphone stream.getTracks().forEach(track => track.stop()); - log('Microphone availability check successful', 'success'); return true; } catch (error) { @@ -467,24 +379,18 @@ export async function checkMicrophoneAvailability() { export function isHttpNonLocalhost() { const protocol = window.location.protocol; const hostname = window.location.hostname; - // Check if it is HTTP protocol if (protocol !== 'http:') { return false; } - // localhost and 127.0.0.1 can use microphone if (hostname === 'localhost' || hostname === '127.0.0.1') { return false; } - // Private IP addresses can also use microphone (browser allows) - if (hostname.startsWith('192.168.') || - hostname.startsWith('10.') || - hostname.startsWith('172.')) { + if (hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.')) { return false; } - // Other HTTP access is considered non-localhost return true; -} \ No newline at end of file +} diff --git a/main/xiaozhi-server/test/js/core/audio/recorder.test.browser.js b/main/xiaozhi-server/test/js/core/audio/recorder.test.browser.js index 6d88b5f1..38f5e46e 100644 --- a/main/xiaozhi-server/test/js/core/audio/recorder.test.browser.js +++ b/main/xiaozhi-server/test/js/core/audio/recorder.test.browser.js @@ -1,9 +1,23 @@ +/** + * Audio recording module tests - Browser compatible version + * Test microphone availability detection functionality + * + * This version works without Vitest - uses the simple test framework from test-runner.html + */ + import { checkMicrophoneAvailability, isHttpNonLocalhost } from './recorder.js'; describe('Microphone Availability Detection', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + // Reset mocks before each test + vi.clearAllMocks(); + }); + /** + * Test checkMicrophoneAvailability function - success case + */ test('should return true when microphone is available', async () => { + // Mock navigator.mediaDevices.getUserMedia to return a successful stream const mockTrack = { stop: vi.fn() }; const mockStream = { getTracks: () => [mockTrack] }; navigator.mediaDevices.getUserMedia = vi.fn().mockResolvedValue(mockStream); @@ -13,60 +27,105 @@ describe('Microphone Availability Detection', () => { expect(mockTrack.stop).toHaveBeenCalled(); }); + /** + * Test checkMicrophoneAvailability function - failure case + */ test('should return false when microphone is not available', async () => { + // Mock getUserMedia to throw an error navigator.mediaDevices.getUserMedia = vi.fn().mockRejectedValue(new Error('Permission denied')); const result = await checkMicrophoneAvailability(); expect(result).toBe(false); expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled(); }); + /** + * Test checkMicrophoneAvailability function - browser not supported + */ test('should return false when browser does not support getUserMedia', async () => { + // Mock navigator.mediaDevices.getUserMedia to be undefined const originalGetUserMedia = navigator.mediaDevices.getUserMedia; navigator.mediaDevices.getUserMedia = undefined; const result = await checkMicrophoneAvailability(); expect(result).toBe(false); + // Restore navigator.mediaDevices.getUserMedia = originalGetUserMedia; }); + /** + * Test isHttpNonLocalhost function - HTTP non-localhost + * Note: window.location properties are read-only in browsers, so we test the logic indirectly + */ test('should return true for HTTP non-localhost access', () => { - expect(typeof isHttpNonLocalhost()).toBe('boolean'); + // Since window.location is read-only, we'll test by checking the actual implementation + // This test verifies the function works correctly with the current location + // In a real browser environment, this would test against actual location + const result = isHttpNonLocalhost(); + // Just verify the function runs without error + expect(typeof result).toBe('boolean'); }); + /** + * Test isHttpNonLocalhost function - localhost should return false + * Note: window.location properties are read-only in browsers + */ test('should return false for localhost', () => { + // Test the logic by checking if current location is localhost const result = isHttpNonLocalhost(); + // If we're on localhost, result should be false if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { expect(result).toBe(false); } else { + // Otherwise just verify function returns boolean expect(typeof result).toBe('boolean'); } }); + /** + * Test isHttpNonLocalhost function - 127.0.0.1 should return false + * Note: window.location properties are read-only in browsers + */ test('should return false for 127.0.0.1', () => { + // Test the logic by checking if current location is 127.0.0.1 const result = isHttpNonLocalhost(); + // If we're on 127.0.0.1, result should be false if (window.location.hostname === '127.0.0.1') { expect(result).toBe(false); } else { + // Otherwise just verify function returns boolean expect(typeof result).toBe('boolean'); } }); + /** + * Test isHttpNonLocalhost function - private IP should return false + * Note: window.location properties are read-only in browsers + */ test('should return false for private IP addresses', () => { + // Test the logic by checking if current location is a private IP const result = isHttpNonLocalhost(); const hostname = window.location.hostname; const isPrivateIP = hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.'); if (isPrivateIP && window.location.protocol === 'http:') { expect(result).toBe(false); } else { + // Otherwise just verify function returns boolean expect(typeof result).toBe('boolean'); } }); + /** + * Test isHttpNonLocalhost function - HTTPS should return false + * Note: window.location properties are read-only in browsers + */ test('should return false for HTTPS protocol', () => { + // Test the logic by checking if current protocol is HTTPS const result = isHttpNonLocalhost(); + // If we're on HTTPS, result should be false if (window.location.protocol === 'https:') { expect(result).toBe(false); } else { + // Otherwise just verify function returns boolean expect(typeof result).toBe('boolean'); } }); -}); \ No newline at end of file +}); diff --git a/main/xiaozhi-server/test/js/core/audio/recorder.test.js b/main/xiaozhi-server/test/js/core/audio/recorder.test.js index 8005f12d..c160e5f2 100644 --- a/main/xiaozhi-server/test/js/core/audio/recorder.test.js +++ b/main/xiaozhi-server/test/js/core/audio/recorder.test.js @@ -17,26 +17,12 @@ describe('Microphone Availability Detection', () => { */ test('should return true when microphone is available', async () => { // Mock navigator.mediaDevices.getUserMedia to return a successful stream - const mockTrack = { - stop: vi.fn() - }; - const mockStream = { - getTracks: () => [mockTrack] - }; - + const mockTrack = { stop: vi.fn() }; + const mockStream = { getTracks: () => [mockTrack] }; global.navigator.mediaDevices.getUserMedia = vi.fn().mockResolvedValue(mockStream); - const result = await checkMicrophoneAvailability(); - expect(result).toBe(true); - expect(global.navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({ - audio: { - echoCancellation: true, - noiseSuppression: true, - sampleRate: 16000, - channelCount: 1 - } - }); + expect(global.navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, channelCount: 1 } }); expect(mockTrack.stop).toHaveBeenCalled(); }); @@ -45,11 +31,8 @@ describe('Microphone Availability Detection', () => { */ test('should return false when microphone is not available', async () => { // Mock getUserMedia to throw an error - const mockError = new Error('Permission denied'); - global.navigator.mediaDevices.getUserMedia = vi.fn().mockRejectedValue(mockError); - + global.navigator.mediaDevices.getUserMedia = vi.fn().mockRejectedValue(new Error('Permission denied')); const result = await checkMicrophoneAvailability(); - expect(result).toBe(false); expect(global.navigator.mediaDevices.getUserMedia).toHaveBeenCalled(); }); @@ -61,11 +44,8 @@ describe('Microphone Availability Detection', () => { // Mock navigator without mediaDevices const originalMediaDevices = global.navigator.mediaDevices; delete global.navigator.mediaDevices; - const result = await checkMicrophoneAvailability(); - expect(result).toBe(false); - // Restore global.navigator.mediaDevices = originalMediaDevices; }); @@ -75,15 +55,7 @@ describe('Microphone Availability Detection', () => { */ test('should return true for HTTP non-localhost access', () => { // Mock window.location for HTTP non-localhost - Object.defineProperty(window, 'location', { - value: { - protocol: 'http:', - hostname: 'example.com' - }, - writable: true, - configurable: true - }); - + Object.defineProperty(window, 'location', { value: { protocol: 'http:', hostname: 'example.com' }, writable: true, configurable: true }); const result = isHttpNonLocalhost(); expect(result).toBe(true); }); @@ -92,15 +64,7 @@ describe('Microphone Availability Detection', () => { * Test isHttpNonLocalhost function - localhost should return false */ test('should return false for localhost', () => { - Object.defineProperty(window, 'location', { - value: { - protocol: 'http:', - hostname: 'localhost' - }, - writable: true, - configurable: true - }); - + Object.defineProperty(window, 'location', { value: { protocol: 'http:', hostname: 'localhost' }, writable: true, configurable: true }); const result = isHttpNonLocalhost(); expect(result).toBe(false); }); @@ -109,15 +73,7 @@ describe('Microphone Availability Detection', () => { * Test isHttpNonLocalhost function - 127.0.0.1 should return false */ test('should return false for 127.0.0.1', () => { - Object.defineProperty(window, 'location', { - value: { - protocol: 'http:', - hostname: '127.0.0.1' - }, - writable: true, - configurable: true - }); - + Object.defineProperty(window, 'location', { value: { protocol: 'http:', hostname: '127.0.0.1' }, writable: true, configurable: true }); const result = isHttpNonLocalhost(); expect(result).toBe(false); }); @@ -127,17 +83,8 @@ describe('Microphone Availability Detection', () => { */ test('should return false for private IP addresses', () => { const privateIPs = ['192.168.1.100', '10.0.0.1', '172.16.0.1']; - privateIPs.forEach(ip => { - Object.defineProperty(window, 'location', { - value: { - protocol: 'http:', - hostname: ip - }, - writable: true, - configurable: true - }); - + Object.defineProperty(window, 'location', { value: { protocol: 'http:', hostname: ip }, writable: true, configurable: true }); const result = isHttpNonLocalhost(); expect(result).toBe(false); }); @@ -147,16 +94,8 @@ describe('Microphone Availability Detection', () => { * Test isHttpNonLocalhost function - HTTPS should return false */ test('should return false for HTTPS protocol', () => { - Object.defineProperty(window, 'location', { - value: { - protocol: 'https:', - hostname: 'example.com' - }, - writable: true, - configurable: true - }); - + Object.defineProperty(window, 'location', { value: { protocol: 'https:', hostname: 'example.com' }, writable: true, configurable: true }); const result = isHttpNonLocalhost(); expect(result).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/main/xiaozhi-server/test/js/core/mcp/tools.js b/main/xiaozhi-server/test/js/core/mcp/tools.js index 3fca3001..02a3e09e 100644 --- a/main/xiaozhi-server/test/js/core/mcp/tools.js +++ b/main/xiaozhi-server/test/js/core/mcp/tools.js @@ -24,7 +24,6 @@ export function setWebSocket(ws) { export async function initMcpTools() { // 加载默认工具数据 const defaultMcpTools = await fetch("js/config/default-mcp-tools.json").then(res => res.json()); - const savedTools = localStorage.getItem('mcpTools'); if (savedTools) { try { @@ -36,11 +35,10 @@ export async function initMcpTools() { } else { mcpTools = [...defaultMcpTools]; } - renderMcpTools(); // Only setup event listeners if DOM elements exist if (document.getElementById('toggleMcpTools')) { - setupMcpEventListeners(); + setupMcpEventListeners(); } } @@ -50,51 +48,21 @@ export async function initMcpTools() { function renderMcpTools() { const container = document.getElementById('mcpToolsContainer'); const countSpan = document.getElementById('mcpToolsCount'); - if (!container) { return; // Container not found, skip rendering } - if (countSpan) { countSpan.textContent = `${mcpTools.length} 个工具`; } - if (mcpTools.length === 0) { container.innerHTML = '