第十章:实时音频(WebRTC & MediaStream)—— 让声音实时流动

前九章我们处理的音频都是"静态"的——文件、流媒体、本地处理。这一章进入另一个维度:实时音频。麦克风采集、噪声消除、实时传输、多人通话……这些场景背后是 WebRTC 和 MediaStream 这套完整的实时通信技术栈。理解它,你才能真正驾驭浏览器里的"实时声音"。


一、MediaStream:实时音频的数据容器

MediaStream 是实时音频(和视频)数据的容器对象,由一组 MediaStreamTrack 组成。每个 Track 代表一路独立的媒体轨道(一路麦克风音频、一路摄像头视频等)。

MediaStream
  ├── AudioTrack (MediaStreamTrack, kind="audio")
  │     ├── 来源:麦克风 / Web Audio / 屏幕录制
  │     └── 状态:enabled / muted / readyState
  └── VideoTrack (MediaStreamTrack, kind="video")

获取麦克风输入:getUserMedia

// 基础用法:请求麦克风权限
async function getMicrophoneStream(constraints = {}) {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        // 采样率(浏览器可能不完全遵守)
        sampleRate: 48000,
        // 声道数
        channelCount: 1,
        // 回声消除(浏览器内置,WebRTC 场景必开)
        echoCancellation: true,
        // 噪声抑制
        noiseSuppression: true,
        // 自动增益控制(让声音音量自动稳定)
        autoGainControl: true,
        // 延迟(低延迟模式,部分浏览器支持)
        latency: 0.01,
      },
      video: false,
    });
 
    console.log('麦克风权限已获取');
    console.log('音频轨道:', stream.getAudioTracks()[0].label);
    return stream;
 
  } catch (err) {
    // 常见错误处理
    const errorMessages = {
      NotAllowedError:  '用户拒绝了麦克风权限',
      NotFoundError:    '未找到麦克风设备',
      NotReadableError: '麦克风被其他应用占用',
      OverconstrainedError: '设备不满足约束条件',
    };
    throw new Error(errorMessages[err.name] || `未知错误: `);
  }
}

枚举可用音频设备

async function listAudioDevices() {
  // 必须先获取权限,否则 label 字段为空
  await navigator.mediaDevices.getUserMedia({ audio: true })
    .then(s => s.getTracks().forEach(t => t.stop())); // 立即释放
 
  const devices = await navigator.mediaDevices.enumerateDevices();
 
  const audioInputs  = devices.filter(d => d.kind === 'audioinput');
  const audioOutputs = devices.filter(d => d.kind === 'audiooutput');
 
  console.log('输入设备(麦克风):');
  audioInputs.forEach(d => console.log(` - [] `));
 
  console.log('输出设备(扬声器):');
  audioOutputs.forEach(d => console.log(` - [] `));
 
  return { audioInputs, audioOutputs };
}
 
// 切换麦克风设备
async function switchMicrophone(deviceId) {
  return navigator.mediaDevices.getUserMedia({
    audio: { deviceId: { exact: deviceId } },
  });
}
 
// 监听设备变化(插拔耳机等)
navigator.mediaDevices.addEventListener('devicechange', async () => {
  console.log('设备列表发生变化');
  const { audioInputs } = await listAudioDevices();
  updateDeviceSelector(audioInputs);
});

MediaStreamTrack 控制

const stream = await getMicrophoneStream();
const track  = stream.getAudioTracks()[0];
 
// 静音(不是停止,只是不发送数据)
track.enabled = false;
 
// 恢复
track.enabled = true;
 
// 获取实际约束(浏览器实际使用的参数)
const settings = track.getSettings();
console.log('实际采样率:', settings.sampleRate);
console.log('实际声道数:', settings.channelCount);
console.log('回声消除:', settings.echoCancellation);
 
// 动态修改约束
await track.applyConstraints({
  echoCancellation: false, // 关闭回声消除(音乐场景)
  noiseSuppression: false, // 关闭噪声抑制(保留环境音)
});
 
// 停止轨道(释放麦克风,摄像头指示灯熄灭)
track.stop();

二、Web Audio API 与 MediaStream 的结合

