Vue3 实时音频录制与转写 Composable 技术实现

Vue3 实时音频录制与转写 Composable 技术实现

前言

本文介绍如何基于 Vue3 Composition API 实现一个实时音频录制与转写的 Composable,涉及 Web Audio API、WebSocket 实时通信、音频格式转换等技术。

技术栈

  • Vue3 Composition API: 组合式函数封装
  • MediaRecorder API: 浏览器音频录制
  • Web Audio API: 音频流处理与格式转换
  • WebSocket: 实时双向通信
  • TypeScript: 类型安全

核心功能

  1. 实时音频录制(支持暂停/继续/停止)
  2. 音频流实时处理与传输
  3. WebSocket 实时通信接收转写结果
  4. 实时字幕逐句显示
  5. 字幕定时保存机制

技术架构

text 复制代码
┌─────────────────────────────────────────┐
│      useAudioRecorder Composable       │
├─────────────────────────────────────────┤
│  1. 音频录制层 (MediaRecorder)          │
│  2. 音频处理层 (Web Audio API)          │
│  3. 实时通信层 (WebSocket)              │
│  4. 字幕处理层 (文本处理)                │
│  5. 数据持久化层 (定时保存)              │
└─────────────────────────────────────────┘

一、音频录制实现

1.1 获取麦克风权限

typescript 复制代码
// 获取用户麦克风权限
audioStream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,    // 回声消除
    noiseSuppression: true,    // 降噪
    autoGainControl: true,     // 自动增益控制
  },
});

技术要点

  • getUserMedia 返回 MediaStream 对象
  • 音频约束配置优化录音质量
  • 需要用户授权,需要处理权限拒绝情况

1.2 创建 MediaRecorder

typescript 复制代码
// 检测浏览器支持的音频格式
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
  ? 'audio/webm;codecs=opus'  // 优先使用 Opus 编码
  : 'audio/webm';              // 降级方案

// 创建 MediaRecorder 实例
mediaRecorder = new MediaRecorder(audioStream, { mimeType });

// 监听数据可用事件
mediaRecorder.ondataavailable = (event) => {
  if (event.data.size > 0) {
    audioChunks.push(event.data);  // 收集音频块
  }
};

// 开始录制(每3秒触发一次 ondataavailable)
mediaRecorder.start(3000);

技术要点

  • MediaRecorder 用于录制音频流
  • start(timeslice) 参数控制数据分片间隔
  • 音频数据以 Blob 格式存储

二、音频处理与格式转换

2.1 双音频流架构

为了实现录音保存和实时转写并行,需要创建两个独立的音频上下文:

typescript 复制代码
// 音频流1:用于录音保存(原始采样率)
audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;  // 用于音频可视化
audioContext.createMediaStreamSource(audioStream).connect(analyser);

// 音频流2:用于实时转写(16000Hz采样率)
asrAudioContext = new AudioContext({ sampleRate: 16000 });

为什么需要双流?

  • 录音保存需要高质量(原始采样率)
  • 实时转写需要标准采样率(16000Hz,ASR 标准)
  • 两个流互不干扰,独立处理

2.2 音频格式转换

Web Audio API 返回的是 Float32Array(范围 -1.0 到 1.0),而 ASR 服务通常需要 16 位 PCM 格式(范围 -32768 到 32767)。

typescript 复制代码
/**
 * 将 Float32 音频转换为 16 位 PCM
 * @param input Float32Array 音频数据
 * @returns Uint8Array 16位PCM数据
 */
const convertTo16BitPCM = (input: Float32Array): Uint8Array => {
  const buffer = new ArrayBuffer(input.length * 2);  // 16位 = 2字节
  const view = new DataView(buffer);
  
  input.forEach((sample, i) => {
    // 限制范围到 [-1, 1]
    const value = Math.max(-1, Math.min(1, sample));
    
    // 转换为16位整数
    // 负数: value * 0x8000 = -32768
    // 正数: value * 0x7FFF = 32767
    view.setInt16(
      i * 2, 
      value < 0 ? value * 0x8000 : value * 0x7FFF, 
      true  // little-endian 字节序
    );
  });
  
  return new Uint8Array(buffer);
};

