realtime 语音录取
This commit is contained in:
		
							parent
							
								
									c95e6a2552
								
							
						
					
					
						commit
						d808bbfe26
					
				
							
								
								
									
										322
									
								
								src/audio_processor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										322
									
								
								src/audio_processor.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,322 @@ | |||||||
|  | // 音频处理模块 - 提取自 new_app.js 的高级音频处理功能
 | ||||||
|  | 
 | ||||||
|  | class AudioProcessor { | ||||||
|  |     constructor(options = {}) { | ||||||
|  |         this.audioContext = null; | ||||||
|  |         this.isRecording = false; | ||||||
|  |         this.audioChunks = []; | ||||||
|  |          | ||||||
|  |         // VAD相关属性
 | ||||||
|  |         this.isSpeaking = false; | ||||||
|  |         this.silenceThreshold = options.silenceThreshold || 0.01; | ||||||
|  |         this.silenceTimeout = options.silenceTimeout || 1000; | ||||||
|  |         this.minSpeechDuration = options.minSpeechDuration || 300; | ||||||
|  |         this.silenceTimer = null; | ||||||
|  |         this.speechStartTime = null; | ||||||
|  |         this.audioBuffer = []; | ||||||
|  |          | ||||||
|  |         // API配置
 | ||||||
|  |         this.apiConfig = { | ||||||
|  |             url: 'https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash', | ||||||
|  |             headers: { | ||||||
|  |                 'X-Api-App-Key': '1988591469', | ||||||
|  |                 'X-Api-Access-Key': 'mdEyhgZ59on1-NK3GXWAp3L4iLldSG0r', | ||||||
|  |                 'X-Api-Resource-Id': 'volc.bigasr.auc_turbo', | ||||||
|  |                 'X-Api-Request-Id': this.generateUUID(), | ||||||
|  |                 'X-Api-Sequence': '-1', | ||||||
|  |                 'Content-Type': 'application/json' | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // 回调函数
 | ||||||
|  |         this.onSpeechStart = options.onSpeechStart || (() => {}); | ||||||
|  |         this.onSpeechEnd = options.onSpeechEnd || (() => {}); | ||||||
|  |         this.onRecognitionResult = options.onRecognitionResult || (() => {}); | ||||||
|  |         this.onError = options.onError || (() => {}); | ||||||
|  |         this.onStatusUpdate = options.onStatusUpdate || (() => {}); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 生成UUID
 | ||||||
|  |     generateUUID() { | ||||||
|  |         return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { | ||||||
|  |             const r = Math.random() * 16 | 0; | ||||||
|  |             const v = c == 'x' ? r : (r & 0x3 | 0x8); | ||||||
|  |             return v.toString(16); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 计算音频能量(音量)
 | ||||||
|  |     calculateAudioLevel(audioData) { | ||||||
|  |         let sum = 0; | ||||||
|  |         for (let i = 0; i < audioData.length; i++) { | ||||||
|  |             sum += audioData[i] * audioData[i]; | ||||||
|  |         } | ||||||
|  |         return Math.sqrt(sum / audioData.length); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 语音活动检测
 | ||||||
|  |     detectVoiceActivity(audioData) { | ||||||
|  |         const audioLevel = this.calculateAudioLevel(audioData); | ||||||
|  |         const currentTime = Date.now(); | ||||||
|  |          | ||||||
|  |         if (audioLevel > this.silenceThreshold) { | ||||||
|  |             if (!this.isSpeaking) { | ||||||
|  |                 this.isSpeaking = true; | ||||||
|  |                 this.speechStartTime = currentTime; | ||||||
|  |                 this.audioBuffer = []; | ||||||
|  |                 this.onSpeechStart(); | ||||||
|  |                 this.onStatusUpdate('检测到语音,开始录音...', 'speaking'); | ||||||
|  |                 console.log('开始说话'); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             if (this.silenceTimer) { | ||||||
|  |                 clearTimeout(this.silenceTimer); | ||||||
|  |                 this.silenceTimer = null; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             return true; | ||||||
|  |         } else { | ||||||
|  |             if (this.isSpeaking && !this.silenceTimer) { | ||||||
|  |                 this.silenceTimer = setTimeout(() => { | ||||||
|  |                     this.handleSpeechEnd(); | ||||||
|  |                 }, this.silenceTimeout); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             return this.isSpeaking; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 语音结束处理
 | ||||||
|  |     async handleSpeechEnd() { | ||||||
|  |         if (this.isSpeaking) { | ||||||
|  |             const speechDuration = Date.now() - this.speechStartTime; | ||||||
|  |              | ||||||
|  |             if (speechDuration >= this.minSpeechDuration) { | ||||||
|  |                 console.log(`语音结束,时长: ${speechDuration}ms`); | ||||||
|  |                 await this.processAudioBuffer(); | ||||||
|  |                 this.onStatusUpdate('语音识别中...', 'processing'); | ||||||
|  |             } else { | ||||||
|  |                 console.log('说话时长太短,忽略'); | ||||||
|  |                 this.onStatusUpdate('等待语音输入...', 'ready'); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             this.isSpeaking = false; | ||||||
|  |             this.speechStartTime = null; | ||||||
|  |             this.audioBuffer = []; | ||||||
|  |             this.onSpeechEnd(); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         if (this.silenceTimer) { | ||||||
|  |             clearTimeout(this.silenceTimer); | ||||||
|  |             this.silenceTimer = null; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 处理音频缓冲区并发送到API
 | ||||||
|  |     async processAudioBuffer() { | ||||||
|  |         if (this.audioBuffer.length === 0) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         try { | ||||||
|  |             // 合并所有音频数据
 | ||||||
|  |             const totalLength = this.audioBuffer.reduce((sum, buffer) => sum + buffer.length, 0); | ||||||
|  |             const combinedBuffer = new Float32Array(totalLength); | ||||||
|  |             let offset = 0; | ||||||
|  |              | ||||||
|  |             for (const buffer of this.audioBuffer) { | ||||||
|  |                 combinedBuffer.set(buffer, offset); | ||||||
|  |                 offset += buffer.length; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 转换为WAV格式并编码为base64
 | ||||||
|  |             const wavBuffer = this.encodeWAV(combinedBuffer, 16000); | ||||||
|  |             const base64Audio = this.arrayBufferToBase64(wavBuffer); | ||||||
|  |              | ||||||
|  |             // 调用ASR API
 | ||||||
|  |             await this.callASRAPI(base64Audio); | ||||||
|  |              | ||||||
|  |         } catch (error) { | ||||||
|  |             console.error('处理音频数据失败:', error); | ||||||
|  |             this.onError('处理音频数据失败: ' + error.message); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 调用ASR API
 | ||||||
|  |     async callASRAPI(base64AudioData) { | ||||||
|  |         try { | ||||||
|  |             const requestBody = { | ||||||
|  |                 user: { | ||||||
|  |                     uid: "1988591469" | ||||||
|  |                 }, | ||||||
|  |                 audio: { | ||||||
|  |                     data: base64AudioData | ||||||
|  |                 }, | ||||||
|  |                 request: { | ||||||
|  |                     model_name: "bigmodel" | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             const response = await fetch(this.apiConfig.url, { | ||||||
|  |                 method: 'POST', | ||||||
|  |                 headers: this.apiConfig.headers, | ||||||
|  |                 body: JSON.stringify(requestBody) | ||||||
|  |             }); | ||||||
|  |              | ||||||
|  |             if (!response.ok) { | ||||||
|  |                 throw new Error(`HTTP error! status: ${response.status}`); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             const result = await response.json(); | ||||||
|  |             this.handleASRResponse(result); | ||||||
|  |              | ||||||
|  |         } catch (error) { | ||||||
|  |             console.error('ASR API调用失败:', error); | ||||||
|  |             this.onError('ASR API调用失败: ' + error.message); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 处理ASR响应
 | ||||||
|  |     handleASRResponse(response) { | ||||||
|  |         console.log('ASR响应:', response); | ||||||
|  |          | ||||||
|  |         if (response && response.result) { | ||||||
|  |             const recognizedText = response.result.text; | ||||||
|  |             this.onRecognitionResult(recognizedText); | ||||||
|  |             this.onStatusUpdate('识别完成', 'completed'); | ||||||
|  |         } else { | ||||||
|  |             console.log('未识别到文字'); | ||||||
|  |             this.onStatusUpdate('未识别到文字', 'ready'); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 编码WAV格式
 | ||||||
|  |     encodeWAV(samples, sampleRate) { | ||||||
|  |         const length = samples.length; | ||||||
|  |         const buffer = new ArrayBuffer(44 + length * 2); | ||||||
|  |         const view = new DataView(buffer); | ||||||
|  |          | ||||||
|  |         // WAV文件头
 | ||||||
|  |         const writeString = (offset, string) => { | ||||||
|  |             for (let i = 0; i < string.length; i++) { | ||||||
|  |                 view.setUint8(offset + i, string.charCodeAt(i)); | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         writeString(0, 'RIFF'); | ||||||
|  |         view.setUint32(4, 36 + length * 2, true); | ||||||
|  |         writeString(8, 'WAVE'); | ||||||
|  |         writeString(12, 'fmt '); | ||||||
|  |         view.setUint32(16, 16, true); | ||||||
|  |         view.setUint16(20, 1, true); | ||||||
|  |         view.setUint16(22, 1, true); | ||||||
|  |         view.setUint32(24, sampleRate, true); | ||||||
|  |         view.setUint32(28, sampleRate * 2, true); | ||||||
|  |         view.setUint16(32, 2, true); | ||||||
|  |         view.setUint16(34, 16, true); | ||||||
|  |         writeString(36, 'data'); | ||||||
|  |         view.setUint32(40, length * 2, true); | ||||||
|  |          | ||||||
|  |         // 写入音频数据
 | ||||||
|  |         let offset = 44; | ||||||
|  |         for (let i = 0; i < length; i++) { | ||||||
|  |             const sample = Math.max(-1, Math.min(1, samples[i])); | ||||||
|  |             view.setInt16(offset, sample * 0x7FFF, true); | ||||||
|  |             offset += 2; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         return buffer; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // ArrayBuffer转Base64
 | ||||||
|  |     arrayBufferToBase64(buffer) { | ||||||
|  |         let binary = ''; | ||||||
|  |         const bytes = new Uint8Array(buffer); | ||||||
|  |         for (let i = 0; i < bytes.byteLength; i++) { | ||||||
|  |             binary += String.fromCharCode(bytes[i]); | ||||||
|  |         } | ||||||
|  |         return btoa(binary); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 开始录音
 | ||||||
|  |     async startRecording() { | ||||||
|  |         try { | ||||||
|  |             const stream = await navigator.mediaDevices.getUserMedia({ | ||||||
|  |                 audio: { | ||||||
|  |                     sampleRate: 16000, | ||||||
|  |                     channelCount: 1, | ||||||
|  |                     echoCancellation: true, | ||||||
|  |                     noiseSuppression: true | ||||||
|  |                 } | ||||||
|  |             }); | ||||||
|  |              | ||||||
|  |             this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ | ||||||
|  |                 sampleRate: 16000 | ||||||
|  |             }); | ||||||
|  |              | ||||||
|  |             const source = this.audioContext.createMediaStreamSource(stream); | ||||||
|  |             const processor = this.audioContext.createScriptProcessor(4096, 1, 1); | ||||||
|  |              | ||||||
|  |             processor.onaudioprocess = (event) => { | ||||||
|  |                 const inputBuffer = event.inputBuffer; | ||||||
|  |                 const inputData = inputBuffer.getChannelData(0); | ||||||
|  |                  | ||||||
|  |                 // 语音活动检测
 | ||||||
|  |                 if (this.detectVoiceActivity(inputData)) { | ||||||
|  |                     // 如果检测到语音活动,缓存音频数据
 | ||||||
|  |                     this.audioBuffer.push(new Float32Array(inputData)); | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             source.connect(processor); | ||||||
|  |             processor.connect(this.audioContext.destination); | ||||||
|  |              | ||||||
|  |             this.isRecording = true; | ||||||
|  |             this.onStatusUpdate('等待语音输入...', 'ready'); | ||||||
|  |              | ||||||
|  |             return true; | ||||||
|  |              | ||||||
|  |         } catch (error) { | ||||||
|  |             console.error('启动录音失败:', error); | ||||||
|  |             this.onError('启动录音失败: ' + error.message); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 停止录音
 | ||||||
|  |     stopRecording() { | ||||||
|  |         if (this.audioContext) { | ||||||
|  |             this.audioContext.close(); | ||||||
|  |             this.audioContext = null; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         if (this.silenceTimer) { | ||||||
|  |             clearTimeout(this.silenceTimer); | ||||||
|  |             this.silenceTimer = null; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // 如果正在说话,处理最后的音频
 | ||||||
|  |         if (this.isSpeaking) { | ||||||
|  |             this.handleSpeechEnd(); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         this.isRecording = false; | ||||||
|  |         this.isSpeaking = false; | ||||||
|  |         this.audioBuffer = []; | ||||||
|  |          | ||||||
|  |         this.onStatusUpdate('录音已停止', 'stopped'); | ||||||
|  |         console.log('录音已停止'); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 获取录音状态
 | ||||||
|  |     getRecordingStatus() { | ||||||
|  |         return { | ||||||
|  |             isRecording: this.isRecording, | ||||||
|  |             isSpeaking: this.isSpeaking, | ||||||
|  |             hasAudioContext: !!this.audioContext | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // 导出模块
 | ||||||
|  | export { AudioProcessor }; | ||||||
| @ -6,6 +6,9 @@ import { getLLMConfig, getMinimaxiConfig, getAudioConfig, validateConfig } from | |||||||
| 
 | 
 | ||||||
| // 防止重复播放的标志
 | // 防止重复播放的标志
 | ||||||
| let isPlaying = false; | let isPlaying = false; | ||||||
|  | // 音频播放队列
 | ||||||
|  | let audioQueue = []; | ||||||
|  | let isProcessingQueue = false; | ||||||
| 
 | 
 | ||||||
| async function chatWithAudioStream(userInput) { | async function chatWithAudioStream(userInput) { | ||||||
|   // 验证配置
 |   // 验证配置
 | ||||||
| @ -20,31 +23,22 @@ async function chatWithAudioStream(userInput) { | |||||||
|   const minimaxiConfig = getMinimaxiConfig(); |   const minimaxiConfig = getMinimaxiConfig(); | ||||||
|   const audioConfig = getAudioConfig(); |   const audioConfig = getAudioConfig(); | ||||||
|    |    | ||||||
|   // 1. 请求大模型回答
 |   // 清空音频队列
 | ||||||
|   console.log('\n=== 请求大模型回答 ==='); |   audioQueue = []; | ||||||
|   const llmResponse = await requestLLMStream({ |  | ||||||
|     apiKey: llmConfig.apiKey, |  | ||||||
|     model: llmConfig.model, |  | ||||||
|     messages: [ |  | ||||||
|       { role: 'system', content: 'You are a helpful assistant.' }, |  | ||||||
|       { role: 'user', content: userInput }, |  | ||||||
|     ], |  | ||||||
|   }); |  | ||||||
|    |    | ||||||
|   // 提取大模型回答内容(现在直接返回内容)
 |   // 定义段落处理函数
 | ||||||
|   const llmContent = llmResponse; |   const handleSegment = async (segment) => { | ||||||
|  |     console.log('\n=== 处理文本段落 ==='); | ||||||
|  |     console.log('段落内容:', segment); | ||||||
|      |      | ||||||
|   console.log('\n=== 大模型回答 ==='); |     try { | ||||||
|   console.log("llmResponse: ", llmContent); |       // 为每个段落生成音频
 | ||||||
|    |  | ||||||
|   // 2. 合成音频
 |  | ||||||
|   console.log('\n=== 开始合成音频 ==='); |  | ||||||
|       const audioResult = await requestMinimaxi({ |       const audioResult = await requestMinimaxi({ | ||||||
|         apiKey: minimaxiConfig.apiKey, |         apiKey: minimaxiConfig.apiKey, | ||||||
|         groupId: minimaxiConfig.groupId, |         groupId: minimaxiConfig.groupId, | ||||||
|         body: { |         body: { | ||||||
|           model: audioConfig.model, |           model: audioConfig.model, | ||||||
|       text: llmContent, |           text: segment, | ||||||
|           stream: audioConfig.stream, |           stream: audioConfig.stream, | ||||||
|           language_boost: audioConfig.language_boost, |           language_boost: audioConfig.language_boost, | ||||||
|           output_format: audioConfig.output_format, |           output_format: audioConfig.output_format, | ||||||
| @ -54,30 +48,70 @@ async function chatWithAudioStream(userInput) { | |||||||
|         stream: true, |         stream: true, | ||||||
|       }); |       }); | ||||||
|        |        | ||||||
|   // 3. 流式播放音频
 |       // 将音频添加到播放队列
 | ||||||
|   console.log('\n=== 开始流式播放音频 ==='); |       if (audioResult && audioResult.data && audioResult.data.audio) { | ||||||
|   // console.log('音频数据长度:', audioResult.data.audio.length);
 |         audioQueue.push({ | ||||||
|   await playAudioStream(audioResult.data.audio); |           text: segment, | ||||||
|  |           audioHex: audioResult.data.audio | ||||||
|  |         }); | ||||||
|  |         console.log('音频已添加到队列,队列长度:', audioQueue.length); | ||||||
|  |          | ||||||
|  |         // 开始处理队列
 | ||||||
|  |         processAudioQueue(); | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       console.error('生成音频失败:', error); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   // 1. 请求大模型回答,并实时处理段落
 | ||||||
|  |   console.log('\n=== 请求大模型回答 ==='); | ||||||
|  |   const llmResponse = await requestLLMStream({ | ||||||
|  |     apiKey: llmConfig.apiKey, | ||||||
|  |     model: llmConfig.model, | ||||||
|  |     messages: [ | ||||||
|  |       { role: 'system', content: 'You are a helpful assistant.' }, | ||||||
|  |       { role: 'user', content: userInput }, | ||||||
|  |     ], | ||||||
|  |     onSegment: handleSegment // 传入段落处理回调
 | ||||||
|  |   }); | ||||||
|  |    | ||||||
|  |   console.log('\n=== 大模型完整回答 ==='); | ||||||
|  |   console.log("llmResponse: ", llmResponse); | ||||||
|    |    | ||||||
|   return { |   return { | ||||||
|     userInput, |     userInput, | ||||||
|     llmResponse: llmContent, |     llmResponse, | ||||||
|     audioResult, |     audioQueue: audioQueue.map(item => ({ text: item.text, hasAudio: !!item.audioHex })) | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // 处理音频播放队列
 | ||||||
|  | async function processAudioQueue() { | ||||||
|  |   if (isProcessingQueue) return; | ||||||
|  |    | ||||||
|  |   isProcessingQueue = true; | ||||||
|  |    | ||||||
|  |   // while (audioQueue.length > 0) {
 | ||||||
|  |   //   const audioItem = audioQueue.shift();
 | ||||||
|  |   //   console.log('\n=== 播放队列中的音频 ===');
 | ||||||
|  |   //   console.log('文本:', audioItem.text);
 | ||||||
|  |      | ||||||
|  |   //   try {
 | ||||||
|  |   //     await playAudioStream(audioItem.audioHex);
 | ||||||
|  |   //   } catch (error) {
 | ||||||
|  |   //     console.error('播放音频失败:', error);
 | ||||||
|  |   //   }
 | ||||||
|  |   // }
 | ||||||
|  |    | ||||||
|  |   isProcessingQueue = false; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // 流式播放音频
 | // 流式播放音频
 | ||||||
| async function playAudioStream(audioHex) { | async function playAudioStream(audioHex) { | ||||||
|   if (isPlaying) { |  | ||||||
|     console.log('音频正在播放中,跳过重复播放'); |  | ||||||
|     return; |  | ||||||
|   } |  | ||||||
|    |  | ||||||
|   console.log('=== 开始播放音频 ==='); |   console.log('=== 开始播放音频 ==='); | ||||||
|   console.log('音频数据长度:', audioHex.length); |   console.log('音频数据长度:', audioHex.length); | ||||||
|    |    | ||||||
|   isPlaying = true; |  | ||||||
|    |  | ||||||
|   // 将hex转换为ArrayBuffer
 |   // 将hex转换为ArrayBuffer
 | ||||||
|   const audioBuffer = hexToArrayBuffer(audioHex); |   const audioBuffer = hexToArrayBuffer(audioHex); | ||||||
|    |    | ||||||
| @ -102,13 +136,11 @@ async function playAudioStream(audioHex) { | |||||||
|     return new Promise((resolve) => { |     return new Promise((resolve) => { | ||||||
|       source.onended = () => { |       source.onended = () => { | ||||||
|         console.log('音频播放完成'); |         console.log('音频播放完成'); | ||||||
|         isPlaying = false; |  | ||||||
|         resolve(); |         resolve(); | ||||||
|       }; |       }; | ||||||
|     }); |     }); | ||||||
|   } catch (error) { |   } catch (error) { | ||||||
|     console.error('音频播放失败:', error); |     console.error('音频播放失败:', error); | ||||||
|     isPlaying = false; |  | ||||||
|     throw error; |     throw error; | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @ -175,4 +207,6 @@ async function playAudioStreamNode(audioHex) { | |||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| export { chatWithAudioStream, playAudioStream, playAudioStreamNode}; | export { chatWithAudioStream, playAudioStream, playAudioStreamNode}; | ||||||
| @ -16,11 +16,11 @@ export const config = { | |||||||
|   audio: { |   audio: { | ||||||
|     model: 'speech-02-hd', |     model: 'speech-02-hd', | ||||||
|     voiceSetting: { |     voiceSetting: { | ||||||
|       voice_id: 'yantu-qinggang', |       voice_id: 'yantu-qinggang-2', | ||||||
|       speed: 1, |       speed: 1, | ||||||
|       vol: 1, |       vol: 1, | ||||||
|       pitch: 0, |       pitch: 0, | ||||||
|       emotion: 'happy', |       // emotion: 'happy',
 | ||||||
|     }, |     }, | ||||||
|     audioSetting: { |     audioSetting: { | ||||||
|       sample_rate: 32000, |       sample_rate: 32000, | ||||||
|  | |||||||
							
								
								
									
										139
									
								
								src/index - 副本.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								src/index - 副本.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,139 @@ | |||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="zh-CN"> | ||||||
|  | <head> | ||||||
|  |     <meta charset="UTF-8"> | ||||||
|  |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|  |     <title>实时语音识别</title> | ||||||
|  |     <style> | ||||||
|  |         body { | ||||||
|  |             font-family: Arial, sans-serif; | ||||||
|  |             max-width: 800px; | ||||||
|  |             margin: 0 auto; | ||||||
|  |             padding: 20px; | ||||||
|  |             background-color: #f5f5f5; | ||||||
|  |         } | ||||||
|  |         .container { | ||||||
|  |             background: white; | ||||||
|  |             padding: 30px; | ||||||
|  |             border-radius: 10px; | ||||||
|  |             box-shadow: 0 2px 10px rgba(0,0,0,0.1); | ||||||
|  |         } | ||||||
|  |         .controls { | ||||||
|  |             text-align: center; | ||||||
|  |             margin-bottom: 30px; | ||||||
|  |         } | ||||||
|  |         .record-btn { | ||||||
|  |             background: #4CAF50; | ||||||
|  |             color: white; | ||||||
|  |             border: none; | ||||||
|  |             padding: 15px 30px; | ||||||
|  |             font-size: 18px; | ||||||
|  |             border-radius: 50px; | ||||||
|  |             cursor: pointer; | ||||||
|  |             transition: all 0.3s; | ||||||
|  |         } | ||||||
|  |         .record-btn:hover { | ||||||
|  |             background: #45a049; | ||||||
|  |         } | ||||||
|  |         .record-btn.recording { | ||||||
|  |             background: #f44336; | ||||||
|  |             animation: pulse 1s infinite; | ||||||
|  |         } | ||||||
|  |         @keyframes pulse { | ||||||
|  |             0% { transform: scale(1); } | ||||||
|  |             50% { transform: scale(1.05); } | ||||||
|  |             100% { transform: scale(1); } | ||||||
|  |         } | ||||||
|  |         .status { | ||||||
|  |             margin: 20px 0; | ||||||
|  |             padding: 10px; | ||||||
|  |             border-radius: 5px; | ||||||
|  |             text-align: center; | ||||||
|  |             font-weight: bold; | ||||||
|  |         } | ||||||
|  |         .status.connected { | ||||||
|  |             background: #d4edda; | ||||||
|  |             color: #155724; | ||||||
|  |             border: 1px solid #c3e6cb; | ||||||
|  |         } | ||||||
|  |         .status.speaking { | ||||||
|  |             background: #fff3cd; | ||||||
|  |             color: #856404; | ||||||
|  |             border: 1px solid #ffeaa7; | ||||||
|  |             animation: speaking-pulse 0.5s infinite alternate; | ||||||
|  |         } | ||||||
|  |         .status.processing { | ||||||
|  |             background: #cce7ff; | ||||||
|  |             color: #004085; | ||||||
|  |             border: 1px solid #99d6ff; | ||||||
|  |         } | ||||||
|  |         .status.disconnected { | ||||||
|  |             background: #f8d7da; | ||||||
|  |             color: #721c24; | ||||||
|  |             border: 1px solid #f5c6cb; | ||||||
|  |         } | ||||||
|  |         @keyframes speaking-pulse { | ||||||
|  |             0% { opacity: 0.7; } | ||||||
|  |             100% { opacity: 1; } | ||||||
|  |         } | ||||||
|  |         .results { | ||||||
|  |             max-height: 400px; | ||||||
|  |             overflow-y: auto; | ||||||
|  |             border: 1px solid #ddd; | ||||||
|  |             border-radius: 5px; | ||||||
|  |             padding: 15px; | ||||||
|  |             background: #fafafa; | ||||||
|  |         } | ||||||
|  |         .result-item { | ||||||
|  |             margin-bottom: 15px; | ||||||
|  |             padding: 10px; | ||||||
|  |             background: white; | ||||||
|  |             border-radius: 5px; | ||||||
|  |             border-left: 4px solid #4CAF50; | ||||||
|  |         } | ||||||
|  |         .timestamp { | ||||||
|  |             font-size: 12px; | ||||||
|  |             color: #666; | ||||||
|  |             margin-bottom: 5px; | ||||||
|  |         } | ||||||
|  |         .text { | ||||||
|  |             font-size: 16px; | ||||||
|  |             line-height: 1.4; | ||||||
|  |         } | ||||||
|  |         .help { | ||||||
|  |             margin-top: 20px; | ||||||
|  |             padding: 15px; | ||||||
|  |             background: #e3f2fd; | ||||||
|  |             border-radius: 5px; | ||||||
|  |             font-size: 14px; | ||||||
|  |             color: #1565c0; | ||||||
|  |         } | ||||||
|  |     </style> | ||||||
|  | </head> | ||||||
|  | <body> | ||||||
|  |     <div class="container"> | ||||||
|  |         <h1>实时语音识别</h1> | ||||||
|  |          | ||||||
|  |         <div class="controls"> | ||||||
|  |             <button id="recordBtn" class="record-btn">开始录音</button> | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         <div id="status" class="status disconnected">未连接</div> | ||||||
|  |          | ||||||
|  |         <div class="help"> | ||||||
|  |             <strong>使用说明:</strong><br> | ||||||
|  |             1. 点击"开始录音"按钮开启麦克风<br> | ||||||
|  |             2. 系统会自动检测您的语音,只有在检测到说话时才开始录音<br> | ||||||
|  |             3. 说话结束后会自动发送音频进行识别<br> | ||||||
|  |             4. 识别结果会显示在下方区域 | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         <h3>识别结果:</h3> | ||||||
|  |         <div id="results" class="results"> | ||||||
|  |             <!-- 识别结果将显示在这里 --> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  |      | ||||||
|  |     <script src="new_app.js"></script> | ||||||
|  | </body> | ||||||
|  | </html> | ||||||
							
								
								
									
										93
									
								
								src/index.js
									
									
									
									
									
								
							
							
						
						
									
										93
									
								
								src/index.js
									
									
									
									
									
								
							| @ -1,5 +1,6 @@ | |||||||
| // WebRTC 音视频通话应用
 | // WebRTC 音视频通话应用
 | ||||||
| import { chatWithAudioStream } from './chat_with_audio.js'; | import { chatWithAudioStream } from './chat_with_audio.js'; | ||||||
|  | import { AudioProcessor } from './audio_processor.js'; | ||||||
| 
 | 
 | ||||||
| class WebRTCChat { | class WebRTCChat { | ||||||
|     constructor() { |     constructor() { | ||||||
| @ -15,6 +16,30 @@ class WebRTCChat { | |||||||
|         this.videoStreams = new Map(); // 存储不同视频的MediaStream
 |         this.videoStreams = new Map(); // 存储不同视频的MediaStream
 | ||||||
|         this.currentVideoStream = null; |         this.currentVideoStream = null; | ||||||
|          |          | ||||||
|  |         // 初始化音频处理器
 | ||||||
|  |         this.audioProcessor = new AudioProcessor({ | ||||||
|  |             onSpeechStart: () => { | ||||||
|  |                 this.voiceStatus.textContent = '检测到语音,开始录音...'; | ||||||
|  |                 this.logMessage('检测到语音,开始录音...', 'info'); | ||||||
|  |             }, | ||||||
|  |             onSpeechEnd: () => { | ||||||
|  |                 // 语音结束回调
 | ||||||
|  |             }, | ||||||
|  |             onRecognitionResult: (text) => { | ||||||
|  |                 // ASRTEXT = text;
 | ||||||
|  |                 this.voiceStatus.textContent = '识别完成'; | ||||||
|  |                 this.logMessage(`语音识别结果: ${text}`, 'success'); | ||||||
|  |                 this.handleVoiceInput(text); | ||||||
|  |             }, | ||||||
|  |             onError: (error) => { | ||||||
|  |                 this.voiceStatus.textContent = '识别失败'; | ||||||
|  |                 this.logMessage(error, 'error'); | ||||||
|  |             }, | ||||||
|  |             onStatusUpdate: (message, status) => { | ||||||
|  |                 this.voiceStatus.textContent = message; | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |          | ||||||
|         this.initializeElements(); |         this.initializeElements(); | ||||||
|         this.initializeSocket(); |         this.initializeSocket(); | ||||||
|         this.loadVideoMapping(); |         this.loadVideoMapping(); | ||||||
| @ -627,65 +652,34 @@ class WebRTCChat { | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     // 修改:使用音频处理器的语音录制功能
 | ||||||
|     async startVoiceRecording() { |     async startVoiceRecording() { | ||||||
|         try { |         const success = await this.audioProcessor.startRecording(); | ||||||
|             const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |  | ||||||
|             this.mediaRecorder = new MediaRecorder(stream); |  | ||||||
|             this.audioChunks = []; |  | ||||||
|              |  | ||||||
|             this.mediaRecorder.ondataavailable = (event) => { |  | ||||||
|                 this.audioChunks.push(event.data); |  | ||||||
|             }; |  | ||||||
|              |  | ||||||
|             this.mediaRecorder.onstop = () => { |  | ||||||
|                 const audioBlob = new Blob(this.audioChunks, { type: 'audio/wav' }); |  | ||||||
|                 this.processVoiceInput(audioBlob); |  | ||||||
|             }; |  | ||||||
|              |  | ||||||
|             this.mediaRecorder.start(); |  | ||||||
|             this.isRecording = true; |  | ||||||
|          |          | ||||||
|  |         if (success) { | ||||||
|             this.startVoiceButton.disabled = true; |             this.startVoiceButton.disabled = true; | ||||||
|             this.stopVoiceButton.disabled = false; |             this.stopVoiceButton.disabled = false; | ||||||
|             this.voiceStatus.textContent = '正在录音...'; |  | ||||||
|             this.startVoiceButton.classList.add('recording'); |             this.startVoiceButton.classList.add('recording'); | ||||||
|              |             this.voiceStatus.textContent = '等待语音输入...'; | ||||||
|             this.logMessage('开始语音录制', 'info'); |             this.logMessage('高级语音录制已启动', 'success'); | ||||||
|         } catch (error) { |         } else { | ||||||
|             this.logMessage('无法访问麦克风: ' + error.message, 'error'); |             this.voiceStatus.textContent = '录音启动失败'; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     // 修改:停止语音录制
 | ||||||
|     stopVoiceRecording() { |     stopVoiceRecording() { | ||||||
|         if (this.mediaRecorder && this.isRecording) { |         this.audioProcessor.stopRecording(); | ||||||
|             this.mediaRecorder.stop(); |  | ||||||
|             this.isRecording = false; |  | ||||||
|          |          | ||||||
|         this.startVoiceButton.disabled = false; |         this.startVoiceButton.disabled = false; | ||||||
|         this.stopVoiceButton.disabled = true; |         this.stopVoiceButton.disabled = true; | ||||||
|             this.voiceStatus.textContent = '点击开始语音输入'; |  | ||||||
|         this.startVoiceButton.classList.remove('recording'); |         this.startVoiceButton.classList.remove('recording'); | ||||||
|  |         this.voiceStatus.textContent = '点击开始语音输入'; | ||||||
|          |          | ||||||
|             this.logMessage('停止语音录制', 'info'); |         this.logMessage('语音录制已停止', 'info'); | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     async processVoiceInput(audioBlob) { |  | ||||||
|         // 这里可以集成语音识别API,如Web Speech API或第三方服务
 |  | ||||||
|         // 为了演示,我们使用一个简单的模拟识别
 |  | ||||||
|         const mockText = this.simulateSpeechRecognition(); |  | ||||||
|          |  | ||||||
|         this.socket.emit('voice-input', { |  | ||||||
|             audioData: audioBlob, |  | ||||||
|             text: mockText |  | ||||||
|         }); |  | ||||||
|          |  | ||||||
|         this.logMessage(`语音识别结果: ${mockText}`, 'info'); |  | ||||||
|          |  | ||||||
|         // 根据语音识别结果切换视频流
 |  | ||||||
|         await this.handleVoiceInput(mockText); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     // 处理语音输入结果
 | ||||||
|     async handleVoiceInput(text) { |     async handleVoiceInput(text) { | ||||||
|         // 根据文本查找对应视频
 |         // 根据文本查找对应视频
 | ||||||
|         let videoFile = this.videoMapping['默认'] || this.defaultVideo; |         let videoFile = this.videoMapping['默认'] || this.defaultVideo; | ||||||
| @ -705,7 +699,20 @@ class WebRTCChat { | |||||||
|             type: 'voice',  |             type: 'voice',  | ||||||
|             text  |             text  | ||||||
|         }); |         }); | ||||||
|  |          | ||||||
|  |         // 调用大模型处理
 | ||||||
|  |         try { | ||||||
|  |             this.logMessage('正在处理语音输入,请稍候...', 'info'); | ||||||
|  |             const result = await chatWithAudioStream(text); | ||||||
|  |             this.logMessage(`大模型回答: ${result.llmResponse}`, 'success'); | ||||||
|  |         } catch (error) { | ||||||
|  |             this.logMessage(`处理语音输入失败: ${error.message}`, 'error'); | ||||||
|  |             console.error('chatWithAudioStream error:', error); | ||||||
|         } |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // 删除原有的简单音频处理方法
 | ||||||
|  |     // processVoiceInput() 和 simulateSpeechRecognition() 方法已被移除
 | ||||||
| 
 | 
 | ||||||
|     simulateSpeechRecognition() { |     simulateSpeechRecognition() { | ||||||
|         // 模拟语音识别,随机返回预设的文本
 |         // 模拟语音识别,随机返回预设的文本
 | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| // 以流式方式请求LLM大模型接口,并打印流式返回内容
 | // 以流式方式请求LLM大模型接口,并打印流式返回内容
 | ||||||
| 
 | 
 | ||||||
| async function requestLLMStream({ apiKey, model, messages }) { | async function requestLLMStream({ apiKey, model, messages, onSegment }) { | ||||||
|   const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions', { |   const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions', { | ||||||
|     method: 'POST', |     method: 'POST', | ||||||
|     headers: { |     headers: { | ||||||
| @ -26,6 +26,10 @@ async function requestLLMStream({ apiKey, model, messages }) { | |||||||
|   let done = false; |   let done = false; | ||||||
|   let buffer = ''; |   let buffer = ''; | ||||||
|   let content = ''; |   let content = ''; | ||||||
|  |   let pendingText = ''; // 待处理的文本片段
 | ||||||
|  | 
 | ||||||
|  |   // 分段分隔符
 | ||||||
|  |   const segmentDelimiters = /[,。:;!?,.:;!?]/; | ||||||
| 
 | 
 | ||||||
|   while (!done) { |   while (!done) { | ||||||
|     const { value, done: doneReading } = await reader.read(); |     const { value, done: doneReading } = await reader.read(); | ||||||
| @ -47,6 +51,10 @@ async function requestLLMStream({ apiKey, model, messages }) { | |||||||
|            |            | ||||||
|           if (jsonStr === '[DONE]') { |           if (jsonStr === '[DONE]') { | ||||||
|             console.log('LLM SSE流结束'); |             console.log('LLM SSE流结束'); | ||||||
|  |             // 处理最后的待处理文本
 | ||||||
|  |             if (pendingText.trim() && onSegment) { | ||||||
|  |               await onSegment(pendingText.trim()); | ||||||
|  |             } | ||||||
|             continue; |             continue; | ||||||
|           } |           } | ||||||
|            |            | ||||||
| @ -55,7 +63,29 @@ async function requestLLMStream({ apiKey, model, messages }) { | |||||||
|             if (obj.choices && obj.choices[0] && obj.choices[0].delta && obj.choices[0].delta.content) { |             if (obj.choices && obj.choices[0] && obj.choices[0].delta && obj.choices[0].delta.content) { | ||||||
|               const deltaContent = obj.choices[0].delta.content; |               const deltaContent = obj.choices[0].delta.content; | ||||||
|               content += deltaContent; |               content += deltaContent; | ||||||
|  |               pendingText += deltaContent; | ||||||
|               console.log('LLM内容片段:', deltaContent); |               console.log('LLM内容片段:', deltaContent); | ||||||
|  |                | ||||||
|  |               // 检查是否包含分段分隔符
 | ||||||
|  |               if (segmentDelimiters.test(pendingText)) { | ||||||
|  |                 // 按分隔符分割文本
 | ||||||
|  |                 const segments = pendingText.split(segmentDelimiters); | ||||||
|  |                  | ||||||
|  |                 // 处理完整的段落(除了最后一个,因为可能不完整)
 | ||||||
|  |                 for (let i = 0; i < segments.length - 1; i++) { | ||||||
|  |                   const segment = segments[i].trim(); | ||||||
|  |                   if (segment && onSegment) { | ||||||
|  |                     // 找到对应的分隔符
 | ||||||
|  |                     const delimiterMatch = pendingText.match(segmentDelimiters); | ||||||
|  |                     const segmentWithDelimiter = segment + (delimiterMatch ? delimiterMatch[0] : ''); | ||||||
|  |                     console.log('检测到完整段落:', segmentWithDelimiter); | ||||||
|  |                     await onSegment(segmentWithDelimiter); | ||||||
|  |                   } | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 // 保留最后一个不完整的段落
 | ||||||
|  |                 pendingText = segments[segments.length - 1] || ''; | ||||||
|  |               } | ||||||
|             } |             } | ||||||
|           } catch (e) { |           } catch (e) { | ||||||
|             console.error('解析LLM SSE数据失败:', e, '原始数据:', jsonStr); |             console.error('解析LLM SSE数据失败:', e, '原始数据:', jsonStr); | ||||||
|  | |||||||
| @ -1,5 +1,135 @@ | |||||||
| // 以流式或非流式方式请求 minimaxi 大模型接口,并打印/返回内容
 | // 以流式或非流式方式请求 minimaxi 大模型接口,并打印/返回内容
 | ||||||
| 
 | 
 | ||||||
|  | // 在文件顶部添加音频播放相关的变量和函数
 | ||||||
|  | let audioContext = null; | ||||||
|  | let audioQueue = []; // 音频队列
 | ||||||
|  | let isPlaying = false; | ||||||
|  | let isProcessingQueue = false; // 队列处理状态
 | ||||||
|  | let nextStartTime = 0; // 添加这行来声明 nextStartTime 变量
 | ||||||
|  | 
 | ||||||
|  | // 初始化音频上下文
 | ||||||
|  | function initAudioContext() { | ||||||
|  |   if (!audioContext) { | ||||||
|  |     audioContext = new (window.AudioContext || window.webkitAudioContext)(); | ||||||
|  |   } | ||||||
|  |   return audioContext; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // 将hex字符串转换为ArrayBuffer
 | ||||||
|  | function hexToArrayBuffer(hex) { | ||||||
|  |   const bytes = new Uint8Array(hex.length / 2); | ||||||
|  |   for (let i = 0; i < hex.length; i += 2) { | ||||||
|  |     bytes[i / 2] = parseInt(hex.substr(i, 2), 16); | ||||||
|  |   } | ||||||
|  |   return bytes.buffer; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // 将音频添加到队列(不等待播放)
 | ||||||
|  | async function addAudioToQueue(audioHex) { | ||||||
|  |   if (!audioHex || audioHex.length === 0) return; | ||||||
|  |    | ||||||
|  |   try { | ||||||
|  |     const ctx = initAudioContext(); | ||||||
|  |     const audioBuffer = hexToArrayBuffer(audioHex); | ||||||
|  |     const audioData = await ctx.decodeAudioData(audioBuffer); | ||||||
|  |      | ||||||
|  |     // 将解码后的音频数据添加到队列
 | ||||||
|  |     audioQueue.push({ | ||||||
|  |       audioData, | ||||||
|  |       timestamp: Date.now() | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     console.log(`音频已添加到队列,队列长度: ${audioQueue.length}`); | ||||||
|  |      | ||||||
|  |     // 启动队列处理器(如果还没有运行)
 | ||||||
|  |     if (!isProcessingQueue) { | ||||||
|  |       processAudioQueue(); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('音频解码失败:', error); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // 队列处理器 - 独立运行,按顺序播放音频
 | ||||||
|  | async function processAudioQueue() { | ||||||
|  |   if (isProcessingQueue) return; | ||||||
|  |    | ||||||
|  |   isProcessingQueue = true; | ||||||
|  |   console.log('开始处理音频队列'); | ||||||
|  |    | ||||||
|  |   while (audioQueue.length > 0 || isPlaying) { | ||||||
|  |     // 如果当前没有音频在播放,且队列中有音频
 | ||||||
|  |     if (!isPlaying && audioQueue.length > 0) { | ||||||
|  |       const audioItem = audioQueue.shift(); | ||||||
|  |       await playAudioData(audioItem.audioData); | ||||||
|  |     } else { | ||||||
|  |       // 等待一小段时间再检查
 | ||||||
|  |       await new Promise(resolve => setTimeout(resolve, 50)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   isProcessingQueue = false; | ||||||
|  |   console.log('音频队列处理完成'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // 播放单个音频数据
 | ||||||
|  | function playAudioData(audioData) { | ||||||
|  |   return new Promise((resolve) => { | ||||||
|  |     try { | ||||||
|  |       const ctx = initAudioContext(); | ||||||
|  |       const source = ctx.createBufferSource(); | ||||||
|  |       source.buffer = audioData; | ||||||
|  |       source.connect(ctx.destination); | ||||||
|  |        | ||||||
|  |       isPlaying = true; | ||||||
|  |        | ||||||
|  |       source.onended = () => { | ||||||
|  |         console.log('音频片段播放完成'); | ||||||
|  |         isPlaying = false; | ||||||
|  |         resolve(); | ||||||
|  |       }; | ||||||
|  |        | ||||||
|  |       // 超时保护
 | ||||||
|  |       setTimeout(() => { | ||||||
|  |         if (isPlaying) { | ||||||
|  |           console.log('音频播放超时,强制结束'); | ||||||
|  |           isPlaying = false; | ||||||
|  |           resolve(); | ||||||
|  |         } | ||||||
|  |       }, (audioData.duration + 0.5) * 1000); | ||||||
|  |        | ||||||
|  |       source.start(0); | ||||||
|  |       console.log(`开始播放音频片段,时长: ${audioData.duration}秒`); | ||||||
|  |        | ||||||
|  |     } catch (error) { | ||||||
|  |       console.error('播放音频失败:', error); | ||||||
|  |       isPlaying = false; | ||||||
|  |       resolve(); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // 修改原来的playAudioChunk函数,改为addAudioToQueue
 | ||||||
|  | const playAudioChunk = addAudioToQueue; | ||||||
|  | 
 | ||||||
|  | // 清空音频队列
 | ||||||
|  | function clearAudioQueue() { | ||||||
|  |   audioQueue.length = 0; | ||||||
|  |   console.log('音频队列已清空'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // 获取队列状态
 | ||||||
|  | function getQueueStatus() { | ||||||
|  |   return { | ||||||
|  |     queueLength: audioQueue.length, | ||||||
|  |     isPlaying, | ||||||
|  |     isProcessingQueue | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // 移除waitForCurrentAudioToFinish函数,不再需要
 | ||||||
|  | 
 | ||||||
| async function requestMinimaxi({ apiKey, groupId, body, stream = true }) { | async function requestMinimaxi({ apiKey, groupId, body, stream = true }) { | ||||||
|   const url = `https://api.minimaxi.com/v1/t2a_v2`; |   const url = `https://api.minimaxi.com/v1/t2a_v2`; | ||||||
|   const reqBody = { ...body, stream }; |   const reqBody = { ...body, stream }; | ||||||
| @ -24,7 +154,7 @@ async function requestMinimaxi({ apiKey, groupId, body, stream = true }) { | |||||||
|     console.log(JSON.stringify(result, null, 2)); |     console.log(JSON.stringify(result, null, 2)); | ||||||
|     return result; |     return result; | ||||||
|   } else { |   } else { | ||||||
|     // 流式,解析每个chunk,合并audio
 |     // 流式,解析每个chunk,实时播放音频
 | ||||||
|     const reader = response.body.getReader(); |     const reader = response.body.getReader(); | ||||||
|     const decoder = new TextDecoder('utf-8'); |     const decoder = new TextDecoder('utf-8'); | ||||||
|     let done = false; |     let done = false; | ||||||
| @ -32,25 +162,28 @@ async function requestMinimaxi({ apiKey, groupId, body, stream = true }) { | |||||||
|     let audioHex = ''; |     let audioHex = ''; | ||||||
|     let lastFullResult = null; |     let lastFullResult = null; | ||||||
|      |      | ||||||
|  |     // 重置播放状态
 | ||||||
|  |     nextStartTime = 0; | ||||||
|  |     if (audioContext) { | ||||||
|  |       nextStartTime = audioContext.currentTime; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     while (!done) { |     while (!done) { | ||||||
|       const { value, done: doneReading } = await reader.read(); |       const { value, done: doneReading } = await reader.read(); | ||||||
|       done = doneReading; |       done = doneReading; | ||||||
|       if (value) { |       if (value) { | ||||||
|         const chunk = decoder.decode(value, { stream: true }); |         const chunk = decoder.decode(value, { stream: true }); | ||||||
|         buffer += chunk; |         buffer += chunk; | ||||||
|         // console.log('收到原始chunk:', chunk);
 |  | ||||||
|          |          | ||||||
|         // 处理SSE格式的数据(以\n分割)
 |         // 处理SSE格式的数据(以\n分割)
 | ||||||
|         let lines = buffer.split('\n'); |         let lines = buffer.split('\n'); | ||||||
|         buffer = lines.pop(); // 最后一行可能是不完整的,留到下次
 |         buffer = lines.pop(); // 最后一行可能是不完整的,留到下次
 | ||||||
|         for (const line of lines) { |         for (const line of lines) { | ||||||
|           if (!line.trim()) continue; |           if (!line.trim()) continue; | ||||||
|           // console.log('处理行:', line);
 |  | ||||||
|            |            | ||||||
|           // 检查是否是SSE格式的数据行
 |           // 检查是否是SSE格式的数据行
 | ||||||
|           if (line.startsWith('data:')) { |           if (line.startsWith('data:')) { | ||||||
|             const jsonStr = line.substring(6); // 移除 'data: ' 前缀
 |             const jsonStr = line.substring(6); // 移除 'data: ' 前缀
 | ||||||
|             // console.log('提取的JSON字符串:', jsonStr);
 |  | ||||||
|              |              | ||||||
|             if (jsonStr.trim() === '[DONE]') { |             if (jsonStr.trim() === '[DONE]') { | ||||||
|               console.log('SSE流结束'); |               console.log('SSE流结束'); | ||||||
| @ -59,17 +192,19 @@ async function requestMinimaxi({ apiKey, groupId, body, stream = true }) { | |||||||
|              |              | ||||||
|             try { |             try { | ||||||
|               const obj = JSON.parse(jsonStr); |               const obj = JSON.parse(jsonStr); | ||||||
|               // 流式,解析每个chunk,合并audio
 |               // 流式,解析每个chunk,实时播放音频
 | ||||||
|               if (obj.data && obj.data.audio) { |               if (obj.data && obj.data.audio && obj.data.status === 1) { | ||||||
|  |                 console.log('收到音频数据片段!', obj.data.audio.length); | ||||||
|                 audioHex += obj.data.audio; |                 audioHex += obj.data.audio; | ||||||
|  |                  | ||||||
|  |                 // 立即播放这个音频片段
 | ||||||
|  |                 await playAudioChunk(obj.data.audio); | ||||||
|               } |               } | ||||||
|               // status=2为最后一个chunk,记录完整结构
 |               // status=2为最后一个chunk,记录完整结构
 | ||||||
|               if (obj.data && obj.data.status === 2) { |               if (obj.data && obj.data.status === 2) { | ||||||
|                 lastFullResult = obj; |                 lastFullResult = obj; | ||||||
|                 console.log('收到最终状态'); |                 console.log('收到最终状态'); | ||||||
|               } |               } | ||||||
|               // 实时打印每个chunk
 |  | ||||||
|               console.log('解析成功:', JSON.stringify(obj)); |  | ||||||
|             } catch (e) { |             } catch (e) { | ||||||
|               console.error('解析SSE数据失败:', e, '原始数据:', jsonStr); |               console.error('解析SSE数据失败:', e, '原始数据:', jsonStr); | ||||||
|             } |             } | ||||||
| @ -83,7 +218,11 @@ async function requestMinimaxi({ apiKey, groupId, body, stream = true }) { | |||||||
|             try { |             try { | ||||||
|               const obj = JSON.parse(line); |               const obj = JSON.parse(line); | ||||||
|               if (obj.data && obj.data.audio) { |               if (obj.data && obj.data.audio) { | ||||||
|  |                 console.log('收到无data:音频数据!', obj.data.audio.length); | ||||||
|                 audioHex += obj.data.audio; |                 audioHex += obj.data.audio; | ||||||
|  |                  | ||||||
|  |                 // 立即播放这个音频片段
 | ||||||
|  |                 await playAudioChunk(obj.data.audio); | ||||||
|               } |               } | ||||||
|               if (obj.data && obj.data.status === 2) { |               if (obj.data && obj.data.status === 2) { | ||||||
|                 lastFullResult = obj; |                 lastFullResult = obj; | ||||||
| @ -109,4 +248,135 @@ async function requestMinimaxi({ apiKey, groupId, body, stream = true }) { | |||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export { requestMinimaxi }; | // 火山引擎TTS方法
 | ||||||
|  | async function requestVolcanTTS({  | ||||||
|  |   appId,  | ||||||
|  |   accessKey,  | ||||||
|  |   resourceId = 'volc.service_type.10029',  | ||||||
|  |   appKey = 'aGjiRDfUWi', | ||||||
|  |   body,  | ||||||
|  |   stream = true  | ||||||
|  | }) { | ||||||
|  |   const url = 'https://openspeech.bytedance.com/api/v3/tts/unidirectional'; | ||||||
|  |    | ||||||
|  |   // 生成请求ID
 | ||||||
|  |   const requestId = generateUUID(); | ||||||
|  |    | ||||||
|  |   const response = await fetch(url, { | ||||||
|  |     method: 'POST', | ||||||
|  |     headers: { | ||||||
|  |       'X-Api-App-Id': appId, | ||||||
|  |       'X-Api-Access-Key': accessKey, | ||||||
|  |       'X-Api-Resource-Id': resourceId, | ||||||
|  |       'X-Api-App-Key': appKey, | ||||||
|  |       'X-Api-Request-Id': requestId, | ||||||
|  |       'Content-Type': 'application/json', | ||||||
|  |       'Accept': stream ? 'text/event-stream' : 'application/json', | ||||||
|  |       'Cache-Control': 'no-cache', | ||||||
|  |     }, | ||||||
|  |     body: JSON.stringify(body), | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   if (!response.ok) { | ||||||
|  |     throw new Error(`HTTP error! status: ${response.status}`); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   if (!stream) { | ||||||
|  |     // 非流式,直接返回JSON
 | ||||||
|  |     const result = await response.json(); | ||||||
|  |     console.log('火山引擎TTS非流式结果:', JSON.stringify(result, null, 2)); | ||||||
|  |     return result; | ||||||
|  |   } else { | ||||||
|  |     // 流式,解析每个chunk,合并audio
 | ||||||
|  |     const reader = response.body.getReader(); | ||||||
|  |     const decoder = new TextDecoder('utf-8'); | ||||||
|  |     let done = false; | ||||||
|  |     let buffer = ''; | ||||||
|  |     let audioBase64 = ''; | ||||||
|  |     let lastFullResult = null; | ||||||
|  | 
 | ||||||
|  |     while (!done) { | ||||||
|  |       const { value, done: doneReading } = await reader.read(); | ||||||
|  |       done = doneReading; | ||||||
|  |       if (value) { | ||||||
|  |         const chunk = decoder.decode(value, { stream: true }); | ||||||
|  |         buffer += chunk; | ||||||
|  |          | ||||||
|  |         // 处理SSE格式的数据(以\n分割)
 | ||||||
|  |         let lines = buffer.split('\n'); | ||||||
|  |         buffer = lines.pop(); // 最后一行可能是不完整的,留到下次
 | ||||||
|  |          | ||||||
|  |         for (const line of lines) { | ||||||
|  |           if (!line.trim()) continue; | ||||||
|  |            | ||||||
|  |           // 检查是否是SSE格式的数据行
 | ||||||
|  |           if (line.startsWith('data:')) { | ||||||
|  |             const jsonStr = line.substring(6); // 移除 'data: ' 前缀
 | ||||||
|  |              | ||||||
|  |             if (jsonStr.trim() === '[DONE]') { | ||||||
|  |               console.log('火山引擎TTS流结束'); | ||||||
|  |               continue; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             try { | ||||||
|  |               const obj = JSON.parse(jsonStr); | ||||||
|  |               // 流式,解析每个chunk,合并audio base64数据
 | ||||||
|  |               if (obj.data) { | ||||||
|  |                 audioBase64 += obj.data; | ||||||
|  |                 lastFullResult = obj; | ||||||
|  |               } | ||||||
|  |               // 实时打印每个chunk
 | ||||||
|  |               console.log('火山引擎TTS解析成功:', JSON.stringify(obj)); | ||||||
|  |             } catch (e) { | ||||||
|  |               console.error('解析火山引擎TTS数据失败:', e, '原始数据:', jsonStr); | ||||||
|  |             } | ||||||
|  |           } else if (line.startsWith('event: ') || line.startsWith('id: ') || line.startsWith('retry: ')) { | ||||||
|  |             // 忽略SSE的其他字段
 | ||||||
|  |             console.log('忽略SSE字段:', line); | ||||||
|  |             continue; | ||||||
|  |           } else if (line.trim() && !line.startsWith('data:')) { | ||||||
|  |             // 尝试直接解析(兼容非SSE格式)
 | ||||||
|  |             try { | ||||||
|  |               const obj = JSON.parse(line); | ||||||
|  |               if (obj.data) { | ||||||
|  |                 audioBase64 += obj.data; | ||||||
|  |                 lastFullResult = obj; | ||||||
|  |               } | ||||||
|  |               console.log('火山引擎TTS直接解析成功:', JSON.stringify(obj)); | ||||||
|  |             } catch (e) { | ||||||
|  |               console.error('解析火山引擎TTS chunk失败:', e, line); | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 合成最终结构
 | ||||||
|  |     console.log('火山引擎TTS音频数据总长度:', audioBase64.length); | ||||||
|  |      | ||||||
|  |     if (lastFullResult) { | ||||||
|  |       // 更新最终结果的音频数据
 | ||||||
|  |       lastFullResult.data = audioBase64; | ||||||
|  |       console.log('火山引擎TTS最终合成结果:', JSON.stringify(lastFullResult, null, 2)); | ||||||
|  |       return lastFullResult; | ||||||
|  |     } else { | ||||||
|  |       // 没有完整结构,返回合成的audio
 | ||||||
|  |       return {  | ||||||
|  |         code: 0,  | ||||||
|  |         message: '',  | ||||||
|  |         data: audioBase64  | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // 生成UUID的辅助函数
 | ||||||
|  | function generateUUID() { | ||||||
|  |   return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { | ||||||
|  |     const r = Math.random() * 16 | 0; | ||||||
|  |     const v = c === 'x' ? r : (r & 0x3 | 0x8); | ||||||
|  |     return v.toString(16); | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export { requestMinimaxi, requestVolcanTTS }; | ||||||
							
								
								
									
										346
									
								
								src/new_app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										346
									
								
								src/new_app.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,346 @@ | |||||||
|  | let ASRTEXT = '' | ||||||
|  | 
 | ||||||
|  | class HttpASRRecognizer { | ||||||
|  |     constructor() { | ||||||
|  |         this.mediaRecorder = null; | ||||||
|  |         this.audioContext = null; | ||||||
|  |         this.isRecording = false; | ||||||
|  |         this.audioChunks = []; | ||||||
|  |          | ||||||
|  |         // VAD相关属性
 | ||||||
|  |         this.isSpeaking = false; | ||||||
|  |         this.silenceThreshold = 0.01; | ||||||
|  |         this.silenceTimeout = 1000; | ||||||
|  |         this.minSpeechDuration = 300; | ||||||
|  |         this.silenceTimer = null; | ||||||
|  |         this.speechStartTime = null; | ||||||
|  |         this.audioBuffer = []; | ||||||
|  |          | ||||||
|  |         // API配置
 | ||||||
|  |         this.apiConfig = { | ||||||
|  |             url: 'https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash', | ||||||
|  |             headers: { | ||||||
|  |                 'X-Api-App-Key': '1988591469', | ||||||
|  |                 'X-Api-Access-Key': 'mdEyhgZ59on1-NK3GXWAp3L4iLldSG0r', | ||||||
|  |                 'X-Api-Resource-Id': 'volc.bigasr.auc_turbo', | ||||||
|  |                 'X-Api-Request-Id': this.generateUUID(), | ||||||
|  |                 'X-Api-Sequence': '-1', | ||||||
|  |                 'Content-Type': 'application/json' | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         this.recordBtn = document.getElementById('startVoiceButton'); | ||||||
|  |         this.statusDiv = document.getElementById('status'); | ||||||
|  |         this.resultsDiv = document.getElementById('results'); | ||||||
|  |          | ||||||
|  |         this.initEventListeners(); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     initEventListeners() { | ||||||
|  |         this.recordBtn.addEventListener('click', () => { | ||||||
|  |             if (this.isRecording) { | ||||||
|  |                 this.stopRecording(); | ||||||
|  |             } else { | ||||||
|  |                 this.startRecording(); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 生成UUID
 | ||||||
|  |     generateUUID() { | ||||||
|  |         return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { | ||||||
|  |             const r = Math.random() * 16 | 0; | ||||||
|  |             const v = c == 'x' ? r : (r & 0x3 | 0x8); | ||||||
|  |             return v.toString(16); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 计算音频能量(音量)
 | ||||||
|  |     calculateAudioLevel(audioData) { | ||||||
|  |         let sum = 0; | ||||||
|  |         for (let i = 0; i < audioData.length; i++) { | ||||||
|  |             sum += audioData[i] * audioData[i]; | ||||||
|  |         } | ||||||
|  |         return Math.sqrt(sum / audioData.length); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 语音活动检测
 | ||||||
|  |     detectVoiceActivity(audioData) { | ||||||
|  |         const audioLevel = this.calculateAudioLevel(audioData); | ||||||
|  |         const currentTime = Date.now(); | ||||||
|  |          | ||||||
|  |         if (audioLevel > this.silenceThreshold) { | ||||||
|  |             if (!this.isSpeaking) { | ||||||
|  |                 this.isSpeaking = true; | ||||||
|  |                 this.speechStartTime = currentTime; | ||||||
|  |                 this.audioBuffer = []; | ||||||
|  |                 this.updateStatus('检测到语音,开始录音...', 'speaking'); | ||||||
|  |                 console.log('开始说话'); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             if (this.silenceTimer) { | ||||||
|  |                 clearTimeout(this.silenceTimer); | ||||||
|  |                 this.silenceTimer = null; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             return true; | ||||||
|  |         } else { | ||||||
|  |             if (this.isSpeaking && !this.silenceTimer) { | ||||||
|  |                 this.silenceTimer = setTimeout(() => { | ||||||
|  |                     this.onSpeechEnd(); | ||||||
|  |                 }, this.silenceTimeout); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             return this.isSpeaking; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 语音结束处理
 | ||||||
|  |     async onSpeechEnd() { | ||||||
|  |         if (this.isSpeaking) { | ||||||
|  |             const speechDuration = Date.now() - this.speechStartTime; | ||||||
|  |              | ||||||
|  |             if (speechDuration >= this.minSpeechDuration) { | ||||||
|  |                 console.log(`语音结束,时长: ${speechDuration}ms`); | ||||||
|  |                 await this.processAudioBuffer(); | ||||||
|  |                 // this.updateStatus('语音识别中...', 'processing');
 | ||||||
|  |                 console.log('语音识别中') | ||||||
|  |             } else { | ||||||
|  |                 console.log('说话时长太短,忽略'); | ||||||
|  |                 // this.updateStatus('等待语音输入...', 'ready');
 | ||||||
|  |                 console.log('等待语音输入...') | ||||||
|  | 
 | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             this.isSpeaking = false; | ||||||
|  |             this.speechStartTime = null; | ||||||
|  |             this.audioBuffer = []; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         if (this.silenceTimer) { | ||||||
|  |             clearTimeout(this.silenceTimer); | ||||||
|  |             this.silenceTimer = null; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 处理音频缓冲区并发送到API
 | ||||||
|  |     async processAudioBuffer() { | ||||||
|  |         if (this.audioBuffer.length === 0) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         try { | ||||||
|  |             // 合并所有音频数据
 | ||||||
|  |             const totalLength = this.audioBuffer.reduce((sum, buffer) => sum + buffer.length, 0); | ||||||
|  |             const combinedBuffer = new Float32Array(totalLength); | ||||||
|  |             let offset = 0; | ||||||
|  |              | ||||||
|  |             for (const buffer of this.audioBuffer) { | ||||||
|  |                 combinedBuffer.set(buffer, offset); | ||||||
|  |                 offset += buffer.length; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 转换为WAV格式并编码为base64
 | ||||||
|  |             const wavBuffer = this.encodeWAV(combinedBuffer, 16000); | ||||||
|  |             const base64Audio = this.arrayBufferToBase64(wavBuffer); | ||||||
|  |              | ||||||
|  |             // 调用ASR API
 | ||||||
|  |             await this.callASRAPI(base64Audio); | ||||||
|  |              | ||||||
|  |         } catch (error) { | ||||||
|  |             console.error('处理音频数据失败:', error); | ||||||
|  |             this.updateStatus('识别失败', 'error'); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 调用ASR API
 | ||||||
|  |     async callASRAPI(base64AudioData) { | ||||||
|  |         try { | ||||||
|  |             const requestBody = { | ||||||
|  |                 user: { | ||||||
|  |                     uid: "1988591469" | ||||||
|  |                 }, | ||||||
|  |                 audio: { | ||||||
|  |                     data: base64AudioData | ||||||
|  |                 }, | ||||||
|  |                 request: { | ||||||
|  |                     model_name: "bigmodel" | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             const response = await fetch(this.apiConfig.url, { | ||||||
|  |                 method: 'POST', | ||||||
|  |                 headers: this.apiConfig.headers, | ||||||
|  |                 body: JSON.stringify(requestBody) | ||||||
|  |             }); | ||||||
|  |              | ||||||
|  |             if (!response.ok) { | ||||||
|  |                 throw new Error(`HTTP error! status: ${response.status}`); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             const result = await response.json(); | ||||||
|  |             this.handleASRResponse(result); | ||||||
|  |              | ||||||
|  |         } catch (error) { | ||||||
|  |             console.error('ASR API调用失败:', error); | ||||||
|  |             this.updateStatus('API调用失败', 'error'); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 处理ASR响应
 | ||||||
|  |     handleASRResponse(response) { | ||||||
|  |         console.log('ASR响应:', response); | ||||||
|  |          | ||||||
|  |         if (response && response.data && response.data.result) { | ||||||
|  |             ASRTEXT = response.data.result; | ||||||
|  |             // this.displayResult(text);
 | ||||||
|  |             // this.updateStatus('识别完成', 'completed');
 | ||||||
|  |             console.log('识别完成') | ||||||
|  |         } else { | ||||||
|  |             console.log('未识别到文字'); | ||||||
|  |             // this.updateStatus('未识别到文字', 'ready');
 | ||||||
|  | 
 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 显示识别结果
 | ||||||
|  |     displayResult(text) { | ||||||
|  |         const resultElement = document.createElement('div'); | ||||||
|  |         resultElement.className = 'result-item'; | ||||||
|  |         resultElement.innerHTML = ` | ||||||
|  |             <span class="timestamp">${new Date().toLocaleTimeString()}</span> | ||||||
|  |             <span class="text">${text}</span> | ||||||
|  |         `;
 | ||||||
|  |         this.resultsDiv.appendChild(resultElement); | ||||||
|  |         this.resultsDiv.scrollTop = this.resultsDiv.scrollHeight; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 更新状态显示
 | ||||||
|  |     updateStatus(message, status) { | ||||||
|  |         this.statusDiv.textContent = message; | ||||||
|  |         this.statusDiv.className = `status ${status}`; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 编码WAV格式
 | ||||||
|  |     encodeWAV(samples, sampleRate) { | ||||||
|  |         const length = samples.length; | ||||||
|  |         const buffer = new ArrayBuffer(44 + length * 2); | ||||||
|  |         const view = new DataView(buffer); | ||||||
|  |          | ||||||
|  |         // WAV文件头
 | ||||||
|  |         const writeString = (offset, string) => { | ||||||
|  |             for (let i = 0; i < string.length; i++) { | ||||||
|  |                 view.setUint8(offset + i, string.charCodeAt(i)); | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         writeString(0, 'RIFF'); | ||||||
|  |         view.setUint32(4, 36 + length * 2, true); | ||||||
|  |         writeString(8, 'WAVE'); | ||||||
|  |         writeString(12, 'fmt '); | ||||||
|  |         view.setUint32(16, 16, true); | ||||||
|  |         view.setUint16(20, 1, true); | ||||||
|  |         view.setUint16(22, 1, true); | ||||||
|  |         view.setUint32(24, sampleRate, true); | ||||||
|  |         view.setUint32(28, sampleRate * 2, true); | ||||||
|  |         view.setUint16(32, 2, true); | ||||||
|  |         view.setUint16(34, 16, true); | ||||||
|  |         writeString(36, 'data'); | ||||||
|  |         view.setUint32(40, length * 2, true); | ||||||
|  |          | ||||||
|  |         // 写入音频数据
 | ||||||
|  |         let offset = 44; | ||||||
|  |         for (let i = 0; i < length; i++) { | ||||||
|  |             const sample = Math.max(-1, Math.min(1, samples[i])); | ||||||
|  |             view.setInt16(offset, sample * 0x7FFF, true); | ||||||
|  |             offset += 2; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         return buffer; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // ArrayBuffer转Base64
 | ||||||
|  |     arrayBufferToBase64(buffer) { | ||||||
|  |         let binary = ''; | ||||||
|  |         const bytes = new Uint8Array(buffer); | ||||||
|  |         for (let i = 0; i < bytes.byteLength; i++) { | ||||||
|  |             binary += String.fromCharCode(bytes[i]); | ||||||
|  |         } | ||||||
|  |         return btoa(binary); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     async startRecording() { | ||||||
|  |         try { | ||||||
|  |             const stream = await navigator.mediaDevices.getUserMedia({ | ||||||
|  |                 audio: { | ||||||
|  |                     sampleRate: 16000, | ||||||
|  |                     channelCount: 1, | ||||||
|  |                     echoCancellation: true, | ||||||
|  |                     noiseSuppression: true | ||||||
|  |                 } | ||||||
|  |             }); | ||||||
|  |              | ||||||
|  |             this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ | ||||||
|  |                 sampleRate: 16000 | ||||||
|  |             }); | ||||||
|  |              | ||||||
|  |             const source = this.audioContext.createMediaStreamSource(stream); | ||||||
|  |             const processor = this.audioContext.createScriptProcessor(4096, 1, 1); | ||||||
|  |              | ||||||
|  |             processor.onaudioprocess = (event) => { | ||||||
|  |                 const inputBuffer = event.inputBuffer; | ||||||
|  |                 const inputData = inputBuffer.getChannelData(0); | ||||||
|  |                  | ||||||
|  |                 // 语音活动检测
 | ||||||
|  |                 if (this.detectVoiceActivity(inputData)) { | ||||||
|  |                     // 如果检测到语音活动,缓存音频数据
 | ||||||
|  |                     this.audioBuffer.push(new Float32Array(inputData)); | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             source.connect(processor); | ||||||
|  |             processor.connect(this.audioContext.destination); | ||||||
|  |              | ||||||
|  |             this.isRecording = true; | ||||||
|  |             this.recordBtn.textContent = '停止录音'; | ||||||
|  |             this.recordBtn.className = 'btn recording'; | ||||||
|  |             // this.updateStatus('等待语音输入...', 'ready');
 | ||||||
|  |              | ||||||
|  |         } catch (error) { | ||||||
|  |             console.error('启动录音失败:', error); | ||||||
|  |             // this.updateStatus('录音启动失败', 'error');
 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     stopRecording() { | ||||||
|  |         if (this.audioContext) { | ||||||
|  |             this.audioContext.close(); | ||||||
|  |             this.audioContext = null; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         if (this.silenceTimer) { | ||||||
|  |             clearTimeout(this.silenceTimer); | ||||||
|  |             this.silenceTimer = null; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // 如果正在说话,处理最后的音频
 | ||||||
|  |         if (this.isSpeaking) { | ||||||
|  |             this.onSpeechEnd(); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         this.isRecording = false; | ||||||
|  |         this.isSpeaking = false; | ||||||
|  |         this.audioBuffer = []; | ||||||
|  |          | ||||||
|  |         this.recordBtn.textContent = '开始录音'; | ||||||
|  |         this.recordBtn.className = 'btn'; | ||||||
|  |         console.log('录音已停止'); | ||||||
|  |         // this.updateStatus('录音已停止', 'stopped');
 | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // 初始化应用
 | ||||||
|  | document.addEventListener('DOMContentLoaded', () => { | ||||||
|  |     const asrRecognizer = new HttpASRRecognizer(); | ||||||
|  |     console.log('HTTP ASR识别器已初始化'); | ||||||
|  | }); | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user