NDK RTMP直播客户端二

在之前完成的实战项目【FFmpeg音视频播放器】属于拉流范畴,接下来将完成推流工作,通过RTMP实现推流,即直播客户端。简单的说,就是将手机采集的音频数据和视频数据,推到服务器端。

接下来的RTMP直播客户端系列,主要实现红框和紫色部分:

 本节主要内容:

​1.Java层视频编码工作。

2.Native层视频编码器工作。

3.Native层视频推流编码工作。

源码:

NdkPush: 通过RTMP实现推流,直播客户端。

一、Java层视频编码

1)MainActivity:

MainActivity只与中转站NdkPusher打交道,用户操作页面相关功能是调用NdkPusher分发下去;

初始化NdkPusher.java

mNdkPusher = new NdkPusher(this, Camera.CameraInfo.CAMERA_FACING_BACK, 640, 480, 25, 800000);

首次点击【切换摄像头】时,设置Camera与Surface绑定

/**
 * 切换摄像头
 *
 * @param view
 */
public void switchCamera(View view) {
	if (initPermission()) {
		if (!isBind) {
			mNdkPusher.setPreviewDisplay(mSurfaceHolder);
			isBind = true;
		}
		mNdkPusher.switchCamera();
	}
}

点击【开始直播】时,开始直播,并设置rtmp服务器地址

/**
 * 开始直播
 *
 * @param view
 */
public void startLive(View view) {
	mNdkPusher.startLive("rtmp://139.224.136.101/myapp");
}

点击【停止直播】时,停止直播

/**
 * 停止直播
 *
 * @param view
 */
public void stopLive(View view) {
	mNdkPusher.stopLive();
}

页面关闭,释放资源

/**
 * 释放工作
 */
@Override
protected void onDestroy() {
	super.onDestroy();
	mNdkPusher.release();
}

2)NdkPusher:

中转站,分发MainActivity事件和和Native层打交道;

NdkPusher初始化时,主要是的三件事,

①:初始化native层需要的加载,
②:实例化视频通道并传递基本参数(宽高,fps,码率等),
③:实例化音频通道(下一节内容)

public NdkPusher(Activity activity, int cameraId, int width, int height, int fps, int bitrate) {
	native_init();
	// 将this传递给VideoChannel,方便VideoChannel操控native层
	mVideoChannel = new VideoChannel(this, activity, cameraId, width, height, fps, bitrate);
}

分发给视频通道VideoChannel-->SurfaceView与中转站里面的Camera绑定

public void setPreviewDisplay(SurfaceHolder surfaceHolder) {
	mVideoChannel.setPreviewDisplay(surfaceHolder);
}

分发给视频通道VideoChannel-->切换摄像头

public void switchCamera() {
	mVideoChannel.switchCamera();
}

开始直播,调用native层开始直播工作,分发给视频通道VideoChannel开始直播

public void startLive(String path) {
	native_start(path);
	mVideoChannel.startLive();
}

停止直播,调用native层停止直播工作,分发给视频通道VideoChannel停止直播

public void stopLive() {
	mVideoChannel.stopLive();
	native_stop();
}

释放工作,释放native层数据和视频通道VideoChannel

public void release() {
	mVideoChannel.release();
	native_release();
}

与native层通讯函数

// 音频 视频 公用的
private native void native_init(); // 初始化
private native void native_start(String path); // 开始直播start(音频视频通用一套代码) path:rtmp推流地址
private native void native_stop(); // 停止直播
private native void native_release(); // onDestroy--->release释放工作

// 下面是视频独有
public native void native_initVideoEncoder(int width, int height, int mFps, int bitrate); // 初始化x264编码器
public native void native_pushVideo(byte[] data); // 相机画面的数据 byte[] 推给 native层

3)VideoChannel:

视频通道,处理NdkPusher分发下来的事件和将CameraHelper的Camera画面数据推送到native层。

初始化CameraHelper,设置Camera相机预览帮助类,onPreviewFrame(nv21)数据的回调监听和宽高发送改变的监听

