RTMP 直播推流 Demo(二)—— 音频推流与视频推流

音视频编解码系列目录:

Android 音视频基础知识
Android 音视频播放器 Demo(一)------ 视频解码与渲染
Android 音视频播放器 Demo(二)------ 音频解码与音视频同步
RTMP 直播推流 Demo(一)------ 项目配置与视频预览
RTMP 直播推流 Demo(二)------ 音频推流与视频推流

上一节我们对项目进行了配置,并且实现了摄像头预览,摄像头采集到的图像数据已经可以通过 LivePusher 传递到 Native 层,接下来就可以开始音视频编码与推流了。

1、视频编码

通过 CameraHelper 可以获取视频的帧数据,我们需要对其进行编码再将数据"塞"到 RTMPPacket 中,然后才能将 RTMPPacket 发送给服务器。

编码之前,需要先对编码器等元素进行初始化。

1.1 初始化

Native 层的初始化是由 LivePusher 触发的:

kotlin 复制代码
class LivePusher(
    activity: Activity,
    cameraId: Int,
    previewWidth: Int,
    previewHeight: Int,
    fps: Int,
    bitrate: Int
) {
    private val mVideoChannel: VideoChannel
    private val mAudioChannel: AudioChannel

    init {
        // 这个 nativeInit() 要在 AudioChannel 之前初始化,因为后者初始化时会用到 Native 方法
        nativeInit()
        mVideoChannel =
            VideoChannel(this, activity, cameraId, previewWidth, previewHeight, fps, bitrate)
        mAudioChannel = AudioChannel(this, 44100, 2)
    }
}

Native 入口为 native-lib,初始化时要创建 Native 层的 VideoChannel 和 AudioChannel:

cpp 复制代码
// 视频通道,处理视频编码等工作
VideoChannel *videoChannel = nullptr;
// 音频通道,处理音频编码
AudioChannel *audioChannel = nullptr;

extern "C"
JNIEXPORT void JNICALL
Java_com_rtmp_pusher_LivePusher_nativeInit(JNIEnv *env, jobject thiz) {
    videoChannel = new VideoChannel;
    videoChannel->setVideoCallback(callback);
    audioChannel = new AudioChannel;
    audioChannel->setAudioCallback(callback);
    // releasePacket 是丢弃 AVPacket 队列中元素的回调函数
    packets.setReleaseCallback(releasePacket);
}

我们使用 x264 进行视频编码,在编码前需要对 x264 编码器进行初始化,初始化的时机是 SurfaceView 的尺寸发生变化时(因为编码器初始化需要宽高数据)。在 CameraHelper 的 setPreviewOrientation() 中我们通过 OnSurfaceSizeChangedListener 将宽高变化发送出去了,在 VideoChannel 中接收时做编码器的初始化:

kotlin 复制代码
	// CameraHelper.OnSurfaceSizeChangedListener
    override fun onSizeChanged(width: Int, height: Int) {
        mLivePusher.nativeInitVideoEncoder(width, height, mFps, mBitrate)
    }

在 native-lib 的对应函数中,将初始化的具体工作交给 Native 的 VideoChannel:

cpp 复制代码
extern "C"
JNIEXPORT void JNICALL
Java_com_rtmp_pusher_LivePusher_nativeInitVideoEncoder(JNIEnv *env, jobject thiz, jint width,
                                                       jint height, jint fps, jint bitrate) {
    if (videoChannel) {
        videoChannel->initVideoEncoder(width, height, fps, bitrate);
    }
}

initVideoEncoder() 主要是设置 x264 编码器的参数并打开编码器,此外还需根据宽高数据创建接收视频一帧数据(理解成一张图片)的对象 x264_picture_t:

cpp 复制代码
/**
 * 初始化 x264 编码器,为了防止被多次初始化,需要加互斥锁保证线程安全
 */
