Skip to content

音视频流传输与播放

本章介绍如何在已经建立的连接上,由设备端发送音视频,并在客户端完成播放。

如果你还没完成 SDK 接入或连接流程,先看 C SDK 接入、对应客户端的 SDK 接入页(例如 Web)和建立与设备端的连接

约定音视频流 ID

stream_id 标识同一条连接中的一路音频、视频或流消息。设备端发送、客户端播放、订阅音视频流和请求关键帧时,都要使用双方约定好的编号。

当前支持的范围是:

  • stream_id 取值范围是 015
  • 同一条连接内,音频和视频不能共用同一个 stream_id

Flutter Android 示例和 DevTools CLI 约定的是下面的值:

用途stream_id
远端音频播放10
远端视频播放11

设备端发送音视频

客户端连接成功并订阅音视频后,设备端会收到 on_subscribe_audio / on_subscribe_video。回调返回 0 表示接受订阅,非 0 表示拒绝。接受订阅后,设备应当开始向这条连接发送对应的音频或视频;收到 on_unsubscribe_audio / on_unsubscribe_video 后停止发送。

设备端可能同时接受多个客户端连接。实现送流时,先确认下面这些规则:

  • 按连接保存送流状态。 每个 hconn 都要有独立的上下文。一个客户端订阅,不能让设备端同时向其他没有订阅的连接送流。C SDK 可以通过 TiRtcConnSetUserData()TiRtcConnGetUserData() 绑定并取回连接上下文。
  • 回调里只更新状态。 SDK 回调运行在内部线程中,不要直接执行采集、编码等耗时操作。连接成功并且对应的送流状态开启后,再由业务线程调用 TiRtcSendAudioStream() / TiRtcSendVideoStream()
  • 多路流继续按 stream_id 区分。 下面的示例固定使用一路音频和一路视频。如果一条连接支持多路音视频,应按 hconnstream_id 和媒体类型分别保存订阅状态。
  • 发送拥塞时尽快恢复关键帧。 如果发送返回 TIRTC_E_BUSY,丢弃后续非关键帧,并尽快补一个关键帧。
  • 连接结束后清理状态。 停止这条连接上的全部发送,并释放为该 hconn 创建的连接上下文。
  • 按业务决定是否需要订阅。 本章以收到订阅后送流为主线。subscribe 不是必要条件;如果连接成功本身就代表客户端需要立即播放,也可以在 on_conn_accepted 后直接送流,省去一次订阅信令往返。如果连接还用于设备控制等其他业务,建议仍按订阅状态决定是否送流。

下面的代码省略了采集、编码、发送线程和连接清理的具体实现,只展示订阅状态如何控制每条连接的送流。

c
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

#include <tirtc/tiRTC.h>

static const uint8_t kAudioStreamId = 10;
static const uint8_t kVideoStreamId = 11;

/* 每个 hconn 创建一个 ConnCtx,分别记录这条连接的送流状态。 */
typedef struct {
    tirtc_conn_t hconn;
    volatile int audio_enabled;    /* 客户端是否订阅了音频 stream_id 10。 */
    volatile int video_enabled;    /* 客户端是否订阅了视频 stream_id 11。 */
    volatile int force_key_frame;  /* 下一帧视频是否需要输出关键帧。 */
} ConnCtx;

static void on_conn_accepted(tirtc_conn_t hconn)
{
    ConnCtx *ctx = (ConnCtx *)calloc(1, sizeof(ConnCtx));
    if (!ctx) {
        return;
    }
    ctx->hconn = hconn;

    /* 把上下文绑定到 hconn,后续回调可以取回同一条连接的状态。 */
    if (TiRtcConnSetUserData(hconn, ctx) != 0) {
        free(ctx);
        return;
    }

    /* 启动这条连接的发送线程,初始不送流,等待客户端订阅。 */
}

/* 订阅回调只修改状态,由发送线程真正执行采集、编码和送流。 */
static int on_subscribe_audio(tirtc_conn_t hconn, uint8_t stream_id)
{
    ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
    if (!ctx || stream_id != kAudioStreamId) {
        return -1;
    }
    /* 发送线程看到 audio_enabled 后,开始向这个 hconn 发送音频。 */
    ctx->audio_enabled = 1;
    return 0;
}

