Skip to content

播放音视频

这页讲的是:在一条已经建立的连接上,设备端如何开始发送音视频,客户端如何按约定的 stream_id 接收并播放。

如果你还没完成建连,先看建立连接

先准备这几项

  • 设备端已经启动,并且已经在 on_conn_accepted(hconn) 里拿到这次连接的 hconn
  • 客户端已经持有这次播放要使用的 conn 连接对象
  • 双端已经约定好音频流和视频流使用的 stream_id

什么是 stream_id

stream_id 用来标识同一条连接里的某一条 stream

你可以把它理解成“这条连接里第几路音频、视频或流内消息”的编号。比如你们约定 10 表示主音频、11 表示主视频,那么设备端发送 stream_id = 11 的视频帧时,客户端就应该把 streamId = 11 绑定到对应的视频输出对象。

这里先只看已经明确的公开约束:

  • 取值范围是 015
  • 一条连接上可以同时有多条独立音视频流,用 stream_id 区分。
  • 在同一条连接里,音频和视频不能共用同一个 stream_id

stream_id 只回答“这是哪一条 stream”;音频还是视频,看 media

双端只需要先约定:哪个编号给音频,哪个编号给视频。只要一致,而且不冲突,就可以工作。

什么时候开始发送

连接建立,不等于媒体已经开始发送。常见做法有三种:

做法客户端处理设备端处理
连上就发只要绑定好 output 并建连成功,就直接接收on_conn_accepted(hconn) 后直接开始发送
收到订阅再发连接进入 CONNECTED 后调用 subscribeVideo(streamId) / subscribeAudio(streamId);停止前再调用对应 unsubscribe*()on_subscribe_video / on_subscribe_audio 里开始,在 on_unsubscribe_* 里停止
业务自己决定由你的业务决定何时订阅、退订或请求关键帧由你的业务状态机决定何时开始、何时停止

不管你选哪种做法,都要满足这些要求:

  • 每路视频的第一帧必须是关键帧。
  • 第一帧关键帧要尽快发出去。
  • 收到 on_request_key_frame 后,下一帧要切成关键帧。
  • 如果你按订阅发送,就要真的响应 on_subscribe_*on_unsubscribe_*,客户端也要显式发起对应的订阅或退订。

设备端:把音视频送上这条连接

设备端通常会为每条连接维护一份上下文:回调里改状态,工作线程里真正编码和发送。

c
#include <stdint.h>
#include <string.h>

enum {
  kAudioStreamId = 10,
  kVideoStreamId = 11,
};

typedef struct {
  tirtc_conn_t hconn;
  volatile int audio_enabled;
  volatile int video_enabled;
  volatile int force_video_key_frame;
} ConnCtx;

static int send_h264_frame(
    tirtc_conn_t hconn,
    const void *data,
    uint32_t len,
    uint32_t ts_ms,
    int is_key_frame) {
  TIRTCFRAMEINFO fi = {0};
  fi.stream_id = kVideoStreamId;
  fi.media = TIRTC_VIDEO_H264;
  fi.ts = ts_ms;
  fi.length = len;
  if (is_key_frame) {
    fi.flags |= TIRTC_FRAME_FLAG_KEY_FRAME;
  }
  return TiRtcSendVideoStream(hconn, &fi, data);
}

static int send_g711a_frame(
    tirtc_conn_t hconn,
    const void *data,
    uint32_t len,
    uint32_t ts_ms) {
  TIRTCFRAMEINFO fi = {0};
  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);
}

static int on_subscribe_video(tirtc_conn_t hconn, uint8_t stream_id) {
  ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
  if (ctx != NULL && stream_id == kVideoStreamId) {
    ctx->video_enabled = 1;
    ctx->force_video_key_frame = 1;
  }
  return 0;
}

static void on_unsubscribe_video(tirtc_conn_t hconn, uint8_t stream_id) {
  ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
  if (ctx != NULL && stream_id == kVideoStreamId) {
    ctx->video_enabled = 0;
  }
}

static int on_subscribe_audio(tirtc_conn_t hconn, uint8_t stream_id) {
  ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
  if (ctx != NULL && stream_id == kAudioStreamId) {
    ctx->audio_enabled = 1;
  }
  return 0;
}

static void on_unsubscribe_audio(tirtc_conn_t hconn, uint8_t stream_id) {
  ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
  if (ctx != NULL && stream_id == kAudioStreamId) {
    ctx->audio_enabled = 0;
  }
}

static void on_request_key_frame(tirtc_conn_t hconn, uint8_t stream_id) {
  ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
  if (ctx != NULL && stream_id == kVideoStreamId) {
    ctx->force_video_key_frame = 1;
  }
}

这段代码已经把发送控制面收住了:固定 stream_id、响应 subscribe / unsubscribe、响应关键帧请求。

继续把发送循环接起来时,再盯住这几件事:

  • 每路视频的第一帧必须是关键帧。
  • 收到 on_request_key_frame 后,下一帧切成关键帧。
  • 如果发送的是 JPEG,每帧天然就是关键帧。
  • TiRtcSendVideoStream() 返回 TIRTC_E_BUSY 时,不要继续堆非关键帧;非关键帧可以丢,关键帧短暂重试。
  • 回调都在 SDK 内部线程里执行;回调里只改状态,不直接做阻塞 IO、编码或长时间计算。
  • 收到 on_conn_error 后,这条连接上的收发已经不再可靠,不要继续沿用旧 hconn

如果你选择“连上就发”,就在 on_conn_accepted(hconn) 后直接打开发送;如果你选择“业务自己决定”,就由你的业务代码同时控制设备端发送开关和客户端对应的订阅、退订动作。