麦克风流可以直接接入 Web Audio 处理链,实现实时音频处理:

class RealtimeAudioProcessor {
  constructor() {
    this.audioCtx = new AudioContext({ sampleRate: 48000 });
    this.stream   = null;
    this.source   = null;
  }
 
  async start() {
    // 1. 获取麦克风流
    this.stream = await getMicrophoneStream();
 
    // 2. 创建 MediaStream 源节点
    this.source = this.audioCtx.createMediaStreamSource(this.stream);
 
    // 3. 构建处理链
    const gainNode    = this.audioCtx.createGain();
    const analyser    = this.audioCtx.createAnalyser();
    const compressor  = this.audioCtx.createDynamicsCompressor();
 
    gainNode.gain.value = 1.2;
    analyser.fftSize    = 2048;
 
    // 4. 连接处理链
    this.source
      .connect(gainNode)
      .connect(compressor)
      .connect(analyser);
 
    // 注意:麦克风直接连 destination 会产生回声!
    // 除非使用耳机,否则不要把麦克风直接连到 destination
    // analyser.connect(this.audioCtx.destination);
 
    // 5. 将处理后的音频输出为新的 MediaStream(用于录制或 WebRTC)
    const dest = this.audioCtx.createMediaStreamDestination();
    analyser.connect(dest);
    this.processedStream = dest.stream;
 
    return this.processedStream;
  }
 
  stop() {
    this.stream?.getTracks().forEach(t => t.stop());
    this.source?.disconnect();
  }
}

三、浏览器录音:MediaRecorder

MediaRecorder 是浏览器内置的录音/录像 API,直接接受 MediaStream 作为输入:

class AudioRecorder {
  constructor() {
    this.recorder  = null;
    this.chunks    = [];
    this.stream    = null;
    this.startTime = 0;
  }
 
  // 检测最优录音格式
  static getBestMimeType() {
    const types = [
      'audio/webm; codecs=opus',
      'audio/webm',
      'audio/ogg; codecs=opus',
      'audio/mp4',
    ];
    return types.find(t => MediaRecorder.isTypeSupported(t)) || '';
  }
 
  async start(options = {}) {
    this.stream = await getMicrophoneStream();
    this.chunks = [];
 
    const mimeType = AudioRecorder.getBestMimeType();
    console.log('录音格式:', mimeType);
 
    this.recorder = new MediaRecorder(this.stream, {
      mimeType,
      audioBitsPerSecond: options.bitrate || 128000,
    });
 
    // 每隔 timeslice 毫秒收集一次数据(实时处理场景用)
    // 不传 timeslice 则录完后一次性收集
    this.recorder.addEventListener('dataavailable', (e) => {
      if (e.data.size > 0) {
        this.chunks.push(e.data);
      }
    });
 
    this.recorder.addEventListener('stop', () => {
      this._onRecordingComplete();
    });
 
    this.recorder.start(options.timeslice); // timeslice: 可选,单位 ms
    this.startTime = Date.now();
    console.log('开始录音...');
  }
 
  pause() {
    if (this.recorder?.state === 'recording') {
      this.recorder.pause();
    }
  }
 
  resume() {
    if (this.recorder?.state === 'paused') {
      this.recorder.resume();
    }
  }
 
  stop() {
    if (this.recorder?.state !== 'inactive') {
      this.recorder.stop();
      this.stream.getTracks().forEach(t => t.stop());
    }
  }
 
  getDuration() {
    return (Date.now() - this.startTime) / 1000;
  }
 
  _onRecordingComplete() {
    const mimeType = this.recorder.mimeType;
    const blob     = new Blob(this.chunks, { type: mimeType });
    const url      = URL.createObjectURL(blob);
    const duration = this.getDuration();
 
    console.log(`录音完成: s, s`);
 
    // 触发下载
    this._triggerDownload(blob, mimeType);
 
    // 也可以直接播放
    const audio = document.getElementById('playback');
    if (audio) audio.src = url;
 
    return { blob, url, duration };
  }
 
