企业直播推拉流链路实践

avatar
FE @字节跳动

背景

企业直播,为企业提供直播、点播、互动等音视频处理技术服务,以及基于应用场景的定制化直播平台搭建服务

直播基础知识

下图是一张简单的直播过程图,从图中可以看出,主播端部分其实就是推流部分,而观众端是拉流部分。其中推流部分主要涉及音视频数据的采集,预处理,编码,和封装。而拉流主要是通过约定好的协议进行音视频流的获取,再通过播放器进行解码播放。

image.png

推流

推流是指将从设备里采集到的音视频数据通过流媒体协议发送到流媒体服务器的过程,主要包括以下几步:

  1. 选择流媒体协议(RTMP、HLS、HTTP-FLV)
  2. 采集音视频数据
  3. 硬编码,软编码音视频数据

软编码就是利用 CPU 资源来压缩音视频数据,对设备性能要求高

硬编码是使用非 CPU 进行编码,如显卡 GPU、专用的 DSP、FPGA、ASIC 芯片等

软编码的话,现在广泛采用 FFmpeg 库结合编码库来实现,FFmpeg + X624 来编码视频数据 YUV/RGB 输出 H264 数据,FFmpeg + fdk_aac 来编码音频数据 PCM 输出 AAC 数据

  1. 根据所选流媒体协议对音视频数据进行封装
  2. 将音视频数据打包成 packet
  3. 与服务器交互发送封包数据
  4. 根据所选流媒体协议,在与服务连接成功后,就可以发送相应的封包数据了

那么我们是怎么实现的推流呢?

下面简单介绍一下企业直播的两款推流产品:直播伴侣和网页推流

直播伴侣

产品介绍

直播伴侣是一款依赖Electron开发的windows桌面端推流工具,核心功能依赖于mediaSDK。目前提供的主要功能有:

  • 兼容多站点登录
  • 推流直播
  • 直播连麦
  • 主持人互动聊天
  • 支持多种开播素材:桌面、窗口、截屏、文档、视频等
  • 美颜和虚拟形象

技术架构

image.png

核心功能

推流直播

直播伴侣支持添加多种直播源,包括摄像头,全屏桌面,应用窗口,游戏进程,图片,采集卡、视频、文字等。

进行推流直播时,会经历以下几个步骤:

  1. 调用mediasdk.addSource添加源
  2. 连接socket(服务端通过流状态回调,判断直播间状态,同时通过socket同步状态)
  3. 获取到推流地址
  4. 调用mediaSDK.startStream进行推流。

image.png

直播连麦

连麦分为嘉宾端和主持人端,其中嘉宾端的逻辑要比主持人端的复杂。嘉宾端比主持人端复杂的地方在于嘉宾进入直播间时,需要先轮询判断直播间的连麦状态,如果此时主持人开启连麦,停止轮询,才启动嘉宾连麦进入房间流程

image.png

MediaSDK

技术架构

image.png

MediaSDK对底层系统的操控、捕获、设备通信等都是通过c++实现的,使用node-gyp对c++进行接口封装之后使其具备被js调用的能力。

MediaSDK_Node.node

c++核心代码编译成node-addon文件MediaSDK_Node.node,项目中通过js引入

module.exports = require('../../lib/MediaSDK_Node.node')
proxySDK

proxySDK是mediaSDK的代理,拦截了get/apply/constructor方法,记录SDK API的调用日志,方便进行API 调用的统计和记录,在出现问题时也能方便追踪

export const proxysdk = new Proxy(
  {},
  {
    get(target, _p0, receiver) {
      const p0 = _p0 as string
      return new Proxy(function () {}, {
        apply(target, ctx, args) {
          if (blacklist.includes(p0)) {
            return mediasdk[p0].apply(ctx, args)
          }
          const _args = args.map((arg: any) => {
            // 只打印函数名
            if (typeof arg === 'function') {
              return arg.name
            }
            return arg
          })
          return applyActionAndLog(
            ACTION_TYPE.APPLY,
            `mediasdk.${p0}(${_args})`,
            mediasdk[p0],
            { ctx: ctx, arg: args },
          )
        },

        construct(target, args) {
          return applyActionAndLog(
            ACTION_TYPE.CONSTRUCT,
            `mediasdk.${p0}(${args}`,
            mediasdk[p0],
            args,
          )
        },

        get(target, p1, receiver) {
          return applyActionAndLog(
            ACTION_TYPE.GET,
            `mediasdk.${p0}(${p1 as string})`,
            mediasdk[p0],
            { arg: p1 },
          )
        },
      })
    },
  },
)
mediasdkWeb