public VideoChannel(NdkPusher ndkPusher, Activity activity, int cameraId, int width, int height, int fps, int bitrate) {
	this.mNdkPusher = ndkPusher; // 回调给中转站
	this.mFps = fps; // fps 每秒钟多少帧
	this.bitrate = bitrate; // 码率
	mCameraHelper = new CameraHelper(activity, cameraId, width, height);
	mCameraHelper.setPreviewCallback(this); // 设置Camera相机预览帮助类,onPreviewFrame(nv21)数据的回调监听
	mCameraHelper.setOnChangedSizeListener(this); // 宽高发送改变的监听回调设置
}

调用帮助类:与Surface绑定

public void setPreviewDisplay(SurfaceHolder surfaceHolder) {
	mCameraHelper.setPreviewDisplay(surfaceHolder);
}

调用帮助类-->切换摄像头

public void switchCamera() {
	mCameraHelper.switchCamera();
}

开始直播,只修改标记 让其可以进入if 完成图像数据推送

public void startLive() {
	isLive = true;
}

停止直播,只修改标记 让其可以不要进入if 就不会再数据推送了

public void stopLive() {
	isLive = false;
}

释放,调用帮助类-->停止预览

public void release() {
	mCameraHelper.stopPreview();
}

Camera预览画面的数据,回调到这里,再通过mNdkPusher,将数据推送到native层

@Override
public void onPreviewFrame(byte[] data, Camera camera) {
	// data == nv21 数据
	if (isLive) {
		// 图像数据推送
		mNdkPusher.native_pushVideo(data);
	}
}

Camera发送宽高改变,回调到这里,再通过mNdkPusher,将数据推送到native层

@Override
public void onChanged(int width, int height) {
	// 视频编码器的初始化有关:width,height,fps,bitrate
	mNdkPusher.native_initVideoEncoder(width, height, mFps, bitrate); // 初始化x264编码器
}

4)CameraHelper第一节已完成。

二、Native层视频编码器

1)native-lib.cpp:

处理Java层NdkPusher调用的native函数;

native层初始化工作:

NdkPusher构造函数调用到这里,初始化native层VideoChannel,设置 Camera预览画面的数据推送到native层,videoChannel编码后数据,通过callback回调到native-lib.cpp,加入队列。

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_push_NdkPusher_native_1init(JNIEnv *env, jobject thiz) {
    // 初始化 VideoChannel
    videoChannel = new VideoChannel();
    // 设置 Camera预览画面的数据推送到native层,videoChannel编码后数据,通过callback回调到native-lib.cpp,加入队列
    videoChannel->setVideoCallback(callback);
    // 设置 队列的释放工作 回调
    packets.setReleaseCallback(releasePackets);
}

videoCallback 函数指针的实现(将编码后数据存放packet到队列)

void callback(RTMPPacket *packet) {
    if (packet) {
        if (packet->m_nTimeStamp == -1) {
            packet->m_nTimeStamp = RTMP_GetTime() - start_time; // 如果是sps+pps 没有时间搓,如果是I帧就需要有时间搓
        }
        packets.push(packet); // 存入队列里面
    }
}

释放RTMPPacket * 包的函数指针实现,T无法释放, 让外界释放

void releasePackets(RTMPPacket **packet) {
    if (packet) {
        RTMPPacket_Free(*packet);
        delete packet;
        packet = nullptr;
    }
}

 初始化x264编码器,Camera宽高改变,回调到这里,首次设置预览时触发;分发到VideoChannel视频通道初始化编码器。

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_push_NdkPusher_native_1initVideoEncoder(JNIEnv *env, jobject thiz, jint width,
                                                     jint height, jint fps, jint bitrate) {
    if (videoChannel) {
        videoChannel->initVideoEncoder(width, height, fps, bitrate);
    }
}