  _triggerDownload(blob, mimeType) {
    const ext  = mimeType.includes('webm') ? 'webm'
               : mimeType.includes('ogg')  ? 'ogg'
               : 'mp4';
    const a    = document.createElement('a');
    a.href     = URL.createObjectURL(blob);
    a.download = `recording-.`;
    a.click();
    URL.revokeObjectURL(a.href);
  }
}

实时录音波形预览

录音时实时显示波形,提升用户体验:

class RecordingVisualizer {
  constructor(stream, canvas) {
    this.audioCtx = new AudioContext();
    this.source   = this.audioCtx.createMediaStreamSource(stream);
    this.analyser = this.audioCtx.createAnalyser();
    this.analyser.fftSize = 1024;
    this.source.connect(this.analyser);
 
    this.canvas  = canvas;
    this.ctx     = canvas.getContext('2d');
    this.data    = new Uint8Array(this.analyser.frequencyBinCount);
    this.animId  = null;
 
    // 音量计(VU Meter)
    this.vuData  = new Float32Array(this.analyser.fftSize);
  }
 
  start() {
    const draw = () => {
      this.animId = requestAnimationFrame(draw);
      this._drawVUMeter();
    };
    draw();
  }
 
  stop() {
    cancelAnimationFrame(this.animId);
    this.audioCtx.close();
  }
 
  _drawVUMeter() {
    this.analyser.getByteTimeDomainData(this.data);
    this.analyser.getFloatTimeDomainData(this.vuData);
 
    const { ctx, canvas } = this;
    const W = canvas.width, H = canvas.height;
 
    ctx.fillStyle = '#0a0a1a';
    ctx.fillRect(0, 0, W, H);
 
    // 计算 RMS 音量
    let rms = 0;
    for (let i = 0; i < this.vuData.length; i++) {
      rms += this.vuData[i] * this.vuData[i];
    }
    rms = Math.sqrt(rms / this.vuData.length);
    const db = Math.max(-60, 20 * Math.log10(rms));
 
    // 绘制 VU 表
    const vuWidth  = W * 0.15;
    const vuHeight = H * 0.8;
    const vuY      = H * 0.1;
    const fillH    = ((db + 60) / 60) * vuHeight;
 
    // 背景
    ctx.fillStyle = '#1a1a2e';
    ctx.fillRect(W - vuWidth - 10, vuY, vuWidth, vuHeight);
 
    // 音量条(绿→黄→红)
    const gradient = ctx.createLinearGradient(0, vuY + vuHeight, 0, vuY);
    gradient.addColorStop(0,    '#00e676');
    gradient.addColorStop(0.7,  '#ffea00');
    gradient.addColorStop(0.9,  '#ff1744');
    gradient.addColorStop(1.0,  '#ff1744');
    ctx.fillStyle = gradient;
    ctx.fillRect(
      W - vuWidth - 10,
      vuY + vuHeight - fillH,
      vuWidth,
      fillH
    );
 
    // 波形
    ctx.strokeStyle = '#00e5ff';
    ctx.lineWidth   = 1.5;
    ctx.beginPath();
    const sliceW = (W - vuWidth - 20) / this.data.length;
    for (let i = 0; i < this.data.length; i++) {
      const v = this.data[i] / 128.0;
      const x = i * sliceW;
      const y = (v / 2) * H;
      i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    }
    ctx.stroke();
 
    // 录音指示(红点闪烁)
    const blink = Math.floor(Date.now() / 500) % 2 === 0;
    if (blink) {
      ctx.fillStyle = '#ff1744';
      ctx.beginPath();
      ctx.arc(15, 15, 6, 0, Math.PI * 2);
      ctx.fill();
    }
  }
}

四、WebRTC 音频:点对点实时通话

WebRTC(Web Real-Time Communication)是浏览器原生的点对点实时通信技术,音频部分基于 RTP/SRTP 协议传输,默认使用 Opus 编解码器。

WebRTC 建立连接的核心流程

流程图画布 · 115%
Mermaid 流程图加载中...

完整的 WebRTC 音频通话实现