转换原理

  • Float32: 32位浮点数,范围 [-1.0, 1.0]
  • 16位PCM: 16位整数,范围 [-32768, 32767]
  • 使用线性映射:PCM = Float32 × 32767(正数)或 Float32 × 32768(负数)

2.3 音频分块处理

使用 ScriptProcessorNode 处理音频流,并按固定大小分块发送:

typescript 复制代码
// 常量定义
const PCM_CHUNK_SIZE = 6400;  // 200ms音频数据
// 计算:16000Hz × 0.2s × 2字节 = 6400字节

const ASR_SAMPLE_RATE = 16000;  // ASR标准采样率

// 设置音频处理器
const setupAudioProcessor = () => {
  if (!wsClient?.isConnected() || !asrAudioContext || !audioStream) return;
  
  // 清空缓冲区
  audioBuffer = [];
  
  // 创建音频源
  const asrSource = asrAudioContext.createMediaStreamSource(audioStream);
  
  // 创建脚本处理器(已废弃但兼容性好)
  // 参数:bufferSize, numberOfInputChannels, numberOfOutputChannels
  audioProcessor = asrAudioContext.createScriptProcessor(4096, 1, 1);
  
  // 连接音频节点
  asrSource.connect(audioProcessor);
  audioProcessor.connect(asrAudioContext.destination);
  
  // 处理音频数据
  audioProcessor.onaudioprocess = (e) => {
    if (!wsClient?.isConnected()) return;
    
    // 1. 获取输入音频数据(Float32格式)
    const inputData = e.inputBuffer.getChannelData(0);
    
    // 2. 转换为16位PCM
    const pcmData = convertTo16BitPCM(inputData);
    
    // 3. 累积到缓冲区
    audioBuffer.push(...pcmData);
    
    // 4. 达到200ms数据量时发送
    if (audioBuffer.length >= PCM_CHUNK_SIZE) {
      const chunk = new Uint8Array(
        audioBuffer.slice(0, PCM_CHUNK_SIZE)
      );
      
      // 5. 通过WebSocket发送(二进制数据)
      wsClient.send(chunk, false);  // false: 不加入消息队列
      
      // 6. 移除已发送的数据
      audioBuffer = audioBuffer.slice(PCM_CHUNK_SIZE);
    }
  };
};

分块策略

  • 块大小: 6400字节 = 200ms音频
  • 为什么200ms : 平衡实时性和网络效率
    • 太小:网络请求频繁,增加延迟
    • 太大:实时性差,用户体验不佳
  • 缓冲区管理: 使用数组累积,达到阈值后发送并清空

三、WebSocket 实时通信

3.1 连接建立

typescript 复制代码
/**
 * 连接WebSocket接收实时转写结果
 */
const connectWebSocket = async (recordId: string) => {
  try {
    const wsUrl = `wss://your-server.com/api/asr/realtime`;
    const token = getAuthToken();  // 获取认证token
    
    // 使用封装的WebSocket客户端 你自己可以封装一个WebSocket的工具类
    wsClient = createWebSocket({
      url: wsUrl,
      binaryType: 'arraybuffer',      // 支持二进制数据
      heartbeatInterval: 30000,       // 30秒心跳
      reconnectInterval: 3000,        // 3秒重连间隔
      maxReconnectAttempts: 10,       // 最多重连10次
      headers: {
        Authorization: token ? `Bearer ${token}` : '',
      },
    }, {
      onOpen: () => {
        console.log('WebSocket连接成功');
      },
      onMessage: (message) => {
        handleTranscriptionMessage(message);
      },
      onError: (event) => {
        console.error('WebSocket错误:', event);
      },
      onClose: () => {
        console.log('WebSocket连接关闭');
      }
    });
    
    await wsClient.connect();
  } catch (error) {
    console.error('WebSocket连接失败:', error);
    throw error;
  }
};