2)VideoChannel.cpp:

 native层视频通道,初始化x264编码器和处理相机原始数据编码,再回到给native-lib.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_len = width * height;
    uv_len = y_len / 4;

    // 防止重复初始化x264编码器
    if (videoEncoder) {
        x264_encoder_close(videoEncoder);
        videoEncoder = nullptr;
    }
    // 防止重复初始化pic_in
    if (pic_in) {
        x264_picture_clean(pic_in);
        DELETE(pic_in);
    }
    // TODO 初始化x264编码器
    x264_param_t param;// x264的参数集

    // 设置编码器属性
    // ultrafast 最快  (直播必须快)
    // zerolatency 零延迟(直播必须快)
    x264_param_default_preset(&param, "ultrafast", "zerolatency");

    // 编码规格:https://wikipedia.tw.wjbk.site/wiki/H.264 看图片
    param.i_level_idc = 32; // 3.2 中等偏上的规格  自动用 码率,模糊程度,分辨率

    // 输入数据格式是 YUV420P  平面模式VVVVVUUUU,如果没有P,  就是交错模式VUVUVUVU
    param.i_csp = X264_CSP_I420;
    param.i_width = width;
    param.i_height = height;

    // 不能有B帧,如果有B帧会影响编码、解码效率(快)
    param.i_bframe = 0;

    // 码率控制方式。CQP(恒定质量),CRF(恒定码率),ABR(平均码率)
    param.rc.i_rc_method = X264_RC_CRF;

    // 设置码率
    param.rc.i_bitrate = bitrate / 1000;

    // 瞬时最大码率 网络波动导致的
    param.rc.i_vbv_max_bitrate = bitrate / 1000 * 1.2;

    // 设置了i_vbv_max_bitrate就必须设置buffer大小,码率控制区大小,单位Kb/s
    param.rc.i_vbv_buffer_size = bitrate / 1000;

    // 码率控制不是通过 timebase 和 timestamp,码率的控制,完全不用时间搓   ,而是通过 fps 来控制 码率(根据你的fps来自动控制)
    param.b_vfr_input = 0;

    // 分子 分母
    // 帧率分子
    param.i_fps_num = fps;
    // 帧率分母
    param.i_fps_den = 1;
    param.i_timebase_den = param.i_fps_num;
    param.i_timebase_num = param.i_fps_den;

    // 告诉人家,到底是什么时候,来一个I帧, 计算关键帧的距离
    // 帧距离(关键帧)  2s一个关键帧   (就是把两秒钟一个关键帧告诉人家)
    param.i_keyint_max = fps * 2;

    // sps序列参数   pps图像参数集,所以需要设置header(sps pps)
    // 是否复制sps和pps放在每个关键帧的前面 该参数设置是让每个关键帧(I帧)都附带sps/pps。
    param.b_repeat_headers = 1;

    // 并行编码线程数
    param.i_threads = 1;

    // profile级别,baseline级别 (把我们上面的参数进行提交)
    x264_param_apply_profile(&param, "baseline");

    // 输入图像初始化
    pic_in = new x264_picture_t(); // 本身空间的初始化
    x264_picture_alloc(pic_in, param.i_csp, param.i_width, param.i_height); // pic_in内部成员初始化等

    // 打开编码器 一旦打开成功,我们的编码器就拿到了
    videoEncoder = x264_encoder_open(&param);
    if (videoEncoder) {
        LOGE("x264编码器打开成功");
    }

    pthread_mutex_unlock(&mutex);
}

三、Native层视频推流编码

1)native-lib.cpp:

开始直播 ---> 启动工作

创建子线程实现:
1.连接流媒体服务器;
2.发包;

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_push_NdkPusher_native_1start(JNIEnv *env, jobject thiz, jstring path_) {
    /**
     * 创建子线程:
     * 1.连接流媒体服务器;
     * 2.发包;
     */
    if (isStart) {
        return;
    }
    isStart = true;
    const char *path = env->GetStringUTFChars(path_, nullptr);
    // 深拷贝
    char *url = new char(strlen(path) + 1); // C++的堆区开辟 new -- delete
    strcpy(url, path);
    // 创建线程来进行直播
    pthread_create(&pid_start, nullptr, task_start, url);
    env->ReleaseStringUTFChars(path_, path); // 你随意释放,我已经深拷贝了
}

连接RTMP服务器,遍历压缩包队列,将数据发送到RTMP服务器