class WebRTCAudioCall {
  constructor(signalingServer) {
    this.pc          = null;  // RTCPeerConnection
    this.localStream = null;
    this.remoteAudio = document.getElementById('remoteAudio');
    this.ws          = new WebSocket(signalingServer);
 
    // ICE 服务器配置(STUN 用于 NAT 穿透,TURN 用于中继)
    this.iceConfig = {
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },
        { urls: 'stun:stun1.l.google.com:19302' },
        // TURN 服务器(付费,用于严格 NAT 环境)
        // {
        //   urls: 'turn:your-turn-server.com:3478',
        //   username: 'user',
        //   credential: 'password',
        // },
      ],
    };
 
    this._setupSignaling();
  }
 
  // ── 信令处理 ─────────────────────────────────────────
  _setupSignaling() {
    this.ws.addEventListener('message', async (event) => {
      const msg = JSON.parse(event.data);
 
      switch (msg.type) {
        case 'offer':
          await this._handleOffer(msg.sdp);
          break;
        case 'answer':
          await this._handleAnswer(msg.sdp);
          break;
        case 'ice-candidate':
          await this._handleICECandidate(msg.candidate);
          break;
        case 'hang-up':
          this.hangUp();
          break;
      }
    });
  }
 
  _send(data) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }
 
  // ── 发起通话 ─────────────────────────────────────────
  async call() {
    this.localStream = await getMicrophoneStream({
      echoCancellation: true,
      noiseSuppression: true,
      autoGainControl:  true,
    });
 
    this._createPeerConnection();
 
    // 添加本地音频轨道
    this.localStream.getAudioTracks().forEach(track => {
      this.pc.addTrack(track, this.localStream);
    });
 
    // 创建并发送 Offer
    const offer = await this.pc.createOffer({
      offerToReceiveAudio: true,
      offerToReceiveVideo: false,
    });
 
    await this.pc.setLocalDescription(offer);
    this._send({ type: 'offer', sdp: offer });
  }
 
  // ── 接受通话 ─────────────────────────────────────────
  async _handleOffer(sdp) {
    this.localStream = await getMicrophoneStream();
    this._createPeerConnection();
 
    this.localStream.getAudioTracks().forEach(track => {
      this.pc.addTrack(track, this.localStream);
    });
 
    await this.pc.setRemoteDescription(new RTCSessionDescription(sdp));
 
    const answer = await this.pc.createAnswer();
    await this.pc.setLocalDescription(answer);
    this._send({ type: 'answer', sdp: answer });
  }
 
  async _handleAnswer(sdp) {
    await this.pc.setRemoteDescription(new RTCSessionDescription(sdp));
  }
 
  async _handleICECandidate(candidate) {
    if (candidate && this.pc) {
      await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
    }
  }
 
  // ── 创建 PeerConnection ───────────────────────────────
  _createPeerConnection() {
    this.pc = new RTCPeerConnection(this.iceConfig);
 
    // ICE 候选收集
    this.pc.addEventListener('icecandidate', (e) => {
      if (e.candidate) {
        this._send({ type: 'ice-candidate', candidate: e.candidate });
      }
    });
 
    // 连接状态变化
    this.pc.addEventListener('connectionstatechange', () => {
      console.log('连接状态:', this.pc.connectionState);
      switch (this.pc.connectionState) {
        case 'connected':
          console.log('✅ P2P 连接已建立');
          this._onConnected();
          break;
        case 'disconnected':
        case 'failed':
          console.warn('连接断开,尝试重连...');
          this._onDisconnected();
          break;
      }
    });
 
    // ICE 连接状态
    this.pc.addEventListener('iceconnectionstatechange', () => {
      console.log('ICE 状态:', this.pc.iceConnectionState);
    });
 
    // 接收远端音频轨道
    this.pc.addEventListener('track', (e) => {
      console.log('收到远端轨道:', e.track.kind);
      if (e.track.kind === 'audio') {
        this.remoteAudio.srcObject = e.streams[0];
        this.remoteAudio.play();
      }
    });
  }
 
  // ── 音频质量控制 ─────────────────────────────────────
  async _onConnected() {
    // 获取发送端 RTCRtpSender,调整编解码器参数
    const sender = this.pc.getSenders()
      .find(s => s.track?.kind === 'audio');
 
    if (sender) {
      const params = sender.getParameters();
      if (params.encodings && params.encodings.length > 0) {
        // 设置最大码率(bps)
        params.encodings[0].maxBitrate = 128000; // 128kbps
        await sender.setParameters(params);
      }
    }
  }
 
  // ── 静音控制 ─────────────────────────────────────────
  mute(muted) {
    this.localStream?.getAudioTracks().forEach(t => {
      t.enabled = !muted;
    });
  }
 
  // ── 挂断 ─────────────────────────────────────────────
  hangUp() {
    this._send({ type: 'hang-up' });
    this.localStream?.getTracks().forEach(t => t.stop());
    this.pc?.close();
    this.pc          = null;
    this.localStream = null;
    if (this.remoteAudio) {
      this.remoteAudio.srcObject = null;
    }
  }
 
  _onDisconnected() {
    // 可以在这里实现自动重连逻辑
    this.hangUp();
  }
}