void VideoChannel::initVideoEncoder(int width, int height, int fps, int bitrate) {
    pthread_mutex_lock(&mutex);

    // 初始化编码器所需的参数
    mWidth = width;
    mHeight = height;
    mFps = fps;
    mBitrate = bitrate;

    // y 的个数为宽高之积,uv 的数量分别都是 y 的 1/4
    y_len = width * height;
    uv_len = y_len / 4;

    // 防止重复初始化编码器
    if (videoEncoder) {
        x264_encoder_close(videoEncoder);
        videoEncoder = nullptr;
    }
    // 防止重复初始化一帧图片
    if (pic_in) {
        x264_picture_clean(pic_in);
        DELETE(pic_in)
    }

    // 初始化 x264 编码器
    x264_param_t param;
    // 超快、零延迟,因为直播要求时效性
    x264_param_default_preset(&param, "ultrafast", "zerolatency");
    // 编码级别,代表了编码器能够处理的视频参数和编码限制,如视频分辨率、帧率、比特率等,32 为中等偏上,
    // 对应的最大编码码率为 2000,最大清晰度为 1280 * 720,最高帧率为 60 帧
    param.i_level_idc = 32;
    // 输出格式为 YUV420P,P 是 Planar 表示 UV 平面格式,例如 VVVVUUUU,没有 P 就是交错模式 VUVUVUVU
    param.i_csp = X264_CSP_I420;
    param.i_width = width;
    param.i_height = height;
    // 设置两个 picture 之间的 B 帧数量为 0。直播不能有 B 帧,因为影响编解码效率
    param.i_bframe = 0;
    // 码率控制方式:CQP(恒定质量)、CRF(恒定码率)、ABR(平均码率)
    param.rc.i_rc_method = X264_RC_CRF;
    // 码率、瞬时最大码率、码率控制区大小(单位 Kb/s,设置了瞬时最大码率就必须设置它)
    param.rc.i_bitrate = bitrate / 1000;
    param.rc.i_vbv_max_bitrate = bitrate / 1000 * 1.2;
    param.rc.i_vbv_buffer_size = bitrate / 1000;
    // 0 表示只使用 fps 进行码率控制,1 表示使用时间基和时间戳
    param.b_vfr_input = 0;
    // 帧率分子与分母
    param.i_fps_num = fps;
    param.i_fps_den = 1;
    // 时间基的分子与分母,二者做除法可以算出帧之间的时间间隔,音视频同步时有用
    param.i_timebase_num = param.i_fps_den;
    param.i_timebase_den = param.i_fps_num;
    // 设置编码器的最大关键帧间隔,编码器在编码视频时会尽量保持每隔 fps * 2 帧生成一个关键帧,即约 2 秒一个关键帧
    param.i_keyint_max = fps * 2;
    // 是否重复头部,设置为 1,编码时会在每个关键帧之前加入 SPS/PPS
    param.b_repeat_headers = 1;
    // 指定并行编码线程数,如果设置为 0 则会由 x264 编码库自动决定线程数
    param.i_threads = 1;
    // 将 param 参数应用到 H.264/AVC 规范定义的编码配置文件上,这些文件包括 baseline、main、high 等
    x264_param_apply_profile(&param, "baseline");

    // 为 pic_in 分配内存空间并初始化内部成员
    pic_in = new x264_picture_t;
    x264_picture_alloc(pic_in, param.i_csp, param.i_width, param.i_height);

    // 打开编码器
    videoEncoder = x264_encoder_open(&param);
    if (videoEncoder) {
        LOGD("成功打开x264编码器");
    }

    pthread_mutex_unlock(&mutex);
}

说明:

  1. 参数设置中用到的方法 x264_param_default_preset() 和 x264_param_apply_profile() 都有可选参数,这些参数在源码的函数上面以数组的形式呈现。比如 x264_param_default_preset():

    cpp 复制代码
    static const char * const x264_preset_names[] = { "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo", 0 };
    
    static const char * const x264_tune_names[] = { "film", "animation", "grain", "stillimage", "psnr", "ssim", "fastdecode", "zerolatency", 0 };
    
    /*  returns 0 on success, negative on failure (e.g. invalid preset/tune name). */
    X264_API int x264_param_default_preset( x264_param_t *, const char *preset, const char *tune );

    x264_preset_names 就是 preset 的可选参数数组,x264_tune_names 就是 tune 的可选参数数组,如果选 0 表示使用默认配置。其他函数也是类似的套路。

  2. 参数全部设置完毕后通过 x264_encoder_open() 打开编码器会得到一个 x264_t 类型的对象,也就是编码器对象,我们将其设置为成员变量:

    cpp 复制代码
    class VideoChannel {
    ...
    private:
        ...
        // x264 解码器
        x264_t *videoEncoder;
        // 表示 x264 的一帧图片
        x264_picture_t *pic_in;
    };

    此外 x264_picture_t 表示 x264 的一帧图片,也保存为成员变量,在通过 x264_picture_alloc() 为其分配了内存之后,编码器的初始化工作就完成了

1.2 将数据传递到 Native 层

初始化完毕可以进行编码了,上层的 VideoChannel 在 CameraHelper.OnPreviewListener 接口的 onPreviewFrame() 中会接收到 CameraHelper 传来的视频数据:

kotlin 复制代码
	// CameraHelper.OnPreviewListener
    override fun onPreviewFrame(data: ByteArray) {
        if (mIsLiving) {
            mLivePusher.nativePushVideo(data)
        }
    }

mIsLiving 表示是否开始直播推流了,通过如下方法进行控制:

kotlin 复制代码
	fun startLive() {
        mIsLiving = true
    }

    fun stopLive() {
        mIsLiving = false
    }

具体的编码工作还是交给 Native 的 VideoChannel 执行:

cpp 复制代码
extern "C"
JNIEXPORT void JNICALL
Java_com_rtmp_pusher_LivePusher_nativePushVideo(JNIEnv *env, jobject thiz, jbyteArray data_) {
    if (!videoChannel || !readyPushing) {
        return;
    }

    jbyte *data = env->GetByteArrayElements(data_, nullptr);
    videoChannel->encodeData(data);
    env->ReleaseByteArrayElements(data_, data, 0);
}

