FFmpeg - Android 直播推拉流

直播推拉流程

1. 搭建自己的流媒体服务器

在实际的开发过程中,我们是可以不用自己来搭建流媒体服务器的,访问后台的接口会返回媒体房间和 IM 房间。但现在我们自己测试就无法用公司的接口了,当然也可以去抓一些第三方的直播接口,我强烈不推荐大家这么做。最好的办法就是自己搭建一个简单的流媒体服务器。

首先登录自己的云主机,下载解压 nginxrtmp

sudo wget https://github.com/nginx/nginx/archive/release-1.17.1.tar.gz
sudo wget https://github.com/arut/nginx-rtmp-module/archive/v1.2.1.tar.gz
sudo tar -zxvf release-1.17.1.tar.gz
sudo tar -zxvf v1.2.1.tar.gz

然后编译安装 nginxrtmp

./auto/configure --add-module=/lib/nginx/nginx-rtmp-module-1.2.1
make
make install

最后配置测试流媒体服务器

cd /usr/local/nginx/sbin/
./nginx
.\ffmpeg.exe -re -i 01.mp4 -vcodec libx264 -acodec aac -f flv rtmp://148.70.96.230/myapp/mystream

2. 集成 RTMP 推流的源码

当我们的流媒体服务器搭建好后,要用 ffmpeg 测试一下,确保流媒体服务器搭建成功后,我们再来集成 RTMP 推流的源码。

git clone git://git.ffmpeg.org/rtmpdump

set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DNO_CRYPTO")

/**
 * 初始化连接流媒体服务器
 */
void *initConnectFun(void *context) {
    DZLivePush *pLivePush = (DZLivePush *) context;
    // 创建 RTMP
    pLivePush->pRtmp = RTMP_Alloc();
    // 初始化 RTMP
    RTMP_Init(pLivePush->pRtmp);
    // 设置连接超时
    pLivePush->pRtmp->Link.timeout = 10;
    pLivePush->pRtmp->Link.lFlags |= RTMP_LF_LIVE;
    RTMP_SetupURL(pLivePush->pRtmp, pLivePush->url);
    RTMP_EnableWrite(pLivePush->pRtmp);
    // 连接失败回调到 java 层
    if (!RTMP_Connect(pLivePush->pRtmp, NULL)) {
        LOGE("connect url error");
        pLivePush->pJniCall->callConnectError(THREAD_CHILD, RTMP_CONNECT_ERROR_CODE, "connect url error");
        return (void *) RTMP_CONNECT_ERROR_CODE;
    }
    if (!RTMP_ConnectStream(pLivePush->pRtmp, 0)) {
        LOGE("connect stream url error");
        pLivePush->pJniCall->callConnectError(THREAD_CHILD, RTMP_STREAM_CONNECT_ERROR_CODE, "connect stream url error");
        return (void *) RTMP_STREAM_CONNECT_ERROR_CODE;
    }
    // 连接成功也回调到 Java 层,可以开始推流了
    LOGE("connect succeed");
    pLivePush->pJniCall->callConnectSuccess(THREAD_CHILD);
    return (void *) 0;
}

3. H.264 协议介绍

我们打算采用最常见的 H.264 来编码推流,那么现在我们不得不来了解一下 H.264 的协议了,这些东西虽说看似比较枯燥复杂,但这也是最最重要的部分。首先需要明确 H264 可以分为两层:1.VCL video codinglayer(视频编码层),2.NAL network abstraction layer(网络提取层)。对于 VCL 具体的编解码算法这里暂时先不介绍,只介绍常用的 NAL 层,即网络提取层,这是解码的基础。


NAL

SPS:序列参数集
PPS:图像参数集
I帧:帧内编码帧,可独立解码生成完整的图片。
P帧: 前向预测编码帧,需要参考其前面的一个I 或者B 来生成一张完整的图片。
B帧: 双向预测内插编码帧,则要参考其前一个I或者P帧及其后面的一个P帧来生成一张完整的图片

根据上面所说,现在我们就得思考几个问题了:

  1. SPS 和 PPS 到底存的是什么数据?
  2. 我们怎么判断获取每一个 NALU ?
  3. 如何判断某一个 NALU 是 I 帧、P 帧、 B 帧还是其他?

4. 直播推流视频数据

关于怎么预览相机,怎么编码成 H264,怎么获取 SPS 和 PPS 大家需要先看看之前的《FFmpeg - 朋友圈录制视频添加背景音乐》。为了确保直播过程中进来的用户也可以正常的观看直播,我们需要在每个关键帧前先把 SPS 和 PPS 推送到流媒体服务器。

/**
 * 发送 sps 和 pps 到流媒体服务器
 * @param spsData sps 的数据
 * @param spsLen sps 的数据长度
 * @param ppsData pps 的数据
 * @param ppsLen pps 的数据长度
 */
