You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在浏览器中实时播放并控制Twilio通话媒体流?

解决Twilio通话流浏览器端实时播放及音质问题

问题背景

我需要在通话发起后直接在浏览器中播放Twilio通话的音频流,同时支持暂停、播放、音量调节操作。当前已完成架构:Python应用触发通话后,通知Twilio将媒体流发送至Node.js搭建的WebSocket服务,前端通过SSE已能接收通话状态和媒体payload,但存在两个核心问题:

  • 用Speaker播放时音质极差,仅每秒出现杂音脉冲
  • 用twilio-media-stream-save-audio-file保存的WAV文件播放速度慢、音质糟糕

解决方案

一、后端Node.js服务优化

问题根源在于对Twilio媒体流的编码处理及传输方式错误:Twilio发送的是8kHz单声道A-law编码的音频数据,SSE不适合实时音频流传输,且之前的解码/转发逻辑存在断流风险。

1. 拆分WebSocket服务,直接转发媒体流

将原WebSocket拆分为两个服务:一个接收Twilio的媒体流,另一个向前端推送音频数据,避免SSE的定时推送导致数据断流。

修改后的核心代码:

const WebSocket = require('ws');
const express = require('express')
const app = express()
const server = require('http').createServer(app);
// 分别创建Twilio流接收、前端推送的WebSocket服务
const twilioWss = new WebSocket.Server({ port: 4001 });
const clientWss = new WebSocket.Server({ port: 4002 });
const port = 4000

app.set('view engine', 'ejs');

// 存储前端连接实例
let clientWs = null;

// 处理Twilio媒体流连接
twilioWss.on('connection', (ws) => {
    console.log('Twilio media stream connected')
    ws.on('message', (message) => {
        const msg = JSON.parse(message)
        switch (msg.event) {
            case 'connected':
                clientWs?.send(JSON.stringify({event: 'connected'}));
                break
            case 'start':
                clientWs?.send(JSON.stringify({event: 'started'}));
                break
            case 'media':
                // 直接转发原始A-law payload给前端,减轻后端压力
                clientWs?.send(JSON.stringify({
                    event: 'media',
                    payload: msg.media.payload
                }));
                break
            case 'stop':
                clientWs?.send(JSON.stringify({event: 'stopped'}));
                break
            default:
                console.log('Unknown event:', msg.event)
                break
        }
    });
});

// 处理前端WebSocket连接
clientWss.on('connection', (ws) => {
    console.log('Frontend client connected')
    clientWs = ws;
    ws.on('close', () => clientWs = null);
});

app.get('/', (req, res) => res.render('page'));

server.listen(port, () => console.log('Server started on port ' + port))

2. 修复音频文件保存逻辑(可选)

若需要保存通话录音,需手动处理A-law解码和WAV文件生成,避免第三方包的参数不匹配问题:

const fs = require('fs');
const WaveFile = require('wavefile').WaveFile;
const alawmulaw = require("alawmulaw").alaw;

// 初始化WAV文件参数
let wavFile = new WaveFile();
wavFile.fromScratch(1, 8000, '16', []);

// 在Twilio的media事件中添加:
const buff = Buffer.from(msg.media.payload, 'base64');
const pcmData = alawmulaw.decode(buff);
// 追加解码后的PCM数据
wavFile.fromScratch(1, 8000, '16', [...wavFile.data.samples, ...Array.from(pcmData.buffer)]);

// 通话结束时保存文件
ws.on('close', () => {
    fs.writeFileSync('call-recording.wav', wavFile.toBuffer());
});

二、前端浏览器播放实现

使用Web Audio API处理音频流,前端直接解码A-law数据,实现流畅播放及控制功能。

1. 前端完整代码(含控制功能)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Twilio Call Stream Player</title>
  <style>
    .controls { margin: 20px 0; }
    button { margin-right: 10px; padding: 8px 12px; }
    input[type="range"] { width: 200px; }
  </style>
</head>
<body>
  <h1>Twilio Call Stream</h1>
  <div id="connectionState">Connection State: Not Established</div>
  <div class="controls">
    <button id="playBtn">Play</button>
    <button id="pauseBtn">Pause</button>
    <label>Volume: <input type="range" id="volumeSlider" min="0" max="1" step="0.1" value="0.5"></label>
  </div>

  <script>
    // 前端实现A-law解码,无需依赖外部库
    function alawDecode(buffer) {
      const decoded = new Int16Array(buffer.length);
      for (let i = 0; i < buffer.length; i++) {
        let aVal = buffer[i] ^ 0x55;
        let sign = (aVal & 0x80) >> 7;
        let exponent = (aVal & 0x70) >> 4;
        let mantissa = aVal & 0x0F;
        let pcmVal = (mantissa << 4) + 8;
        if (exponent !== 0) pcmVal += 0x100;
        pcmVal <<= exponent - 1;
        decoded[i] = sign ? -pcmVal : pcmVal;
      }
      return decoded;
    }

    // 初始化Web Audio API
    const audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 8000});
    let isPlaying = false;
    const volumeNode = audioContext.createGain();
    volumeNode.connect(audioContext.destination);
    volumeNode.gain.value = 0.5;

    // 音频数据队列,避免播放断流
    const audioQueue = [];
    let isProcessingQueue = false;

    // 处理音频队列,连续播放
    function processAudioQueue() {
      if (!isPlaying || audioQueue.length === 0) {
        isProcessingQueue = false;
        return;
      }
      isProcessingQueue = true;
      const pcmData = audioQueue.shift();
      const buffer = audioContext.createBuffer(1, pcmData.length, 8000);
      buffer.copyToChannel(pcmData, 0);
      
      const source = audioContext.createBufferSource();
      source.buffer = buffer;
      source.connect(volumeNode);
      source.start();
      
      source.onended = () => processAudioQueue();
    }

    // 连接后端WebSocket
    const ws = new WebSocket('ws://localhost:4002');
    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      switch(data.event) {
        case 'connected':
          document.getElementById('connectionState').textContent = 'Connection State: Connected';
          break;
        case 'started':
          document.getElementById('connectionState').textContent = 'Connection State: Call in Progress';
          break;
        case 'media':
          if (!isPlaying) return;
          // 解码A-law数据并加入队列
          const base64Buffer = Uint8Array.from(atob(data.payload), c => c.charCodeAt(0));
          const pcmData = alawDecode(base64Buffer);
          audioQueue.push(pcmData);
          if (!isProcessingQueue) processAudioQueue();
          break;
        case 'stopped':
          document.getElementById('connectionState').textContent = 'Connection State: Call Ended';
          isPlaying = false;
          break;
      }
    };

    // 控制按钮事件绑定
    document.getElementById('playBtn').addEventListener('click', () => {
      if (audioContext.state === 'suspended') audioContext.resume();
      isPlaying = true;
      if (!isProcessingQueue) processAudioQueue();
    });

    document.getElementById('pauseBtn').addEventListener('click', () => {
      isPlaying = false;
      // 停止当前播放的音频
      const sources = audioContext.createBufferSource();
      sources.stop();
    });

    document.getElementById('volumeSlider').addEventListener('input', (e) => {
      volumeNode.gain.value = parseFloat(e.target.value);
    });
  </script>
</body>
</html>

2. 核心优化点

  • 用WebSocket替代SSE传输媒体流,保证数据实时性和连续性
  • 前端本地解码A-law数据,避免后端解码错误
  • 通过音频队列+Web Audio API实现连续播放,解决断流问题
  • 原生实现播放、暂停、音量控制功能

内容的提问来源于stack exchange,提问作者rachid boufous

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.08 02:18:14