播放音视频
本章介绍如何在已经建立的连接上,由设备端发送音视频,并在客户端完成播放。
设备端 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.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。每路视频流发送的第一帧必须是关键帧。收到 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 标识同一条连接中的一路音频、视频或流消息。设备端发送、客户端播放、订阅音视频流和请求关键帧时,设备端和客户端要为同一路流使用相同编号。
当前支持的范围是:
stream_id取值范围是0到15。- 同一条连接内,音频和视频不能共用同一个
stream_id。
本文示例使用下面的值:
| 用途 | stream_id |
|---|---|
| 设备端发送的音频 | 10 |
| 设备端发送的视频 A | 11 |
| 设备端发送的视频 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区分。 下面的示例固定使用一路音频和一路视频。如果一条连接支持多路音视频,应按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: 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 ?? "")")
}
}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
}
}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 | 本地输出绑定 | 远端送流请求 |
|---|---|---|---|
| 麦克风音频 | 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、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,只展示多路视频相对单路播放新增的对象和调用。
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)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
}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 的最终结果。
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, 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。
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;snapshot, err := videoOutput.TakeSnapshot()
if err != nil {
return err
}尚未出画时,截图会返回 no-frame。Flutter 和 React Native 可以调用文件对象的 moveToGallery 保存到系统媒体库;其他平台使用返回的路径自行复制或移动。文件不再需要时调用 delete,Go 调用 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或收到尺寸变化回调,并检查对应的错误回调。
仍然无法定位时,按照排查接入问题保留同一次复现的设备端和客户端证据。