Kaynağa Gözat

Merge branch 'master' of http://59.110.91.129:3000/zhangmengying/AIClass into wanzi

丸子 1 ay önce
ebeveyn
işleme
bae14eb3d2

+ 1 - 1
src/components/ai/image/ImageToImage.vue

@@ -137,7 +137,7 @@
 <script setup>
 import {ref, onMounted, onUnmounted, defineEmits, computed} from 'vue'
 import {AiImageStatusEnum, CreatePainting, PaintingGetMys} from '@/api/questions.js'
-import VoiceInput from '../voice/VoiceInput.vue'
+import VoiceInput from '../voice/VoiceInput_Api.vue'
 
 // 导入全局状态
 import { globalState } from '@/utils/globalState.js'

+ 1 - 1
src/components/ai/image/TextToImage.vue

@@ -146,7 +146,7 @@ import demo4 from '@/assets/images/ai-demo/ai-image-demo4.png'
 
 // 导入全局状态
 import { globalState } from '@/utils/globalState.js'
-import VoiceInput from '../voice/VoiceInput.vue'
+import VoiceInput from '../voice/VoiceInput_Api.vue'
 // 终止按钮
 import stopicon from "@/assets/icon/stopicon.png";
 

+ 2 - 2
src/components/ai/text/TextToText.vue

@@ -42,7 +42,7 @@
                 @keyup.enter="handleSendByKeydown"
               />
                 <!-- 语音输入按钮 -->
-              <VoiceInput
+              <VoiceInputApi
                 inputSelector="input[type='text']"
                 lang="zh-CN"
                 maxDuration="10"
@@ -80,7 +80,7 @@ import { teacherList } from '@/api/teachers.js'
 // 导入全局状态
 import { globalState } from "@/utils/globalState.js";
 
-import VoiceInput from '../voice/VoiceInput2.vue'
+import VoiceInputApi from '../voice/VoiceInput_Api.vue'
 
 
 // 终止按钮

+ 1 - 1
src/components/ai/video/ImageToVideo.vue

@@ -104,7 +104,7 @@ import {ref, onMounted, onUnmounted, defineEmits, computed} from 'vue'
 import {AiImageStatusEnum, CreateVideo, VideoGetMys} from '@/api/questions.js'
 import { useRouter } from 'vue-router'
 
-import VoiceInput from '../voice/VoiceInput.vue'
+import VoiceInput from '../voice/VoiceInput_Api.vue'
 
 // 导入全局状态
 import { globalState } from '@/utils/globalState.js'

+ 1 - 1
src/components/ai/vision/VisionThink.vue

@@ -106,7 +106,7 @@ import {ref, onMounted, defineEmits, computed} from 'vue'
 import {VisionThink} from '@/api/questions.js'
 import { useRouter } from 'vue-router'
 
-import VoiceInput from '../voice/VoiceInput.vue'
+import VoiceInput from '../voice/VoiceInput_Api.vue'
 
 // 导入全局状态
 import { globalState } from '@/utils/globalState.js'

+ 156 - 135
src/components/ai/voice/VoiceInput2.vue → src/components/ai/voice/VoiceInput_Api.vue

@@ -68,6 +68,65 @@ const resultPollingInterval = ref(null) // 结果轮询定时器
 const audioContext = ref(null) // 音频上下文
 const mediaStreamSource = ref(null) // 媒体流源
 const scriptProcessor = ref(null) // 脚本处理器