void *task_start(void *args) {
    char *url = static_cast<char *>(args);
    // RTMPDump API 九部曲
    RTMP *rtmp = nullptr;
    int result; // 返回值判断成功失败
    do {
        // 1.1,rtmp 初始化
        rtmp = RTMP_Alloc();
        if (!rtmp) {
            LOGE("rtmp 初始化失败");
            break;
        }
        // 1.2,rtmp 初始化
        RTMP_Init(rtmp);
        rtmp->Link.timeout = 5; // 设置连接的超时时间(以秒为单位的连接超时)
        // 2,rtmp 设置流媒体地址
        result = RTMP_SetupURL(rtmp, url);
        if (!result) { // result == 0 和 ffmpeg不同,0代表失败
            LOGE("rtmp 设置流媒体地址失败");
            break;
        }
        // 3,开启输出模式
        RTMP_EnableWrite(rtmp);
        // 4,建立连接
        result = RTMP_Connect(rtmp, nullptr);
        if (!result) { // result == 0 和 ffmpeg不同,0代表失败
            LOGE("rtmp 建立连接失败:%d, url: %s", result, url);
            break;
        }
        // 5,连接流
        result = RTMP_ConnectStream(rtmp, 0);
        if (!result) { // result == 0 和 ffmpeg不同,0代表失败
            LOGE("rtmp 连接流失败");
            break;
        }
        start_time = RTMP_GetTime();
        // 准备好了,可以开始向服务器推流了
        readyPushing = true;
        // 队列开始工作
        packets.setWork(1);
        RTMPPacket *packet = nullptr;
        // 从队列里面获取压缩包,直接发给服务器
        while (readyPushing) {
            packets.pop(packet); // 阻塞式
            if (!readyPushing) {
                break;
            }
            // 取不到数据,重新取,可能还没生产出来
            if (!packet) {
                continue;
            }
            // 到这里就是成功的获取队列的ptk了,可以发送给流媒体服务器
            packet->m_nInfoField2 = rtmp->m_stream_id;// 给rtmp的流id
            // 成功取出数据包,发送
            result = RTMP_SendPacket(rtmp, packet, 1); // 1==true 开启内部缓冲
            // packet 你都发给服务器了,可以大胆释放
            releasePackets(&packet);
            if (!result) { // result == 0 和 ffmpeg不同,0代表失败
                LOGE("rtmp 失败 自动断开服务器");
                break;
            }
        }
        releasePackets(&packet); // 只要跳出循环,就释放
    } while (false);
    // 本次一系列释放工作
    isStart = false;
    readyPushing = false;
    packets.setWork(0);
    packets.clear();
    if (rtmp) {
        RTMP_Close(rtmp);
        RTMP_Free(rtmp);
    }
    delete url;

    return nullptr;
}

Camera预览画面的数据,回调到这里,将原始数据进行x264编码后,得到的RTMPPkt(压缩数据)加入队列里面

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_push_NdkPusher_native_1pushVideo(JNIEnv *env, jobject thiz, jbyteArray data_) {
    if (!videoChannel || !readyPushing) { return; }
    // 把jni ---> C语言的
    jbyte *data = env->GetByteArrayElements(data_, nullptr);
    // data == nv21数据,编码,加入队列
    videoChannel->encodeData(data);
    env->ReleaseByteArrayElements(data_, data, 0); // 释放byte[]
}

2)VideoChannel.cpp:

视频原始数据编码工作