void DZLivePush::pushSpsPps(jbyte *spsData, jint spsLen, jbyte *ppsData, jint ppsLen) {
    // frame type : 1关键帧,2 非关键帧 (4bit)
    // CodecID : 7表示 AVC (4bit)  , 与 frame type 组合起来刚好是 1 个字节  0x17
    // fixed : 0x00 0x00 0x00 0x00 (4byte)
    // configurationVersion  (1byte)  0x01版本
    // AVCProfileIndication  (1byte)  sps[1] profile
    // profile_compatibility (1byte)  sps[2] compatibility
    // AVCLevelIndication    (1byte)  sps[3] Profile level
    // lengthSizeMinusOne    (1byte)  0xff   包长数据所使用的字节数

    // sps + pps 的数据
    // sps number            (1byte)  0xe1   sps 个数
    // sps data length       (2byte)  sps 长度
    // sps data                       sps 的内容
    // pps number            (1byte)  0x01   pps 个数
    // pps data length       (2byte)  pps 长度
    // pps data                       pps 的内容

    // body 长度 = spsLen + ppsLen + 上面所罗列出来的 16 字节
    int bodySize = spsLen + ppsLen + 16;
    // 初始化创建 RTMPPacket
    RTMPPacket *pPacket = static_cast<RTMPPacket *>(malloc(sizeof(RTMPPacket)));
    RTMPPacket_Alloc(pPacket, bodySize);
    RTMPPacket_Reset(pPacket);

    // 按照上面的协议,开始一个一个给 body 赋值
    char *body = pPacket->m_body;
    int index = 0;

    // CodecID 与 frame type 组合起来刚好是 1 个字节  0x17
    body[index++] = 0x17;
    // fixed : 0x00 0x00 0x00 0x00 (4byte)
    body[index++] = 0x00;
    body[index++] = 0x00;
    body[index++] = 0x00;
    body[index++] = 0x00;
    //0x01版本
    body[index++] = 0x01;
    // sps[1] profile
    body[index++] = spsData[1];
    // sps[2] compatibility
    body[index++] = spsData[2];
    // sps[3] Profile level
    body[index++] = spsData[3];
    // 0xff   包长数据所使用的字节数
    body[index++] = 0xff;

    // 0xe1   sps 个数
    body[index++] = 0xe1;
    // sps 长度
    body[index++] = (spsLen >> 8) & 0xff;
    body[index++] = spsLen & 0xff;
    // sps 的内容
    memcpy(&body[index], spsData, spsLen);
    index += spsLen;
    // 0x01   pps 个数
    body[index++] = 0x01;
    // pps 长度
    body[index++] = (ppsLen >> 8) & 0xff;
    body[index++] = ppsLen & 0xff;
    // pps 的内容
    memcpy(&body[index], ppsData, ppsLen);

    // 设置 RTMPPacket 的参数
    pPacket->m_packetType = RTMP_PACKET_TYPE_VIDEO;
    pPacket->m_nBodySize = bodySize;
    pPacket->m_nTimeStamp = 0;
    pPacket->m_hasAbsTimestamp = 0;
    pPacket->m_nChannel = 0x04;
    pPacket->m_headerType = RTMP_PACKET_SIZE_MEDIUM;
    pPacket->m_nInfoField2 = this->pRtmp->m_stream_id;
    // 添加到发送队列
    pPacketQueue->push(pPacket);
}

紧接着发送每一帧的数据

/**
 * 发送每一帧的视频数据到服务器
 * @param videoData
 * @param dataLen
 * @param keyFrame
 */
void DZLivePush::pushVideo(jbyte *videoData, jint dataLen, jboolean keyFrame) {
    // frame type : 1关键帧,2 非关键帧 (4bit)
    // CodecID : 7表示 AVC (4bit)  , 与 frame type 组合起来刚好是 1 个字节  0x17
    // fixed : 0x01 0x00 0x00 0x00 (4byte)  0x01  表示 NALU 单元

    // video data length       (4byte)  video 长度
    // video data

    // body 长度 = dataLen + 上面所罗列出来的 9 字节
    int bodySize = dataLen + 9;
    // 初始化创建 RTMPPacket
    RTMPPacket *pPacket = static_cast<RTMPPacket *>(malloc(sizeof(RTMPPacket)));
    RTMPPacket_Alloc(pPacket, bodySize);
    RTMPPacket_Reset(pPacket);

    // 按照上面的协议,开始一个一个给 body 赋值
    char *body = pPacket->m_body;
    int index = 0;

    // CodecID 与 frame type 组合起来刚好是 1 个字节  0x17
    if (keyFrame) {
        body[index++] = 0x17;
    } else {
        body[index++] = 0x27;
    }
    // fixed : 0x01 0x00 0x00 0x00 (4byte)  0x01  表示 NALU 单元
    body[index++] = 0x01;
    body[index++] = 0x00;
    body[index++] = 0x00;
    body[index++] = 0x00;

    // (4byte)  video 长度
    body[index++] = (dataLen >> 24) & 0xff;
    body[index++] = (dataLen >> 16) & 0xff;
    body[index++] = (dataLen >> 8) & 0xff;
    body[index++] = dataLen & 0xff;
    // video data
    memcpy(&body[index], videoData, dataLen);

    // 设置 RTMPPacket 的参数
    pPacket->m_packetType = RTMP_PACKET_TYPE_VIDEO;
    pPacket->m_nBodySize = bodySize;
    pPacket->m_nTimeStamp = RTMP_GetTime() - startPushTime;
    pPacket->m_hasAbsTimestamp = 0;
    pPacket->m_nChannel = 0x04;
    pPacket->m_headerType = RTMP_PACKET_SIZE_LARGE;
    pPacket->m_nInfoField2 = this->pRtmp->m_stream_id;
    pPacketQueue->push(pPacket);
}