+const audioDataBuffer = ref([]) // 音频数据缓冲区
+const MAX_BUFFER_SIZE = 1024 * 10 // 10KB
+let batchSendTimer = null // 批量发送定时器
+
+// 提取资源释放函数
+const releaseResources = () => {
+  // 停止音频处理
+  if (scriptProcessor.value) {
+    if (scriptProcessor.value.port) {
+      scriptProcessor.value.port.close();
+    }
+    scriptProcessor.value.disconnect();
+  }
+  if (mediaStreamSource.value) {
+    mediaStreamSource.value.disconnect();
+  }
+  if (audioContext.value) {
+    audioContext.value.close();
+  }
+  
+  // 释放媒体流资源
+  if (mediaStream.value) {
+    mediaStream.value.getTracks().forEach(track => track.stop())
+    mediaStream.value = null
+  }
+  
+  // 停止轮询
+  stopResultPolling()
+}
+
+// 重置录音状态
+const resetRecordingState = () => {
+  isRecording.value = false
+  countdown.value = 0
+  emit('recordingStatusChanged', false)
+  releaseResources()
+}
+
+// 增强错误处理
+const handleError = (error, context) => {
+  console.error(`${context}失败:`, error);
+  
+  let errorMessage = '语音识别服务出现错误,请重试';
+  
+  if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
+    errorMessage = '麦克风权限被拒绝,请允许麦克风访问';
+  } else if (error.name === 'NotFoundError') {
+    errorMessage = '未找到麦克风设备';
+  } else if (error.name === 'NotReadableError') {
+    errorMessage = '麦克风被其他应用占用';
+  } else if (error.response) {
+    errorMessage = error.response.data?.message || '后端服务错误';
+  }
+  
+  ElMessage.error(errorMessage);
+  
+  // 重置状态
+  resetRecordingState();
+}
 
 // 检测浏览器是否支持语音识别
 const checkBrowserSupport = () => {
@@ -160,23 +219,6 @@ const initSpeechRecognition = () => {
   return instance
 }
 
-// 获取浏览器信息
-const getBrowserInfo = () => {
-  const userAgent = window.navigator.userAgent.toLowerCase()
-  if (userAgent.includes('edg')) {
-    return { name: 'Edge', version: (userAgent.match(/edg\/([\d.]+)/) || [])[1] }
-  }
-  if (userAgent.includes('chrome')) {
-    return { name: 'Chrome', version: (userAgent.match(/chrome\/([\d.]+)/) || [])[1] }
-  }
-  if (userAgent.includes('firefox')) {
-    return { name: 'Firefox', version: (userAgent.match(/firefox\/([\d.]+)/) || [])[1] }
-  }
-  if (userAgent.includes('safari')) {
-    return { name: 'Safari', version: (userAgent.match(/version\/([\d.]+)/) || [])[1] }
-  }
-  return { name: 'Unknown', version: 'Unknown' }
-}
 
 // 启动后端识别服务
 const startBackendRecognition = async () => {
@@ -204,32 +246,81 @@ const stopBackendRecognition = async () => {
     });
     return stopResult;
   } catch (error) {
-    console.error('停止后端识别服务失败:', error);
+    handleError(error, '停止后端识别服务');
     return null;
   }
 }
 