五、WebRTC 音频统计与质量监控

WebRTC 提供了丰富的统计接口,用于监控通话质量:

class CallQualityMonitor {
  constructor(peerConnection) {
    this.pc       = peerConnection;
    this.interval = null;
    this.prevStats = {};
  }
 
  start(onReport) {
    this.interval = setInterval(async () => {
      const report = await this._collectStats();
      onReport(report);
    }, 2000); // 每 2 秒收集一次
  }
 
  stop() {
    clearInterval(this.interval);
  }
 
  async _collectStats() {
    const stats  = await this.pc.getStats();
    const report = {
      rtt:          null, // 往返延迟(ms)
      packetLoss:   null, // 丢包率(%)
      jitter:       null, // 抖动(ms)
      bitrate:      null, // 实时码率(kbps)
      audioLevel:   null, // 音频电平
    };
 
    stats.forEach(stat => {
      // 入站 RTP 流(接收端统计)
      if (stat.type === 'inbound-rtp' && stat.kind === 'audio') {
        report.jitter      = (stat.jitter * 1000).toFixed(1); // 转 ms
        report.audioLevel  = stat.audioLevel;
 
        // 计算丢包率
        const prev = this.prevStats[stat.id];
        if (prev) {
          const lostDelta    = stat.packetsLost    - prev.packetsLost;
          const receivedDelta = stat.packetsReceived - prev.packetsReceived;
          const total        = lostDelta + receivedDelta;
          report.packetLoss  = total > 0
            ? ((lostDelta / total) * 100).toFixed(1)
            : '0.0';
 
          // 计算码率
          const bytesDelta   = stat.bytesReceived - prev.bytesReceived;
          const timeDelta    = (stat.timestamp    - prev.timestamp) / 1000;
          report.bitrate     = ((bytesDelta * 8) / timeDelta / 1000).toFixed(1);
        }
        this.prevStats[stat.id] = stat;
      }
 
      // 候选对统计(RTT)
      if (stat.type === 'candidate-pair' && stat.state === 'succeeded') {
        report.rtt = stat.currentRoundTripTime
          ? (stat.currentRoundTripTime * 1000).toFixed(0)
          : null;
      }
    });
 
    return report;
  }
}
 
// 使用
const monitor = new CallQualityMonitor(webrtcCall.pc);
monitor.start((report) => {
  console.log(`RTT: ms | 丢包: % | 抖动: ms | 码率: kbps`);
 
  // 根据质量指标调整 UI
  if (parseFloat(report.packetLoss) > 5) {
    showWarning('网络质量较差,通话可能受影响');
  }
});

六、噪声消除与音频增强

浏览器内置的 noiseSuppressionechoCancellation 已经能处理大多数场景,但对于更高要求的场景,可以用 AudioWorklet 实现自定义的噪声处理:

// noise-gate-processor.js
// 噪声门:低于阈值的信号静音,高于阈值的信号正常通过
class NoiseGateProcessor extends AudioWorkletProcessor {
  static get parameterDescriptors() {
    return [
      {
        name: 'threshold',
        defaultValue: 0.02,  // 阈值(线性幅度)
        minValue: 0,
        maxValue: 1,
        automationRate: 'k-rate',
      },
      {
        name: 'attack',
        defaultValue: 0.003, // 开门时间(秒)
        minValue: 0.001,
        maxValue: 0.5,
        automationRate: 'k-rate',
      },
      {
        name: 'release',
        defaultValue: 0.1,   // 关门时间(秒)
        minValue: 0.001,
        maxValue: 2.0,
        automationRate: 'k-rate',
      },
    ];
  }
 