encodeData() 的内容较多,我们放在下一小节中单独讲解。

1.3 编码

编码的工作包括如下内容:

  1. 将摄像头采集到的 NV21 格式数据转换为 I420 格式数据
  2. 使用 x264 对一帧图片进行编码
  3. 将编码后的数据发送到队列中

格式转换

由于安卓摄像头采集的是 NV21 格式,但 Windows 或 IOS 都是 I420 格式,因此需要进行转换。二者的不同之处在于 UV 的排列方式:

    Y Y Y Y Y Y      Y Y Y Y Y Y      
    Y Y Y Y Y Y      Y Y Y Y Y Y      
    Y Y Y Y Y Y      Y Y Y Y Y Y      
    Y Y Y Y Y Y      Y Y Y Y Y Y      
    V U V U V U      U U U U U U
    V U V U V U      V V V V V V            
     - NV21 -          - I420 - 

因此我们要把 YUV 按照 I420 的排列方式存入 pic_in 中:

cpp 复制代码
void VideoChannel::encodeData(signed char *data) {
    pthread_mutex_lock(&mutex);

    /*
     * 1.将摄像头采集到的 NV21 格式转换为 I420 格式
     */
    // 先将 y 拷贝到 pic_in->img.plane[0]
    memcpy(pic_in->img.plane[0], data, y_len);
    // 再拷贝 vu,u、v 分别存放在 pic_in->img.plane[1],pic_in->img.plane[2]
    // 在 NV21 中,vu 成对出现,v 在前
    for (int i = 0; i < uv_len; ++i) {
        *(pic_in->img.plane[1] + i) = *(data + y_len + i * 2 + 1);
        *(pic_in->img.plane[2] + i) = *(data + y_len + i * 2);
    }
    ...
}

用 x264 编码

使用 x264_encoder_encode() 对刚刚得到的 pic_in 进行编码,得到编码后的 NAL 数组 nal 以及 NALU 的数量 pi_nal,当然还有编码后的图片 pic_out:

cpp 复制代码
void VideoChannel::encodeData(signed char *data) {
    ...

    /*
     * 2.编码
     */
    // 编码后得到的 NAL 数组
    x264_nal_t *nal = nullptr;
    // NAL 中 NALU 的数量
    int pi_nal;
    // 编码后输出的图片
    x264_picture_t pic_out;
    // 编码一张图片,如果失败会返回负值
    if (x264_encoder_encode(videoEncoder, &nal, &pi_nal, pic_in, &pic_out) < 0) {
        LOGE("x264编码失败");
        pthread_mutex_unlock(&mutex);
        return;
    }
    ...
}

发送到队列中

上一步我们得到了编码后的 NALU 数组,接下来要做的就是将这些 NALU 封装进 RTMPPacket 并发送到队列中。在转换过程中,对于不同的 NALU 类型有不同的操作。这里我们先简单复习一下 NALU 的相关知识。

H.264 原始码流(又称为裸流),是由一个接一个的 NALU 组成的:

每个 NALU 之间都有一个起始码 00 00 00 01(此 NALU 有多片)或 00 00 01(此 NALU 只有一片),而 NALU 本身由 NAL 头和挂载数据 RBSP 组成,其中 NAL 头的低五位表示帧类型,我们需要关注的类型有四种:

  1. NAL 头的数据为 0x67,低五位为 0x67 & 0x1F = 7,类型是 NAL_SPS,表示这是一个 SPS 帧
  2. NAL 头的数据为 0x68,低五位为 0x68 & 0x1F = 8,类型是 NAL_PPS,表示这是一个 PPS 帧
  3. NAL 头的数据为 0x65,低五位为 0x65 & 0x1F = 5,类型是 NAL_SLICE_IDR,表示这是一个 IDR 帧,即关键帧
  4. NAL 头的数据为 0x61,低五位为 0x61 & 0x1F = 1,类型是 NAL_SLICE,表示这是一个非关键帧

这四种类型的格式如下,其中 SPS 与 PPS 是放在一起的:

这里要解释一下为什么第一个字节是 0x17 或 0x27,下表是第一个字节的含义:

字段 占位 描述
FrameType 4 帧类型。 1: key frame(for AVC, a seekable frame) 2: inter frame(for AVC, a non-seekable frame) 3: disposable inter frame(H.263 only) 4: generated keyframe(reserved for server use only) 5: video info/command frame
CodecID 4 编码类型。 1: JPEG(目前未用到) 2: Sorenson H.263 3: Screen video 4: On2 VP6 5: On2 VP6 with alpha channel 6: Screen video version 2 7: AVC(高级视频编码)

关键帧的 FrameType 是 1,非关键帧的 FrameType 是 2,CodecID 都是 AVC 即 7,因此关键帧的第一个字节是 0x17,非关键帧第一个字节是 0x27。

此外,SPS 与 PPS 的数据需要注意每个字节的内容:

接下来进入代码:

cpp 复制代码
void VideoChannel::encodeData(signed char *data) {
	/*
     * 3.将编码后的数据发送到队列中
     */
    // SPS 与 PPS 的长度
    int sps_len, pps_len;
    // SPS 与 PPS 的内容
    uint8_t sps[100], pps[100];
    // 视频帧的展示时间戳要进行累加
    pic_in->i_pts += 1;

    for (int i = 0; i < pi_nal; ++i) {
        if (nal[i].i_type == NAL_SPS) {
            // SPS 的长度和内容都要刨除起始码的 4 个字节
            sps_len = nal[i].i_payload - 4;
            memcpy(sps, nal[i].p_payload + 4, sps_len);
        } else if (nal[i].i_type == NAL_PPS) {
            // PPS 的长度和内容也要刨除起始码的 4 个字节
            pps_len = nal[i].i_payload - 4;
            memcpy(pps, nal[i].p_payload + 4, pps_len);
            // PPS 在 SPS 后面,这里既然拿到了 PPS,说明 SPS 也已经拿到了,可以发送二者了
            sendSpsPps(sps, pps, sps_len, pps_len);
        } else {
            // 发送非 SPS、PPS 的数据帧,可能是 I 帧或 P 帧
            sendFrame(nal[i].i_type, nal[i].i_payload, nal[i].p_payload);
        }
    }

    pthread_mutex_unlock(&mutex);
}

sendSpsPps() 参照上面的表格填写每个字节的数据:

cpp 复制代码
/**
 * 将 SPS 与 PPS 发送到队列中
 */
void VideoChannel::sendSpsPps(uint8_t *sps, uint8_t *pps, int sps_len, int pps_len) {
    /*
     * 1.创建一个 RTMPPacket 并分配空间
     */
    // RTMPPacket 的数据长度
    int body_size = 5 + 8 + sps_len + 3 + pps_len;
    auto *packet = new RTMPPacket;
    RTMPPacket_Alloc(packet, body_size);

    /*
     * 2.逐个字节填充 RTMPPacket 的内容
     */
    int index = 0;
    // SPS 与 PPS 的第一个字节都是 0x17
    packet->m_body[index++] = 0x17;
    // 第 2 ~ 5 个字节都是 0
    packet->m_body[index++] = 0x00;
    packet->m_body[index++] = 0x00;
    packet->m_body[index++] = 0x00;
    packet->m_body[index++] = 0x00;
    // 第 6 个字节表示配置版本,这里我们写 0x01
    packet->m_body[index++] = 0x01;
    // 第 7 ~ 9 个字节分别是 sps[1]、sps[2]、sps[3]
    packet->m_body[index++] = sps[1];
    packet->m_body[index++] = sps[2];
    packet->m_body[index++] = sps[3];
    // 第 10 个字节是包长数据所使用的字节数,通常为 0xFF
    packet->m_body[index++] = 0xFF;
    // 第 11 个字节表示 SPS 个数,通常为 0xE1
    packet->m_body[index++] = 0xE1;
    // 接下来是 SPS 长度,用两个字节表示,先存高 8 位
    packet->m_body[index++] = (sps_len >> 8) & 0xFF;
    packet->m_body[index++] = sps_len & 0xFF;
    // SPS 数据内容
    memcpy(&packet->m_body[index], sps, sps_len);
    index += sps_len;
    // 最后是 PPS 的个数、长度和内容,与 SPS 类型
    packet->m_body[index++] = 0x01;
    packet->m_body[index++] = (pps_len >> 8) & 0xFF;
    packet->m_body[index++] = pps_len & 0xFF;
    memcpy(&packet->m_body[index], pps, pps_len);

    /*
     * 3.封包处理
     */
    // 包的类型是视频包
    packet->m_packetType = RTMP_PACKET_TYPE_VIDEO;
    // 包的大小
    packet->m_nBodySize = body_size;
    // 通道 ID,可随意赋值但不要与 rtmp.c 内的 m_nChannel = 0x04 冲突
    packet->m_nChannel = 10;
    // SPS 与 PPS 没有时间戳
    packet->m_nTimeStamp = 0;
    // 不使用绝对时间戳,而是使用相对时间戳
    packet->m_hasAbsTimestamp = 0;
    // 包的头部类型,像 SPS 与 PPS 这种不是很大的可以设置为中等大小
    packet->m_headerType = RTMP_PACKET_SIZE_MEDIUM;

    /*
     * 4.通过回调将 RTMPPacket 存入队列,因为队列是在 native-lib 中控制的
     */
    videoCallback(packet);
}

sendFrame() 就是发送关键帧或非关键帧的数据,要比发送 SPS 和 PPS 简单些:

cpp 复制代码
/**
 * 将数据帧(I 帧或 P 帧)发送到队列中
 * @param type 帧的类型
 * @param payload_size 帧的数据长度
 * @param payload 帧数据
 */