void VideoChannel::encodeData(signed char *data) {
    pthread_mutex_lock(&mutex);

    // 把nv21的y分量 Copy i420的y分量
    memcpy(pic_in->img.plane[0], data, y_len);
    // 把nv21的vuvuvuvu 转化成 i420的 uuuuvvvv
    for (int i = 0; i < uv_len; ++i) {
        // u 数据
        // data + y_len + i * 2 + 1 : 移动指针取 data(nv21) 中 u 的数据
        *(pic_in->img.plane[1] + i) = *(data + y_len + i * 2 + 1);

        // v 数据
        // data + y_len + i * 2 : 移动指针取 data(nv21) 中 v 的数据
        *(pic_in->img.plane[2] + i) = *(data + y_len + i * 2);
    }

    x264_nal_t *nal = nullptr; // 通过H.264编码得到NAL数组(理解)
    int pi_nal; // pi_nal是nal中输出的NAL单元的数量
    x264_picture_t pic_out; // 输出编码后图片 (编码后的图片)

    // 1.视频编码器, 2.nal,  3.pi_nal是nal中输出的NAL单元的数量, 4.输入原始的图片,  5.输出编码后图片
    int ret = x264_encoder_encode(videoEncoder, &nal, &pi_nal, pic_in,
                                  &pic_out); // 进行编码(本质的理解是:编码一张图片)
    if (ret < 0) { // 返回值:x264_encoder_encode函数 返回返回的 NAL 中的字节数。如果没有返回 NAL 单元,则在错误时返回负数和零。
        LOGE("x264编码失败");
        pthread_mutex_unlock(&mutex); // 注意:一旦编码失败了,一定要解锁,否则有概率性造成死锁了
        return;
    }

    // 发送 Packets 入队queue
    // sps(序列参数集) pps(图像参数集) 说白了就是:告诉我们如何解码图像数据
    int sps_len, pps_len; // sps 和 pps 的长度
    uint8_t sps[100]; // 用于接收 sps 的数组定义
    uint8_t pps[100]; // 用于接收 pps 的数组定义
    pic_in->i_pts += 1; // pts显示的时间(+=1 目的是每次都累加下去), dts编码的时间

    // 遍历nal中输出的NAL单元,组件压缩包数据,加入队列
    for (int i = 0; i < pi_nal; ++i) {
        if (nal[i].i_type == NAL_SPS) {
            sps_len = nal[i].i_payload - 4; // 去掉起始码(之前我们学过的内容:00 00 00 01)
            memcpy(sps, nal[i].p_payload + 4, sps_len); // 由于上面减了4,所以+4挪动这里的位置开始
        } else if (nal[i].i_type == NAL_PPS) {
            pps_len = nal[i].i_payload - 4; // 去掉起始码 之前我们学过的内容:00 00 00 01)
            memcpy(pps, nal[i].p_payload + 4, pps_len); // 由于上面减了4,所以+4挪动这里的位置开始

            // sps + pps == 1个压缩包数据
            sendSpsPps(sps, pps, sps_len, pps_len); // pps是跟在sps后面的,这里拿到的pps表示前面的sps肯定拿到了
        } else {
            // 发送 I帧 P帧
            sendFrame(nal[i].i_type, nal[i].i_payload, nal[i].p_payload);
        }
    }
}

组装sps + pps == 1个压缩包数据,存入队列

void VideoChannel::sendSpsPps(uint8_t *sps, uint8_t *pps, int sps_len, int pps_len) {
    // 根据协议设置压缩包数据长度
    int body_size = 5 + 8 + sps_len + 3 + pps_len;

    RTMPPacket *packet = new RTMPPacket; // 开始封包RTMPPacket
    RTMPPacket_Alloc(packet, body_size); // 堆区实例化 RTMPPacket

    int i = 0;
    packet->m_body[i++] = 0x17; // 十六进制转换成二进制,二进制查表 就懂了

    packet->m_body[i++] = 0x00;   // 重点是此字节 如果是1 帧类型(关键帧 非关键帧), 如果是0一定是 sps pps
    packet->m_body[i++] = 0x00;
    packet->m_body[i++] = 0x00;
    packet->m_body[i++] = 0x00;

    // 看图说话
    packet->m_body[i++] = 0x01; // 版本

    packet->m_body[i++] = sps[1];
    packet->m_body[i++] = sps[2];
    packet->m_body[i++] = sps[3];

    packet->m_body[i++] = 0xFF;
    packet->m_body[i++] = 0xE1;

    // 两个字节表达一个长度,需要位移
    // 用两个字节来表达 sps的长度,所以就需要位运算,取出sps_len高8位 再取出sps_len低8位
    //(位运算:https://blog.csdn.net/qq_31622345/article/details/98070787)
    // https://www.cnblogs.com/zhu520/p/8143688.html
    packet->m_body[i++] = (sps_len >> 8) & 0xFF; // 取高8位
    packet->m_body[i++] = sps_len & 0xFF; // 去低8位

    memcpy(&packet->m_body[i], sps, sps_len); // sps拷贝进去了

    i += sps_len; // 拷贝完sps数据 ,i移位,(下面才能准确移位)

    packet->m_body[i++] = 0x01; // pps个数,用一个字节表示

    packet->m_body[i++] = (pps_len >> 8) & 0xFF; // 取高8位
    packet->m_body[i++] = pps_len & 0xFF; // 去低8位

    memcpy(&packet->m_body[i], pps, pps_len); // pps拷贝进去了

    i += pps_len; // 拷贝完pps数据 ,i移位,(下面才能准确移位)

    // 封包处理
    packet->m_packetType = RTMP_PACKET_TYPE_VIDEO; // 包类型 视频包
    packet->m_nBodySize = body_size; // 设置好 sps+pps的总大小
    packet->m_nChannel = 10; // 通道ID,随便写一个,注意:不要写的和rtmp.c(里面的m_nChannel有冲突 4301行)
    packet->m_nTimeStamp = 0; // sps pps 包 没有时间戳
    packet->m_hasAbsTimestamp = 0; // 时间戳绝对或相对 也没有时间搓
    packet->m_headerType = RTMP_PACKET_SIZE_MEDIUM; // 包的类型:数据量比较少,不像帧数据(那就很大了),所以设置中等大小的包

    // packet 存入队列
    videoCallback(packet);
}