技术要点

  • 使用 WSS(WebSocket Secure)保证传输安全
  • 配置心跳机制保持连接活跃
  • 自动重连机制处理网络波动
  • Token 认证通过 URL 参数传递(WebSocket 不支持自定义请求头)

3.2 消息处理

typescript 复制代码
/**
 * 处理转写消息
 */
const handleTranscriptionMessage = (message: WebSocketMessage) => {
  try {
    let result = message.data;
    
    // 1. 解析JSON消息
    if (typeof result === 'string') {
      // 错误检查
      if (result.includes('error') || result.includes('Error') || result.includes('timeout')) {
        console.warn('收到错误消息:', result);
        return;
      }
      try {
        result = JSON.parse(result);
      } catch {
        return;  // 解析失败,忽略
      }
    }
    
    // 2. 检查错误字段
    if (result?.error) {
      console.warn('WebSocket返回错误:', result.error);
      return;
    }
    
    // 3. 提取转写文本
    const text = result?.result?.text || result?.text;
    if (text) {
      // 更新完整字幕
      subtitles.value = text;
      accumulatedText.value = text;
      
      // 更新当前显示的字幕(逐句显示)
      updateCurrentSubtitle(text, currentSubtitle, lastDisplayedSentenceEnd);
    }
  } catch (error) {
    console.error('处理转写消息失败:', error);
  }
};

四、实时字幕显示

4.1 逐句显示逻辑

实现智能的字幕逐句显示,基于标点符号识别完整句子:

typescript 复制代码
/**
 * 更新当前显示的字幕(逐句显示)
 * @param fullText 完整字幕文本
 * @param currentSubtitle 当前显示的字幕(响应式引用)
 * @param lastDisplayedSentenceEnd 已显示的最后一句的结束位置
 */
const updateCurrentSubtitle = (
  fullText: string, 
  currentSubtitle: Ref<string>, 
  lastDisplayedSentenceEnd: Ref<number>
) => {
  if (!fullText) {
    currentSubtitle.value = '';
    return;
  }
  
  // 如果文本长度没有增加,无需更新
  if (fullText.length <= lastDisplayedSentenceEnd.value) {
    return;
  }
  
  // 获取新增的文本部分
  const newText = fullText.substring(lastDisplayedSentenceEnd.value);
  
  // 尝试匹配完整句子(以标点符号结尾)
  const sentenceMatch = newText.match(/^[^。!?\n\.]+[。!?\n\.]+/);
  
  if (sentenceMatch) {
    // 找到完整句子
    const completeSentence = sentenceMatch[0].trim();
    if (completeSentence) {
      // 移除末尾多余的标点符号(保留最后一个)
      const displayText = completeSentence.replace(
        /[。!?\n\.]+$/, 
        (match) => match[match.length - 1]
      );
      currentSubtitle.value = displayText;
      
      // 更新已显示位置
      lastDisplayedSentenceEnd.value = 
        lastDisplayedSentenceEnd.value + sentenceMatch[0].length;
    }
  } else {
    // 没有完整句子,查找最后一个标点符号
    const lastPunctuationIndex = Math.max(
      fullText.lastIndexOf('。'),
      fullText.lastIndexOf('!'),
      fullText.lastIndexOf('?'),
      fullText.lastIndexOf('\n'),
      fullText.lastIndexOf('.')
    );
    
    if (lastPunctuationIndex >= lastDisplayedSentenceEnd.value) {
      // 有新的标点符号,显示标点符号之后的内容
      const afterLastPunctuation = fullText.substring(lastPunctuationIndex + 1).trim();
      if (afterLastPunctuation) {
        currentSubtitle.value = afterLastPunctuation;
      }
    } else {
      // 没有标点符号,显示新文本部分
      const displayText = newText.trim();
      if (displayText) {
        currentSubtitle.value = displayText;
      }
    }
  }
};

