web audio api前端录音与实时语音转写技术实现

75 阅读8分钟

前端录音与实时语音转写技术实现

一、项目概述

这是一个基于浏览器原生能力的前端录音工具,集成了阿里云通译听悟的实时语音转写(ASR)能力。项目核心功能包括:

  • 音频录制:使用 Web Audio API 实现高质量录音
  • 实时转写:通过 WebSocket 将音频流实时发送到阿里云 ASR 服务
  • 波形可视化:提供频率柱状图
  • 录音控制:支持开始、暂停、继续、停止等操作

二、技术架构

┌─────────────────────────────────────────────────────────────┐
│                      Vue 组件层 (index.vue)                  │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  录音控制   │  │  ASR展示    │  │   WebSocket 通信    │  │
│  └──────┬──────┘  └──────┬──────┘  └──────────┬──────────┘  │
└─────────┼────────────────┼───────────────────┼─────────────┘
          │                │                   │
          ▼                ▼                   ▼
┌─────────────────┐  ┌───────────────┐  ┌─────────────────────┐
│   Recorder类    │  │ WaveformVisual│  │   阿里云 ASR API    │
│  (recorder.js)  │  │  (wave.js)    │  │   (听悟服务)        │
└────────┬────────┘  └───────────────┘  └─────────────────────┘
         │
         ▼
┌─────────────────────────────────────────────────────────────┐
│              Web Audio API + getUserMedia                    │
└─────────────────────────────────────────────────────────────┘

三、核心实现原理

3.1 音频采集原理

浏览器录音主要依赖两个核心 API:

API作用浏览器支持
getUserMedia获取麦克风音频流所有现代浏览器
AudioContext音频处理上下文Chrome 35+, Firefox 25+

音频处理流程

麦克风 → MediaStream → AudioContext → ScriptProcessorNode → PCM数据
                              ↓
                        AnalyserNode → 波形数据

3.2 Recorder 类核心设计

Recorder 类是整个录音功能的核心,采用了面向对象的设计模式:

class Recorder {
  constructor(options = {}) {
    // 配置参数
    this.config = {
      sampleBits: 16,        // 采样位数
      sampleRate: 16000,     // 采样率(阿里云ASR推荐)
      numChannels: 1,        // 声道数
      compiling: true,       // 边录边转模式
    };
  }
}

关键技术点

1) 音频节点创建
// 创建音频分析节点
this.analyser = this.context.createAnalyser();
this.analyser.fftSize = 2048;

// 创建脚本处理节点(每采集4096个样本触发一次)
this.recorder = this.context.createScriptProcessor(4096, numChannels, numChannels);

为什么选择 4096?

  • 平衡延迟和性能
  • 4096 样本 ≈ 95ms(44.1kHz采样率下)
  • 适合实时处理场景
2) 音频数据采集
this.recorder.onaudioprocess = e => {
  // 获取左声道PCM数据(Float32Array,范围[-1, 1])
  let lData = e.inputBuffer.getChannelData(0);
  
  // 存储原始数据
  this.lBuffer.push(new Float32Array(lData));
  
  // 边录边转:立即转换为PCM格式
  if (this.config.compiling) {
    let pcm = this.transformIntoPCM(lData, rData);
    this.tempPCM.push(pcm);
  }
};
3) PCM 编码原理

PCM(Pulse Code Modulation)是音频的原始数字表示形式:

static encodePCM(bytes, sampleBits, littleEdian = true) {
  // Float32Array [-1, 1] → Int16 [-32768, 32767]
  for (let i = 0; i < bytes.length; i++, offset += 2) {
    let s = Math.max(-1, Math.min(1, bytes[i]));
    // 负数 * 32768,正数 * 32767
    data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, littleEdian);
  }
}
采样位数范围计算公式
8-bit[0, 255]val = s * 128 + 128
16-bit[-32768, 32767]val = s * 32768 (负数) / s * 32767 (正数)
4) WAV 文件格式

WAV = WAV Header (44字节) + PCM 数据

