播放音视频
本章介绍如何在已经建立的连接上,由设备端发送音视频,并在客户端完成播放。
设备端 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.264 | TIRTC_VIDEO_H264 | 支持 | 支持 |
| H.265 | TIRTC_VIDEO_H265 | 支持 | 支持 |
| JPEG/MJPEG | TIRTC_VIDEO_JPEG | 支持 | 支持 |
音频格式
| 编码格式 | 设备端 TIRTCFRAMEINFO.media | 客户端 SDK 接收或播放 | Web SDK 播放 |
|---|---|---|---|
| G.711 A-law | TIRTC_AUDIO_ALAW | 支持 | 支持单声道 |
| AAC-LC(ADTS) | TIRTC_AUDIO_AAC | 支持 | 不支持 |
| 16 位 PCM | TIRTC_AUDIO_PCM | 支持 | 不支持 |
| Opus | TIRTC_AUDIO_OPUS | 支持 | 不支持 |
| AMR | TIRTC_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 关键帧为例:
00 00 00 01 [SPS]
00 00 00 01 [PPS]
00 00 00 01 [IDR]H.265 关键帧要携带 VPS、SPS 和 PPS。以 IDR 关键帧为例:
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 的包长可以直接计算:
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创建的连接上下文。
下面的代码省略了采集、编码、发送线程和连接清理的具体实现,只展示订阅状态如何控制每条连接的送流。
#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 不会直接修改编码器,设备应用需要按建议更新视频编码码率。
设备端接入时需要完成三件事:
- 在
TIRTCCALLBACKS中注册on_update_bitrate。 - 连接成功后,为准备发送的视频流调用
TiRtcConnSetVideoBitrateParams(),其中start_bps填当前编码码率。 - 收到
on_update_bitrate后,把target_bitrate_bps投递到编码线程并更新编码器。它是绝对目标值,单位 bps,不是增量或比例。
下面的代码沿用前文定义的 kVideoStreamId 和连接回调。示例中的码率只用于说明调用方式;实际范围应根据设备编码能力、分辨率、帧率和业务需要确定。
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()。
下面三处使用的编号必须一致:
设备端 TIRTCFRAMEINFO.stream_id
= 客户端音视频输出绑定的 streamId
= 客户端 subscribeAudio()/subscribeVideo() 使用的 streamId可以先使用 Flutter Android Example 连接设备,确认设备端送流是否正常:
在 Example 中填写 remote_id(客户端要连接的目标)和 token(本次连接凭证),并确认音频、视频 stream_id 与设备端发送保持一致。
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();
}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();
}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()
}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();
}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 ?? "")")
}
}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();
}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 通知设备切换这路视频的清晰度:
- App 通过当前连接发送双方约定的清晰度切换命令。
- 设备收到命令后调整当前视频流。
- 设备继续使用原 Stream ID 和原视频编码格式发送。新档位的第一帧应当是可独立解码的关键帧:H.264 在同一次完整帧提交中依次携带匹配的 SPS、PPS 和 IDR;H.265 携带匹配的 VPS、SPS、PPS 和可独立解码关键帧;MJPEG 提交目标尺寸的完整 JPEG。在这帧之前,不要发送依赖旧编码参数的目标档位增量帧。PTS 在同一时间线上持续递增,帧数据、宽高和关键帧标记相互一致。
- 为缩短 App 从发出切换命令到看到新清晰度画面的等待,设备端建议在切档时立即触发并发送上述关键帧,不要等待常规 GOP 中的下一个自然关键帧。否则 App 只能继续等待新的可解码点,新画面可能较晚出现。这是设备端改善切换体验的实现建议,不是 TiRTC SDK 对发送时机的强制要求。
- 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 | 本地输出绑定 | 远端送流请求 |
|---|---|---|---|
| 麦克风音频 | 10 | Audio Output 选择音频流 10 | 连接成功后请求设备发送音频流 10 |
| 摄像头 A 视频 | 11 | Video Output A 选择视频流 11 | 连接成功后请求设备发送视频流 11 |
| 摄像头 B 视频 | 12 | Video 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:
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,只展示多路视频相对单路播放新增的对象和调用。
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();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} />
</>
);
}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)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 });
}
}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)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 });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 的最终结果。
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!;val start = connection.startRecording(videoStreamId, audioStreamId)
val task = start.task ?: return
// 保留 task 并继续播放。以下代码在用户触发停止操作时执行。
val result = task.stop()
val file = result.file ?: returnlet 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 }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;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;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。
final result = await videoOutput.takeSnapshot();
if (!result.success || result.data == null) return;
final TiRtcSnapshotFile snapshot = result.data!;val result = videoOutput.takeSnapshot()
val snapshot = result.file ?: returnlet result = await videoOutput.takeSnapshot()
guard let snapshot = result.file else { return }const result = await videoOutput.takeSnapshot();
if (!result.success || result.data === null) return;
const snapshot = result.data;const result = await videoOutput.takeSnapshot();
if (!result.success || result.data === null) return;
const snapshot = result.data;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 中的临时源文件。
需要把客户端麦克风声音发回对端时,继续看语音对讲。
常见问题
已连接但没有声音或画面
连接成功只说明设备端和客户端已经建立数据传输链路,但不代表设备端已经开始发送音视频。先确认媒体有没有从设备端发出,再检查客户端有没有接到正确的流。
确认设备端已经开始送流。
- 按订阅送流时,确认客户端在连接成功后调用了
subscribeAudio()/subscribeVideo(),设备端收到了对应的on_subscribe_audio/on_subscribe_video,并开始调用发送接口。只收到订阅回调但没有真正送流,客户端仍然收不到音视频。 - 连接后直接送流时,确认设备端拿到
hconn后已经开始调用TiRtcSendAudioStream()/TiRtcSendVideoStream()。两种方式都要检查发送接口的返回值。
- 按订阅送流时,确认客户端在连接成功后调用了
确认三处
stream_id完全一致。 对照设备端TIRTCFRAMEINFO.stream_id、客户端音视频输出绑定的streamId,以及subscribeAudio()/subscribeVideo()使用的streamId。音频和视频分别核对,不要把两路编号写反。确认格式和每帧数据符合要求。
media要与实际编码格式一致,而且播放端要支持这种格式。视频流要从关键帧开始;H.264 关键帧检查 SPS、PPS 和可独立解码的图像数据,H.265 关键帧检查 VPS、SPS、PPS 和可独立解码的图像数据。完整检查项见音视频格式与帧要求。确认客户端已经准备好播放。 音频输出和视频输出需要绑定到当前连接;视频输出还要挂载到真实可见、没有被遮挡的视图。继续观察音频输出是否进入
playing/PLAYING,视频输出是否进入rendering/RENDERING或收到尺寸变化回调,并检查对应的错误回调。
仍然无法定位时,按照排查接入问题保留同一次复现的设备端和客户端证据。