// Audio recording module import { log } from '../../utils/logger.js?v=0205'; import { initOpusEncoder } from './opus-codec.js?v=0205'; import { getAudioPlayer } from './player.js?v=0205'; // Audio recorder class export class AudioRecorder { constructor() { this.isRecording = false; this.audioContext = null; this.analyser = null; this.audioProcessor = null; this.audioProcessorType = null; this.audioSource = null; this.opusEncoder = null; this.pcmDataBuffer = new Int16Array(); this.audioBuffers = []; this.totalAudioSize = 0; this.visualizationRequest = null; this.recordingTimer = null; this.websocket = null; // Callback functions this.onRecordingStart = null; this.onRecordingStop = null; this.onVisualizerUpdate = null; } // Set WebSocket instance setWebSocket(ws) { this.websocket = ws; } // Get AudioContext instance getAudioContext() { return getAudioPlayer().getAudioContext(); } // Initialize encoder initEncoder() { if (!this.opusEncoder) { this.opusEncoder = initOpusEncoder(); } return this.opusEncoder; } // PCM processor code getAudioProcessorCode() { return ` class AudioRecorderProcessor extends AudioWorkletProcessor { constructor() { super(); this.buffers = []; this.frameSize = 960; 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.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.bufferIndex = 0; } this.buffer[this.bufferIndex++] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767))); } return true; } } registerProcessor('audio-recorder-processor', AudioRecorderProcessor); `; } // 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('使用AudioWorklet处理音频', 'success'); const silent = this.audioContext.createGain(); silent.gain.value = 0; audioProcessor.connect(silent); silent.connect(this.audioContext.destination); return { node: audioProcessor, type: 'worklet' }; } else { log('AudioWorklet不可用,使用ScriptProcessorNode作为后备方案', 'warning'); return this.createScriptProcessor(); } } catch (error) { log(`创建音频处理器失败: ${error.message},尝试后备方案`, 'error'); return this.createScriptProcessor(); } } // Create ScriptProcessor as fallback createScriptProcessor() { 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('使用ScriptProcessorNode作为后备方案成功', 'warning'); return { node: scriptProcessor, type: 'processor' }; } catch (fallbackError) { log(`后备方案也失败: ${fallbackError.message}`, 'error'); return null; } } // 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); } } // Encode and send Opus data encodeAndSendOpus(pcmData = null) { if (!this.opusEncoder) { log('Opus编码器未初始化', '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); } catch (error) { log(`WebSocket发送错误: ${error.message}`, 'error'); } } } else { log('Opus编码失败,未返回有效数据', 'error'); } } else { if (this.pcmDataBuffer.length > 0) { const samplesPerFrame = 960; if (this.pcmDataBuffer.length < samplesPerFrame) { const paddedBuffer = new Int16Array(samplesPerFrame); paddedBuffer.set(this.pcmDataBuffer); this.encodeAndSendOpus(paddedBuffer); } else { this.encodeAndSendOpus(this.pcmDataBuffer.slice(0, samplesPerFrame)); } this.pcmDataBuffer = new Int16Array(0); } } } catch (error) { log(`Opus编码错误: ${error.message}`, 'error'); } } // Start recording async start() { if (this.isRecording) return false; try { if (!this.initEncoder()) { log('无法开始录音: Opus编码器初始化失败', 'error'); return false; } log('请至少录制1-2秒音频以确保收集足够的数据', 'info'); 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('无法创建音频处理器', '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(`已发送录音开始消息`, 'info'); } else { log('WebSocket未连接,无法发送开始消息', '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(() => { recordingSeconds += 0.1; if (this.onRecordingStart) { this.onRecordingStart(recordingSeconds); } }, 100); log('已开始PCM直接录音', 'success'); return true; } catch (error) { log(`直接录音启动错误: ${error.message}`, 'error'); this.isRecording = false; return false; } } // Start visualization startVisualization(dataArray) { const draw = () => { this.visualizationRequest = requestAnimationFrame(() => draw()); if (!this.isRecording) return; this.analyser.getByteFrequencyData(dataArray); if (this.onVisualizerUpdate) { this.onVisualizerUpdate(dataArray); } }; draw(); } // 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('已发送录音停止信号', 'info'); } if (this.onRecordingStop) { this.onRecordingStop(); } log('已停止PCM直接录音', 'success'); return true; } catch (error) { log(`直接录音停止错误: ${error.message}`, 'error'); return false; } } // Get analyser getAnalyser() { return this.analyser; } } // Create singleton instance let audioRecorderInstance = null; export function getAudioRecorder() { if (!audioRecorderInstance) { audioRecorderInstance = new AudioRecorder(); } return audioRecorderInstance; } /** * Check if microphone is available * @returns {Promise} Returns true if available, false if not available */ export async function checkMicrophoneAvailability() { // Check if browser supports getUserMedia API if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { log('浏览器不支持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 } }); // Immediately stop all tracks to release microphone stream.getTracks().forEach(track => track.stop()); log('麦克风可用性检查成功', 'success'); return true; } catch (error) { log(`麦克风不可用: ${error.message}`, 'warning'); return false; } } /** * Check if it is HTTP non-localhost access * @returns {boolean} Returns true if it is HTTP non-localhost access */ 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.')) { return false; } // Other HTTP access is considered non-localhost return true; }