static int on_subscribe_video(tirtc_conn_t hconn, uint8_t stream_id)
{
    ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
    if (!ctx || stream_id != kVideoStreamId) {
        return -1;
    }
    /* 开始或恢复视频时,让编码器尽快输出一个关键帧。 */
    ctx->force_key_frame = 1;
    ctx->video_enabled = 1;
    return 0;
}

/* 两个取消订阅回调都只修改当前 hconn,不影响其他客户端连接。 */
static void on_unsubscribe_audio(tirtc_conn_t hconn, uint8_t stream_id)
{
    ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
    if (ctx && stream_id == kAudioStreamId) {
        ctx->audio_enabled = 0;
    }
}

static void on_unsubscribe_video(tirtc_conn_t hconn, uint8_t stream_id)
{
    ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
    if (ctx && stream_id == kVideoStreamId) {
        ctx->video_enabled = 0;
    }
}

/*
 * 下面两个函数只能在连接成功后调用。
 * 发送线程还要先检查当前 ConnCtx 的 audio_enabled / video_enabled。
 */
int send_audio(tirtc_conn_t hconn, const void *data, uint32_t len, uint32_t ts_ms)
{
    TIRTCFRAMEINFO fi;
    memset(&fi, 0, sizeof(fi));
    /* 必须和客户端 attach、subscribeAudio() 使用的 streamId 一致。 */
    fi.stream_id = kAudioStreamId;
    fi.media = TIRTC_AUDIO_ALAW;
    fi.flags = TIRTC_AUDIOSAMPLE_8K16B1C;
    fi.ts = ts_ms;
    fi.length = len;
    return TiRtcSendAudioStream(hconn, &fi, data);
}

int send_video(tirtc_conn_t hconn, const void *data, uint32_t len, uint32_t ts_ms, int key_frame)
{
    TIRTCFRAMEINFO fi;
    memset(&fi, 0, sizeof(fi));
    /* 必须和客户端 attach、subscribeVideo() 使用的 streamId 一致。 */
    fi.stream_id = kVideoStreamId;
    fi.media = TIRTC_VIDEO_H264;
    fi.flags = key_frame ? TIRTC_FRAME_FLAG_KEY_FRAME : 0;
    fi.ts = ts_ms;
    fi.length = len;
    return TiRtcSendVideoStream(hconn, &fi, data);
}

static void on_request_key_frame(tirtc_conn_t hconn, uint8_t stream_id)
{
    ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
    if (ctx && stream_id == kVideoStreamId) {
        /* 回调里只改状态,下一次编码时再输出关键帧。 */
        ctx->force_key_frame = 1;
    }
}

/* 初始化 SDK 时注册这些回调,结构体的生命周期要覆盖整个 SDK 运行期。 */
static const TIRTCCALLBACKS kCallbacks = {
    .on_conn_accepted = on_conn_accepted,
    .on_subscribe_audio = on_subscribe_audio,
    .on_unsubscribe_audio = on_unsubscribe_audio,
    .on_subscribe_video = on_subscribe_video,
    .on_unsubscribe_video = on_unsubscribe_video,
    .on_request_key_frame = on_request_key_frame,
};

播放远端音视频

客户端需要把音视频输出绑定到准备播放的远端流。连接成功后,调用 subscribeAudio() / subscribeVideo();停止播放时,调用对应的 unsubscribeAudio() / unsubscribeVideo()

下面三处使用的编号必须一致:

text
设备端 TIRTCFRAMEINFO.stream_id
  = 客户端音视频输出绑定的 streamId
  = 客户端 subscribeAudio()/subscribeVideo() 使用的 streamId

可以先使用 Flutter Android Example 连接设备,确认设备端送流是否正常:

在 Example 中填写设备端的 remote_id 和连接信息,并确认音频、视频 stream_id 与设备端发送保持一致。

dart
final TiRtcConn conn = TiRtcConn();
final TiRtcAudioOutput audioOutput = TiRtcAudioOutput();
final TiRtcVideoOutput videoOutput = TiRtcVideoOutput();

Widget buildVideoView() => videoOutput.view();