发送帧信息,把帧类型 RTMPPacket 存入队列

void VideoChannel::sendFrame(int type, int payload, uint8_t *pPayload) {
    // 去掉起始码 00 00 00 01 或者 00 00 01
    if (pPayload[2] == 0x00){ // 00 00 00 01
        pPayload += 4; // 例如:共10个,挪动4个后,还剩6个
        // 保证 我们的长度是和上的数据对应,也要是6个,所以-= 4
        payload -= 4;
    }else if(pPayload[2] == 0x01){ // 00 00 01
        pPayload +=3; // 例如:共10个,挪动3个后,还剩7个
        // 保证 我们的长度是和上的数据对应,也要是7个,所以-= 3
        payload -= 3;
    }

    // 根据协议设置压缩包数据长度
    int body_size = 5 + 4 + payload;

    RTMPPacket *packet = new RTMPPacket; // 开始封包RTMPPacket
    RTMPPacket_Alloc(packet, body_size); // 堆区实例化 RTMPPacket

    // 区分关键帧 和 非关键帧
    packet->m_body[0] = 0x27; // 普通帧 非关键帧
    if(type == NAL_SLICE_IDR){
        packet->m_body[0] = 0x17; // 关键帧
    }

    packet->m_body[1] = 0x01; // 重点是此字节 如果是1 帧类型(关键帧或非关键帧), 如果是0一定是 sps pps
    packet->m_body[2] = 0x00;
    packet->m_body[3] = 0x00;
    packet->m_body[4] = 0x00;

    // 四个字节表达一个长度,需要位移
    // 用四个字节来表达 payload帧数据的长度,所以就需要位运算
    //(位运算:https://blog.csdn.net/qq_31622345/article/details/98070787)
    // https://www.cnblogs.com/zhu520/p/8143688.html
    packet->m_body[5] = (payload >> 24) & 0xFF;
    packet->m_body[6] = (payload >> 16) & 0xFF;
    packet->m_body[7] = (payload >> 8) & 0xFF;
    packet->m_body[8] = payload & 0xFF;

    memcpy(&packet->m_body[9], pPayload, payload); // 拷贝H264的裸数据

    packet->m_packetType = RTMP_PACKET_TYPE_VIDEO; // 包类型,是视频类型
    packet->m_nBodySize = body_size; // 设置好 关键帧 或 普通帧 的总大小
    packet->m_nChannel = 10; // 通道ID,随便写一个,注意:不要写的和rtmp.c(里面的m_nChannel有冲突 4301行)
    packet->m_nTimeStamp = -1; // 帧数据有时间戳
    packet->m_hasAbsTimestamp = 0; // 时间戳绝对或相对 用不到,不需要
    packet->m_headerType = RTMP_PACKET_SIZE_LARGE ; // 包的类型:若是关键帧的话,数据量比较大,所以设置大包

    // 把最终的 帧类型 RTMPPacket 存入队列
    videoCallback(packet);
}

当压缩数据加入队列后,开启直播创建的子线程将会获取队列的压缩数据,发送到RTMP服务器。

源码:

NdkPush: 通过RTMP实现推流,直播客户端。

视频推流完成,下一节开始音频推流工作。。。

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

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

相关文章

Redis用于全局ID生成器、分布式锁的解决方案