5. 直播推流音频数据

最后就是把录制的声音数据推到媒体房间,这部分流程跟视频推流类似。

/**
 * 发送音频数据到服务器
 * @param audioData 
 * @param dataLen 
 */
void DZLivePush::pushAudio(jbyte *audioData, jint dataLen) {
    // 2 字节头信息
    // 前四位表示音频数据格式 AAC  10(A)
    // 五六位表示采样率 0 = 5.5k  1 = 11k  2 = 22k  3(11) = 44k
    // 七位表示采样采样的精度 0 = 8bits  1 = 16bits
    // 八位表示音频类型  0 = mono  1 = stereo
    // 我们这里算出来第一个字节是 0xAF
    // 0x01 代表 aac 原始数据

    // body 长度 = dataLen + 上面所罗列出来的 2 字节
    int bodySize = dataLen + 2;
    // 初始化创建 RTMPPacket
    RTMPPacket *pPacket = static_cast<RTMPPacket *>(malloc(sizeof(RTMPPacket)));
    RTMPPacket_Alloc(pPacket, bodySize);
    RTMPPacket_Reset(pPacket);

    // 按照上面的协议,开始一个一个给 body 赋值
    char *body = pPacket->m_body;
    int index = 0;
    // 我们这里算出来第一个字节是 0xAF
    body[index++] = 0xAF;
    body[index++] = 0x01;
    // audio data
    memcpy(&body[index], audioData, dataLen);

    // 设置 RTMPPacket 的参数
    pPacket->m_packetType = RTMP_PACKET_TYPE_AUDIO;
    pPacket->m_nBodySize = bodySize;
    pPacket->m_nTimeStamp = RTMP_GetTime() - startPushTime;
    pPacket->m_hasAbsTimestamp = 0;
    pPacket->m_nChannel = 0x04;
    pPacket->m_headerType = RTMP_PACKET_SIZE_LARGE;
    pPacket->m_nInfoField2 = this->pRtmp->m_stream_id;
    pPacketQueue->push(pPacket);
}

万丈高楼平地起,这些的确都很简单基础,后面其实还有很多扩展,比如美颜滤镜,IM 聊天房间,礼物动画贴图等等。

这是 NDK 实战的最后一篇文章了,周六日我们花了接近半年的时间来学习,我知道很多同学并未从事这方面的开发,最后我再啰嗦啰嗦,给大家打点鸡血。我个人是很幸运的,能把学到的东西用到工作中,但在三年前我其实也不知道,自己以后会从事这方面的工作。

这部分知识也是大家从中级到高级进阶的一个必经过程,通过学习 NDK 我们能把 Android 的上下层打通,以前只能是阅读 Java 层的代码,到现在能阅读 FrameWorker 的 Native 层源码,且 Android 很多的核心代码都是在 C/C++ 中,因此当我们无法看懂这部分代码时,我们很难说自己理解了 Android,也不能算是一个高级工程师,我希望大家可以从这些方面去花些时间。

从开发布局上来说,我们已经能做一些别人不能做的东西了,个人的价值在于别人不能做的我们能做,这其实就是一个学习成本的问题,因此目前从事 NDK 开发的工作相比于只是简单的做 Android 应用的薪资来说要高个 1.5 - 2 倍,至少我们公司是这样。且现在很多企业招聘岗位也都是要求会 NDK 开发者优先。

为何我们不继续接着讲 OpenGL 和音视频的一些高级知识呢?目前讲的这些东西都是很基础的,高级进阶内容其实是在我们后面规划中的,这些知识其实很少有人能跟上来,从最近的上课活跃度就能体现出来,这也是为什么在网上我们几乎找不到系统的学习方案,出了问题也很难查到答案的一个原因,希望大家可以多花些时间。

视频地址:https://pan.baidu.com/s/19eFV02TyjOD3few0HlZs1w
视频密码:6u50

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343