static encodeWAV(bytes, sampleRate, numChannels, sampleBits) {
  let buffer = new ArrayBuffer(44 + bytes.byteLength);
  let view = new DataView(buffer);
  
  // RIFF 标识
  writeString(view, 0, 'RIFF');
  view.setUint32(4, 36 + bytes.byteLength, true);
  writeString(view, 8, 'WAVE');
  
  // fmt 子块
  writeString(view, 12, 'fmt ');
  view.setUint32(16, 16, true);        // 子块大小
  view.setUint16(20, 1, true);         // 音频格式(PCM=1)
  view.setUint16(22, numChannels, true);
  view.setUint32(24, sampleRate, true);
  // ... 更多头部信息
  
  // data 子块
  writeString(view, 36, 'data');
  view.setUint32(40, bytes.byteLength, true);
  
  // 追加 PCM 数据
  return new Uint8Array(buffer);
}

3.3 边录边转实现

核心流程

录音开始 → onaudioprocess 触发 → 转换PCM → WebSocket发送 → 阿里云ASR
              ↓                                            ↓
         每4096样本触发                              返回识别结果

关键代码

// index.vue 中的处理
processFn(params) {
  const newBuff = params.data[params.data.length - 1];  // 获取最新PCM块
  if (this.wsObj?.readyState == 1) {
    this.wsObj.send(newBuff);  // 实时发送
  }
}

3.4 阿里云实时 ASR 集成

WebSocket 通信协议

// 建立连接
new Promise((resolve, reject) => {
        this.wsObj = new WebSocket(this.wsSrc);  // wsSrc 从通义拿到的websocket连接地址
        this.wsObj.binaryType = "arraybuffer"; //传输的是 ArrayBuffer 类型的数据
        this.wsObj.onopen = async () => {
          if (this.wsObj.readyState == 1) {
            const params = {
              header: {
                name: "StartTranscription",
                namespace: "SpeechTranscriber",
              },
              payload: { format: "pcm" },
            };
            this.wsObj.send(JSON.stringify(params));
            console.log("WebSocket连接已成功打开");
            resolve(true);
          } else if ([2, 3].includes(this.wsObj.readyState)) {
            reject(false);
          }
        };
        this.wsObj.onerror = (err) => {
          console.error("WebSocket错误:", err);
          reject(false);
        };
        this.wsObj.onclose = () => {
          console.log("WebSocket连接已关闭");
          // reject(false);
          setTimeout( ()=> {  // 简单实现自动重连
              console.log("WebSocket 尝试重新连接");
              this.openWs();
          }, 3 * 1000);
        };
        this.wsObj.onmessage = (msg) => {
          this.handleMessage(msg);
        };
      });
// 1. 连接建立后发送开始指令
const params = {
  header: {
    name: "StartTranscription",
    namespace: "SpeechTranscriber",
  },
  payload: { format: "pcm" },
};
wsObj.send(JSON.stringify(params));

// 2. 持续发送音频数据
wsObj.send(pcmData);  // ArrayBuffer 格式

// 3. 接收识别结果
wsObj.onmessage = (msg) => {
  const dataJson = JSON.parse(msg.data);
  switch (dataJson.header.name) {
    case "SentenceBegin":           // 句子开始
    case "TranscriptionResultChanged": // 实时结果变化
    case "SentenceEnd":             // 句子结束
    case "ResultTranslated":        // 翻译结果
  }
};

事件类型说明

来源于通义文档,最新请参考通义文档(在文档底部有提供地址)

事件说明用途
SentenceBegin新句子开始初始化新句子对象
TranscriptionResultChanged识别中结果变化实时更新显示
SentenceEnd句子结束合并最终结果
ResultTranslated翻译完成显示翻译内容

3.5 波形可视化实现

项目提供两种可视化方式:

1) 频率柱状图 (wave.js)

image.png

class WaveformVisualizer {
  constructor(canvas, options = {}) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');

    // 默认配置
    this.config = {
      barWidth: options.barWidth || 5,
      barSpacing: options.barSpacing || 2,
      minHeight: options.minHeight || 5, // 最小高度百分比
      amplification: options.amplification || 3, // 放大倍数
      smoothing: options.smoothing || 0.7, // 平滑度 (0-1)
      barColor: options.barColor || '#4d79ff',
      gradientColor: options.gradientColor || '#ff4d4d'
    };

    // 状态变量
    this.isDrawing = false;
    this.animationId = null;
    this.currentData = new Uint8Array(1024);
    this.smoothedData = new Float32Array(1024);

