Vue3集成百度实时语音识别

示例

SpeechRecognitionModal.vue 组件

javascript 复制代码
<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">&times;</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">
                  <path
                    d="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">
                <path
                  d="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">
                <path
                  d="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, // 百度云控制台应用 appid
      appkey: '', // api key
      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 || '请点击"开始录音"按钮并说话...';
});

// 生成随机ID函数
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;

    // 1. 获取麦克风权限
    mediaStream = await navigator.mediaDevices.getUserMedia({
      audio: {
        sampleRate: 16000,
        channelCount: 1,
        echoCancellation: false,
        noiseSuppression: false,
        autoGainControl: false
      }
    });

    // 2. 初始化音频上下文
    audioContext = new (window.AudioContext || window.webkitAudioContext)({
      sampleRate: 16000
    });

    // 3. 建立WebSocket连接
    initWebSocket();

    // 4. 创建音频处理器
    const source = audioContext.createMediaStreamSource(mediaStream);
    processor = audioContext.createScriptProcessor(4096, 1, 1);

    processor.onaudioprocess = (e) => {
      if (!isRecording.value || !socket || socket.readyState !== WebSocket.OPEN) return;

      // 获取音频数据并转换为16位PCM
      const audioData = e.inputBuffer.getChannelData(0);
      const pcmData = convertFloat32ToInt16(audioData);

      // 发送音频数据帧 (Binary帧)
      socket.send(pcmData);
    };

    source.connect(processor);
    processor.connect(audioContext.destination);

    isRecording.value = true;

  } catch (error) {
    console.error('初始化失败:', error);
    handleError(error);
    stopRecording();
  }
};

// 初始化WebSocket连接
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();
  };
};

// 将Float32转换为Int16
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) {
    // console.log('语音检测提示:', data.err_msg);
    return;
  }

  recognitionError.value = data.err_no || 'service-error';
  // console.error('识别错误:', data.err_msg || '未知错误');
};

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>

组件使用方法

javascript 复制代码
<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;
};
相关推荐
量子位12 分钟前
北大团队引领 3D 生成与对齐革新:OctGPT 打破扩散模型垄断
人工智能·gpt·aigc
京东零售技术15 分钟前
生成式 AI 引爆广告效率革命,揭秘京东大模型应用架构的实践之道
人工智能
量子位21 分钟前
华人横扫 ICLR 2025 杰出论文奖,三篇均为华人一作,中科大何向南团队 / 清华姚班北大校友在列
人工智能
量子位21 分钟前
无需数据标注!测试时强化学习,模型数学能力暴增 | 清华 & 上海 AI Lab
人工智能·gitlab·aigc
硅谷秋水24 分钟前
UniOcc:自动驾驶占用预测和预报的统一基准
人工智能·深度学习·机器学习·计算机视觉·自动驾驶
Dm_dotnet26 分钟前
使用CAMEL实现Graph RAG过程记录
人工智能
BuluAI算力云28 分钟前
Second Me重磅升级:全平台Docker部署+OpenAI协议兼容
人工智能
Kenley31 分钟前
MTP(Multi-Token Prediction)
人工智能
链诸葛33 分钟前
Smart AI:在AI浪潮中崛起的智能NFT生态革命者
人工智能
京东零售技术34 分钟前
打破"沙漏“现象→提高生成式搜索/推荐的上限
人工智能