全局ID生成器 每个店铺都可以发布优惠卷 当用户抢购时&#xff0c;就会生成订单并保存到tb_voucher_order这张表中&#xff0c;而订单表如果使用数据库自增id就存在一些问题&#xff1a; 1.id的规律性太明显 2.受单表数据量的限制 全局ID生成器&#xff0c;是一种在分布式系…

Atlassian后Server时代 | Server版vs.数据中心版,二者的区别在哪里?

2024年2月&#xff0c;也就是一年不到&#xff0c;Atlassian将终止对Server产品及插件的所有支持。 此公告发布后&#xff0c;许多用户需要了解怎样的前进方向才是最适合企业的。为此&#xff0c;Atlassian提供了本地部署的数据中心&#xff08;Data Center&#xff09;版以及云…

线段树笔记草稿

一个左节点u << 1和右节点u << 1 | 1 的证明 区间修改部分 1.批量等值修改 前提条件 是要区间修改&#xff0c;区间查询&#xff0c;且修改操作修改的值是相同的 情景 一般是要对一个数组执行k次操作&#xff0c;每次改变其中一个区间内所有元素的值&#x…

ChatGPT文本框再次升级,打造出新型操作系统

在ChatGPT到来之前&#xff0c;没有谁能够预见。但是&#xff0c;它最终还是来了&#xff0c;并引起了不小的轰动&#xff0c;甚至有可能颠覆整个行业。 从某种程度上说&#xff0c;ChatGPT可能是历史上增长最快的应用程序&#xff0c;仅在两个多月就拥有了1亿多活跃用户&…

Adaptive Weight Assignment Scheme For Multi-task Learning

Adaptive Weight Assignment Scheme For Multi-task Learning 题目Adaptive Weight Assignment Scheme For Multi-task Learning译题用于多任务学习的自适应权重分配方案时间2022年期刊/会议IAES International Journal of Artificial Intelligence (IJ-AI) 摘要&#xff1a;如…

【AutoGPT】你自己运行,我先睡了—— ChatGPT过时了吗?

系列文章目录 【AI绘画】Midjourney和Stable Diffusion教程_山楂山楂丸的博客-CSDN博客 目录 系列文章目录 前言 一、AutoGPT是什么&#xff1f; 二、AutoGPT带来的利弊 三、AutoGPT和ChatGPT区别 四、未来 总结 前言 ChatGPT是否过时&#xff1f;AutoGPT的兴起&#…

MappingGenerator PRO 2023.3 Visual Studio 2019-2022

您的私人编码助手 MappingGenerator 最初是作为 AutoMapper 的设计时替代品创建的。现在它正在演变为编码助手&#xff0c;您可以将最平凡的编码任务委派给它&#xff1a; 生成映射生成显式转换实施克隆生成投影表达式脚手架方法调用脚手架对象创建清理方法调用方便ILogger的使…

ChatGPT风口下的中外“狂飙”,一文看懂微软、谷歌、百度、腾讯、华为、字节跳动们在做什么?

毫无疑问&#xff0c;ChatGPT正成为搅动市场情绪的buzzword。 历史经历过无线电&#xff0c;半导体&#xff0c;计算机&#xff0c;移动通讯&#xff0c;互联网&#xff0c;移动互联网&#xff0c;社交媒体&#xff0c;云计算等多个时代&#xff0c;产业界也一直在寻找Next Bi…

Golang每日一练(leetDay0031)

目录 91. 解码方法 Decode Ways &#x1f31f;&#x1f31f; 92. 反转链表 II Reverse Linked List II &#x1f31f;&#x1f31f; 93. 复原 IP 地址 Restore IP Addresses &#x1f31f;&#x1f31f; &#x1f31f; 每日一练刷题专栏 &#x1f31f; Golang每日一练…

【JVM】JVM之执行引擎

文章目录一、前言二、名词解释机器码指令指令集汇编语言高级语言字节码虚拟机&物理机前端编译器&后端编译器三、JVM之执行引擎执行引擎是如何工作的&#xff1f;解释器即时编译器&#xff08;JIT&#xff09;分层编译策略虚拟机执行模式热点代码&探测方式1&#xf…

如何在 Linux 中使用 Chage 命令,修改Linux系统用户密码更改策略

