|
|
@@ -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()
|