Skip to content

播放音视频 ​

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

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

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

音视频格式与帧要求 ​

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

选择支持的格式 ​

下面分别列出设备端 C SDK 可以标记和发送的媒体格式,以及客户端 SDK 和 Web SDK 的接收、播放支持范围。其中“客户端 SDK”包括 Flutter、React Native、Android、iOS / macOS、HarmonyOS 和 Python。Python 把解码或编码帧交给无界面应用,不创建渲染视图或采集输入。

视频格式 ​

编码格式设备端 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 01 或 00 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。每路视频开始或恢复发送时,第一帧应当是携带完整参数集的关键帧。新客户端订阅这路视频,或者产品在连接成功后直接开始播放时,设备端建议立即触发该关键帧并尽快发送,不要等待常规 GOP 中的下一个自然关键帧。否则客户端只能等待新的可解码点,首画面会较晚出现。这是设备端改善出图体验的实现建议,不是 TiRTC SDK 对发送时机的强制要求。收到 on_request_key_frame,或发送因拥塞暂停后,也要尽快输出一个携带完整参数集的关键帧。

发送 JPEG/MJPEG 时,每次调用传入一张完整的 JPEG/JFIF 图片,并设置 TIRTC_FRAME_FLAG_KEY_FRAME。TIRTC_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 自身。media、flags、ts 和 length 都要与同一次发送的数据对应。

控制单帧大小和发送节奏 ​

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

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

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

约定音视频流 ID ​

stream_id 标识同一条连接、一个发送方向中的一路音频、视频或流消息。本章播放的是“设备端 → 客户端”方向:设备端发送帧时填写的编号,必须与客户端 Output 绑定、订阅和请求关键帧时使用的编号一致。

约定编号时遵守这些规则:

  • stream_id 取值范围是 0 到 15。
  • 同一发送方向内,不同音视频流使用不同编号,音频和视频不能共用编号。
  • 相反方向使用独立的编号空间,可以复用相同数值。例如,客户端发给设备端的对讲音频可以复用某个下行编号;仍需确保该方向的发送端和接收端配置一致。

本文、配套 API Samples 和 DevTools CLI 使用 10 表示设备端音频、11 表示第一路设备端视频,多路播放示例再用 12 表示第二路视频。这些只是便于直接联调的示例值,不是 SDK 保留或固定的编号。实际项目可以改用 0..15 内的其他值,但要同步修改设备端发送和客户端绑定、订阅等配置。

设备端发送音视频 ​

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

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

本文采用第一种方式。客户端调用订阅接口请求音视频后,设备端会收到 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 区分。 下面的示例使用一路音频和一路视频。如果一条连接支持多路音视频,应按 hconn、stream_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_bps,stream_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: TiRtcPlatformView) {
        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();
}
python
from threading import Event

import tirtc

audio_received = Event()
video_received = Event()

with tirtc.AudioOutput(lambda frame: audio_received.set()) as audio_output:
    with tirtc.VideoOutput(lambda frame: video_received.set()) as video_output:
        audio_output.attach(conn, 10)
        video_output.attach(conn, 11)

        # connect() 成功返回后再订阅;设备端也要使用相同的 stream_id。
        conn.connect(remote_id, token=token, timeout=120)
        conn.subscribe_audio(10)
        conn.subscribe_video(11)
        conn.request_video_keyframe(11)

        if not audio_received.wait(30) or not video_received.wait(30):
            raise TimeoutError("没有收到完整的音视频帧")

        conn.unsubscribe_video(11)
        conn.unsubscribe_audio(10)
        video_output.detach()
        audio_output.detach()
        conn.disconnect()

在播放期间切换视频清晰度 ​

TiRTC 没有内建“高清”“标清”等选项,也不会主动通知设备切换。App 与设备端自行约定可选的清晰度及其切换命令;TiRTC 只负责在两端之间传递命令和音视频数据。

约定可选清晰度和切换命令 ​

最简单的方式是 App 与设备端事先约定清晰度选项和一条切换命令,App 发送目标清晰度,设备收到后切换。如果设备支持的选项需要动态获取,也可以再约定一条查询命令,由 App 在 TiRTC 连接建立后通过命令消息向设备查询。

命令使用的命令 ID、payload 格式和清晰度取值都由 App 与设备端自行约定。是否查询可选项、是否让设备返回切换结果,也由业务需要决定。命令发送成功只表示 SDK 已经接受发送请求,不表示设备已经完成切换。

