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

音视频编解码系列目录:

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

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

1、视频编码

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

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

1.1 初始化

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

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:

// 视频通道,处理视频编码等工作
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 中接收时做编码器的初始化:

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

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

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:

/**
 * 初始化 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():

    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 类型的对象,也就是编码器对象,我们将其设置为成员变量:

    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 传来的视频数据:

	// CameraHelper.OnPreviewListener
    override fun onPreviewFrame(data: ByteArray) {
        if (mIsLiving) {
            mLivePusher.nativePushVideo(data)
        }
    }

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

	fun startLive() {
        mIsLiving = true
    }

    fun stopLive() {
        mIsLiving = false
    }

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

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 中:

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:

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 组成的:

3.2.5-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 是放在一起的:

2024-3-9.H264视频包格式

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

字段占位描述
FrameType4帧类型。
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
CodecID4编码类型。
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 的数据需要注意每个字节的内容:

2024-3-9.SPS与PPS格式

接下来进入代码:

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() 参照上面的表格填写每个字节的数据:

/**
 * 将 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 简单些:

/**
 * 将数据帧(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 存入队列的:

class VideoChannel {
public:
    typedef void (*VideoCallback)(RTMPPacket *packet);
    void setVideoCallback(VideoCallback videoCallback);
private:
    VideoCallback videoCallback;
}

native-lib 实现它:

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 的工作:

/**
 * 在子线程中开启 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 的使用步骤如下:

3.2.5.2-RtmpDump代码流程

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

/**
 * 链接 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 录制音频:

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 上:

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 层编码:

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 完成:

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():

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() 向队列中发送一个音频头:

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

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

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 执行完毕:

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 的析构函数要销毁同步锁、关闭编码器:

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:

AudioChannel::~AudioChannel() {
    DELETE(buffer)
    if (audioEncoder) {
        faacEncClose(audioEncoder);
        audioEncoder = nullptr;
    }
    audioCallback = nullptr;
}

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

2024-4-22.直播效果4

拉流端通过 ffplay 拉流:

ffplay -i rtmp://xxx.xx.xx.xx/myapp

效果图:

2024-4-22.直播效果2

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/588233.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Linux开发板 FTP 服务器移植与搭建

VSFTPD&#xff08;Very Secure FTP Daemon&#xff09;是一个安全、稳定且快速的FTP服务器软件&#xff0c;广泛用于Unix和Linux操作系统。它以其轻量级、高效和易于配置而受到赞誉。VSFTPD不仅支持标准的FTP命令和操作&#xff0c;还提供了额外的安全特性&#xff0c;如匿名F…

【Go语言快速上手(六)】管道, 网络编程,反射,用法讲解

&#x1f493;博主CSDN主页:杭电码农-NEO&#x1f493;   ⏩专栏分类:Go语言专栏⏪   &#x1f69a;代码仓库:NEO的学习日记&#x1f69a;   &#x1f339;关注我&#x1faf5;带你学习更多Go语言知识   &#x1f51d;&#x1f51d; GO快速上手 1. 前言2. 初识管道3. 管…

面试:Spring(IOC、AOP、事务失效、循环引用、SpringMVC、SpringBoot的自动配置原理、Spring框架常见注解)

目录 一、Spring的单例Bean是否是线程安全的&#xff1f; 二、什么是AOP 1、介绍 &#xff08;1&#xff09;记录操作日志 &#xff08;2&#xff09;实现Spring中的事务 三、spring中事务失效的场景有哪些&#xff1f; 1、异常捕获处理 2、抛出检查异常 3、非public方…

【yolov8】yolov8剪枝训练流程

yolov8剪枝训练流程 流程&#xff1a; 约束剪枝微调 一、正常训练 yolo train model./weights/yolov8s.pt datayolo_bvn.yaml epochs100 ampFalse projectprun nametrain二、约束训练 2.1 修改YOLOv8代码&#xff1a; ultralytics/yolo/engine/trainer.py 添加内容&#…

freertos入门---创建FreeRTOS工程

freertos入门—创建FreeRTOS工程 1 STM32CubeMx配置 双击运行STM32CubeMX,在首页选择“ACCESS TO MCU SELECTOR”,如下图所示&#xff1a;   在MCU选型界面&#xff0c;输入自己想要开发的芯片型号&#xff0c;如&#xff1a;STM32F103C8T6: 2 配置时钟 在“System Core”…

手机测试之-adb

一、Android Debug Bridge 1.1 Android系统主要的目录 1.2 ADB工具介绍 ADB的全称为Android Debug Bridge,就是起到调试桥的作用,是Android SDK里面一个多用途调试工具,通过它可以和Android设备或模拟器通信,借助adb工具,我们可以管理设备或手机模拟器的状态。还可以进行很多…

与Apollo共创生态:探索自动驾驶的未来蓝图

目录 引言Apollo开放平台Apollo开放平台企业生态计划Apollo X 企业自动驾驶解决方案&#xff1a;加速企业场景应用落地Apollo开放平台携手伙伴共创生态生态共创会员权益 个人心得与展望技术的多元化应用数据驱动的智能化安全与可靠性的重视 结语 引言 就在2024年4月19日&#x…

简约大气的全屏背景壁纸导航网源码(免费)

简约大气的全屏背景壁纸导航网模板 效果图部分代码领取源码下期更新预报 效果图 部分代码 <!DOCTYPE html> <html lang"zh-CN"> <!--版权归孤独 --> <head><meta charset"UTF-8"><meta http-equiv"X-UA-Compatible…

pyqt QSplitter控件

pyqt QSplitter控件 QSplitter控件效果代码 QSplitter控件 PyQt中的QSplitter控件是一个强大的布局管理器&#xff0c;它允许用户通过拖动边界来动态调整子控件的大小。这个控件对于创建灵活的、用户可定制的用户界面非常有用。 QSplitter控件可以水平或垂直地分割其包含的子…

阿里云开源大模型开发环境搭建

ModelScope是阿里云通义千问开源的大模型开发者社区&#xff0c;本文主要描述AI大模型开发环境的搭建。 如上所示&#xff0c;安装ModelScope大模型基础库开发框架的命令行参数&#xff0c;使用清华大学提供的镜像地址 如上所示&#xff0c;在JetBrains PyCharm的项目工程终端控…

交通 | 电动汽车车辆路径问题及FRVCP包的调用以及代码案例

编者按&#xff1a; 电动汽车的应用给车辆路线问题带来了更多的挑战&#xff0c;如何为给定路线行驶的电动汽车设计充电决策是一个需要解决的难题&#xff0c;本文介绍了开源python包frvcpy使用精确式算法对该问题求解。 文献解读&#xff1a;Aurelien Froger, Jorge E Mendo…

前端开发框架Vue

版权声明 本文原创作者&#xff1a;谷哥的小弟作者博客地址&#xff1a;http://blog.csdn.net/lfdfhl Vue概述 Vue.js&#xff08;简称Vue&#xff09;是由尤雨溪&#xff08;Evan You&#xff09;创建并维护的一款开源前端开发框架。Vue以其轻量级、易上手和高度灵活的特点&…

06_电子设计教程基础篇(学习视频推荐)

文章目录 前言一、基础视频1、电路原理3、模电4、高频电子线路5、电力电子技术6、数学物理方法7、电磁场与电磁波8、信号系统9、自动控制原理10、通信原理11、单片机原理 二、科普视频1、工科男孙老师2、达尔闻3、爱上半导体4、华秋商城5、JT硬件乐趣6、洋桃电子 三、教学视频1…

【openLooKeng集成Hive连接器完整过程】

【openLooKeng集成Hive连接器完整过程】 一、摘要二、正文2.1 环境说明2.2 Hadoop安装2.2.1. 准备工作2.2.2 在协调节点coordinator上进行安装hadoop2.2.3、将Hadoop安装目录分发到从节点worker2.2.4、在协调节点coordinator上启动hadoop集群2.3 MySQL安装2.4 Hive安装及基本操…

讯饶科技 X2Modbus 敏感信息泄露

讯饶科技 X2Modbus 敏感信息泄露 文章目录 讯饶科技 X2Modbus 敏感信息泄露漏洞描述影响版本实现原理漏洞复现修复建议 漏洞描述 X2Modbus是一款功能很强大的协议转换网关&#xff0c; 这里的X代表各家不同 的通信协议&#xff0c;2是To的谐音表示转换&#xff0c;Modbus就是最…

【C++题解】1035. 判断成绩等级

问题&#xff1a;1035. 判断成绩等级 类型&#xff1a;多分支结构 题目描述&#xff1a; 输入某学生成绩&#xff0c;如果 86 分以上(包括 86 分&#xff09;则输出 VERY GOOD &#xff0c;如果在 60 到 85 之间的则输出 GOOD (包括 60 和 85 )&#xff0c;小于 60 的则输出 …

MySQL数据库安装——zip压缩包形式

安装压缩包zip形式的 MySQL 8数据库 一 、先进入官网下载 https://dev.mysql.com/downloads/mysql/ 二、解压到某个文件夹 我解压到了D:\mysql\mysql8 下面 然后在这个文件夹下手动创建 my.ini 文件和 data 文件夹 my.ini 内容如下&#xff1a; 注意 basedir 和 datadi…

企业气候风险披露、报表词频、文本分析数据集合(2007-2022年)

01、数据介绍 企业气候风险披露是指企业通过一定的方式&#xff0c;将气候变化对其影响、自身采取的应对措施等信息披露出来。这有助于投资者更准确地评估企业价值&#xff0c;发现投资机会&#xff0c;规避投资风险。解企业在气候风险方面的关注度和披露情况。 可以帮助利益…

JavaEE_操作系统之进程(计算机体系,,指令,进程的概念、组成、特性、PCB)

一、冯诺依曼体系&#xff08;Von Neumann Architecture&#xff09; 现代的计算机, 大多遵守冯诺依曼体系结构 CPU 中央处理器: 进行算术运算和逻辑判断.存储器: 分为外存和内存, 用于存储数据(使用二进制方式存储)输入设备: 用户给计算机发号施令的设备.输出设备: 计算机个…

Pixelmator Pro for Mac:简洁而强大的图像编辑软件

Pixelmator Pro for Mac是一款专为Mac用户设计的图像编辑软件&#xff0c;它集简洁的操作界面与强大的功能于一身&#xff0c;为用户提供了卓越的图像编辑体验。 Pixelmator Pro for Mac v3.5.9中文激活版下载 该软件支持多种文件格式&#xff0c;包括常见的JPEG、PNG、TIFF等&…