Chage是一个用于修改Linux系统用户密码更改策略的命令行工具。在本文中&#xff0c;我们将介绍如何在Linux系统中使用Chage命令。 检查用户密码过期信息 使用Chage命令可以检查用户密码更改策略和过期信息。要检查特定用户的密码过期信息&#xff0c;可以使用以下命令&#x…

PPT NO.1【用ppt如何做一张海报+字体】

PPT做得好的人&#xff0c;一定是站在观众的角度思考的人。 1、设置幻灯片尺寸大小&#xff1a; 设置完成后如下&#xff1a; 2、加载一张自己喜欢的图片进来&#xff1a;【图片越高清越好】 将图片铺满空白的地方&#xff0c;调整好自己喜欢的区域&#xff1a; 做裁剪&#xf…

数据结构---递归转化为非递归

递归转化为非递归前言快速排序非递归归并排序的非递归前言 为什么要学习非递归写法呢&#xff1f; 当我们在用递归实现一个程序的时候&#xff0c;要考虑一个问题&#xff0c;这个程序用递归去实现&#xff0c;当数据量庞大的时候&#xff0c;会不会造成栈溢出(STACK OVERFLOW…

代码随想录_226翻转二叉树、101对称二叉树

leetcode 226. 翻转二叉树 ​​​226. 翻转二叉树 给你一棵二叉树的根节点 root &#xff0c;翻转这棵二叉树&#xff0c;并返回其根节点。 示例 1&#xff1a; 输入&#xff1a;root [4,2,7,1,3,6,9] 输出&#xff1a;[4,7,2,9,6,3,1]示例 2&#xff1a; 输入&#xff1a;r…

算法训练第五十五天 | 392.判断子序列、115.不同的子序列

动态规划part15392.判断子序列题目描述思路总结115.不同的子序列题目描述思路392.判断子序列 题目链接&#xff1a;392.判断子序列 参考&#xff1a;https://programmercarl.com/0392.%E5%88%A4%E6%96%AD%E5%AD%90%E5%BA%8F%E5%88%97.html 题目描述 给定字符串 s 和 t &…

【CocosCreator入门】CocosCreator组件 | Graphics(绘制)组件

Cocos Creator 是一款流行的游戏开发引擎&#xff0c;具有丰富的组件和工具&#xff0c;其中Graphics组件允许您在游戏中绘制2D图形和几何形状&#xff0c;并通过编写脚本来控制其外观和行为。 目录 一、组件属性 二、组件方法 三、脚本示例 一、组件属性 属性功能说明lineW…

面试篇-Java并发之CAS:掌握原理、优缺点和应用场景分析,避免竞态问题

1、CAS介绍及原理 多线程中的CAS&#xff08;Compare-and-Swap&#xff09;操作是一种常见的并发控制方法&#xff0c;用于实现原子性更新共享变量的值。其核心思想是通过比较内存地址上的值和期望值是否相等来确定是否可以进行更新操作&#xff0c;从而避免多线程条件下的竞态…

用PyTorch构建基于卷积神经网络的手写数字识别模型

本文参加新星计划人工智能(Pytorch)赛道&#xff1a;https://bbs.csdn.net/topics/613989052 目录 一、MINST数据集介绍与分析 二、卷积神经网络 三、基于卷积神经网络的手写数字识别 一、MINST数据集介绍与分析 MINST数据库是机器学习领域非常经典的一个数据集&#xff0c…

动力节点王鹤SpringBoot3笔记——第八章 文章管理模块

目录 第八章 文章管理模块 8.1 配置文件 8.2 视图文件 8.3 Java代码 第八章 文章管理模块 创建新的Spring Boot项目&#xff0c;综合运用视频中的知识点&#xff0c;做一个文章管理的后台应用。 新的Spring Boot项目Lession20-BlogAdmin。Maven构建工具&#xff0c;包…

【UE4】关卡流送的demo

关卡流送功能可以将地图文件加载到内存中&#xff0c;或者从内存中卸载&#xff0c;并在游戏过程中切换地图的可视性。 这样一来&#xff0c;场景便能拆分为较小的地图块&#xff0c;并且只有相关部分才会占用资源并被渲染。 正确设置后&#xff0c;开发者便能创建大型、无缝衔…