  constructor() {
    super();
    this.gateGain    = 0;   // 当前门控增益(0=关,1=开)
    this.isOpen      = false;
  }
 
  process(inputs, outputs, parameters) {
    const input     = inputs[0][0];
    const output    = outputs[0][0];
    if (!input || !output) return true;
 
    const threshold = parameters.threshold[0];
    const attack    = parameters.attack[0];
    const release   = parameters.release[0];
 
    // attack/release 转换为每采样的增益变化量
    const attackCoeff  = 1 - Math.exp(-1 / (sampleRate * attack));
    const releaseCoeff = 1 - Math.exp(-1 / (sampleRate * release));
 
    for (let i = 0; i < input.length; i++) {
      const level = Math.abs(input[i]);
 
      // 判断是否超过阈值
      if (level > threshold) {
        // 开门:增益向 1 靠近
        this.gateGain += attackCoeff  * (1 - this.gateGain);
 
      } else {
        // 关门:增益向 0 靠近
        this.gateGain += releaseCoeff * (0 - this.gateGain);
      }
 
      // 应用门控增益
      output[i] = input[i] * this.gateGain;
    }
 
    return true;
  }
}
 
registerProcessor('noise-gate-processor', NoiseGateProcessor);

在主线程中使用噪声门:

async function setupNoiseGate(stream) {
  const audioCtx = new AudioContext({ sampleRate: 48000 });
 
  // 加载 Worklet
  await audioCtx.audioWorklet.addModule('noise-gate-processor.js');
 
  const source   = audioCtx.createMediaStreamSource(stream);
  const noiseGate = new AudioWorkletNode(audioCtx, 'noise-gate-processor');
 
  // 调整参数
  noiseGate.parameters.get('threshold').value = 0.015; // 阈值
  noiseGate.parameters.get('attack').value    = 0.002; // 2ms 开门
  noiseGate.parameters.get('release').value   = 0.15;  // 150ms 关门
 
  // 输出为新的 MediaStream(用于 WebRTC 发送)
  const dest = audioCtx.createMediaStreamDestination();
  source.connect(noiseGate);
  noiseGate.connect(dest);
 
  return dest.stream; // 经过噪声门处理的干净音频流
}

七、多人音频房间:SFU 架构

两人通话用 P2P 即可,但多人场景(3 人以上)P2P 的连接数会爆炸性增长(N 人需要 N×(N-1)/2 条连接)。生产环境的多人通话通常采用 SFU(Selective Forwarding Unit) 架构:

P2P 架构(4人):           SFU 架构(4人):
A ←→ B                      A ←→ SFU ←→ B
A ←→ C        vs            C ←→ SFU ←→ D
A ←→ D                      (每人只需 1 条连接)
B ←→ C
B ←→ D
C ←→ D
共 6 条连接                  共 4 条连接

SFU 服务器接收每个用户的音频流,然后选择性地转发给其他用户,不做混音(保持低延迟)。常用的开源 SFU 方案有 mediasoupJanusLiveKit

客户端接入 SFU 的核心逻辑与标准 WebRTC 基本一致,差异在于信令协议:

class SFUAudioRoom {
  constructor(roomId, userId) {
    this.roomId      = roomId;
    this.userId      = userId;
    this.producers   = new Map(); // 本地发送的轨道
    this.consumers   = new Map(); // 远端接收的轨道
    this.remoteAudios = new Map(); // 远端音频元素
  }
 
  async join(signalingUrl) {
    this.ws = new WebSocket(signalingUrl);
 
    return new Promise((resolve) => {
      this.ws.addEventListener('open', async () => {
        // 1. 加入房间,获取 SFU 的 RTP 能力
        const { routerRtpCapabilities } = await this._request('join', {
          roomId: this.roomId,
          userId: this.userId,
        });
 
        // 2. 加载设备能力(mediasoup-client)
        // 实际项目需要引入 mediasoup-client 库
        // this.device = new mediasoupClient.Device();
        // await this.device.load({ routerRtpCapabilities });
 
        resolve();
      });
 
      this.ws.addEventListener('message', (e) => {
        this._handleMessage(JSON.parse(e.data));
      });
    });
  }
 