显示策略

  1. 完整句子优先: 识别以标点符号结尾的完整句子
  2. 标点符号处理: 保留最后一个标点,移除多余的
  3. 增量更新: 只显示新增部分,避免重复显示
  4. 容错处理: 没有标点符号时显示新文本部分

4.2 字幕状态管理

typescript 复制代码
// 响应式状态
const subtitles = ref('');              // 完整字幕文本
const currentSubtitle = ref('');        // 当前显示的字幕
const accumulatedText = ref('');        // 累积文本
const lastDisplayedSentenceEnd = ref(0); // 已显示的最后一句的结束位置

// 重置字幕状态
const resetSubtitleState = () => {
  subtitles.value = '';
  currentSubtitle.value = '';
  accumulatedText.value = '';
  lastDisplayedSentenceEnd.value = 0;
};

六、完整生命周期管理

6.1 开始录音

typescript 复制代码
const startRecording = async () => {
  try {
    // 1. 初始化状态
    audioChunks = [];
    resetSubtitleState();
    subtitleIdentification.value = uuidv4();
    
    // 2. 获取麦克风权限
    audioStream = await navigator.mediaDevices.getUserMedia({
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true,
      },
    });
    
    // 3. 创建音频上下文(用于可视化)
    audioContext = new AudioContext();
    const newAnalyser = audioContext.createAnalyser();
    newAnalyser.fftSize = 256;
    audioContext.createMediaStreamSource(audioStream).connect(newAnalyser);
    analyser.value = newAnalyser;
    
    // 4. 创建 MediaRecorder
    const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
      ? 'audio/webm;codecs=opus'
      : 'audio/webm';
    mediaRecorder = new MediaRecorder(audioStream, { mimeType });
    
    mediaRecorder.ondataavailable = (event) => {
      if (event.data.size > 0) {
        audioChunks.push(event.data);
      }
    };
    
    // 5. 连接 WebSocket
    try {
      await connectWebSocket(currentRecordId.value);
      startSubtitleSaveTimer();
      
      // 6. 创建用于ASR的音频上下文
      if (wsClient?.isConnected()) {
        asrAudioContext = new AudioContext({ sampleRate: 16000 });
        setupAudioProcessor();
      }
    } catch (error) {
      console.error('WebSocket连接失败,继续录音但不显示实时字幕:', error);
    }
    
    // 7. 开始录音
    mediaRecorder.start(3000);
    isRecording.value = true;
    isPaused.value = false;
    recordingTime.value = 0;
    
    // 8. 开始计时
    const startTime = Date.now();
    recordingTimer = setInterval(() => {
      const elapsed = Math.floor((Date.now() - startTime) / 100);
      recordingTime.value = elapsed;
    }, 100);
  } catch (error) {
    console.error('开始录音失败:', error);
    throw error;
  }
};

6.2 暂停/继续录音

typescript 复制代码
/**
 * 暂停录音
 */
const pauseRecording = async () => {
  if (mediaRecorder?.state !== 'recording') return;
  
  mediaRecorder.pause();
  isPaused.value = true;
  isRecording.value = false;
  
  // 停止计时器
  if (recordingTimer) {
    clearInterval(recordingTimer);
    recordingTimer = null;
  }
  
  // 停止音频处理器
  if (audioProcessor) {
    audioProcessor.disconnect();
    audioProcessor = null;
  }
  
  // 保存字幕并断开WebSocket
  if (subtitles.value && subtitleIdentification.value) {
    await saveCurrentSubtitles();
    stopSubtitleSaveTimer();
  }
  
  if (wsClient) {
    wsClient.disconnect();
    wsClient = null;
  }
};

/**
 * 继续录音
 */
