示例

SpeechRecognitionModal.vue 组件
<template><transition name="modal-fade"><div v-if="isOpen" class="modal-overlay" @click.self="handleOverlayClick"><div class="modal-container"><div class="modal-header"><h2>语音输入</h2><button class="close-button" @click="closeModal">×</button></div><div class="modal-body"><div class="status-indicator"><div v-if="isRecording" class="mic-animation"><div class="mic-icon"><svg viewBox="0 0 24 24"><pathd="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z" /></svg></div><div class="sound-wave"><div class="wave"></div><div class="wave"></div><div class="wave"></div></div></div><div v-else class="mic-ready"><svg viewBox="0 0 24 24"><pathd="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z" /></svg></div><p class="status-text">{{ statusText }}</p><div v-if="recognitionError" class="error-message"><svg viewBox="0 0 24 24" class="error-icon"><pathd="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z" /></svg><span>{{ friendlyErrorMessage }}</span></div></div><div class="result-container"><div class="result-content" :class="{ 'has-result': displayText }">{{ displayText }}</div><!-- 声音提示(只在需要时显示) --><div v-if="friendlyErrorMessage &&[3301, 3305, 3312, '-3005', '-3006'].includes(recognitionError)" class="voice-hint">{{ friendlyErrorMessage }}</div></div></div><div class="modal-footer"><button @click="toggleRecording" class="control-button" :class="{ 'listening': isRecording }":disabled="!isBrowserSupported">{{ isRecording ? '停止录音' : '开始录音' }}</button><button @click="confirmResult" class="confirm-button" :disabled="!transcript">使用内容</button></div></div></div></transition>
</template><script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';const props = defineProps({isOpen: {type: Boolean,required: true},baiduConfig: {type: Object,required: true,default: () => ({appid: 0, appkey: '', dev_pid: 15372,format: 'pcm',sample: 16000})}
});const emit = defineEmits(['close', 'confirm']);const isRecording = ref(false);
const transcript = ref('');
const recognitionError = ref(null);
const isBrowserSupported = ref(true);
const interimTranscript = ref('');
let audioContext = null;
let mediaStream = null;
let processor = null;
let socket = null;
const errorMessageMap = {3300: '输入参数不正确',3301: '请提高音量并清晰发音',3302: '鉴权失败,请检查API密钥',3303: '服务器内部错误',3304: 'GPS信息获取失败',3305: '未检测到有效语音',3307: '识别引擎繁忙',3308: '请求超时',3309: '引擎错误',3310: '音频过长(超过60秒)',3311: '音频数据异常',3312: '发音不清晰',3313: '服务不可用',3314: '服务器过载','-3005': '请提高音量并清晰发音', '-3006': '请提高音量并清晰发音', 'no-speech': '未检测到语音,请靠近麦克风说话','audio-capture': '无法访问麦克风','not-allowed': '麦克风权限被拒绝','network': '网络连接失败','default': '识别服务异常'
};
const friendlyErrorMessage = computed(() => {if (!recognitionError.value) return '';if ([3301, 3305, 3312, '-3005', '-3006'].includes(recognitionError.value)) {return '请提高音量并清晰发音';}return errorMessageMap[recognitionError.value] || errorMessageMap['default'];
});const statusText = computed(() => {if (recognitionError.value) return '识别遇到问题';return isRecording.value ? '正在聆听中...' : '点击开始录音按钮';
});
const displayText = computed(() => {let baseText = transcript.value || '';if (interimTranscript.value) {baseText += baseText ? ' ' + interimTranscript.value : interimTranscript.value;}return baseText || '请点击"开始录音"按钮并说话...';
});
const generateRandomId = () => {return Math.random().toString(36).substring(2, 15) +Math.random().toString(36).substring(2, 15);
};
const initRecording = async () => {try {transcript.value = '';interimTranscript.value = '';recognitionError.value = null;mediaStream = await navigator.mediaDevices.getUserMedia({audio: {sampleRate: 16000,channelCount: 1,echoCancellation: false,noiseSuppression: false,autoGainControl: false}});audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 16000});initWebSocket();const source = audioContext.createMediaStreamSource(mediaStream);processor = audioContext.createScriptProcessor(4096, 1, 1);processor.onaudioprocess = (e) => {if (!isRecording.value || !socket || socket.readyState !== WebSocket.OPEN) return;const audioData = e.inputBuffer.getChannelData(0);const pcmData = convertFloat32ToInt16(audioData);socket.send(pcmData);};source.connect(processor);processor.connect(audioContext.destination);isRecording.value = true;} catch (error) {console.error('初始化失败:', error);handleError(error);stopRecording();}
};
const initWebSocket = () => {const cuid = `web_${generateRandomId()}`;const sn = generateRandomId();socket = new WebSocket(`wss://vop.baidu.com/realtime_asr?sn=${sn}`);socket.onopen = () => {const startFrame = {type: "START",data: {...props.baiduConfig,cuid: cuid}};socket.send(JSON.stringify(startFrame));};socket.onmessage = (event) => {try {const data = JSON.parse(event.data);if (data.err_no === 0 || [3301, 3305, 3312, '-3005', '-3006'].includes(data.err_no)) {recognitionError.value = null;}if (data.err_no !== 0) {handleApiError(data);return;}if (data.type === "MID_TEXT") {interimTranscript.value = data.result;} else if (data.type === "FIN_TEXT") {if (data.result) {transcript.value += transcript.value ? '。' + data.result : data.result;}interimTranscript.value = '';}} catch (e) {console.error('解析错误:', e);}};socket.onclose = (event) => {if (isRecording.value) stopRecording();};socket.onerror = (error) => {recognitionError.value = 'network';stopRecording();};
};
const convertFloat32ToInt16 = (buffer) => {const length = buffer.length;const buf = new Int16Array(length);for (let i = 0; i < length; i++) {buf[i] = Math.min(1, buffer[i]) * 32767;}return buf;
};
const handleApiError = (data) => {if (data.err_no === -3005 || data.err_no === -3006) {return;}recognitionError.value = data.err_no || 'service-error';
};const handleError = (error) => {recognitionError.value = error.name === 'NotAllowedError' ?'not-allowed' :error.message.includes('network') ? 'network' : 'audio-capture';
};
const stopRecording = () => {isRecording.value = false;if (socket && socket.readyState === WebSocket.OPEN) {socket.send(JSON.stringify({ type: "FINISH" }));}if (processor) {processor.disconnect();processor = null;}if (audioContext) {audioContext.close().catch(console.error);audioContext = null;}if (mediaStream) {mediaStream.getTracks().forEach(track => track.stop());mediaStream = null;}if (interimTranscript.value) {transcript.value += interimTranscript.value + "\n";interimTranscript.value = '';}
};const toggleRecording = () => {isRecording.value ? stopRecording() : initRecording();
};const closeModal = () => {stopRecording();if (socket) {socket.close();socket = null;}emit('close');
};const confirmResult = () => {emit('confirm', transcript.value);closeModal();
};const handleOverlayClick = (event) => {if (event.target === event.currentTarget) closeModal();
};
onMounted(() => {isBrowserSupported.value = !!navigator.mediaDevices && !!window.WebSocket;
});onBeforeUnmount(() => {stopRecording();if (socket) {socket.close();socket = null;}
});
</script><style scoped>
.modal-overlay {position: fixed;top: 0;left: 0;right: 0;bottom: 0;background-color: rgba(0, 0, 0, 0.5);display: flex;justify-content: center;align-items: center;z-index: 1000;
}.modal-container {background-color: white;border-radius: 12px;box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);width: 90%;max-width: 500px;max-height: 90vh;display: flex;flex-direction: column;overflow: hidden;
}.modal-header {padding: 16px 24px;border-bottom: 1px solid #eee;display: flex;justify-content: space-between;align-items: center;
}.modal-header h2 {margin: 0;font-size: 1.25rem;color: #333;
}.close-button {background: none;border: none;font-size: 1.5rem;cursor: pointer;color: #666;padding: 0;line-height: 1;outline: none;
}.modal-body {padding: 24px;flex: 1;overflow-y: auto;
}.status-indicator {display: flex;flex-direction: column;align-items: center;margin-bottom: 24px;
}.mic-animation {display: flex;align-items: center;gap: 12px;margin-bottom: 8px;
}.mic-icon svg,
.mic-ready svg {width: 36px;height: 36px;fill: #4a6cf7;
}.sound-wave {display: flex;align-items: center;gap: 4px;height: 36px;
}.wave {width: 6px;height: 16px;background-color: #4a6cf7;border-radius: 3px;animation: wave 1.2s infinite ease-in-out;
}.wave:nth-child(1) {animation-delay: -0.6s;
}.wave:nth-child(2) {animation-delay: -0.3s;
}.wave:nth-child(3) {animation-delay: 0s;
}@keyframes wave {0%,60%,100% {transform: scaleY(0.4);}30% {transform: scaleY(1);}
}.mic-ready svg {opacity: 0.7;
}.status-text {margin: 0;color: #666;font-size: 0.9rem;text-align: center;font-weight: 500;color: #333;margin-bottom: 4px;
}.result-container {background-color: #f8f9fa;border-radius: 8px;padding: 16px;min-height: 120px;
}.voice-hint {display: flex;align-items: center;gap: 6px;margin-top: 8px;color: #ff9800;font-size: 0.85rem;
}.result-content {color: #666;font-size: 0.95rem;line-height: 1.5;
}.result-content.has-result {color: #333;
}.info-message {display: flex;align-items: center;gap: 8px;margin-top: 8px;color: #666;font-size: 0.8rem;
}.modal-footer {padding: 16px 24px;border-top: 1px solid #eee;display: flex;justify-content: flex-end;gap: 12px;
}.control-button {padding: 8px 16px;background-color: #f0f2f5;border: none;border-radius: 6px;color: #333;cursor: pointer;font-weight: 500;transition: all 0.2s;
}.control-button.listening {background-color: #ffebee;color: #f44336;
}.control-button:hover {background-color: #e4e6eb;
}.control-button:disabled {background-color: #e0e0e0;color: #9e9e9e;cursor: not-allowed;
}.confirm-button {padding: 8px 16px;background-color: #4a6cf7;border: none;border-radius: 6px;color: white;cursor: pointer;font-weight: 500;transition: background-color 0.2s;
}.confirm-button:hover {background-color: #3a5bd9;
}.confirm-button:disabled {background-color: #cccccc;cursor: not-allowed;
}.error-message {display: flex;align-items: center;justify-content: center;gap: 8px;margin-top: 12px;padding: 8px 12px;background-color: #ffebee;border-radius: 6px;color: #d32f2f;font-size: 0.9rem;
}.error-icon {width: 18px;height: 18px;fill: #d32f2f;
}.modal-fade-enter-active,
.modal-fade-leave-active {transition: opacity 0.3s ease;
}.modal-fade-enter-from,
.modal-fade-leave-to {opacity: 0;
}
</style>
组件使用方法
<SpeechRecognitionModal v-if="showModal" :isOpen="showModal" @close="showModal = false" @confirm="handleRecognitionResult" />const inputText = ref('');
const showModal = ref(false);
const handleRecognitionResult = (text: any) => {inputText.value = text;
};