Skip to content

播放音视频

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

设备端 C SDK 负责传输应用交给它的媒体帧,不负责采集、编码或转码。开始发送前,需要先选择设备端和客户端都支持的编码格式,并按本文要求组织每一帧。否则连接可能已经成功,客户端仍然无法起播或在网络恢复后继续播放。

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

音视频格式与帧要求

发送接口不会检查实际数据是否符合 TIRTCFRAMEINFO.media 标记的编码格式。接入时要依次确认三件事:从下表选择 SDK 支持的格式;每次发送的是一个完整媒体帧;帧信息中的格式、时间戳、长度和关键帧标记与本次数据一致。

选择支持的格式

下面分别列出设备端 C SDK 可以标记和发送的媒体格式,以及客户端 SDK 和 Web SDK 的接收、播放支持范围。其中“客户端 SDK”包括 Flutter、React Native、Android、iOS、HarmonyOS 和 Go;Flutter 还支持 macOS。Go SDK 通过 Output 回调交付音视频帧,不创建播放器界面。

视频格式

编码格式设备端 TIRTCFRAMEINFO.media客户端 SDK 接收或播放Web SDK 播放
H.264TIRTC_VIDEO_H264支持支持
H.265TIRTC_VIDEO_H265支持支持
JPEG/MJPEGTIRTC_VIDEO_JPEG支持支持

音频格式

编码格式设备端 TIRTCFRAMEINFO.media客户端 SDK 接收或播放Web SDK 播放
G.711 A-lawTIRTC_AUDIO_ALAW支持支持单声道
AAC-LC(ADTS)TIRTC_AUDIO_AAC支持不支持
16 位 PCMTIRTC_AUDIO_PCM支持不支持
OpusTIRTC_AUDIO_OPUS支持不支持
AMRTIRTC_AUDIO_AMR仅支持 AMR-NB不支持

C SDK 支持 8 kHz 或 16 kHz、16 位、单声道或双声道的音频采样标记。客户端 SDK 播放 AMR 时仅支持 8 kHz、单声道的 AMR-NB;Web SDK 播放 G.711 A-law 时也使用单声道。

组织视频帧

每次调用 TiRtcSendVideoStream(),传入一个完整的编码帧。一个编码帧可以包含多个编码数据单元(NAL 单元),但不能把同一帧任意拆成多次调用,也不能把多帧拼成一次调用。

发送给 TiRTC 的 H.264 和 H.265 数据需要用前导码分隔每个 NAL 单元。前导码可以是 00 00 0100 00 00 01;这种字节组织方式通常称为 Annex B。不要传入 MP4 中常见的长度前缀格式。

H.264 关键帧要携带 SPS 和 PPS。以 IDR 关键帧为例:

text
00 00 00 01 [SPS]
00 00 00 01 [PPS]
00 00 00 01 [IDR]

H.265 关键帧要携带 VPS、SPS 和 PPS。以 IDR 关键帧为例:

text
00 00 00 01 [VPS]
00 00 00 01 [SPS]
00 00 00 01 [PPS]
00 00 00 01 [IDR]

SPS、PPS 和 VPS 共同描述分辨率、编码档次等解码参数。每个关键帧都要在同一次发送的数据中携带相应参数集,不要只在编码器启动时发送一次。这样客户端首次订阅、重新播放或收到关键帧后,才能立即建立解码状态。

发送关键帧时,在 TIRTCFRAMEINFO.flags 中设置 TIRTC_FRAME_FLAG_KEY_FRAME。每路视频流发送的第一帧必须是关键帧。收到 on_request_key_frame,或发送因拥塞暂停后,也要尽快输出一个携带完整参数集的关键帧。

发送 JPEG/MJPEG 时,每次调用传入一张完整的 JPEG/JFIF 图片,并设置 TIRTC_FRAME_FLAG_KEY_FRAMETIRTC_VIDEO_JPEG 表示连续发送独立 JPEG 图片,不是传入 JPEG 文件路径。

组织音频帧

每次调用 TiRtcSendAudioStream(),传入一个完整音频包,并用 TIRTCFRAMEINFO.flags 标记采样率、位深和声道数。下面的发送间隔用于控制实时传输延迟,不是接口强制的固定包长。