const resumeRecording = async () => {
  if (!isPaused.value || !mediaRecorder) return;
  
  mediaRecorder.resume();
  isPaused.value = false;
  isRecording.value = true;
  
  // 重新连接WebSocket
  if (currentRecordId.value) {
    try {
      await connectWebSocket(currentRecordId.value);
      startSubtitleSaveTimer();
      
      // 重新设置音频处理器
      if (!asrAudioContext && audioStream) {
        asrAudioContext = new AudioContext({ sampleRate: 16000 });
      }
      setupAudioProcessor();
    } catch (error) {
      console.error('WebSocket重连失败,继续录音但不显示实时字幕:', error);
    }
  }
  
  // 恢复计时
  const startTime = Date.now() - recordingTime.value * 100;
  recordingTimer = setInterval(() => {
    const elapsed = Math.floor((Date.now() - startTime) / 100);
    recordingTime.value = elapsed;
  }, 100);
};

6.3 停止录音与资源清理

typescript 复制代码
/**
 * 停止录音
 */
const stopRecording = async (
  onStop?: (audioChunks: Blob[], recordId: string) => Promise<void>
) => {
  if (!mediaRecorder) return;
  
  isRecording.value = false;
  isPaused.value = false;
  
  // 停止计时器
  if (recordingTimer) {
    clearInterval(recordingTimer);
    recordingTimer = null;
  }
  
  // 设置停止事件处理
  mediaRecorder.onstop = async () => {
    try {
      if (audioChunks.length > 0 && currentRecordId.value && onStop) {
        await onStop(audioChunks, currentRecordId.value);
      }
    } finally {
      cleanup();
    }
  };
  
  // 停止录音并断开WebSocket
  mediaRecorder.stop();
  if (wsClient) {
    wsClient.send('stop', false);
    // 延迟断开,确保最后的消息能收到
    setTimeout(() => {
      if (wsClient) {
        wsClient.disconnect();
        wsClient = null;
      }
    }, 500);
  }
  
  // 确保显示最后的内容
  if (subtitles.value) {
    // 处理最后未完成的句子显示逻辑
    // ...
  }
  
  // 延迟重置状态,确保用户能看到最后的内容
  setTimeout(() => {
    resetSubtitleState();
  }, 2000);
};

/**
 * 清理资源
 */
const cleanup = () => {
  // 停止音频流
  if (audioStream) {
    audioStream.getTracks().forEach((track) => track.stop());
    audioStream = null;
  }
  
  // 关闭音频上下文
  if (audioContext) {
    audioContext.close();
    audioContext = null;
  }
  if (asrAudioContext) {
    asrAudioContext.close();
    asrAudioContext = null;
  }
  
  // 断开音频处理器
  if (audioProcessor) {
    audioProcessor.disconnect();
    audioProcessor = null;
  }
  
  analyser.value = null;
  mediaRecorder = null;
  
  // 清理WebSocket
  if (wsClient) {
    wsClient.disconnect();
    wsClient = null;
  }
  
  // 停止字幕保存定时器
  stopSubtitleSaveTimer();
  
  // 清空音频块和缓冲区
  audioChunks = [];
  audioBuffer = [];
};
相关推荐
平凡灵感码头4 小时前
第一次做蓝牙产品,从零开发(5)蓝牙音频项目中功放芯片
单片机·嵌入式硬件·音视频
TengTaiTech13 小时前
单芯片音频二分频新高度:全系列高通QCC平台智能分频方案解析
音视频
qq_3106585116 小时前
mediasoup源码走读(六)——NetEQ
服务器·c++·音视频
YANshangqian17 小时前
Monkey‘s Audio(无损音频压缩器)
音视频
gf13211111 天前
python_基于主视频删减片段并插入镜头视频
linux·python·音视频
光算科技1 天前
YouTube视频字幕转成文章算重复内容吗?
人工智能·音视频
Yutengii1 天前
B站的视频怎么下载到电脑?
音视频
gf13211111 天前
python_图片、字幕文本、音频一键组合
python·音视频·swift