-// 发送音频数据到后端
-const sendAudioData = async (arrayBuffer) => {
+// 批量发送音频数据
+const sendAudioDataBatch = async () => {
+  if (audioDataBuffer.value.length === 0) return
+  
+  const batchData = new Int16Array(audioDataBuffer.value.length)
+  for (let i = 0; i < audioDataBuffer.value.length; i++) {
+    batchData[i] = audioDataBuffer.value[i]
+  }
+  audioDataBuffer.value = []
+  
   try {
     const result = await axios({
       url: 'admin/speech/recognition/send?sessionId=' + sessionId.value,
       method: 'POST',
-      data: arrayBuffer,
+      data: batchData.buffer,
       headers: {
         'Content-Type': 'application/octet-stream'
       }
     });
     return result;
   } catch (error) {
-    console.error('发送音频数据失败:', error);
+    handleError(error, '发送音频数据');
     return null;
   }
 }
 
+// 发送音频数据到后端
+const sendAudioData = async (arrayBuffer) => {
+  // 将音频数据添加到缓冲区
+  const data = new Int16Array(arrayBuffer)
+  for (let i = 0; i < data.length; i++) {
+    audioDataBuffer.value.push(data[i])
+  }
+  
+  // 如果缓冲区达到阈值,立即发送
+  if (audioDataBuffer.value.length >= MAX_BUFFER_SIZE) {
+    await sendAudioDataBatch()
+  }
+}
+
+// 动态调整轮询频率
+const adjustPollingInterval = (baseInterval = 300) => {
+  if (navigator.connection) {
+    const { effectiveType } = navigator.connection;
+    switch (effectiveType) {
+      case '4g':
+        return Math.max(100, baseInterval * 0.5);
+      case '3g':
+        return baseInterval;
+      case '2g':
+        return baseInterval * 2;
+      default:
+        return baseInterval;
+    }
+  }
+  return baseInterval;
+}
+
+// 存储当前识别结果
+const currentRecognitionResult = ref('')
+
 // 启动轮询获取识别结果
 const startResultPolling = () => {
-  // 每300ms获取一次结果
+  // 重置当前识别结果
+  currentRecognitionResult.value = ''
+  
+  // 动态调整轮询频率
+  const pollingInterval = adjustPollingInterval()
+  
+  // 每pollingInterval获取一次结果
   resultPollingInterval.value = setInterval(async () => {
     try {
       const result = await axios({
@@ -237,30 +328,39 @@ const startResultPolling = () => {
         method: 'GET'
       });
 
-      if (result.status === 'success' && result.data && result.data.result) {
-        // 计算处理后的文本
-        let processedText = ''
-        let cursorPos = 0
-        if (recordingStartCursorPos.value >= recordingStartText.value.length) {
-          // 光标位置在文本末尾或找不到输入框时,追加到末尾
-          processedText = recordingStartText.value + result.data.result
-          cursorPos = recordingStartText.value.length + result.data.result.length
-        } else {
-          // 光标位置在文本中间时,插入到光标位置
-          processedText = recordingStartText.value.substring(0, recordingStartCursorPos.value) + result.data.result + recordingStartText.value.substring(recordingStartCursorPos.value)
-          cursorPos = recordingStartCursorPos.value + result.data.result.length
+      if (result.code === 0 && result.data) {
+        // 获取后端返回的结果
+        const newResult = result.data.result || ''
+
+        // 只有当结果发生变化时才更新和发送事件
+        if (newResult !== currentRecognitionResult.value) {
+          // 更新当前识别结果
+          currentRecognitionResult.value = newResult
+
+          // 计算处理后的文本
+          let processedText = ''
+          let cursorPos = 0
+          if (recordingStartCursorPos.value >= recordingStartText.value.length) {
+            // 光标位置在文本末尾或找不到输入框时,追加到末尾
+            processedText = recordingStartText.value + currentRecognitionResult.value
+            cursorPos = recordingStartText.value.length + currentRecognitionResult.value.length
+          } else {
+            // 光标位置在文本中间时,插入到光标位置
+            processedText = recordingStartText.value.substring(0, recordingStartCursorPos.value) + currentRecognitionResult.value + recordingStartText.value.substring(recordingStartCursorPos.value)
+            cursorPos = recordingStartCursorPos.value + currentRecognitionResult.value.length
+          }
+          // 发送识别结果
+          emit('voiceRecognized', {
+            originalText: currentRecognitionResult.value,
+            processedText: processedText,
+            cursorPos: cursorPos
+          })
         }
-        // 发送识别结果
-        emit('voiceRecognized', {
-          originalText: result.data.result,
-          processedText: processedText,
-          cursorPos: cursorPos
-        })
       }
     } catch (error) {
-      console.error('获取识别结果失败:', error);
+      handleError(error, '获取识别结果');
     }
-  }, 300);
+  }, pollingInterval);
 };
 
 // 停止轮询
@@ -269,6 +369,10 @@ const stopResultPolling = () => {
     clearInterval(resultPollingInterval.value);
     resultPollingInterval.value = null;
   }
+  if (batchSendTimer) {
+    clearInterval(batchSendTimer);
+    batchSendTimer = null;
+  }
 };
 
 // 音频重采样函数
@@ -346,39 +450,13 @@ const toggleSpeechInput = async () => {
 
   if (isRecording.value) {
     // 手动停止时立即重置状态,确保在所有浏览器中波纹都能立即关闭
-    isRecording.value = false
-    countdown.value = 0
-    emit('recordingStatusChanged', false)
-    
-    // 停止音频处理
-    if (scriptProcessor.value) {
-      // 对于AudioWorkletNode,需要先关闭端口
-      if (scriptProcessor.value.port) {
-        scriptProcessor.value.port.close();
-      }
-      scriptProcessor.value.disconnect();
-    }
-    if (mediaStreamSource.value) {
-      mediaStreamSource.value.disconnect();
-    }
-    if (audioContext.value) {
-      audioContext.value.close();
-    }
-    
-    // 释放媒体流资源
-    if (mediaStream.value) {
-      mediaStream.value.getTracks().forEach(track => track.stop())
-      mediaStream.value = null
-    }
-    
-    // 停止轮询
-    stopResultPolling()
+    resetRecordingState()
     
     // 通知后端停止识别
     await stopBackendRecognition()
   } else {
     // 生成新的会话ID
-    sessionId.value = 'session_' + Date.now()
+    sessionId.value = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
     
     // 记录输入框状态
     recordInputState()
@@ -389,7 +467,7 @@ const toggleSpeechInput = async () => {
     try {
       // 启动后端识别服务
       const startResult = await startBackendRecognition()
-      if (!startResult || startResult.status !== 'success') {
+      if (!startResult || startResult.code !== 0) {
         return
       }
 
@@ -414,6 +492,9 @@ const toggleSpeechInput = async () => {
       // 检查实际采样率
       console.log('AudioContext采样率:', audioContext.value.sampleRate)
       
+      // 启动批量发送定时器
+      batchSendTimer = setInterval(sendAudioDataBatch, 500)
+      
       // 尝试使用AudioWorkletNode(现代浏览器推荐),如果不支持则回退到ScriptProcessorNode
       if (audioContext.value.audioWorklet && window.AudioWorkletNode) {
         try {
@@ -498,32 +579,7 @@ const toggleSpeechInput = async () => {
           clearInterval(countdownTimer.value) // 倒计时结束清除
           // 停止录音
           if (isRecording.value) {
-            isRecording.value = false
-            emit('recordingStatusChanged', false)
-            
-            // 停止音频处理
-            if (scriptProcessor.value) {
-              // 对于AudioWorkletNode,需要先关闭端口
-              if (scriptProcessor.value.port) {
-                scriptProcessor.value.port.close();
-              }
-              scriptProcessor.value.disconnect();
-            }
-            if (mediaStreamSource.value) {
-              mediaStreamSource.value.disconnect();
-            }
-            if (audioContext.value) {
-              audioContext.value.close();
-            }
-            
-            // 释放媒体流资源
-            if (mediaStream.value) {
-              mediaStream.value.getTracks().forEach(track => track.stop())
-              mediaStream.value = null
-            }
-            
-            // 停止轮询
-            stopResultPolling()
+            resetRecordingState()
             
             // 通知后端停止识别
             stopBackendRecognition()
@@ -531,26 +587,7 @@ const toggleSpeechInput = async () => {
         }
       }, 1000)
     } catch (err) {
-      console.error('麦克风权限获取失败:', err)
-      if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
-        ElMessage.warning('麦克风权限被拒绝,请允许麦克风访问')
-      } else if (err.name === 'NotFoundError') {
-        ElMessage.warning('未找到麦克风设备')
-      } else if (err.name === 'NotReadableError') {
-        ElMessage.warning('麦克风被其他应用占用')
-      } else {
-        ElMessage.warning('启动录音失败,请检查浏览器权限设置')
-      }
-      // 出错时重置状态
-      isRecording.value = false
-      emit('recordingStatusChanged', false)
-      countdown.value = 0
-      
-      // 释放资源
-      if (mediaStream.value) {
-        mediaStream.value.getTracks().forEach(track => track.stop())
-        mediaStream.value = null
-      }
+      handleError(err, '麦克风权限获取');
     }
   }
 }
@@ -559,23 +596,7 @@ const toggleSpeechInput = async () => {
 onUnmounted(() => {
   clearInterval(countdownTimer.value)
   stopResultPolling()
-  
-  // 停止音频处理
-  if (scriptProcessor.value) {
-    scriptProcessor.value.disconnect()
-  }
-  if (mediaStreamSource.value) {
-    mediaStreamSource.value.disconnect()
-  }
-  if (audioContext.value) {
-    audioContext.value.close()
-  }
-  
-  // 释放媒体流资源
-  if (mediaStream.value) {
-    mediaStream.value.getTracks().forEach(track => track.stop())
-    mediaStream.value = null
-  }
+  releaseResources()
   
   // 通知后端停止识别
   stopBackendRecognition()

+ 0 - 1
src/views/AIPage/AIGeneralCourse.vue

@@ -172,7 +172,6 @@ import teachingImg from '@/assets/icon/teaching.png'
 import SelfDirectedLearning from '@/components/study/SelfDirectedLearning.vue'
 import DialogContent from "@/views/AIPage/aiGenerate/DialogContent.vue";
 import {teacherList} from "@/api/teachers.js";
-import SpeechRecognition from "@/views/AIPage/aiGenerate/SpeechRecognition.vue";
 
 const router = useRouter() // 获取当前路由对象
 // 下拉菜单选中项

+ 0 - 234
src/views/AIPage/aiGenerate/SpeechRecognition.vue

@@ -1,234 +0,0 @@
-<template>
-  <div class="speech-recognition">
-    <h2>实时语音识别</h2>
-
-    <div class="controls">
-      <button @click="startRecording" :disabled="isRecording">开始录音</button>
-      <button @click="stopRecording" :disabled="!isRecording">停止录音</button>
-    </div>
-
-    <div class="status">
-      <p>状态: {{ recordingStatus }}</p>
-    </div>
-
-    <div class="result">
-      <h3>识别结果</h3>
-      <div class="result-content">{{ recognitionResult }}</div>
-    </div>
-  </div>
-</template>
-
-<script setup>
-import { ref, onMounted, onUnmounted } from 'vue';
-import axios from '@/utils/request';
-
-const isRecording = ref(false);
-const recordingStatus = ref('就绪');
-const recognitionResult = ref('');
-const mediaRecorder = ref(null);
-const audioChunks = ref([]);
-const sessionId = ref('session_' + Date.now());
-let resultPollingInterval = null;
-
-// 开始录音
-const startRecording = async () => {
-  try {
-    // 检查浏览器兼容性
-    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
-      recordingStatus.value = '当前浏览器不支持语音录制功能';
-      return;
-    }
-
-    // 启动后端识别服务
-    const startResult = await axios({
-      url: 'admin/speech/recognition/start?sessionId=' + sessionId.value,
-      method: 'POST',
-      data: {}
-    });
-
-    if (startResult.status === 'success') {
-      // 获取麦克风权限
-      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
-
-      // 使用Web Audio API捕获音频并转换为PCM格式
-      const audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
-      const mediaStreamSource = audioContext.createMediaStreamSource(stream);
-      const scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
-
-      // 处理音频数据
-      scriptProcessor.onaudioprocess = async (event) => {
-        const inputData = event.inputBuffer.getChannelData(0);
-        // 转换为16位PCM
-        const pcmData = new Int16Array(inputData.length);
-        for (let i = 0; i < inputData.length; i++) {
-          pcmData[i] = inputData[i] * 32768;
-        }
-        // 发送到后端
-        await sendAudioData(pcmData.buffer);
-      };
-
-      // 连接音频节点
-      mediaStreamSource.connect(scriptProcessor);
-      scriptProcessor.connect(audioContext.destination);
-
-      // 存储音频上下文和处理器以便停止
-      mediaRecorder.value = {
-        stop: () => {
-          scriptProcessor.disconnect();
-          mediaStreamSource.disconnect();
-          audioContext.close();
-          stream.getTracks().forEach(track => track.stop());
-        },
-        state: 'recording'
-      };
-      isRecording.value = true;
-      recordingStatus.value = '录音中...';
-
-      // 启动轮询获取识别结果
-      startResultPolling();
-    } else {
-      recordingStatus.value = '启动识别服务失败';
-    }
-  } catch (error) {
-    console.error('启动录音失败:', error);
-    if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
-      recordingStatus.value = '麦克风权限被拒绝,请允许麦克风访问';
-    } else if (error.name === 'NotFoundError') {
-      recordingStatus.value = '未找到麦克风设备';
-    } else {
-      recordingStatus.value = '启动录音失败';
-    }
-  }
-};
-
-// 停止录音
-const stopRecording = async () => {
-  if (mediaRecorder.value && mediaRecorder.value.state === 'recording') {
-    mediaRecorder.value.stop();
-    isRecording.value = false;
-    recordingStatus.value = '录音已停止';
-
-    // 停止轮询
-    stopResultPolling();
-
-    // 通知后端停止识别
-    const stopResult = await axios({
-      url: 'admin/speech/recognition/stop?sessionId=' + sessionId.value,
-      method: 'POST',
-      data: {}
-    });
-
-    if (stopResult.status !== 'success') {
-      recordingStatus.value = '停止识别服务失败';
-    }
-  }
-};
-
-// 启动轮询获取识别结果
-const startResultPolling = () => {
-  // 每300ms获取一次结果
-  resultPollingInterval = setInterval(async () => {
-    try {
-      const result = await axios({
-        url: 'admin/speech/recognition/result?sessionId=' + sessionId.value,
-        method: 'GET'
-      });
-
-      if (result.status === 'success' && result.data && result.data.result) {
-        recognitionResult.value = result.data.result;
-      }
-    } catch (error) {
-      console.error('获取识别结果失败:', error);
-    }
-  }, 300);
-};
-
-// 停止轮询
-const stopResultPolling = () => {
-  if (resultPollingInterval) {
-    clearInterval(resultPollingInterval);
-    resultPollingInterval = null;
-  }
-};
-
-// 发送音频数据到后端
-const sendAudioData = async (arrayBuffer) => {
-  try {
-    // 发送到后端
-    const result = await axios({
-      url: 'admin/speech/recognition/send?sessionId=' + sessionId.value,
-      method: 'POST',
-      data: arrayBuffer,
-      headers: {
-        'Content-Type': 'application/octet-stream'
-      }
-    });
-    if (result.status !== 'success') {
-      console.error('发送音频数据失败:', result.message);
-    }
-  } catch (error) {
-    console.error('处理音频数据失败:', error);
-  }
-};
-
-// 清理资源
-onUnmounted(() => {
-  if (mediaRecorder.value) {
-    mediaRecorder.value.stop();
-  }
-});
-</script>
-
-<style scoped>
-.speech-recognition {
-  max-width: 800px;
-  margin: 0 auto;
-  padding: 20px;
-  background: #f5f5f5;
-  border-radius: 8px;
-  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
-}
-
-.controls {
-  margin: 20px 0;
-}
-
-button {
-  padding: 10px 20px;
-  margin-right: 10px;
-  border: none;
-  border-radius: 4px;
-  background: #4CAF50;
-  color: white;
-  cursor: pointer;
-  font-size: 16px;
-}
-
-button:disabled {
-  background: #cccccc;
-  cursor: not-allowed;
-}
-
-button:nth-child(2) {
-  background: #f44336;
-}
-
-.status {
-  margin: 20px 0;
-  padding: 10px;
-  background: #e3f2fd;
-  border-radius: 4px;
-}
-
-.result {
-  margin: 20px 0;
-}
-
-.result-content {
-  padding: 15px;
-  background: white;
-  border-radius: 4px;
-  min-height: 100px;
-  white-space: pre-wrap;
-}
-</style>