编码格式每次发送的数据建议的组织方式
PCM连续的 16 位小端序(S16LE)PCM 样本,不含 WAV 文件头每包 20 ms
G.711 A-law连续的 A-law 编码数据,不含 WAV 文件头每包 20~40 ms
AAC-LC一个包含完整 ADTS 头的 AAC-LC 编码帧保留 ADTS 头,按编码器输出逐帧发送
Opus一个 Opus 编码包,不含 Ogg 容器每包 20 ms
AMR-NB一个 AMR-NB 编码帧,不含 #!AMR\n 文件头8 kHz、单声道,每帧 20 ms

AAC-LC 音频必须保留 ADTS 头。每次发送一个完整 ADTS 帧,不要拆分一帧或拼接多帧;ADTS 头声明的采样率和声道数必须与实际音频及 flags 一致,ADTS 声明的帧长度必须与本次发送的字节数一致。

PCM 和 G.711 A-law 的包长可以直接计算:

text
PCM 字节数 = 采样率 × 时长(秒)× 声道数 × 2
G.711 A-law 字节数 = 采样率 × 时长(秒)× 声道数

例如,8 kHz 单声道 G.711 A-law 的 20 ms 音频是 160 字节,40 ms 是 320 字节;16 kHz 单声道对应 320 字节和 640 字节。AAC、Opus 和 AMR-NB 的压缩帧长度由编码器输出决定,不要为了凑固定长度而补齐或截断数据。

填写时间戳和帧长度

TIRTCFRAMEINFO.ts 填采集时间戳,单位为毫秒。同一路流的时间戳要随采集时间递增;音频和视频从同一个采集时钟取值,客户端才能按时间戳同步播放。不要分别用两个无关的系统时钟生成音频和视频时间戳。

TIRTCFRAMEINFO.length 只填写本次媒体数据的字节数,不包含 TIRTCFRAMEINFO 自身。mediaflagstslength 都要与同一次发送的数据对应。

控制单帧大小和发送节奏

这里的“帧”是一次发送接口调用传入的完整媒体帧,不是最终发到网络上的单个数据包。SDK 会在传输层完成分片和接收端重组,不要按网络 MTU 自行拆分媒体帧。

视频帧没有一个适用于所有平台和编码参数的固定字节上限。编码时应让单帧大小与 SDK 发送缓冲区留有余量,并根据分辨率、帧率和网络情况控制码率。可以用 TiRtcGetSendBufferUsed() 观察缓冲区占用;需要调整 TIRTC_OPT_MAX_SEND_BUFFER 时,必须在 TiRtcInit() 前设置。

如果发送返回 TIRTC_E_BUSY,停止发送后续非关键帧,让编码器尽快生成新的关键帧,再从这个关键帧恢复。弱网下还可以使用本章后面的视频码率建议,让编码器及时降低输出码率。

约定音视频流 ID

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

当前支持的范围是:

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

本文示例使用下面的值:

用途stream_id
设备端发送的音频10
设备端发送的视频 A11
设备端发送的视频 B(多路播放示例)12

设备端发送音视频

连接建立后,设备端有两种开始发送音视频的方式:

  • 客户端需要播放时先发出请求,设备端收到请求后开始发送。
  • 连接成功后,设备端立即开始发送。

本文采用第一种方式。客户端调用订阅接口请求音视频后,设备端会收到 on_subscribe_audio / on_subscribe_video 回调。回调返回 0 表示接受请求,非 0 表示拒绝。接受后,设备端开始发送对应的音频或视频。收到 on_unsubscribe_audio / on_unsubscribe_video 回调后停止发送。

如果产品约定连接成功后立即播放,设备端可以在 on_conn_accepted 回调后开始发送音视频,不需要等待订阅回调。同一条连接还用于设备控制等其他业务时,建议由客户端明确请求需要的音视频,避免发送不必要的数据。

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

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

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

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,
};

根据网络带宽调整视频发送码率(可选)

这是设备端用于提升弱网表现的可选优化,不是发送视频的前置条件。TGTRP 实时音视频传输协议 已经针对弱网络环境进行深度优化。设备端再根据 C SDK 的建议调整视频编码码率,可以进一步减少弱网下的卡顿和延迟积累。

当连接使用 TGTRP 时,C SDK 会根据网络情况,通过 on_update_bitrate 向设备应用提供建议码率。C SDK 不会直接修改编码器,设备应用需要按建议更新视频编码码率。