    // 初始化Canvas尺寸
    this.resizeCanvas();
    // window.addEventListener('resize', () => this.resizeCanvas());
  }

  // 调整Canvas尺寸
  resizeCanvas() {
    this.canvas.width = this.canvas.offsetWidth;
    this.canvas.height = this.canvas.offsetHeight;
  }

  // 更新配置
  updateConfig(newConfig) {
    this.config = { ...this.config, ...newConfig };
  }

  // 设置数据
  setData(dataArray) {
    if (dataArray && dataArray.length === 1024) {
      this.currentData = dataArray;
    } else {
      this.currentData = new Uint8Array(1024);
    }
  }

  // 处理数据 - 增强波动效果
  processData(dataArray) {
    const { amplification, smoothing } = this.config;
    const processedData = new Uint8Array(1024);

    for (let i = 0; i < dataArray.length; i++) {
      // 应用放大倍数
      let value = dataArray[i] * amplification;

      // 应用平滑处理
      this.smoothedData[i] = smoothing * this.smoothedData[i] + (1 - smoothing) * value;

      // 确保值在0-255范围内
      processedData[i] = Math.min(255, Math.max(0, this.smoothedData[i]));
    }

    return processedData;
  }

  // 绘制波形
  draw(dataArray = this.currentData) {
    const { barWidth, barSpacing, minHeight, barColor, gradientColor } = this.config;
    const ctx = this.ctx;
    const centerY = this.canvas.height / 2;

    // 清除画布
    ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    // 绘制中心线
    ctx.beginPath();
    ctx.moveTo(0, centerY);
    ctx.lineTo(this.canvas.width, centerY);
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
    ctx.lineWidth = 1;
    ctx.stroke();

    // 计算每个柱子的总宽度(柱子宽度+间距)
    const totalBarWidth = barWidth + barSpacing;

    // 计算可以绘制的柱子数量
    const maxBars = Math.floor(this.canvas.width / totalBarWidth);

    // 创建渐变
    const gradient = ctx.createLinearGradient(0, 0, this.canvas.width, 0);  // 水平方向
    gradient.addColorStop(0, barColor);
    gradient.addColorStop(0.5, gradientColor);
    gradient.addColorStop(1, barColor);

    // 计算最小高度(基于画布高度的百分比)
    const minHeightPixels = (minHeight / 100) * centerY;

    // 处理数据以增强波动效果
    const processedData = this.processData(dataArray);

    // 绘制柱子  水平方向渐变
    for (let i = 0; i < maxBars && i < processedData.length; i++) {
      // 计算柱子的x位置
      const x = i * totalBarWidth;

      // 获取处理后的数据值
      const value = processedData[i];

      // 计算实际高度,确保不低于最小高度
      const dynamicHeight = (value / 255) * (centerY - 20);
      const height = Math.max(minHeightPixels, dynamicHeight);

      // 绘制上下对称的柱子
      ctx.fillStyle = gradient;
      ctx.fillRect(x, centerY - height, barWidth, height * 2);
    }
  }

  // 开始绘制
  start(dataGenerator, interval = 100) {
    if (this.isDrawing) return;

    this.isDrawing = true;

    const drawFrame = () => {
      if (!this.isDrawing) return;

      // 获取数据并绘制
      const data = dataGenerator();
      this.setData(data);
      this.draw(data);

      // 继续动画
      this.animationId = setTimeout(() => {
        requestAnimationFrame(drawFrame);
      }, interval);
    };

    drawFrame();
  }

  // 停止绘制
  stop() {
    this.isDrawing = false;
    if (this.animationId) {
      clearTimeout(this.animationId);
      this.animationId = null;
    }

    // 清除画布
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    // 绘制停止状态文本
    this.ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
    this.ctx.font = '24px Arial';
    this.ctx.textAlign = 'center';
    this.ctx.fillText('已停止绘制', this.canvas.width / 2, this.canvas.height / 2);
  }
}

// 模拟录音数据生成器
function createDataGenerator() {
  let frameCount = 0;

  return function () {
    const dataArray = new Uint8Array(1024);

    for (let i = 0; i < dataArray.length; i++) {
      // 基础噪声
      let value = Math.random() * 20;

      // 添加一些周期性波形
      value += Math.sin(i / 20 + frameCount / 20) * 30;
      value += Math.sin(i / 5 + frameCount / 10) * 10;

      // 随机峰值
      if (Math.random() > 0.97) {
        value += Math.random() * 100;
      }

      // 确保值在0-255范围内
      dataArray[i] = Math.min(255, Math.max(0, value));
    }

    frameCount++;
    return dataArray;
  };
}

