音视频流传输与播放
本章介绍如何在已经建立的连接上,由设备端发送音视频,并在客户端完成播放。
如果你还没完成 SDK 接入或连接流程,先看 C SDK 接入、对应客户端的 SDK 接入页(例如 Web)和建立与设备端的连接。
约定音视频流 ID
stream_id 标识同一条连接中的一路音频、视频或流消息。设备端发送、客户端播放、订阅音视频流和请求关键帧时,都要使用双方约定好的编号。
当前支持的范围是:
stream_id取值范围是0到15。- 同一条连接内,音频和视频不能共用同一个
stream_id。
Flutter Android 示例和 DevTools CLI 约定的是下面的值:
| 用途 | stream_id |
|---|---|
| 远端音频播放 | 10 |
| 远端视频播放 | 11 |
设备端发送音视频
客户端连接成功并订阅音视频后,设备端会收到 on_subscribe_audio / on_subscribe_video。回调返回 0 表示接受订阅,非 0 表示拒绝。接受订阅后,设备应当开始向这条连接发送对应的音频或视频;收到 on_unsubscribe_audio / on_unsubscribe_video 后停止发送。
设备端可能同时接受多个客户端连接。实现送流时,先确认下面这些规则:
- 按连接保存送流状态。 每个
hconn都要有独立的上下文。一个客户端订阅,不能让设备端同时向其他没有订阅的连接送流。C SDK 可以通过TiRtcConnSetUserData()和TiRtcConnGetUserData()绑定并取回连接上下文。 - 回调里只更新状态。 SDK 回调运行在内部线程中,不要直接执行采集、编码等耗时操作。连接成功并且对应的送流状态开启后,再由业务线程调用
TiRtcSendAudioStream()/TiRtcSendVideoStream()。 - 多路流继续按
stream_id区分。 下面的示例固定使用一路音频和一路视频。如果一条连接支持多路音视频,应按hconn、stream_id和媒体类型分别保存订阅状态。 - 发送拥塞时尽快恢复关键帧。 如果发送返回
TIRTC_E_BUSY,丢弃后续非关键帧,并尽快补一个关键帧。 - 连接结束后清理状态。 停止这条连接上的全部发送,并释放为该
hconn创建的连接上下文。 - 按业务决定是否需要订阅。 本章以收到订阅后送流为主线。
subscribe不是必要条件;如果连接成功本身就代表客户端需要立即播放,也可以在on_conn_accepted后直接送流,省去一次订阅信令往返。如果连接还用于设备控制等其他业务,建议仍按订阅状态决定是否送流。
下面的代码省略了采集、编码、发送线程和连接清理的具体实现,只展示订阅状态如何控制每条连接的送流。
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <tirtc/tiRTC.h>
static const uint8_t kAudioStreamId = 10;
static const uint8_t kVideoStreamId = 11;
/* 每个 hconn 创建一个 ConnCtx,分别记录这条连接的送流状态。 */
typedef struct {
tirtc_conn_t hconn;
volatile int audio_enabled; /* 客户端是否订阅了音频 stream_id 10。 */
volatile int video_enabled; /* 客户端是否订阅了视频 stream_id 11。 */
volatile int force_key_frame; /* 下一帧视频是否需要输出关键帧。 */
} ConnCtx;
static void on_conn_accepted(tirtc_conn_t hconn)
{
ConnCtx *ctx = (ConnCtx *)calloc(1, sizeof(ConnCtx));
if (!ctx) {
return;
}
ctx->hconn = hconn;
/* 把上下文绑定到 hconn,后续回调可以取回同一条连接的状态。 */
if (TiRtcConnSetUserData(hconn, ctx) != 0) {
free(ctx);
return;
}
/* 启动这条连接的发送线程,初始不送流,等待客户端订阅。 */
}
/* 订阅回调只修改状态,由发送线程真正执行采集、编码和送流。 */
static int on_subscribe_audio(tirtc_conn_t hconn, uint8_t stream_id)
{
ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
if (!ctx || stream_id != kAudioStreamId) {
return -1;
}
/* 发送线程看到 audio_enabled 后,开始向这个 hconn 发送音频。 */
ctx->audio_enabled = 1;
return 0;
}
static int on_subscribe_video(tirtc_conn_t hconn, uint8_t stream_id)
{
ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
if (!ctx || stream_id != kVideoStreamId) {
return -1;
}
/* 开始或恢复视频时,让编码器尽快输出一个关键帧。 */
ctx->force_key_frame = 1;
ctx->video_enabled = 1;
return 0;
}
/* 两个取消订阅回调都只修改当前 hconn,不影响其他客户端连接。 */
static void on_unsubscribe_audio(tirtc_conn_t hconn, uint8_t stream_id)
{
ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
if (ctx && stream_id == kAudioStreamId) {
ctx->audio_enabled = 0;
}
}
static void on_unsubscribe_video(tirtc_conn_t hconn, uint8_t stream_id)
{
ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
if (ctx && stream_id == kVideoStreamId) {
ctx->video_enabled = 0;
}
}
/*
* 下面两个函数只能在连接成功后调用。
* 发送线程还要先检查当前 ConnCtx 的 audio_enabled / video_enabled。
*/
int send_audio(tirtc_conn_t hconn, const void *data, uint32_t len, uint32_t ts_ms)
{
TIRTCFRAMEINFO fi;
memset(&fi, 0, sizeof(fi));
/* 必须和客户端 attach、subscribeAudio() 使用的 streamId 一致。 */
fi.stream_id = kAudioStreamId;
fi.media = TIRTC_AUDIO_ALAW;
fi.flags = TIRTC_AUDIOSAMPLE_8K16B1C;
fi.ts = ts_ms;
fi.length = len;
return TiRtcSendAudioStream(hconn, &fi, data);
}
int send_video(tirtc_conn_t hconn, const void *data, uint32_t len, uint32_t ts_ms, int key_frame)
{
TIRTCFRAMEINFO fi;
memset(&fi, 0, sizeof(fi));
/* 必须和客户端 attach、subscribeVideo() 使用的 streamId 一致。 */
fi.stream_id = kVideoStreamId;
fi.media = TIRTC_VIDEO_H264;
fi.flags = key_frame ? TIRTC_FRAME_FLAG_KEY_FRAME : 0;
fi.ts = ts_ms;
fi.length = len;
return TiRtcSendVideoStream(hconn, &fi, data);
}
static void on_request_key_frame(tirtc_conn_t hconn, uint8_t stream_id)
{
ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
if (ctx && stream_id == kVideoStreamId) {
/* 回调里只改状态,下一次编码时再输出关键帧。 */
ctx->force_key_frame = 1;
}
}
/* 初始化 SDK 时注册这些回调,结构体的生命周期要覆盖整个 SDK 运行期。 */
static const TIRTCCALLBACKS kCallbacks = {
.on_conn_accepted = on_conn_accepted,
.on_subscribe_audio = on_subscribe_audio,
.on_unsubscribe_audio = on_unsubscribe_audio,
.on_subscribe_video = on_subscribe_video,
.on_unsubscribe_video = on_unsubscribe_video,
.on_request_key_frame = on_request_key_frame,
};播放远端音视频
客户端需要把音视频输出绑定到准备播放的远端流。连接成功后,调用 subscribeAudio() / subscribeVideo();停止播放时,调用对应的 unsubscribeAudio() / unsubscribeVideo()。
下面三处使用的编号必须一致:
设备端 TIRTCFRAMEINFO.stream_id
= 客户端音视频输出绑定的 streamId
= 客户端 subscribeAudio()/subscribeVideo() 使用的 streamId可以先使用 Flutter Android Example 连接设备,确认设备端送流是否正常:
在 Example 中填写设备端的 remote_id 和连接信息,并确认音频、视频 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 ?? "")")
}
}const appId = 'your-app-id';
TiRtc.initialize(TiRtcInitOptions({ appId }));
const readyPromise = TiRtc.videoOutputReady();
const conn = new TiRtcConn();
// 10 播放音频,11 显示视频;设备端发送时也要使用这两个 stream_id。
const audioOutput = TiRtcAudioOutput({ connection: conn, streamId: 10 });
const videoOutput = TiRtcVideoOutput({ connection: conn, streamId: 11 });
async function startPlayback({
deviceId,
token,
}) {
await readyPromise;
await conn.connect({ deviceId, token });
audioOutput.attach();
videoOutput.attach();
// await connect() 成功后才能发起订阅。
conn.subscribeAudio({ streamId: 10 });
conn.subscribeVideo({ streamId: 11 });
}
function stopPlayback() {
// 停止播放时先取消订阅,再解除输出绑定并断开连接。
conn.unsubscribeVideo({ streamId: 11 });
conn.unsubscribeAudio({ streamId: 10 });
videoOutput.detach();
audioOutput.detach();
conn.disconnect();
}需要把客户端麦克风声音发回对端时,继续看语音对讲。
成功信号
- 连接进入
connected/CONNECTED。 - 音频输出进入
playing/PLAYING。 - 视频输出进入
rendering/RENDERING,或收到视频尺寸变化回调。
常见问题
已连接但没有声音或画面
连接成功只说明双方已经建立数据传输链路,但不代表设备端已经开始发送音视频。先确认媒体有没有从设备端发出,再检查客户端有没有接到正确的流。
确认设备端已经开始送流。
- 按订阅送流时,确认客户端在连接成功后调用了
subscribeAudio()/subscribeVideo(),设备端收到了对应的on_subscribe_audio/on_subscribe_video,并开始调用发送接口。只收到订阅回调但没有真正送流,客户端仍然收不到音视频。 - 连接后直接送流时,确认设备端拿到
hconn后已经开始调用TiRtcSendAudioStream()/TiRtcSendVideoStream()。两种方式都要检查发送接口的返回值。
- 按订阅送流时,确认客户端在连接成功后调用了
确认三处
stream_id完全一致。 对照设备端TIRTCFRAMEINFO.stream_id、客户端音视频输出绑定的streamId,以及subscribeAudio()/subscribeVideo()使用的streamId。音频和视频分别核对,不要把两路编号写反。确认客户端已经准备好播放。 音频输出和视频输出需要绑定到当前连接;视频输出还要挂载到真实可见、没有被遮挡的视图。继续观察音频输出是否进入
playing/PLAYING,视频输出是否进入rendering/RENDERING或收到尺寸变化回调,并检查对应的错误回调。