设备端接入时需要完成三件事:

  1. TIRTCCALLBACKS 中注册 on_update_bitrate
  2. 连接成功后,为准备发送的视频流调用 TiRtcConnSetVideoBitrateParams(),其中 start_bps 填当前编码码率。
  3. 收到 on_update_bitrate 后,把 target_bitrate_bps 投递到编码线程并更新编码器。它是绝对目标值,单位 bps,不是增量或比例。

下面的代码沿用前文定义的 kVideoStreamId 和连接回调。示例中的码率只用于说明调用方式;实际范围应根据设备编码能力、分辨率、帧率和业务需要确定。

c
static const uint32_t kMinVideoBitrateBps = 400000;
static const uint32_t kStartVideoBitrateBps = 800000;
static const uint32_t kMaxVideoBitrateBps = 2000000;

/* 由设备应用实现:把码率调整任务投递到对应的视频编码线程。 */
void enqueue_video_bitrate_update(tirtc_conn_t hconn,
                                  uint8_t stream_id,
                                  uint32_t target_bitrate_bps);

static void on_update_bitrate(tirtc_conn_t hconn,
                              uint8_t stream_id,
                              uint32_t target_bitrate_bps)
{
    if (stream_id == kVideoStreamId) {
        /* 回调运行在 SDK 内部线程,只投递任务,不在这里重配编码器。 */
        enqueue_video_bitrate_update(hconn, stream_id, target_bitrate_bps);
    }
}

static int enable_video_bitrate_suggestion(tirtc_conn_t hconn)
{
    return TiRtcConnSetVideoBitrateParams(hconn,
                                          kVideoStreamId,
                                          kMinVideoBitrateBps,
                                          kMaxVideoBitrateBps,
                                          kStartVideoBitrateBps);
}

初始化 SDK 时,在应用现有的 TIRTCCALLBACKS 中增加 .on_update_bitrate = on_update_bitrate

应在连接建立后、开始发送这路视频前调用 enable_video_bitrate_suggestion(hconn)。返回 0 表示设置请求已提交;返回非 0 时,继续使用当前编码码率,不等待 on_update_bitrate

三个码率参数必须满足 0 < min_bps <= start_bps <= max_bpsstream_id 必须在 0..15 范围内。参数不合法或当前连接无法提供码率建议时,返回 TIRTC_E_INVALID_PARAMETER

完整参数和返回值见 TiRtcConnSetVideoBitrateParams

播放来自设备端的音视频

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

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

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

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

在 Example 中填写 remote_id(客户端要连接的目标)和 token(本次连接凭证),并确认音频、视频 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 ?? "")")
    }
}
go
func play(ctx context.Context, remoteID, token string) error {
	ended := make(chan error, 1)
	notifyEnded := func(err error) {
		select {
		case ended <- err:
		default:
		}
	}

	var conn *tirtc.Conn
	conn, err := tirtc.NewConn(tirtc.ConnOptions{
		OnStateChanged: func(state tirtc.ConnState, err error) {
			if state == tirtc.ConnConnected {
				if err := conn.SubscribeAudio(10); err != nil {
					notifyEnded(err)
				}
				if err := conn.SubscribeVideo(11); err != nil {
					notifyEnded(err)
				}
			}
			if state == tirtc.ConnDisconnected {
				notifyEnded(err)
			}
		},
	})
	if err != nil {
		return err
	}
	defer conn.Close()

	audioOutput, err := tirtc.NewAudioOutput(tirtc.AudioOutputOptions{
		OnFrame: func(frame tirtc.AudioFrame) { playPCM(frame) },
	})
	if err != nil {
		return err
	}
	defer audioOutput.Close()

	videoOutput, err := tirtc.NewVideoOutput(tirtc.VideoOutputOptions{
		OnFrame: func(frame tirtc.VideoFrame) { render(frame) },
	})
	if err != nil {
		return err
	}
	defer videoOutput.Close()

	if err := audioOutput.Attach(conn, 10); err != nil {
		return err
	}
	defer audioOutput.Detach()
	if err := videoOutput.Attach(conn, 11); err != nil {
		return err
	}
	defer videoOutput.Detach()

	if err := conn.Connect(remoteID, token); err != nil {
		return err
	}

	select {
	case <-ctx.Done():
		_ = conn.Disconnect()
		return ctx.Err()
	case err := <-ended:
		return err
	}
}
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();
}

播放多路音视频

