|
@@ -0,0 +1,522 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="voice-input-container">
|
|
|
|
|
+ <button
|
|
|
|
|
+ @click="toggleSpeechInput"
|
|
|
|
|
+ class="speech-btn"
|
|
|
|
|
+ :class="{ 'recording': isRecording }"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="waveform-container" v-if="isRecording">
|
|
|
|
|
+ <LiveWaveform
|
|
|
|
|
+ style="min-width: 50px;"
|
|
|
|
|
+ :active="isRecording"
|
|
|
|
|
+ :processing="false"
|
|
|
|
|
+ :height="25"
|
|
|
|
|
+ :barWidth="2"
|
|
|
|
|
+ :barGap="1"
|
|
|
|
|
+ :barRadius="1"
|
|
|
|
|
+ :sensitivity="1.2"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-icon v-if="!isRecording"><Microphone /></el-icon>
|
|
|
|
|
+ <!-- 显示倒计时(仅录音时显示) -->
|
|
|
|
|
+ <span v-if="isRecording" class="countdown-text">{{ countdown }}s</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup>
|
|
|
|
|
+import { ref, onMounted, onUnmounted } from 'vue'
|
|
|
|
|
+import { Microphone } from '@element-plus/icons-vue'
|
|
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
|
|
+import LiveWaveform from './LiveWaveform.vue'
|
|
|
|
|
+import axios from '@/utils/request'
|
|
|
|
|
+
|
|
|
|
|
+// 定义props
|
|
|
|
|
+const props = defineProps({
|
|
|
|
|
+ // 语音识别语言,默认为中文
|
|
|
|
|
+ lang: {
|
|
|
|
|
+ type: String,
|
|
|
|
|
+ default: 'zh-CN'
|
|
|
|
|
+ },
|
|
|
|
|
+ // 最大录音时间,默认为10秒
|
|
|
|
|
+ maxDuration: {
|
|
|
|
|
+ type: Number,
|
|
|
|
|
+ default: 10
|
|
|
|
|
+ },
|
|
|
|
|
+ // 输入框选择器,用于获取输入框元素
|
|
|
|
|
+ inputSelector: {
|
|
|
|
|
+ type: String,
|
|
|
|
|
+ default: 'input[type="text"]'
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// 定义emit事件
|
|
|
|
|
+const emit = defineEmits(['voiceRecognized', 'recordingStatusChanged'])
|
|
|
|
|
+
|
|
|
|
|
+// 语音输入响应式变量
|
|
|
|
|
+const isRecording = ref(false) // 录音状态
|
|
|
|
|
+const recognition = ref(null) // 语音识别实例
|
|
|
|
|
+const countdown = ref(0) // 倒计时剩余秒数
|
|
|
|
|
+const countdownTimer = ref(null) // 倒计时定时器
|
|
|
|
|
+const isBrowserSupported = ref(true) // 浏览器是否支持语音识别
|
|
|
|
|
+const mediaStream = ref(null) // 媒体流引用,用于释放资源
|
|
|
|
|
+const recordingStartText = ref('') // 录音开始时的原始文本
|
|
|
|
|
+const recordingStartCursorPos = ref(0) // 录音开始时的光标位置
|
|
|
|
|
+const sessionId = ref('session_' + Date.now()) // 会话ID
|
|
|
|
|
+const resultPollingInterval = ref(null) // 结果轮询定时器
|
|
|
|
|
+const audioContext = ref(null) // 音频上下文
|
|
|
|
|
+const mediaStreamSource = ref(null) // 媒体流源
|
|
|
|
|
+const scriptProcessor = ref(null) // 脚本处理器
|
|
|
|
|
+
|
|
|
|
|
+// 检测浏览器是否支持语音识别
|
|
|
|
|
+const checkBrowserSupport = () => {
|
|
|
|
|
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
|
|
|
|
|
+ if (!SpeechRecognition) {
|
|
|
|
|
+ ElMessage.warning('当前浏览器不支持语音识别输入功能')
|
|
|
|
|
+ isBrowserSupported.value = false
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ return true
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 初始化语音识别
|
|
|
|
|
+const initSpeechRecognition = () => {
|
|
|
|
|
+ if (!checkBrowserSupport()) {
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
|
|
|
|
|
+
|
|
|
|
|
+ const instance = new SpeechRecognition()
|
|
|
|
|
+ instance.lang = props.lang
|
|
|
|
|
+ instance.interimResults = true//是否返回临时结果
|
|
|
|
|
+ instance.maxAlternatives = 1//返回的最大候选结果数
|
|
|
|
|
+ instance.continuous = true//收录音频时是否连续识别
|
|
|
|
|
+
|
|
|
|
|
+ instance.onresult = (event) => {
|
|
|
|
|
+ // 遍历所有结果,包括临时结果
|
|
|
|
|
+ let fullTranscript = ''
|
|
|
|
|
+ for (let i = 0; i < event.results.length; i++) {
|
|
|
|
|
+ fullTranscript += event.results[i][0].transcript
|
|
|
|
|
+ }
|
|
|
|
|
+ // 只在录音状态下发送识别结果
|
|
|
|
|
+ if (isRecording.value) {
|
|
|
|
|
+ // 计算处理后的文本
|
|
|
|
|
+ let processedText = ''
|
|
|
|
|
+ let cursorPos = 0
|
|
|
|
|
+ if (recordingStartCursorPos.value >= recordingStartText.value.length) {
|
|
|
|
|
+ // 光标位置在文本末尾或找不到输入框时,追加到末尾
|
|
|
|
|
+ processedText = recordingStartText.value + fullTranscript
|
|
|
|
|
+ cursorPos = recordingStartText.value.length + fullTranscript.length
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 光标位置在文本中间时,插入到光标位置
|
|
|
|
|
+ processedText = recordingStartText.value.substring(0, recordingStartCursorPos.value) + fullTranscript + recordingStartText.value.substring(recordingStartCursorPos.value)
|
|
|
|
|
+ cursorPos = recordingStartCursorPos.value + fullTranscript.length
|
|
|
|
|
+ }
|
|
|
|
|
+ // 无论是否是最终结果,实时识别结果
|
|
|
|
|
+ emit('voiceRecognized', {
|
|
|
|
|
+ originalText: fullTranscript,
|
|
|
|
|
+ processedText: processedText,
|
|
|
|
|
+ cursorPos: cursorPos
|
|
|
|
|
+ })
|
|
|
|
|
+ // 打印语音识别结果
|
|
|
|
|
+ console.log('语音输入文字:', fullTranscript)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 识别器真正开始监听时触发
|
|
|
|
|
+ instance.onstart = () => {
|
|
|
|
|
+ console.log('语音识别已开始监听')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 识别器结束时清除定时器
|
|
|
|
|
+ instance.onend = () => {
|
|
|
|
|
+ clearInterval(countdownTimer.value)
|
|
|
|
|
+ isRecording.value = false
|
|
|
|
|
+ countdown.value = 0
|
|
|
|
|
+ emit('recordingStatusChanged', false)
|
|
|
|
|
+ // 释放媒体流资源
|
|
|
|
|
+ if (mediaStream.value) {
|
|
|
|
|
+ mediaStream.value.getTracks().forEach(track => track.stop())
|
|
|
|
|
+ mediaStream.value = null
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ instance.onerror = (event) => {
|
|
|
|
|
+ console.error('语音识别错误:', event.error)
|
|
|
|
|
+ clearInterval(countdownTimer.value) // 出错时清除定时器
|
|
|
|
|
+ isRecording.value = false
|
|
|
|
|
+ emit('recordingStatusChanged', false)
|
|
|
|
|
+ ElMessage.error('语音输入失败,请重试!', true)
|
|
|
|
|
+ countdown.value = 0
|
|
|
|
|
+ // 释放媒体流资源
|
|
|
|
|
+ if (mediaStream.value) {
|
|
|
|
|
+ mediaStream.value.getTracks().forEach(track => track.stop())
|
|
|
|
|
+ mediaStream.value = null
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const startResult = await axios({
|
|
|
|
|
+ url: 'admin/speech/recognition/start?sessionId=' + sessionId.value,
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ data: {}
|
|
|
|
|
+ });
|
|
|
|
|
+ return startResult;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('启动后端识别服务失败:', error);
|
|
|
|
|
+ ElMessage.error('启动语音识别服务失败');
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 停止后端识别服务
|
|
|
|
|
+const stopBackendRecognition = async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const stopResult = await axios({
|
|
|
|
|
+ url: 'admin/speech/recognition/stop?sessionId=' + sessionId.value,
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ data: {}
|
|
|
|
|
+ });
|
|
|
|
|
+ return stopResult;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('停止后端识别服务失败:', error);
|
|
|
|
|
+ return 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'
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ return result;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('发送音频数据失败:', error);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 启动轮询获取识别结果
|
|
|
|
|
+const startResultPolling = () => {
|
|
|
|
|
+ // 每300ms获取一次结果
|
|
|
|
|
+ resultPollingInterval.value = 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) {
|
|
|
|
|
+ // 计算处理后的文本
|
|
|
|
|
+ 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
|
|
|
|
|
+ }
|
|
|
|
|
+ // 发送识别结果
|
|
|
|
|
+ emit('voiceRecognized', {
|
|
|
|
|
+ originalText: result.data.result,
|
|
|
|
|
+ processedText: processedText,
|
|
|
|
|
+ cursorPos: cursorPos
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('获取识别结果失败:', error);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 300);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 停止轮询
|
|
|
|
|
+const stopResultPolling = () => {
|
|
|
|
|
+ if (resultPollingInterval.value) {
|
|
|
|
|
+ clearInterval(resultPollingInterval.value);
|
|
|
|
|
+ resultPollingInterval.value = null;
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 记录录音开始时的输入框状态
|
|
|
|
|
+const recordInputState = () => {
|
|
|
|
|
+ const input = document.querySelector(props.inputSelector)
|
|
|
|
|
+ if (input) {
|
|
|
|
|
+ recordingStartCursorPos.value = input.selectionStart
|
|
|
|
|
+ recordingStartText.value = input.value
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 找不到输入框时,默认在最后面追加
|
|
|
|
|
+ recordingStartCursorPos.value = 999999
|
|
|
|
|
+ recordingStartText.value = ''
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 切换录音状态
|
|
|
|
|
+const toggleSpeechInput = async () => {
|
|
|
|
|
+ // 检查浏览器兼容性
|
|
|
|
|
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
|
|
|
+ ElMessage.error('当前浏览器不支持语音录制功能,请使用现代浏览器如Chrome、Firefox或Edge')
|
|
|
|
|
+ isBrowserSupported.value = false
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 检查是否在安全上下文(HTTPS或localhost)
|
|
|
|
|
+ if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
|
|
|
|
+ ElMessage.error('语音录制功能需要在安全上下文(HTTPS或localhost)中运行')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 无论当前状态如何,先清除可能存在的旧定时器
|
|
|
|
|
+ clearInterval(countdownTimer.value)
|
|
|
|
|
+ countdownTimer.value = null
|
|
|
|
|
+ stopResultPolling()
|
|
|
|
|
+
|
|
|
|
|
+ if (isRecording.value) {
|
|
|
|
|
+ // 手动停止时立即重置状态,确保在所有浏览器中波纹都能立即关闭
|
|
|
|
|
+ isRecording.value = false
|
|
|
|
|
+ countdown.value = 0
|
|
|
|
|
+ emit('recordingStatusChanged', false)
|
|
|
|
|
+
|
|
|
|
|
+ // 停止音频处理
|
|
|
|
|
+ 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
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 停止轮询
|
|
|
|
|
+ stopResultPolling()
|
|
|
|
|
+
|
|
|
|
|
+ // 通知后端停止识别
|
|
|
|
|
+ await stopBackendRecognition()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 生成新的会话ID
|
|
|
|
|
+ sessionId.value = 'session_' + Date.now()
|
|
|
|
|
+
|
|
|
|
|
+ // 记录输入框状态
|
|
|
|
|
+ recordInputState()
|
|
|
|
|
+ // 初始化倒计时前再次清除定时器(防止快速点击)
|
|
|
|
|
+ clearInterval(countdownTimer.value)
|
|
|
|
|
+ countdown.value = props.maxDuration // 设置最大录音时间
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 启动后端识别服务
|
|
|
|
|
+ const startResult = await startBackendRecognition()
|
|
|
|
|
+ if (!startResult || startResult.status !== 'success') {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取麦克风权限
|
|
|
|
|
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
|
|
|
+ mediaStream.value = stream
|
|
|
|
|
+
|
|
|
|
|
+ // 使用Web Audio API捕获音频并转换为PCM格式
|
|
|
|
|
+ audioContext.value = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 })
|
|
|
|
|
+ mediaStreamSource.value = audioContext.value.createMediaStreamSource(stream)
|
|
|
|
|
+ scriptProcessor.value = audioContext.value.createScriptProcessor(4096, 1, 1)
|
|
|
|
|
+
|
|
|
|
|
+ // 处理音频数据
|
|
|
|
|
+ scriptProcessor.value.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.value.connect(scriptProcessor.value)
|
|
|
|
|
+ scriptProcessor.value.connect(audioContext.value.destination)
|
|
|
|
|
+
|
|
|
|
|
+ // 先设置UI状态,让用户知道系统正在准备
|
|
|
|
|
+ isRecording.value = true
|
|
|
|
|
+ emit('recordingStatusChanged', true)
|
|
|
|
|
+
|
|
|
|
|
+ // 启动轮询获取识别结果
|
|
|
|
|
+ startResultPolling()
|
|
|
|
|
+
|
|
|
|
|
+ // 启动倒计时
|
|
|
|
|
+ countdownTimer.value = setInterval(() => {
|
|
|
|
|
+ countdown.value--
|
|
|
|
|
+ if (countdown.value <= 0) {
|
|
|
|
|
+ clearInterval(countdownTimer.value) // 倒计时结束清除
|
|
|
|
|
+ // 停止录音
|
|
|
|
|
+ if (isRecording.value) {
|
|
|
|
|
+ isRecording.value = false
|
|
|
|
|
+ emit('recordingStatusChanged', false)
|
|
|
|
|
+
|
|
|
|
|
+ // 停止音频处理
|
|
|
|
|
+ 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
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 停止轮询
|
|
|
|
|
+ stopResultPolling()
|
|
|
|
|
+
|
|
|
|
|
+ // 通知后端停止识别
|
|
|
|
|
+ stopBackendRecognition()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 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
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 组件卸载时清理资源
|
|
|
|
|
+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
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 通知后端停止识别
|
|
|
|
|
+ stopBackendRecognition()
|
|
|
|
|
+})
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped lang="scss">
|
|
|
|
|
+@use 'sass:math';
|
|
|
|
|
+// 定义rpx转换函数
|
|
|
|
|
+@function rpx($px) {
|
|
|
|
|
+ @return math.div($px, 750) * 100vw;
|
|
|
|
|
+}
|
|
|
|
|
+.voice-input-container {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: rpx(8);
|
|
|
|
|
+}
|
|
|
|
|
+.speech-btn {
|
|
|
|
|
+ padding: rpx(5) rpx(10);
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border: 1px solid #ffce1b;
|
|
|
|
|
+ border-radius: rpx(5);
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: rpx(4);
|
|
|
|
|
+
|
|
|
|
|
+ // 取消点击后的边框
|
|
|
|
|
+ &:focus,
|
|
|
|
|
+ &:focus-visible {
|
|
|
|
|
+ outline: none;
|
|
|
|
|
+ box-shadow: none;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ &.recording {
|
|
|
|
|
+ background: #ffeeba;
|
|
|
|
|
+ border-color: #ffc107;
|
|
|
|
|
+
|
|
|
|
|
+ .el-icon {
|
|
|
|
|
+ color: #dc3545;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .el-icon {
|
|
|
|
|
+ font-size: rpx(8);
|
|
|
|
|
+ color: #666;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+.waveform-container {
|
|
|
|
|
+ // width: 100%;
|
|
|
|
|
+ max-width: rpx(30);
|
|
|
|
|
+}
|
|
|
|
|
+.countdown-text {
|
|
|
|
|
+ font-size: rpx(6);
|
|
|
|
|
+ color: #666;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|