mediasdkWeb也是对mediasdk的封装,但它是为了方便在渲染进程中调用而提供的SDK API,从下面的代码中可以看出,除了几个获取源及源回调的监听之外,其他的API调用都会通过IPC转发给主进程,统一由proxySDK调用,并将调用结果返回给渲染进程。

exports.mediasdkWeb = new Proxy(
  {},
  {
    get(target, p) {
      return new Proxy(function () { }, {
        apply(target, thisArg, argArray) {
          if (window.__write_sdk_log__) {
            window.__write_sdk_log__(p, argArray)
          }

          const sdk = remote.require('@byted/mediasdk/lib/MediaSDK_Node.node')
          switch (p) {
            case 'getSourceFactory':
              return sdk[mapping[argArray[0]]]
            case 'getSourceInstance':
              return new sdk[mapping[argArray[1]]](argArray[0])
            case 'getAudioFactory':
              return sdk.AudioInput
            case 'getAudioInstance':
              return new sdk.AudioInput(argArray[0])
            case 'setCallback':
              return sdk.MS_API_SetCallback(argArray[0], argArray[1])
            case 'clearAudioPeakListener':
              return sdk.MS_API_SetCallback(argArray[0], null)
            default:
              try {
                return ipcRenderer.sendSync(
                  'MEDIASDK_CALL_FROM_RENDERER',
                  p,
                  argArray,
                )
              } catch (e) {
                if (window.__write_sdk_log__) {
                  window.__write_sdk_log__('!!!', e.message)
                }
                return null
              }
          }
        },
      })
    },
  },
)
 // 同步 Proxy
      options.electron.ipcMain.on(
        'MEDIASDK_CALL_FROM_RENDERER',
        (e: Electron.Event, method: string, argArray: any[]) => {
          try {
            let returnValue
            if (method.startsWith('MS_API_')) {
              returnValue = (proxysdk as any)[method](...argArray)
              proxysdk.MS_API_WriteLog(
                0,
                `callSDk < ${JSON.stringify(returnValue)}`,
              )
              e.returnValue = returnValue
              return
            }
            returnValue = ((Mediasdk.instance as any)[
              method
            ] as Function).apply(Mediasdk.instance, argArray)
            proxysdk.MS_API_WriteLog(
              0,
              `callSDk < ${JSON.stringify(returnValue)}`,
            )
            e.returnValue = returnValue
          } catch (err) {
            console.error(err)
            e.returnValue = false
          }
        },
      )
SourceStore

存储 SDK 推流源和相关的推流设置等等,集成了对推流源的修改方法,并将修改同步到 store 中

image.png

网页推流

产品介绍

网页推流是一款依托于 WebRTC 的网页端推流工具,主要功能有:

  1. 针对设备的性能差异,支持两种推流模式:轻享模式和自由模式
  2. 在视频显示方面,支持美颜、滤镜、文字转换等
  3. 连麦

技术架构

image.png

核心功能

模式选择和开播环境监测

image.png

上图是一个模式选择的界面,考虑到开播设备性能的差异及使用门槛的问题,提供两种开播方式:轻享模式和自由模式。这两种模式的区别从上图中也可以看出来。

轻享模式如何降低CPU使用率?

轻享模式和自由模式本质区别在于渲染层的不同,自由模式使用 Canvas 绘制多个音视频元素,并使用 captureStream 向 RTC 提供 MediaStream,但是这个过程比较耗费性能,因此在轻享模式下,Scene 对 RTC 侧输入产物由 Canvas 生成的 Mediastream 改为直传 Mediastream(纯 video 推流),并且限制最多允许双流同时推。

image.png

确定好开播模式之后,是一个开播环境检测的界面,可以看到,开播环境检测主要包含摄像头、麦克风、网速和浏览器的检测,其中摄像头和麦克风的检测主要是借助于浏览器的 MediaDevices API。而网速的检测是通过浏览器的 NetworkInformation API 获取到当前网络的下行速度。浏览器检测主要是对 UA 的检测,从而判断当前浏览器环境是否适用于所选的开播模式。

MeidaDevices 的 getUserMedia 方法允许在用户通过提示允许的情况下,打开系统上的相机或屏幕共享和/或麦克风,并提供 MediaStream 包含视频轨道和/或音频轨道的输入。

image.png

推流

流程见下图,本质上是使用 RTCPeerConnection 接口构建一个从当前 web 浏览器到远端的 WebRTC 连接,根据场景生成直播流,并推流到 RTC 房间,房间这个时候就会显示画面, RTC 后端从该房间内获得视频流,使用 FFmpeg 进行重新录制,根据传入的位置参数,进行位置摆放,生成新的视频流,使用 RTMP 协议推送到 CDN,这时候客户端通过流地址就能拉到对应的流。