TiRTC 支持在同一条连接中播放多路远端流。每一路音频或视频都要使用独立的 stream_id:设备端用它标记正在发送的流;客户端先用它选择本地播放或渲染对象,再在连接成功后请求设备发送对应流。SDK 不根据摄像头名称推断编号,应用需要在设备配置或业务协议中维护编号与媒体来源的对应关系。

下面以一路音频和两路视频为例:

媒体来源设备端发送时的 stream_id本地输出绑定远端送流请求
麦克风音频10Audio Output 选择音频流 10连接成功后请求设备发送音频流 10
摄像头 A 视频11Video Output A 选择视频流 11连接成功后请求设备发送视频流 11
摄像头 B 视频12Video Output B 选择视频流 12连接成功后请求设备发送视频流 12

本地输出绑定和远端送流请求解决的是两个不同问题:

  • Output 的 attach 指定收到某个 stream_id 后,由哪个本地播放或渲染对象消费,可以在建立连接前完成;
  • subscribeAudio / subscribeVideo 请求设备端开始发送指定流,只能在连接成功后调用。

只绑定 Output 而不请求送流时,Output 会一直等待数据;只请求送流而没有绑定对应 Output 时,客户端没有对象播放或显示这路媒体。

这里使用的是 TiRTC 实时连接的 Stream ID,不是 Ti 云存的 Channel ID。设备同时接入实时音视频和云录像时,可以维护同一份“媒体来源”配置,但要分别填写两套编号;上表的 101112 不会限制云录像使用哪些 Channel ID。

设备端发送每一路流

设备端为每个客户端连接、每个 stream_id 分别维护订阅和关键帧状态。收到 on_subscribe_video(hconn, 11) 后开始向该连接发送视频 A;收到 on_subscribe_video(hconn, 12) 后开始发送视频 B。发送帧时,把对应编号写入 TIRTCFRAMEINFO.stream_id

c
static const uint8_t kVideoAStreamId = 11;
static const uint8_t kVideoBStreamId = 12;

int send_video_stream(
    tirtc_conn_t hconn,
    uint8_t stream_id,
    const void *data,
    uint32_t len,
    uint32_t ts_ms,
    int key_frame
) {
    if (stream_id != kVideoAStreamId && stream_id != kVideoBStreamId) {
        return TIRTC_E_INVALID_PARAMETER;
    }

    TIRTCFRAMEINFO fi;
    memset(&fi, 0, sizeof(fi));
    fi.stream_id = stream_id;
    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);
}

设备端应只向已经订阅该 stream_id 的连接发送对应视频。两路视频各自从可独立解码的关键帧开始;收到 on_request_key_frame 时,也要根据回调中的 stream_id 通知相应编码器生成关键帧。音频仍通过 TiRtcSendAudioStream() 发送,并使用独立的 stream_id = 10

客户端播放多路视频

Flutter、React Native、Android、HarmonyOS、iOS、Go 和 Web SDK 都支持在同一条连接中播放多路视频。调用模型相同:每一路视频创建一个 Video Output,绑定对应的 stream_id,连接成功后再逐路订阅。界面平台为每个 Video Output 提供一个渲染视图;Go SDK 通过每个 Video Output 的回调分别交付视频帧。

下面使用视频 A(stream_id = 11)和视频 B(stream_id = 12)演示调用关系。两路只是示例,不是 SDK 固定上限。需要播放更多路时,继续为其他有效 stream_id 创建、绑定并订阅 Video Output。

这里的 N 路不是无限路。所有流 ID 必须位于 0..15,每路视频使用不同编号,并且不能与同一连接中的音频流 ID 重复。从编号空间看,一路音频占用一个 ID 后,最多还剩 15 个 ID 可用于视频;这不表示每台终端都保证同时解码 15 路,实际能力还取决于终端的解码、渲染、内存和网络资源。

以下代码片段承接上一节已经创建并配置好的 conn,只展示多路视频相对单路播放新增的对象和调用。

dart
final videoAOutput = TiRtcVideoOutput();
final videoBOutput = TiRtcVideoOutput();

videoAOutput.attach(connection: conn, streamId: 11);
videoBOutput.attach(connection: conn, streamId: 12);

// 在 TiRtcConnState.connected 回调中逐路订阅。
conn.subscribeVideo(streamId: 11);
conn.subscribeVideo(streamId: 12);

final videoAView = videoAOutput.view();
final videoBView = videoBOutput.view();
tsx
const videoAOutput = new TiRtcVideoOutput();
const videoBOutput = new TiRtcVideoOutput();