void startPlayback({
  required String remoteId,
  required String token,
}) {
  conn.onStateChanged = (TiRtcConnState state, int errorCode) {
    debugPrint('conn state=$state error=$errorCode');
    if (state == TiRtcConnState.connected) {
      // 订阅只能在连接成功后发起,streamId 要和下面的输出绑定一致。
      conn.subscribeAudio(streamId: 10);
      conn.subscribeVideo(streamId: 11);
    }
  };
  audioOutput.onStateChanged = (TiRtcAudioOutputState state) {
    debugPrint('audio state=$state');
  };
  videoOutput.onStateChanged = (TiRtcVideoOutputState state) {
    debugPrint('video state=$state');
  };
  videoOutput.onRenderSizeChanged = (Size size) {
    debugPrint('video size=${size.width}x${size.height}');
  };

  // 10 播放音频,11 显示视频;设备端发送时也要使用这两个 stream_id。
  final int audioCode = audioOutput.attach(connection: conn, streamId: 10);
  final int videoCode = videoOutput.attach(connection: conn, streamId: 11);
  final int connectCode = conn.connect(remoteId: remoteId, token: token);
  debugPrint('connect=$connectCode audio=$audioCode video=$videoCode');
}

void stopPlayback() {
  // 停止播放时先取消订阅,再解除输出绑定并断开连接。
  conn.unsubscribeVideo(streamId: 11);
  conn.unsubscribeAudio(streamId: 10);
  videoOutput.detach();
  audioOutput.detach();
  conn.disconnect();
}

void disposePlayback() {
  videoOutput.dispose();
  audioOutput.dispose();
  conn.dispose();
}
tsx
import React from 'react';
import {
  TiRtcAudioOutput,
  TiRtcAudioOutputState,
  TiRtcConn,
  TiRtcConnState,
  TiRtcVideoOutput,
  TiRtcVideoOutputState,
  TiRtcVideoOutputView,
} from 'tirtc-react-native';

const conn = new TiRtcConn();
const audioOutput = new TiRtcAudioOutput();
const videoOutput = new TiRtcVideoOutput();

export function RemoteVideo() {
  return (
    <TiRtcVideoOutputView
      output={videoOutput}
      resizeMode="contain"
      style={{width: '100%', height: '100%'}}
    />
  );
}

function startPlayback(remoteId: string, token: string): void {
  conn.onStateChanged = (state, errorCode) => {
    console.info(`conn state=${state} errorCode=${errorCode}`);
    if (state === TiRtcConnState.connected) {
      // 连接成功后才能订阅,streamId 要和下面的输出绑定一致。
      conn.subscribeAudio(10);
      conn.subscribeVideo(11);
    }
  };
  audioOutput.onStateChanged = (state: TiRtcAudioOutputState) => {
    console.info(`audio state=${state}`);
  };
  videoOutput.onStateChanged = (state: TiRtcVideoOutputState) => {
    console.info(`video state=${state}`);
  };
  videoOutput.onRenderSizeChanged = (size) => {
    console.info(`video size=${size.width}x${size.height}`);
  };

  // 10 播放音频,11 显示视频;设备端发送时也要使用这两个 stream_id。
  const audioCode = audioOutput.attach(conn, 10);
  const videoCode = videoOutput.attach(conn, 11);
  const connectCode = conn.connect(remoteId, token);

  console.info(`connect=${connectCode} audio=${audioCode} video=${videoCode}`);
}

function stopPlayback(): void {
  // 停止播放时先取消订阅,再解除输出绑定并断开连接。
  conn.unsubscribeVideo(11);
  conn.unsubscribeAudio(10);
  videoOutput.detach();
  audioOutput.detach();
  conn.disconnect();
}

function disposePlayback(): void {
  videoOutput.dispose();
  audioOutput.dispose();
  conn.dispose();
}
kotlin
val conn = TiRtcConn()
val audioOutput = TiRtcAudioOutput()
val videoOutput = TiRtcVideoOutput()

fun startPlayback(
  remoteId: String,
  token: String,
  videoContainer: ViewGroup,
) {
  conn.onStateChanged = TiRtcConnStateListener { state, errorCode ->
    Log.i("TiRTC", "conn state=$state errorCode=$errorCode")
    if (state == TiRtcConnState.CONNECTED) {
      // 连接成功后才能订阅,streamId 要和下面的输出绑定一致。
      conn.subscribeAudio(10)
      conn.subscribeVideo(11)
    }
  }
  audioOutput.onStateChanged = TiRtcAudioOutputStateListener { state ->
    Log.i("TiRTC", "audio state=$state")
  }
  videoOutput.onStateChanged = TiRtcVideoOutputStateListener { state ->
    Log.i("TiRTC", "video state=$state")
  }
  videoOutput.onRenderSizeChanged = TiRtcVideoOutputRenderSizeListener { size ->
    Log.i("TiRTC", "video size=${size.width}x${size.height}")
  }

  // 10 播放音频,11 显示视频;设备端发送时也要使用这两个 stream_id。
  val viewCode = videoOutput.attachView(videoContainer)
  val audioCode = audioOutput.attach(conn, 10)
  val videoCode = videoOutput.attach(conn, 11)
  val connectCode = conn.connect(remoteId, token)

  Log.i("TiRTC", "connect=$connectCode view=$viewCode audio=$audioCode video=$videoCode")
}