void VideoChannel::sendFrame(int type, int payload_size, uint8_t *payload) {
    /*
     * 1.先计算 RTMPPacket 的数据长度,该长度由固定的前 5 个字节,加上数据长度的 4 个
     * 字节,最后加上 H.264 裸数据构成。真正的数据长度是 payload_size 减去起始码的长度
     */
    if (payload[2] == 0x00) {
        // 起始码是 00 00 00 01,那么有效的数据大小要减去 4 字节,
        // payload 的指针也要相应的跳过起始码的 4 字节
        payload_size -= 4;
        payload += 4;
    } else if (payload[2] == 0x01) {
        // 起始码是 00 00 01
        payload_size -= 3;
        payload += 3;
    }

    int body_size = 5 + 4 + payload_size;

    /*
     * 2.创建 RTMPPacket 并填入数据
     */
    auto packet = new RTMPPacket;
    RTMPPacket_Alloc(packet, body_size);

    // 关键帧的第 1 个字节是 0x17,非关键帧是 0x27
    if (type == NAL_SLICE_IDR) {
        packet->m_body[0] = 0x17;
    } else {
        packet->m_body[0] = 0x27;
    }

    // 第 2 个字节为 1 表示是关键帧或非关键帧,为 0 就是 PPS、SPS 包
    packet->m_body[1] = 0x01;
    packet->m_body[2] = 0x00;
    packet->m_body[3] = 0x00;
    packet->m_body[4] = 0x00;

    // 接下来的 4 个字节是帧长度,先保存最高的 8 位,以此类推
    packet->m_body[5] = (payload_size >> 24) & 0xFF;
    packet->m_body[6] = (payload_size >> 16) & 0xFF;
    packet->m_body[7] = (payload_size >> 8) & 0xFF;
    packet->m_body[8] = payload_size & 0xFF;

    // 从第 10 个字节开始拷贝帧数据
    memcpy(&packet->m_body[9], payload, payload_size);

    /*
     * 3.封包操作,与 sendSpsPps() 类似,不同之处在于帧数据有时间戳,
     * 并且 m_headerType 为 LARGE 包的类型是视频包
     */
    packet->m_packetType = RTMP_PACKET_TYPE_VIDEO;
    // 包的大小
    packet->m_nBodySize = body_size;
    // 通道 ID,可随意赋值但不要与 rtmp.c 内的 m_nChannel 冲突
    packet->m_nChannel = 10;
    // 帧数据有时间戳
    packet->m_nTimeStamp = -1;
    // 不需要绝对或相对时间戳
    packet->m_hasAbsTimestamp = 0;
    // 包的类型,关键帧数据量大,要设置为大包
    packet->m_headerType = RTMP_PACKET_SIZE_LARGE;

    /*
     * 4.把 RTMPPacket 回调给 native-lib 存入队列中
     */
    videoCallback(packet);
}

两个发送函数最后都是通过 videoCallback 这个回调接口将 RTMPPacket 存入队列的:

cpp 复制代码
class VideoChannel {
public:
    typedef void (*VideoCallback)(RTMPPacket *packet);
    void setVideoCallback(VideoCallback videoCallback);
private:
    VideoCallback videoCallback;
}

native-lib 实现它:

cpp 复制代码
SafeQueue<RTMPPacket *> packets;

void callback(RTMPPacket *packet) {
    if (packet) {
        if (packet->m_nTimeStamp == -1) {
            // I/P/B帧需要时间戳,但是 SPS 和 PPS 本身没有时间戳也就不需要
            packet->m_nTimeStamp = RTMP_GetTime() - start_time;
        }
        packets.put(packet);
    }
}

如果 packet 的 m_nTimeStamp 之前设置为 -1,表示它需要时间戳,那么就计算出时间戳赋值给它,然后放进线程安全的队列 packets 中。

2、视频推流

用户点击界面的开始直播按钮,会触发 LivePusher 的 startLive(),进而调用 nativeStart(),执行对应的 Native 函数,需要开启一个子线程 pid_start 来连接 RTMP 服务器并发送 RTMPPacket 的工作:

cpp 复制代码
/**
 * 在子线程中开启 task_start 任务,通过 RTMP 连接服务器并推送数据
 */
extern "C"
JNIEXPORT void JNICALL
Java_com_rtmp_pusher_LivePusher_nativeStart(JNIEnv *env, jobject thiz, jstring path_) {
    // 避免多次开启
    if (isStart) {
        return;
    }

    isStart = true;
    const char *path = env->GetStringUTFChars(path_, nullptr);

    // 深拷贝,以免在子线程中使用 path 时,path 已经在本函数末尾被回收
    char *url = new char[strlen(path) + 1];
    strcpy(url, path);

    // 子线程开启 task_start 任务
    pthread_create(&pid_start, nullptr, task_start, url);

    // 回收 path
    env->ReleaseStringUTFChars(path_, path);
}

线程任务 task_start 会连接 RTMP 服务器,并且在一个循环中不断取出 RTMPPacket 发送给服务器实现推流。RTMP 的使用步骤如下:

task_start 的内容就是实现如上步骤:

cpp 复制代码
/**
 * 链接 RTMP 服务器,并不断从队列中取出 RTMPPacket 发送至服务器
 * 这里有两个问题需要再精进一些:
 * 1.考虑是否要将 1 ~ 7 步放在 do-while(false) 循环中?
 * 答:如果添加重试功能,可能需要这么做,以更好地进行流程控制
 * 2.如果推流速度远远慢于 RTMPPacket 入队的速度怎么办?
 * 答:可能会造成内存溢出,需要添加入队的速度控制,当前 Demo 代码可以正常运行
 */
void *task_start(void *args) {
    char *url = static_cast<char *>(args);

    // 1.申请内存
    RTMP *rtmp = RTMP_Alloc();
    if (!rtmp) {
        LOGE("RTMP_Alloc failed");
        return nullptr;
    }

    // 2.初始化
    RTMP_Init(rtmp);
    // 设置连接的超时时间,单位为秒
    rtmp->Link.timeout = 5;

    // 3.设置地址
    if (!RTMP_SetupURL(rtmp, url)) {
        LOGE("RTMP_SetupURL failed");
        return nullptr;
    }

    // 4.开启输出模式
    RTMP_EnableWrite(rtmp);

    // 5.连接服务器
    if (!RTMP_Connect(rtmp, nullptr)) {
        LOGE("RTMP_Connect failed");
        return nullptr;
    }

    // 6.连接流
    if (!RTMP_ConnectStream(rtmp, 0)) {
        LOGE("RTMP_ConnectStream failed");
        return nullptr;
    }

    // 记录开始时间,准备向服务器推流
    start_time = RTMP_GetTime();
    readyPushing = true;
    packets.setEnable(true);
    RTMPPacket *packet = nullptr;

    // 获取并将音频头存入队列,实测不用放这个头也是可以成功将音频推流的
    callback(audioChannel->getAudioSeqHeader());

    // 在循环中将数据发送至服务器
    while (readyPushing) {
        // 从队列中取出 packet,队列为空时会阻塞
        packets.get(packet);

        if (!readyPushing) {
            break;
        }

        if (!packet) {
            continue;
        }

        // 成功取出 packet 后为其设置流的 ID
        packet->m_nInfoField2 = rtmp->m_stream_id;
        // 7.发送数据,1 是开启内部缓冲,在收到结果前,packet 会保存在队列中
        if (!RTMP_SendPacket(rtmp, packet, 1)) {
            // RTMP 发送失败会自动断开与服务器的连接,因此就跳出循环
            LOGE("RTMP_SendPacket failed");
            releasePacket(&packet);
            break;
        }
    }
    // 考虑到可能从 if (!readyPushing) 跳出循环的情况,也要进行释放
    releasePacket(&packet);

    // 更新状态
    isStart = false;
    readyPushing = false;
    packets.setEnable(false);
    packets.clear();

    if (rtmp) {
        // 8.关闭连接
        RTMP_Close(rtmp);
        // 9.释放
        RTMP_Free(rtmp);
    }

    DELETE(url)

    return nullptr;
}

代码就是按照图片给出的顺序连接服务器,然后在推流状态下不断从队列中取出 RTMPPacket,通过 RTMP_SendPacket() 发送给服务器,从而实现推流。

3、音频编码与推流

3.1 采集音频

上层的 AudioChannel 控制 AudioRecord 录制音频:

kotlin 复制代码
class AudioChannel(
    private val mLivePusher: LivePusher,
    sampleRateInHz: Int,
    channels: Int
) {
    private val mExecutor: ExecutorService
    private var mAudioRecord: AudioRecord?

    // 比如单通道样本数是 1024,那么双声道就是 2048,再加上采样位数是 16 位,
    // 再乘 2 就是 4096,也就是 mInputSamples 的值了
    private val mInputSamples: Int
    private var mIsLiving = false

    init {
        mExecutor = Executors.newSingleThreadExecutor()

        // 通道配置
        val channelConfig = if (channels == 2)
            AudioFormat.CHANNEL_IN_STEREO
        else
            AudioFormat.CHANNEL_IN_MONO

        // AudioRecord 所需最小的缓冲区大小
        val minBufferSize = AudioRecord.getMinBufferSize(
            sampleRateInHz,
            channelConfig,
            AudioFormat.ENCODING_PCM_16BIT // 采样位数设置为 16 位
        ) * 2

        // 还需获取 faac 编码器缓冲区的大小,获取前需要先初始化 faac 编码器
        mLivePusher.nativeInitAudioEncoder(sampleRateInHz, channels)
        // 采样位数为 16 位,需要再乘以 2 个字节
        mInputSamples = mLivePusher.getInputSamples() * 2

        mAudioRecord = AudioRecord(
            MediaRecorder.AudioSource.MIC,
            sampleRateInHz,
            channelConfig,
            AudioFormat.ENCODING_PCM_16BIT,
            max(minBufferSize, mInputSamples) // 缓冲区取大的,如果取小了数据不完整
        )
    }

    fun startLive() {
        mIsLiving = true
        mExecutor.submit {
            // 开始录音
            mAudioRecord?.startRecording()
            // 录音缓冲区
            val bytes = ByteArray(mInputSamples)
            var len: Int
            while (mIsLiving) {
                len = mAudioRecord?.read(bytes, 0, bytes.size) ?: 0
                if (len > 0) {
                    mLivePusher.nativePushAudio(bytes)
                }
            }
            mAudioRecord?.stop()
        }
    }

    fun stopLive() {
        mIsLiving = false
    }

    fun release() {
        mAudioRecord?.release()
        mAudioRecord = null
    }
}