videoAOutput.attach(conn, 11);
videoBOutput.attach(conn, 12);

// 在 TiRtcConnState.connected 回调中逐路订阅。
conn.subscribeVideo(11);
conn.subscribeVideo(12);

function MultiVideoView() {
  return (
    <>
      <TiRtcVideoOutputView output={videoAOutput} />
      <TiRtcVideoOutputView output={videoBOutput} />
    </>
  );
}
kotlin
val videoAOutput = TiRtcVideoOutput()
val videoBOutput = TiRtcVideoOutput()

videoAOutput.attachView(videoContainerA)
videoBOutput.attachView(videoContainerB)
videoAOutput.attach(conn, 11)
videoBOutput.attach(conn, 12)

// 在 TiRtcConnState.CONNECTED 回调中逐路订阅。
conn.subscribeVideo(11)
conn.subscribeVideo(12)
ts
const videoAOutput = new TiRtcVideoOutput();
const videoBOutput = new TiRtcVideoOutput();

videoAOutput.attach({ connection: conn, streamId: 11 });
videoBOutput.attach({ connection: conn, streamId: 12 });

// 在 TiRtcConnState.connected 回调中逐路订阅。
conn.subscribeVideo({ streamId: 11 });
conn.subscribeVideo({ streamId: 12 });

@Builder
function MultiVideoView() {
  Column() {
    TiRtcVideoOutputView({ output: videoAOutput, fit: TiRtcVideoFit.contain });
    TiRtcVideoOutputView({ output: videoBOutput, fit: TiRtcVideoFit.contain });
  }
}
swift
let videoAOutput = TiRtcVideoOutput()
let videoBOutput = TiRtcVideoOutput()

_ = videoAOutput.attachView(videoViewA)
_ = videoBOutput.attachView(videoViewB)
_ = videoAOutput.attach(connection: conn, streamId: 11)
_ = videoBOutput.attach(connection: conn, streamId: 12)

// 在 didChangeState 收到 .connected 后逐路订阅。
_ = conn.subscribeVideo(streamId: 11)
_ = conn.subscribeVideo(streamId: 12)
go
videoAOutput, err := tirtc.NewVideoOutput(tirtc.VideoOutputOptions{
	OnFrame: func(frame tirtc.VideoFrame) { renderA(frame) },
})
if err != nil {
	return err
}
videoBOutput, err := tirtc.NewVideoOutput(tirtc.VideoOutputOptions{
	OnFrame: func(frame tirtc.VideoFrame) { renderB(frame) },
})
if err != nil {
	return err
}

if err := videoAOutput.Attach(conn, 11); err != nil {
	return err
}
if err := videoBOutput.Attach(conn, 12); err != nil {
	return err
}

// 在 ConnConnected 回调中逐路订阅。
if err := conn.SubscribeVideo(11); err != nil {
	return err
}
if err := conn.SubscribeVideo(12); err != nil {
	return err
}
js
const videoAOutput = TiRtcVideoOutput({ connection: conn, streamId: 11 });
const videoBOutput = TiRtcVideoOutput({ connection: conn, streamId: 12 });

await TiRtc.videoOutputReady();
videoAOutput.attach();
videoBOutput.attach();

// await conn.connect(...) 成功后逐路订阅。
conn.subscribeVideo({ streamId: 11 });
conn.subscribeVideo({ streamId: 12 });

停止其中一路时,只取消该 stream_id 的订阅,并解除、释放对应的 Video Output;其他流和连接可以继续工作。停止全部播放时,逐路执行 unsubscribeVideo 和 Output 清理,再按上一节的顺序断开连接。每增加一路视频,都应检查绑定、订阅和渲染结果;某一路失败不应被当作其他流已经成功播放的证据。

保存播放内容与当前画面

连接处于活动状态时,可以从用户点击开始的时刻保存一路视频和可选一路音频,结束时生成本地 MP4。Video Output 已经出画后,也可以把当前画面保存为 JPEG。两类文件都先写入 SDK cache,成功后再由应用复制、移动或上传。

保存正在播放的内容

调用 startRecording 并保留返回的 RecordingTask。startRecording 只选择需要保存的 Stream ID,不会锁定视频编码格式或分辨率。一个 RecordingTask 期间,所选视频流的编码格式和分辨率必须保持不变。应用可以在任务期间禁用格式切换;需要主动切换时,先调用同一个任务的 stop 并等待最终结果,再切换格式;新格式生效后创建新的 RecordingTask,分别生成 MP4。