  async publish(stream) {
    // 创建发送 Transport
    const transportInfo = await this._request('createTransport', {
      direction: 'send',
    });
 
    // 建立发送连接并发布音频轨道
    const track = stream.getAudioTracks()[0];
    console.log(`发布音频轨道: `);
 
    // 通知其他用户有新的音频流
    await this._request('produce', {
      kind:          'audio',
      rtpParameters: { /* 编解码器参数 */ },
    });
  }
 
  async subscribe(remoteUserId) {
    // 订阅指定用户的音频流
    const { rtpParameters } = await this._request('consume', {
      producerUserId: remoteUserId,
      kind:           'audio',
    });
 
    // 创建接收 Transport 并播放
    const audio = document.createElement('audio');
    audio.autoplay = true;
    this.remoteAudios.set(remoteUserId, audio);
    document.body.appendChild(audio);
 
    console.log(`订阅用户 [] 的音频`);
  }
 
  _handleMessage(msg) {
    switch (msg.type) {
      case 'user-joined':
        console.log(`用户 [] 加入房间`);
        this.subscribe(msg.userId);
        break;
      case 'user-left':
        console.log(`用户 [] 离开房间`);
        this._removeRemoteAudio(msg.userId);
        break;
    }
  }
 
  _removeRemoteAudio(userId) {
    const audio = this.remoteAudios.get(userId);
    if (audio) {
      audio.srcObject = null;
      audio.remove();
      this.remoteAudios.delete(userId);
    }
  }
 
  _request(type, data) {
    return new Promise((resolve) => {
      const id = Date.now();
      this.ws.send(JSON.stringify({ id, type, ...data }));
      const handler = (e) => {
        const msg = JSON.parse(e.data);
        if (msg.id === id) {
          this.ws.removeEventListener('message', handler);
          resolve(msg.data);
        }
      };
      this.ws.addEventListener('message', handler);
    });
  }
 
  async leave() {
    await this._request('leave', { roomId: this.roomId });
    this.remoteAudios.forEach(audio => {
      audio.srcObject = null;
      audio.remove();
    });
    this.ws.close();
  }
}

八、实战:完整的浏览器录音应用

把本章所有内容整合成一个功能完整的录音应用,包含权限申请、实时波形、噪声门、录音下载:

class VoiceRecorderApp {
  constructor(container) {
    this.container  = container;
    this.recorder   = new AudioRecorder();
    this.visualizer = null;
    this.noiseGate  = null;
    this.state      = 'idle'; // idle | recording | paused
 
    this._buildUI();
  }
 
  _buildUI() {
    this.container.innerHTML = `
      <div class="recorder">
        <canvas id="recCanvas" width="600" height="120"></canvas>
 
        <div class="recorder-controls">
          <button id="recBtn" class="rec-btn">● 开始录音</button>
          <button id="pauseBtn" disabled>⏸ 暂停</button>
          <button id="stopBtn"  disabled>⏹ 停止</button>
        </div>
 
        <div class="recorder-info">
          <span id="recTime">00:00</span>
          <span id="recStatus">就绪</span>
          <span id="recFormat"></span>
        </div>
 
        <audio id="playback" controls style="display:none; width:100%"></audio>
      </div>
    `;
 
    document.getElementById('recBtn').addEventListener('click',   () => this._toggleRecord());
    document.getElementById('pauseBtn').addEventListener('click', () => this._togglePause());
    document.getElementById('stopBtn').addEventListener('click',  () => this._stop());
 
    // 录音计时器
    this._timerInterval = null;
    this._elapsedSeconds = 0;
  }
 