3.2 Native 层

上层的 AudioChannel 在 init 时调用了 LivePusher 的 nativeInitAudioEncoder() 对音频编码器进行初始化,这项工作落到 Native 层的 AudioChannel 上:

cpp 复制代码
void AudioChannel::initAudioEncoder(int sample_rate_in_hz, int channels) {
    this->channels = channels;

    // 1.打开 faac 编码器。前两个是入参,后两个是出参
    audioEncoder = faacEncOpen(sample_rate_in_hz, channels, &inputSamples, &maxOutputBytes);
    if (!audioEncoder) {
        LOGE("打开 faac 编码器失败");
        return;
    }

    // 2.配置编码器参数
    faacEncConfigurationPtr config = faacEncGetCurrentConfiguration(audioEncoder);
    // 使用 MPEG4 版本编码
    config->mpegVersion = MPEG4;
    // LC 标准,因为要求的质量不高,因此编解码较快
    config->aacObjectType = LOW;
    // 16 位
    config->inputFormat = FAAC_INPUT_16BIT;
    // 比特流输出格式为 Raw,设置为 1 的话就是 ADTS
    config->outputFormat = 0;
    // 开启 Temporal Noise Shaping,该音频编码技术用于减小编码后的音频中的噪声
    config->useTns = 1;
    // 禁用 Low-Frequency Effect 低频效果
    config->useLfe = 0;

    // 3.把参数设置给编码器
    if (!faacEncSetConfiguration(audioEncoder, config)) {
        LOGE("FAAC 音频编码器参数配置失败");
        return;
    }
    LOGD("FAAC 音频编码器初始化成功");

    buffer = new u_char(maxOutputBytes);
}

随后,上层的 AudioChannel 采集到一帧音频,就会调用 LivePusher 的 nativePushAudio() 让 Native 层编码:

cpp 复制代码
extern "C"
JNIEXPORT void JNICALL
Java_com_rtmp_pusher_LivePusher_nativePushAudio(JNIEnv *env, jobject thiz, jbyteArray data_) {
    if (!audioChannel || !readyPushing) {
        return;
    }
    jbyte *data = env->GetByteArrayElements(data_, nullptr);
    audioChannel->encodeData(data);
    env->ReleaseByteArrayElements(data_, data, 0);
}

具体的编码工作还是由 Native 的 AudioChannel 完成:

cpp 复制代码
void AudioChannel::encodeData(int8_t *data) {
    // 1.编码音频数据
    int byteLen = faacEncEncode(audioEncoder, reinterpret_cast<int32_t *>(data),
                                inputSamples, buffer, maxOutputBytes);
    if (byteLen > 0) {
        // 2.实例化 RTMPPacket 并分配空间
        auto packet = new RTMPPacket;
        int body_size = 2 + byteLen;
        RTMPPacket_Alloc(packet, body_size);

        // 3.设置 RTMPPacket 内容
        // 声道数
        if (channels == 2) {
            packet->m_body[0] = 0xAF;
        } else {
            packet->m_body[0] = 0xAE;
        }
        // 编码出的音频数据都是 0x01
        packet->m_body[1] = 0x01;
        // 音频数据
        memcpy(&packet->m_body[2], buffer, byteLen);

        // 4.封包,与视频封包类似
        // 包类型为音频
        packet->m_packetType = RTMP_PACKET_TYPE_AUDIO;
        packet->m_nBodySize = body_size;
        packet->m_nChannel = 11;
        // 音频帧数据有时间戳
        packet->m_nTimeStamp = -1;
        // 一般不用绝对时间戳
        packet->m_hasAbsTimestamp = 0;
        // 帧数据类型一般是大包,如果是头信息,可以给一个中包或小包
        packet->m_headerType = RTMP_PACKET_SIZE_LARGE;

        // 5.回调加入队列
        audioCallback(packet);
    }
}

由于 RTMPPacket 本身是不区分音频与视频的,也就是说音视频数据编码完成后都可以存入 RTMPPacket 然后通过回调添加到 native-lib 的 RTMPPacket 队列中。最后的 audioCallback 在讲视频初始化时已经写过,回调函数与视频通道的回调函数相同,都是 native-lib 的 callback():

cpp 复制代码
void callback(RTMPPacket *packet) {
    if (packet) {
        if (packet->m_nTimeStamp == -1) {
            // I 帧需要时间戳,但是 SPS 和 PPS 本身没有时间戳也就不需要
            packet->m_nTimeStamp = RTMP_GetTime() - start_time;
        }
        packets.put(packet);
    }
}