任务建立成功不代表 MP4 已经生成。结束保存时,调用同一个任务的 stop,等待文件写入完成。任务期间意外发生编码格式或分辨率变化时,任务可能返回 6113(格式不支持);渲染尺寸变化回调只能用于观察新尺寸是否生效,不能代替 RecordingTask 的最终结果。

dart
final start = connection.startRecording(
  videoStreamId: videoStreamId,
  audioStreamId: audioStreamId,
);
if (!start.success || start.data == null) return;

// 保留任务并继续播放。以下代码在用户触发停止操作时执行。
final result = await start.data!.stop();
if (!result.success || result.data == null) return;
final TiRtcRecordingFile file = result.data!;
kotlin
val start = connection.startRecording(videoStreamId, audioStreamId)
val task = start.task ?: return
// 保留 task 并继续播放。以下代码在用户触发停止操作时执行。
val result = task.stop()
val file = result.file ?: return
swift
let start = connection.startRecording(
    videoStreamId: videoStreamId,
    audioStreamId: NSNumber(value: audioStreamId)
)
guard let task = start.task else { return }
// 保留 task 并继续播放。以下代码在用户触发停止操作时执行。
let result = await task.stop()
guard let file = result.file else { return }
typescript
const start = connection.startRecording({videoStreamId, audioStreamId});
if (!start.success || start.data === null) return;
// 保留任务并继续播放。以下代码在用户触发停止操作时执行。
const result = await start.data.stop();
if (!result.success || result.data === null) return;
const file = result.data;
typescript
const start = connection.startRecording({videoStreamId, audioStreamId});
if (!start.success || start.data === null) return;
// 保留任务并继续播放。以下代码在用户触发停止操作时执行。
const result = await start.data.stop();
if (!result.success || result.data === null) return;
const file = result.data;
go
task, err := connection.StartRecording(tirtc.StartRecordingOptions{
	VideoStreamID: videoStreamID,
	AudioStreamID: &audioStreamID,
})
if err != nil {
	return err
}
// 保留 task 并继续接收媒体。以下代码在用户触发停止操作时执行。
file, err := task.Stop()

一次任务只生成一个 MP4。需要保存两路视频时,分别创建两个任务。两个任务可以选择同一路音频,从而得到“视频 A + 音频”和“视频 B + 音频”两个独立 MP4。SDK 不生成多视频轨文件,也不合成画中画。

H.264 或 H.265 保存为 MP4 时,设备应发送不含 B 帧或其他重排序依赖的低延迟码流;不符合时 RecordingTask 返回“格式不支持”。

保存当前画面

Video Output 成功出画且画面仍可读取时,调用 takeSnapshot。截图只包含视频画面,不包含字幕、按钮等页面 UI。

dart
final result = await videoOutput.takeSnapshot();
if (!result.success || result.data == null) return;
final TiRtcSnapshotFile snapshot = result.data!;
kotlin
val result = videoOutput.takeSnapshot()
val snapshot = result.file ?: return
swift
let result = await videoOutput.takeSnapshot()
guard let snapshot = result.file else { return }
typescript
const result = await videoOutput.takeSnapshot();
if (!result.success || result.data === null) return;
const snapshot = result.data;
typescript
const result = await videoOutput.takeSnapshot();
if (!result.success || result.data === null) return;
const snapshot = result.data;
go
snapshot, err := videoOutput.TakeSnapshot()
if err != nil {
	return err
}

尚未出画时,截图会返回 no-frame。Flutter 和 React Native 可以调用文件对象的 moveToGallery 保存到系统媒体库;其他平台使用返回的路径自行复制或移动。文件不再需要时调用 delete,Go 调用 Delete,清理 SDK cache 中的临时源文件。

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

常见问题

已连接但没有声音或画面

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

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

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

  3. 确认格式和每帧数据符合要求。 media 要与实际编码格式一致,而且播放端要支持这种格式。视频流要从关键帧开始;H.264 关键帧检查 SPS、PPS 和可独立解码的图像数据,H.265 关键帧检查 VPS、SPS、PPS 和可独立解码的图像数据。完整检查项见音视频格式与帧要求

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

仍然无法定位时,按照排查接入问题保留同一次复现的设备端和客户端证据。

TiRTC 开发文档