image.png

连麦

连麦和推流的区别主要在于:

  • 场景的不同,涉及到主持人屏幕、主持人头像和嘉宾头像,嘉宾屏幕的排布
  • 监听socket 信令 onAddStream,意味着接收到远端流
    1. 为该流创建 PeerConnection 连接
    2. 通过 socket 发送 subscribe 事件,同时传递 offer
    3. 监听 signalingMessageRelay 事件,当接收到 anwer 时,把 answer 添加到 pc 中
    4. 监听 pc 的 ontrack 事件获取真实流

拉流

拉流是指通过服务器获取拉流 URL,并通过 URL 从相应的流媒体服务器上获取流媒体资源进行播放的过程。大致分为以下几步:

  1. 根据协议类型(如RTMP、RTP、RTSP、HTTP等),与服务器建立连接并接收数据
  2. 解析二进制数据,从中找到相关流信息
  3. 根据不同的封装格式(如FLV、TS)解复用(demux)分别得到已编码的 H.264 视频数据和 AAC 音频数据
  4. 使用硬解码(对应系统的 API)或软解码(FFmpeg)来解压音视频数据
  5. 经过解码后得到原始的视频数据(YUV)和音频数据(AAC)
  6. 因为音频和视频解码是分开的,需要进行同步,否则会出现音视频不同步的现象
  7. 最后把同步后的音频数据送到耳机或外放,视频数据送到屏幕上显示

下面简单介绍一下目前我们的拉流链路

image.png

播放器插件化机制

西瓜播放器为了方便进行业务扩展,提供了插件化机制以实现业务定制,在不影响核心功能的情况下,将不同的业务功能分散在不同的插件中,便于维护及管理。那么西瓜播放器是如何自定义开发插件,以满足业务需求呢?

以直播播放器的AI字幕插件为例

import WrapperI18n from '@/depend-component/WrapperI18n';
import { ReduxStore } from '@/page/index-pc/index.page';
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import Plugin from 'xgplayer/es/plugin';
import PlayerSubtitle from './PlayerAiSubtitle';

export default class AISubtitlePlugin extends Plugin {
    // 插件的名称,将作为插件实例的唯一key值
    static get pluginName() {
      return 'AISubtitlePlugin';
    }

    static get defaultConfig() {
      return {
        // 挂载在controls的右侧,如果不指定则默认挂载在播放器根节点上
        position: Plugin.POSITIONS.CONTROLS_RIGTH,
        index: 4,
      };
    }

    constructor(args) {
      super(args);
    }
    
    beforePlayerInit() {
      // 播放器调用start初始化播放源之前的逻辑
    }
    
    afterPlayerInit() {
      // 播放器调用start初始化播放源之后的逻辑
    }
    
    afterCreate() {
      // 插件实例化之后的一些逻辑
      const definitionContainer = document.getElementById('subtitlePlugin');
      ReactDOM.render(
        <Provider store={ReduxStore}>
          <WrapperI18n>
            <PlayerSubtitle />
          </WrapperI18n>
        </Provider>,
        definitionContainer,
      );
    }
    
    destroy() {
      return;
    }
    
    render() {
      return '<div id="subtitlePlugin"></div>';
    }
  };
};

从上面的代码中可以看出,自定义插件继承了基类Plugin,且包含了完整的播放器生命周期回调,可以在生命周期内实现一些自定义逻辑。那么如何在项目中使用这个自定义插件呢?需要在播放器初始化的时候注入这个插件,如下:

import XgPlayer from 'xgplayer'
const player = new XgPlayer({
    url: '../video/mp4/xgplayer-demo-720p.mp4',
    plugins: [AISubtitlePlugin],  // 配置参数注册插件
})

在new XgPlayer的时候,会去注册plugins里的插件列表。这样我们自定义的这个插件就能在播放器中使用了。

其他更详细的插件用法,参见v2.h5player.bytedance.com/plugins/

总结

本文主要从以下几个方面介绍了字节直播在推拉流链路方面的实践:

  1. 推流方向:详细讲述了包含网页直播和桌面端直播伴侣在内的技术架构实现
  2. 拉流方向:侧重介绍了西瓜播放器在字节直播拉流侧的落地

希望通过这篇文章,不仅能让大家对我们字节直播的推拉流链路有个全面了解,也能对了解直播过程、做好直播业务有所帮助。