// 无声数据生成器
function createSilenceDataGenerator() {
  return function () {
    // 返回全0数组模拟无声
    return new Uint8Array(1024);
  };
}

export {
  WaveformVisualizer,
  createDataGenerator,
  createSilenceDataGenerator
}

2) 时域波形 (waveTimeDomain.js)

draw(dataArray) {
  // 转换数据范围 [0, 255] → [-1, 1]
  let value = (dataArray[i] - 128) / 128;
  
  // 绘制连续波形线
  ctx.beginPath();
  ctx.moveTo(0, firstY);
  for (let i = 1; i < processedData.length; i++) {
    ctx.lineTo(x, y);
  }
  ctx.stroke();
}

四、使用步骤

4.1 基础录音

import Recorder from "js-audio-recorder";

// 1. 创建实例
const recorder = new Recorder({
  sampleRate: 16000,
  sampleBits: 16,
  numChannels: 1,
  compiling: true,  // 开启边录边转
});

// 2. 监听处理事件
recorder.onprogress = ({ duration, fileSize, vol, data }) => {
  console.log(`录音时长: ${duration}秒`);
};

// 3. 开始录音
await recorder.start();

// 4. 暂停/继续
recorder.pause();
recorder.resume();

// 5. 停止并获取WAV
recorder.stop();
const blob = recorder.getWAVBlob();

// 6. 销毁
await recorder.destroy();

4.2 集成实时转写

// 1. 建立WebSocket连接
const ws = new WebSocket('wss://your-asr-service.com');
ws.binaryType = 'arraybuffer';

// 2. 发送开始指令
ws.send(JSON.stringify({
  header: { name: "StartTranscription", namespace: "SpeechTranscriber" },
  payload: { format: "pcm" }
}));

// 3. 实时发送音频
recorder.onprogress = ({ data }) => {
  const latestPCM = data[data.length - 1];
  ws.send(latestPCM);
};

// 4. 接收识别结果
ws.onmessage = (msg) => {
  const result = JSON.parse(msg.data);
  console.log(result.payload.result);
};

五、性能优化策略

5.1 采样率压缩

static compress(data, inputSampleRate, outputSampleRate) {
  const rate = inputSampleRate / outputSampleRate;
  // 48kHz → 16kHz:每隔3个样本取1个
  while (index < length) {
    result[index] = lData[Math.floor(j)];
    j += rate;
  }
}

5.2 内存管理

// 录音结束后及时清理
clear() {
  this.lBuffer.length = 0;
  this.rBuffer.length = 0;
  this.PCM = null;
}

// 销毁时关闭音频上下文
destroy() {
  this.stopStream();  // 停止媒体流
  return this.closeAudioContext();  // 关闭音频上下文
}

5.3 波形绘制优化

// 简化绘制:减少数据点
const step = Math.ceil(processedData.length / (this.canvas.width / 2));
for (let i = 0; i < processedData.length; i += step) {
  // 只绘制关键帧
}

六、浏览器兼容性

特性ChromeFirefoxSafariEdge
getUserMedia49+44+11+79+
AudioContext35+25+14.1+79+
ScriptProcessorNode14+23+6+12+
WebSocket16+11+7.1+12+

注意事项

  • Safari 需要 HTTPS 环境
  • iOS Safari 有特殊的音频策略
  • 部分浏览器需要用户交互后才能录音

本地开发时解决非https不允许录音问题

由于chrome限制,因安全原因,不允许非安全环境下使用录音权限,这里可以通过配置chrome来解决 浏览器地址栏输入chrome://flags/#unsafely-treat-insecure-origin-as-secure

image.png 启用后,重启浏览器即可解决

七、总结

这个项目展示了如何利用浏览器原生能力构建一个功能完整的录音工具:

  1. Web Audio API 提供强大的音频处理能力
  2. 边录边转 模式实现实时语音识别
  3. WebSocket 实现低延迟的双向通信
  4. Canvas 实现流畅的波形可视化

关键技术要点

  • PCM 数据的编码与格式转换
  • 音频节点的连接与数据流转
  • 阿里云 ASR 协议的正确实现
  • 内存管理和性能优化

参考链接