fun stopPlayback() {
  // 停止播放时先取消订阅,再解除输出绑定并断开连接。
  conn.unsubscribeVideo(11)
  conn.unsubscribeAudio(10)
  videoOutput.detach()
  audioOutput.detach()
  conn.disconnect()
}

fun disposePlayback() {
  videoOutput.dispose()
  audioOutput.dispose()
  conn.dispose()
}
ts
import {
  TiRtcAudioOutput,
  TiRtcAudioOutputState,
  TiRtcConn,
  TiRtcConnState,
  TiRtcVideoFit,
  TiRtcVideoOutput,
  TiRtcVideoOutputState,
  TiRtcVideoOutputView,
} from 'tirtc/Index';

const conn = new TiRtcConn();
const audioOutput = new TiRtcAudioOutput();
const videoOutput = new TiRtcVideoOutput();

@Builder
function RemoteVideoView() {
  TiRtcVideoOutputView({
    output: videoOutput,
    fit: TiRtcVideoFit.contain,
    width: '100%',
    height: '100%',
  });
}

function startPlayback(remoteId: string, token: string): void {
  conn.onStateChanged = (state: TiRtcConnState, errorCode: number): void => {
    console.info(`conn state=${state} error=${errorCode}`);
    if (state === TiRtcConnState.connected) {
      // 连接成功后才能订阅,streamId 要和下面的输出绑定一致。
      conn.subscribeAudio({ streamId: 10 });
      conn.subscribeVideo({ streamId: 11 });
    }
  };
  audioOutput.onStateChanged = (state: TiRtcAudioOutputState): void => {
    console.info(`audio state=${state}`);
  };
  videoOutput.onStateChanged = (state: TiRtcVideoOutputState): void => {
    console.info(`video state=${state}`);
  };

  // 10 播放音频,11 显示视频;设备端发送时也要使用这两个 stream_id。
  const audioCode = audioOutput.attach({ connection: conn, streamId: 10 });
  const videoCode = videoOutput.attach({ connection: conn, streamId: 11 });
  const connectCode = conn.connect({ remoteId, token });

  console.info(`connect=${connectCode} audio=${audioCode} video=${videoCode}`);
}

function stopPlayback(): void {
  // 停止播放时先取消订阅,再解除输出绑定并断开连接。
  conn.unsubscribeVideo({ streamId: 11 });
  conn.unsubscribeAudio({ streamId: 10 });
  videoOutput.detach();
  audioOutput.detach();
  conn.disconnect();
}