客户端:按同一组 stream_id 接收并播放

客户端这边至少要做四件事:创建对象、绑定 streamId、发起连接、在需要时订阅媒体流。

attach(...) 只决定本地播放哪一条 stream;真正请求远端开始或停止发送的是 subscribe*() / unsubscribe*()。下面示例采用“收到订阅再发”策略;如果你的设备端是“连上就发”,可以省略订阅和退订这一步。

kotlin
val conn = TiRtcConn()
val audioOutput = TiRtcAudioOutput()
val videoOutput = TiRtcVideoOutput()

conn.onStateChanged = TiRtcConnStateListener { state ->
  Log.i("TiRTC", "conn state=$state")
  if (state == TiRtcConnState.CONNECTED) {
    conn.subscribeAudio(streamId = 10)
    conn.subscribeVideo(streamId = 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}")
}

videoOutput.attachView(videoContainer)
audioOutput.attach(conn, streamId = 10)
videoOutput.attach(conn, streamId = 11)

val code = conn.connect(remoteId = remoteId, token = token)
if (code != 0) {
  Log.e("TiRTC", "connect request failed code=$code")
}

// 页面不可见时,如果远端按订阅再发,就先退订,再停播断连;页面销毁时再最终 release。
if (conn.state == TiRtcConnState.CONNECTED) {
  conn.unsubscribeAudio(streamId = 10)
  conn.unsubscribeVideo(streamId = 11)
}
audioOutput.detach()
videoOutput.detach()
conn.disconnect()
videoOutput.detachView()
audioOutput.release()
videoOutput.release()
conn.release()
swift
final class PlayerController: NSObject, TiRtcConnDelegate,
  TiRtcAudioOutputDelegate, TiRtcVideoOutputDelegate {
  lazy var conn = TiRtcConn(delegate: self)
  let audioOutput = TiRtcAudioOutput()
  let videoOutput = TiRtcVideoOutput()

  func start(remoteId: String, token: String, videoView: UIView) {
    audioOutput.delegate = self
    videoOutput.delegate = self
    _ = videoOutput.attachView(videoView)
    _ = audioOutput.attach(to: conn, streamId: 10)
    _ = videoOutput.attach(to: conn, streamId: 11)

    let code = conn.connect(to: remoteId, token: token)
    if code != 0 {
      print("connect request failed: \(code)")
    }
  }

  func stop() {
    if conn.state == .connected {
      _ = conn.unsubscribeAudio(streamId: 10)
      _ = conn.unsubscribeVideo(streamId: 11)
    }
    audioOutput.detach()
    videoOutput.detach()
    conn.disconnect()
  }

  func dispose() {
    videoOutput.detachView()
    audioOutput.invalidate()
    videoOutput.invalidate()
    conn.invalidate()
  }

  func conn(_ conn: TiRtcConn, didChangeState state: TiRtcConnState) {
    print("conn state=\(state)")
    if state == .connected {
      _ = conn.subscribeAudio(streamId: 10)
      _ = conn.subscribeVideo(streamId: 11)
    }
  }

  func audioOutput(_ output: TiRtcAudioOutput, didChangeState state: TiRtcAudioOutputState) {
    print("audio state=\(state)")
  }

  func videoOutput(_ output: TiRtcVideoOutput, didChangeState state: TiRtcVideoOutputState) {
    print("video state=\(state)")
  }

  func videoOutput(_ output: TiRtcVideoOutput, didChangeRenderSize size: CGSize) {
    print("video size=\(size.width)x\(size.height)")
  }
}
dart
final TiRtcConn conn = TiRtcConn();
final TiRtcAudioOutput audioOutput = TiRtcAudioOutput();
final TiRtcVideoOutput videoOutput = TiRtcVideoOutput();

conn.onStateChanged = (state) {
  debugPrint('conn state=$state');
  if (state == TiRtcConnState.connected) {
    conn.subscribeAudio(streamId: 10);
    conn.subscribeVideo(streamId: 11);
  }
};
audioOutput.onStateChanged = (state) {
  debugPrint('audio state=$state');
};
videoOutput.onStateChanged = (state) {
  debugPrint('video state=$state');
};
videoOutput.onRenderSizeChanged = (size) {
  debugPrint('video size=${size.width}x${size.height}');
};

audioOutput.attach(connection: conn, streamId: 10);
videoOutput.attach(connection: conn, streamId: 11);

final int code = conn.connect(remoteId: remoteId, token: token);
if (code != 0) {
  debugPrint('connect request failed code=$code');
}

// 页面销毁时,如果远端按订阅再发,就先退订,再释放对象。
if (conn.state == TiRtcConnState.connected) {
  conn.unsubscribeAudio(streamId: 10);
  conn.unsubscribeVideo(streamId: 11);
}
videoOutput.detach();
videoOutput.dispose();
audioOutput.detach();
audioOutput.dispose();
conn.disconnect();
conn.dispose();

跑通后的常见信号是:

  • 连接进入 CONNECTED
  • 音频开始稳定输出
  • 视频开始稳定播放
  • 视频输出拿到渲染尺寸变化回调

客户端这边再记三点:

  • attach(...) 只决定本地消费哪一条 stream;设备端如果采用“收到订阅再发”,你还要显式调用 subscribeAudio() / subscribeVideo(),退出前再调用对应的 unsubscribe*()
  • 如果画面刚接入、解码器刚恢复,或者画面已经花掉,可以主动调用 requestKeyFrame(streamId) 请求对端补关键帧。
  • detach() / disconnect() 不是最终清理;对象生命周期结束时,还要继续 release()invalidate()dispose()

相关文档

Ti RTC 开发文档