此外,回看视频推流一节中的 task_start(),在 while 循环开始发送 RTMPPacket 前,有通过 callback() 向队列中发送一个音频头:

cpp 复制代码
	// 获取并将音频头存入队列,实测不用放这个头也是可以成功将音频推流的
    callback(audioChannel->getAudioSeqHeader());

当然实测时发现没有音频头拉流端也能正常播放音频。音频头具体设置如下:

cpp 复制代码
RTMPPacket *AudioChannel::getAudioSeqHeader() {
    u_char *ppBuffer;
    // 这个 len 对于头而言固定为 2
    u_long len;
    faacEncGetDecoderSpecificInfo(audioEncoder, &ppBuffer, &len);

    auto packet = new RTMPPacket;
    int body_size = 2 + len;
    RTMPPacket_Alloc(packet, body_size);

    // 3.设置 RTMPPacket 内容
    // 声道数
    if (channels == 2) {
        packet->m_body[0] = 0xAF;
    } else {
        packet->m_body[0] = 0xAE;
    }
    // 序列头为 0x00
    packet->m_body[1] = 0x00;
    // 音频数据
    memcpy(&packet->m_body[2], ppBuffer, len);

    // 4.封包,与视频封包类似
    // 包类型为音频
    packet->m_packetType = RTMP_PACKET_TYPE_AUDIO;
    packet->m_nBodySize = body_size;
    packet->m_nChannel = 11;
    // 音频帧的头一般都是没有时间搓的
    packet->m_nTimeStamp = 0;
    // 一般不用绝对时间戳
    packet->m_hasAbsTimestamp = 0;
    // 帧数据类型一般是大包,如果是头信息,可以给一个中包或小包
    packet->m_headerType = RTMP_PACKET_SIZE_LARGE;

    return packet;
}

4、释放与测试结果

释放工作主要是 Native 层的各种成员变量。

首先 native-lib 中要删除两个 Channel 并等待线程 pid_start 执行完毕:

cpp 复制代码
extern "C"
JNIEXPORT void JNICALL
Java_com_rtmp_pusher_LivePusher_nativeStop(JNIEnv *env, jobject thiz) {
    isStart = false;
    readyPushing = false;
    packets.setEnable(false);
    // 等待 pid_start 线程执行完再做后续的善后工作
    pthread_join(pid_start, nullptr);
}

extern "C"
JNIEXPORT void JNICALL
Java_com_rtmp_pusher_LivePusher_nativeRelease(JNIEnv *env, jobject thiz) {
    DELETE(videoChannel)
    DELETE(audioChannel)
}

VideoChannel 的析构函数要销毁同步锁、关闭编码器:

cpp 复制代码
VideoChannel::~VideoChannel() {
    pthread_mutex_destroy(&mutex);
    releaseVideoCodec();
    videoCallback = nullptr;
}

void VideoChannel::releaseVideoCodec() {
    if (videoEncoder) {
        // 释放通过 x264_picture_alloc 分配内存的图片的相关资源
        x264_picture_clean(pic_in);

        // 关闭编码器
        x264_encoder_close(videoEncoder);

        // 释放编码器结构体,通过该函数将结构体内部的字段置空
        x264_encoder_parameters(videoEncoder, nullptr);

        videoEncoder = nullptr;
    }
}

AudioChannel 需要关闭音频编码器,回收 buffer:

cpp 复制代码
AudioChannel::~AudioChannel() {
    DELETE(buffer)
    if (audioEncoder) {
        faacEncClose(audioEncoder);
        audioEncoder = nullptr;
    }
    audioCallback = nullptr;
}

最终的演示效果,手机端推流:

拉流端通过 ffplay 拉流:

shell 复制代码
ffplay -i rtmp://xxx.xx.xx.xx/myapp

效果图:

相关推荐
runing_an_min4 分钟前
ffmpeg视频滤镜:提取缩略图-framestep
ffmpeg·音视频·framestep
小曲曲1 小时前
接口上传视频和oss直传视频到阿里云组件
javascript·阿里云·音视频
安静读书3 小时前
Python解析视频FPS(帧率)、分辨率信息
python·opencv·音视频
佑华硬盘拷贝机3 小时前
音频档案批量拷贝:专业SD拷贝机解决方案
音视频
EasyNVR4 小时前
NVR管理平台EasyNVR多个NVR同时管理:全方位安防监控视频融合云平台方案
安全·音视频·监控·视频监控
长亭外的少年7 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
建群新人小猿10 小时前
会员等级经验问题
android·开发语言·前端·javascript·php
xcLeigh11 小时前
HTML5超酷响应式视频背景动画特效(六种风格,附源码)
前端·音视频·html5
1024小神11 小时前
tauri2.0版本开发苹果ios和安卓android应用,环境搭建和最后编译为apk
android·ios·tauri
兰琛11 小时前
20241121 android中树结构列表(使用recyclerView实现)
android·gitee