function disposePlayback(): void {
  videoOutput.dispose();
  audioOutput.dispose();
  conn.dispose();
}
swift
final class Player: NSObject,
    TiRtcConnDelegate,
    TiRtcAudioOutputDelegate,
    TiRtcVideoOutputDelegate {
    private lazy var conn = TiRtcConn(delegate: self)
    private let audioOutput = TiRtcAudioOutput()
    private let videoOutput = TiRtcVideoOutput()

    func start(remoteId: String, token: String, videoView: UIView) {
        audioOutput.delegate = self
        videoOutput.delegate = self

        // 10 播放音频,11 显示视频;设备端发送时也要使用这两个 stream_id。
        let viewCode = videoOutput.attachView(videoView)
        let audioCode = audioOutput.attach(connection: conn, streamId: 10)
        let videoCode = videoOutput.attach(connection: conn, streamId: 11)
        let connectCode = conn.connect(remoteId: remoteId, token: token)

        print("connect=\(connectCode) view=\(viewCode) audio=\(audioCode) video=\(videoCode)")
    }

    func stop() {
        // 停止播放时先取消订阅,再解除输出绑定并断开连接。
        _ = conn.unsubscribeVideo(streamId: 11)
        _ = conn.unsubscribeAudio(streamId: 10)
        _ = videoOutput.detach()
        _ = videoOutput.detachView()
        _ = audioOutput.detach()
        _ = conn.disconnect()
    }

    func dispose() {
        videoOutput.dispose()
        audioOutput.dispose()
        conn.dispose()
    }

    func conn(_ conn: TiRtcConn, didChangeState state: TiRtcConnState, errorCode: Int32) {
        print("conn state=\(state.rawValue) errorCode=\(errorCode)")
        if state == .connected {
            // 订阅只能在连接成功后发起,streamId 要和输出绑定一致。
            _ = conn.subscribeAudio(streamId: 10)
            _ = conn.subscribeVideo(streamId: 11)
        }
    }

    func conn(_ conn: TiRtcConn, didReceiveCommand commandId: UInt32, data: Data) {}

    func conn(_ conn: TiRtcConn, didReceiveStreamMessage streamId: UInt8, timestampMs: UInt32, data: Data) {}

    func audioOutput(_ output: TiRtcAudioOutput, didChangeState state: TiRtcAudioOutputState) {
        print("audio state=\(state.rawValue)")
    }

    func audioOutput(_ output: TiRtcAudioOutput, didFailWithCode code: Int32, message: String?) {
        print("audio error=\(code) message=\(message ?? "")")
    }

    func videoOutput(_ output: TiRtcVideoOutput, didChangeState state: TiRtcVideoOutputState) {
        print("video state=\(state.rawValue)")
    }

    func videoOutput(_ output: TiRtcVideoOutput, didChangeRenderSize size: CGSize) {
        print("video size=\(Int(size.width))x\(Int(size.height))")
    }

    func videoOutput(_ output: TiRtcVideoOutput, didFailWithCode code: Int32, message: String?) {
        print("video error=\(code) message=\(message ?? "")")
    }
}
js
const appId = 'your-app-id';

TiRtc.initialize(TiRtcInitOptions({ appId }));
const readyPromise = TiRtc.videoOutputReady();

const conn = new TiRtcConn();
// 10 播放音频,11 显示视频;设备端发送时也要使用这两个 stream_id。
const audioOutput = TiRtcAudioOutput({ connection: conn, streamId: 10 });
const videoOutput = TiRtcVideoOutput({ connection: conn, streamId: 11 });

async function startPlayback({
  deviceId,
  token,
}) {
  await readyPromise;

  await conn.connect({ deviceId, token });

  audioOutput.attach();
  videoOutput.attach();

  // await connect() 成功后才能发起订阅。
  conn.subscribeAudio({ streamId: 10 });
  conn.subscribeVideo({ streamId: 11 });
}

function stopPlayback() {
  // 停止播放时先取消订阅,再解除输出绑定并断开连接。
  conn.unsubscribeVideo({ streamId: 11 });
  conn.unsubscribeAudio({ streamId: 10 });
  videoOutput.detach();
  audioOutput.detach();
  conn.disconnect();
}

需要把客户端麦克风声音发回对端时,继续看语音对讲

成功信号

  • 连接进入 connected / CONNECTED
  • 音频输出进入 playing / PLAYING
  • 视频输出进入 rendering / RENDERING,或收到视频尺寸变化回调。

常见问题

已连接但没有声音或画面

连接成功只说明双方已经建立数据传输链路,但不代表设备端已经开始发送音视频。先确认媒体有没有从设备端发出,再检查客户端有没有接到正确的流。

  1. 确认设备端已经开始送流。

    • 按订阅送流时,确认客户端在连接成功后调用了 subscribeAudio() / subscribeVideo(),设备端收到了对应的 on_subscribe_audio / on_subscribe_video,并开始调用发送接口。只收到订阅回调但没有真正送流,客户端仍然收不到音视频。
    • 连接后直接送流时,确认设备端拿到 hconn 后已经开始调用 TiRtcSendAudioStream() / TiRtcSendVideoStream()。两种方式都要检查发送接口的返回值。
  2. 确认三处 stream_id 完全一致。 对照设备端 TIRTCFRAMEINFO.stream_id、客户端音视频输出绑定的 streamId,以及 subscribeAudio() / subscribeVideo() 使用的 streamId。音频和视频分别核对,不要把两路编号写反。

  3. 确认客户端已经准备好播放。 音频输出和视频输出需要绑定到当前连接;视频输出还要挂载到真实可见、没有被遮挡的视图。继续观察音频输出是否进入 playing / PLAYING,视频输出是否进入 rendering / RENDERING 或收到尺寸变化回调,并检查对应的错误回调。

TiRTC 开发文档