  async _toggleRecord() {
    if (this.state !== 'idle') return;
 
    try {
      // 1. 获取麦克风
      const rawStream = await getMicrophoneStream({
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl:  true,
      });
 
      // 2. 应用噪声门
      const cleanStream = await setupNoiseGate(rawStream);
 
      // 3. 启动可视化
      const canvas = document.getElementById('recCanvas');
      this.visualizer = new RecordingVisualizer(rawStream, canvas);
      this.visualizer.start();
 
      // 4. 开始录音
      await this.recorder.start({ bitrate: 128000 });
 
      // 5. 更新状态
      this.state = 'recording';
      this._updateUI();
      this._startTimer();
 
      // 显示录音格式
      document.getElementById('recFormat').textContent =
        AudioRecorder.getBestMimeType();
 
    } catch (err) {
      document.getElementById('recStatus').textContent = err.message;
      console.error('录音启动失败:', err);
    }
  }
 
  _togglePause() {
    if (this.state === 'recording') {
      this.recorder.pause();
      this.visualizer?.stop();
      this.state = 'paused';
      clearInterval(this._timerInterval);
    } else if (this.state === 'paused') {
      this.recorder.resume();
      this.visualizer?.start();
      this.state = 'recording';
      this._startTimer();
    }
    this._updateUI();
  }
 
  async _stop() {
    if (this.state === 'idle') return;
 
    this.recorder.stop();
    this.visualizer?.stop();
    clearInterval(this._timerInterval);
 
    this.state = 'idle';
    this._updateUI();
 
    // 显示回放控件
    const playback = document.getElementById('playback');
    playback.style.display = 'block';
 
    document.getElementById('recStatus').textContent = '录音完成,已自动下载';
  }
 
  _startTimer() {
    this._timerInterval = setInterval(() => {
      this._elapsedSeconds++;
      const m = String(Math.floor(this._elapsedSeconds / 60)).padStart(2, '0');
      const s = String(this._elapsedSeconds % 60).padStart(2, '0');
      document.getElementById('recTime').textContent = `:`;
    }, 1000);
  }
 
  _updateUI() {
    const recBtn   = document.getElementById('recBtn');
    const pauseBtn = document.getElementById('pauseBtn');
    const stopBtn  = document.getElementById('stopBtn');
    const status   = document.getElementById('recStatus');
 
    const stateMap = {
      idle:      { recText: '● 开始录音', pauseText: '⏸ 暂停', recDisabled: false, pauseDisabled: true,  stopDisabled: true,  statusText: '就绪' },
      recording: { recText: '● 录音中…',  pauseText: '⏸ 暂停', recDisabled: true,  pauseDisabled: false, stopDisabled: false, statusText: '录音中' },
      paused:    { recText: '● 开始录音', pauseText: '▶ 继续', recDisabled: true,  pauseDisabled: false, stopDisabled: false, statusText: '已暂停' },
    };
 
    const ui = stateMap[this.state];
    recBtn.textContent   = ui.recText;
    pauseBtn.textContent = ui.pauseText;
    recBtn.disabled      = ui.recDisabled;
    pauseBtn.disabled    = ui.pauseDisabled;
    stopBtn.disabled     = ui.stopDisabled;
    status.textContent   = ui.statusText;
  }
}
 
// 初始化应用
const app = new VoiceRecorderApp(document.getElementById('app'));

九、本章知识图谱

流程图画布 · 115%
Mermaid 流程图加载中...

小结

实时音频是 Web 音频技术栈中最复杂、也最有价值的部分。getUserMedia 打开了麦克风的大门,MediaRecorder 让录音变得简单,Web Audio + MediaStream 的组合实现了实时信号处理,而 WebRTC 则把音频真正推向了网络——点对点、低延迟、加密传输。

理解这套技术栈的关键在于数据流向:麦克风 → MediaStreamWeb Audio 处理链MediaStreamDestinationWebRTC 发送 → 网络 → WebRTC 接收&lt;audio&gt; 播放。每一个环节都可以插入处理逻辑,这正是 Web 实时音频强大灵活性的来源。

下一章我们将聚焦移动端兼容与性能优化——iOS Safari 的种种限制、Android 的碎片化问题、低端设备的性能瓶颈,以及如何在保证功能完整的前提下,让你的音频应用在所有设备上都流畅运行。