保持同一个 Stream ID 切换 ​

同一个摄像头只输出一路视频时,保持原 Stream ID、Video Output 绑定和订阅不变,由 App 通知设备切换这路视频的清晰度:

  1. App 通过当前连接发送双方约定的清晰度切换命令。
  2. 设备收到命令后调整当前视频流。
  3. 设备继续使用原 Stream ID 和原视频编码格式发送。新档位的第一帧应当是可独立解码的关键帧:H.264 在同一次完整帧提交中依次携带匹配的 SPS、PPS 和 IDR;H.265 携带匹配的 VPS、SPS、PPS 和可独立解码关键帧;MJPEG 提交目标尺寸的完整 JPEG。在这帧之前,不要发送依赖旧编码参数的目标档位增量帧。PTS 在同一时间线上持续递增,帧数据、宽高和关键帧标记相互一致。
  4. 为缩短 App 从发出切换命令到看到新清晰度画面的等待,设备端建议在切档时立即触发并发送上述关键帧,不要等待常规 GOP 中的下一个自然关键帧。否则 App 只能继续等待新的可解码点,新画面可能较晚出现。这是设备端改善切换体验的实现建议,不是 TiRTC SDK 对发送时机的强制要求。
  5. App 保持原 Video Output 和渲染 View,不调用取消订阅、重新订阅或重新绑定。SDK 会继续接收和渲染这一路视频;分辨率变化后,Video Output 会回调新的渲染尺寸。

需要在 App 中显示切换结果时,可以使用设备端的业务响应;分辨率发生变化时,也可以通过 Video Output 的渲染尺寸回调确认新画面已经输出。不要通过反复解绑和订阅判断切换结果,这会产生额外的播放空窗。

清晰度切换是否存在可感知的卡顿或黑屏,取决于设备如何切换码流以及新关键帧何时到达,应在实际设备上验证。播放状态不保证每次都重新经过 buffering,因此不要只依赖状态切换判断是否生效。

正在保存播放内容时,不要让同一个 RecordingTask 跨越分辨率或编码格式变化。先停止任务并等待最终结果,再切换设备输出;确认新视频已经输出后创建新的 RecordingTask。

播放多路音视频 ​

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

如果设备本身已经持续提供独立的主、子码流,也可以为不同清晰度使用不同的 Stream ID,并按本节的多路播放方式切换订阅。选择同一 Stream ID 还是不同 Stream ID,取决于设备原本怎样输出视频流。

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

媒体来源设备端发送时的 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。设备同时接入实时音视频和云录像时,可以维护同一份“媒体来源”配置,但要分别填写两套编号;上表的 10、11、12 不会限制云录像使用哪些 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 / macOS、Web 和 Python SDK 都支持在同一条连接中接收多路视频。调用模型相同:每一路视频创建一个 Video Output,绑定对应的 stream_id,连接成功后再逐路订阅。界面平台为每个 Video Output 提供一个渲染视图;Python 通过各 Output 的 on_frame callback 交付帧。

下面使用视频 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)
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 });
python
video_a = tirtc.VideoOutput(on_video_a)
video_b = tirtc.VideoOutput(on_video_b)

video_a.attach(conn, 11)
video_b.attach(conn, 12)

# connect() 成功返回后逐路订阅。
conn.subscribe_video(11)
conn.subscribe_video(12)
conn.request_video_keyframe(11)
conn.request_video_keyframe(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;
python
task = connection.start_recording(
    video_stream_id=video_stream_id,
    audio_stream_id=audio_stream_id,
)

# 保留 task 并继续接收。用户触发停止时取得临时文件。
with task.stop() as recording_file:
    shutil.copyfile(recording_file.path, destination_mp4)

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

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

SDK 可以把 AAC 音频直接写入 MP4,并把 PCM、G.711 A-law、Opus 或 AMR-NB 转为 AAC-LC。设备发送 AAC-LC 时,每帧必须包含完整 ADTS 头;实际音频采样率、声道数和帧内容必须与设备发送时声明的格式一致。

保存当前画面 ​

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;
python
with video_output.take_snapshot() as snapshot:
    shutil.copyfile(snapshot.path, destination_jpeg)

尚未出画时,截图会返回 no-frame。Flutter 和 React Native 可以调用文件对象的 moveToGallery 保存到系统媒体库;其他平台使用返回的路径自行复制或移动。Python 可以先复制 path 指向的文件,再退出文件对象的 context manager。文件不再需要时调用对应的 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 开发文档