摘要:本文从NDI 提供的SDK(Android)出发,通过Android Studio进行开发,实现了Android手机端对NDI视频流的发送和接收,并在局域网里测试,打通了Android端和PC端通过NDI互相串流及镜像显示。
关键字:NDI、JNI、C++、Cmake、NDK、NSD、Service;
一、前言
NDI是Newtech公司(目前被Vizrt收购)基于IP网络里面传输浅压缩视频流的方案,NDI作为一款纯软件标准,相比于RTMP、RTS等视频流协议,它具有更低的延迟(理论延迟只有16行)、4K和高清的实时兼容、支持Alpha通道的传输、以及Tally、元素据、控制协议等、能够快速的构建信号的扩展和连接,任意NDI流、任意分辨率,只要在一个局域网里,就能被发现和接收,并统一的进行输出。
相比于ST2110的标准,NDI是能够更有效的降低制作成本,对于广电制作,NDI有“Full NDI”支持输出更高的码率,对于手机端和PC端,NDI有“NDI HX”支持输出较低的码率,两者在局域网内互相兼容,都能被发送和接收到。
本次项目开发的时候还是基于NDI 4.0的SDK版本,目前NDI已经支持5.0,NDI 5.0向下兼容4.0,此次介绍的一些方法和库,同样适用于5.0版本,具体功能可参考官方文档NDI SDK文档。
二、Android端NDI框架的设计与搭建
2.1、导入NDI SDK并构建依赖项
NDI SDK是用C++原生代码进行开发,想要在Android端使用C++的原生代码,需要下载NDK和CMake两个SDK工具包,其中NDK工具集能够允许Andoroid端调用C++代码,且通过JNI这类平台库,我们可以在MainActivity直接调用C++的函数,CMake是一款外部构建工具,搭配“build.gradle”一起用,用来导入和关联第三方C++库,具体操作方法如下:
1、在“Tools->SDK Manager->Android SDK->SDK tool”下,下载CMake和NDK工具;
2、如果在新建项目的时候,默认勾选了“include C++ support”,会在项目文件的“/src/main/"目录下多一个cpp的文件夹,cpp文件夹的根目录有CMake文件的模板,同时关于C++程序的头文件和编译好的*.so数据库文件,放到对应include和jniLibs文件夹下,如下图所示,这些文件夹也可以通过自建的方式添加到项目里;
3、“CMake.txt”文件关联本地库和第三方C++库的方式如下图所示,其中“native-lib”为本地库,本地库的名称和路径在CMake文件内指定,默认首选路径是cpp文件夹的根目录,当cpp的根目录找不到“native-lib.cpp”文件时,再到include文件夹下找;ndi-lib是导入的第三方库,文件名是“libndi.so”,此处NDI SDK针对不同版本的CPU设定有不同的“libndi.so”文件,系统会根据实际手机的CPU去优选最匹配的“libndi.so”文件,当匹配上第三方库之后,就能在include文件夹下,调用第三方库的C++头文件;
4、“native-lib.cpp”文件默认在关联C++项目的时候自动生成,里面通过JNI接口的方式,打通C++和JAVA的对接,如下图所示,我在此处用C++规范了一些NDI的初始化、推流、拉流的函数,然后通过JNI接口把对应的函数和参数映射到JAVA程序,图中举例是NDI初始化的接口函数,返回类型为“空”,函数名为“initialNDI”,在JAVA文件中对应的调取路径为“Java_com_example_ndkdemo”项目,“RecordService”文件下,这些命名方式都是在“RecordService.java”中引入“native-lib”关联项和函数以后,在”native-lib.cpp“中自动生成的接口函数;
对应“RecordService.java”文件中,首先需要引入“native-lib.cpp”关联项,如下图所示,然后通过自定义的方式创建接口文件,接口文件可以创建很多个,“native-lib.cpp”文件中,也可以引用之前导入好的第三方库文件的头文件,只要是涉及到C++语法的,都可以在“native-lib.cpp”文件中操作,并通过JNI接口转成JAVA函数,具体可以参照JNI的相关教程;
5、”build.gradle“为Android程序的构建文件,其中包括第三方JAVA库的依赖项,还有CMake文件的关联项目,如下图所示,在cppFlags项中,”-frtti“希望Android程序支持RTTI,”-fexceptions“表示Android程序启动对C++异常处理的功能,这两项功能不填,也不影响最终的编译,只是为了有助于调试程序而打开;
6、”AndroidManifest.xml“文件中规范了Android程序的主进程”MainActivity“启动入口、规范程序的权限以及注册主进程中运行的服务,如下图所示,主进程中注册了”RecordService“服务,该服务可以被实例化,并且服务可以以”mediaProjection“接口服务的方式运行于前台服务(服务优先级最高);
原先Android5.0之前,AndroidManifest.xml文件中,能够规范Android程序的所有权限,自5.0以后,某些Android程序的权限,如调取屏幕、摄像头、麦克风等操作,均通过在Activity进程中,动态授权的方式获取,这样做同时也是为了保障Android程序的健康安全,下图中显示AndroidManifest.xml中打开的权限主要为网络访问和前台服务的权限;
2.2、NDI发送端实现方式
NDI发送端程序流程框图如下图所示:
1、在MainActivity主进程建立时,开启一个按钮监听,用于开启录屏服务(RecordService)并推送为前台服务,同时绑定录屏服务并返回一个录屏服务的实例对象;
2、此处把bindService和startForegroundService两种服务的开启方式进行了混用,混用后实现了Service服务与主线程的通信,同时把Service服务实例化,两种服务方式的开启并不会产生两个单独的录屏服务,首先通过bindService绑定并连接上RecordService服务,覆写RecordService内”onCreate“和”onBind“两种方法,具体程序如下,绑定RecordService后使用“ServiceConnection”对服务进行连接,连接前根据具体需求,可以申请一些动态权限,如存储的读写、手机状态的读取和录制音频等,连接成功后,返回RecordService实力对象;
在”RecordService“内的”onCreate"重写函数中,”getDisplayMetrics“为获取手机显示画布,进而得到手机显示的长、宽、dpi等参数;后台线程”serviceThread“,用做后台录屏的子线程容器;“NSDServer”为网络服务发现,为Android自带的,用于在局域网内网络服务发现;initialNDI为初始化NDI的JNI接口程序。
在”RecordService“内的在“onBind”重写函数中,主要为了在主进程中连接上录屏服务(RecordService)后,返回一个服务对象,实现主进程和服务之间的通信,如停止服务的操作。
3、当在MainActivity主进程中按下按钮以后,打开录屏接口(MediaProjectionManager)并触发“startForegroundService”,将RecordService服务开启为前台服务,具体程序如下;
获取MediaProjectionManager服务实例,并调用录屏权限“createScreenCaptureIntent”;
通过"startActivityForResult",将录屏权限(captureIntent)和固定参数(RECORD_REQUEST_CODE)传入其中,等待动态权限的申请结果;
等待动态录屏权限申请通过,之后将通过成功后返回的参数传递给RecordService服务,并将RecordService开启为前台服务;
4、之前在bindService的时候,已经生成并返回了RecordService服务的实例对象,因此“startForegroundService”开启以后,直接在RecordService服务中,重写“onStartCommand”和“onDestroy”两个方法。
在”RecordService“内的”onStartCommand"重写函数中,创建RecordService前台服务的通知栏和ID号(必须创建),将动态申请录屏服务时返回的参数获取到,并依据这些创建“MeidaProjection”录屏服务实例,并开始录屏;
5、录屏和NDI推流通过“startCapture”函数来实现,首先通过"createVirtualDisplay"创建一块虚拟画布,传入之前读取的手机长、宽、dpi参数并通过“ImageReader”实例化对象,将手机桌面读取并放到虚拟画布上;
然后启动之前在RecordService服务“onCreate”时就初始化的线程“serviceThread”,等待“createVirtualDisplay”中创建对应尺寸的画布,程序里等了1000ms,实际创建画布不需要这么长;
最后通过子线程“backgroundHandler”中放入循环的“serviceThread”线程,每次循环后自动去调取回调函数ChildCallback,用于后台录屏数据实时推流NDI;
6、在“ChildCallback”回调函数中,每次回调触发以后,读取最新手机录屏图像,读取图像以RGBA的方式进行排布,每个像素点占4个byte,分别为RGBA,将图像从左往右、从上往下,按照像素点的方式,以16进制数据存入到byte数组中,得到的“b”数组是正向手机录屏数据;
同时将这些像素点,以镜像的方式重排,存入"mirrorByte"数组中,与“b”数组一起,通过JNI接口推送给C++函数,并一起推流,最终实现正向画面和镜像画面同时出现在NDI网络中;
2.3、NSD(Network Service Discovery)服务的使用
为了使得发送的NDI流能够被局域网里的其他设备发现,需要在Android端打开Google原生的NSD服务,并且在Android端NDI目前不支持第三方的网络发现服务(苹果端使用的是Bonjour)。NSD是Andorid SDK内部自带的类库,它的作用是为下一步的连接提供准备,如提供IP地址和端口号等,我们此次发送端,作为NSD服务端发布到局域网中,它定义了主机的名字、端口号,并进行了注册,为NSD客户端的连接做准备。
我在Android发送端程序里,建立了一个“NSDServer”的类,专门用于NSD服务端的注册监听,具体步骤如下:
1、设置注册监听函数,实例化监听服务器,用于监听之后的注册服务是否成功,如注册失败后的操作(onRegistrationFailed)、注册成功后的操作(onServiceRegistered)、解除注册失败后的操作(onUnregistrationFailed)等等;
2、设置注册NSD服务端函数,设置服务端口号、服务端名称、服务类型,服务端的端口号是5960(NDI SDK文档规定);
3、设置取消注册函数,用于停止NSD服务端的操作;
4、NSD服务端状态的监听接口,并设置实例化对象;
5、当“NSDServer”类写完以后,需要在“RecordService”服务中,实例化该类,并调用"startNSDServer"接口,实现整个NSD服务端的初始化、注册及监听;关于NSD客户端的扫描、发现和解析NSD服务器,主要应用在NDI接收端,在此不做介绍。
2.4、NDI SDK中JNI接口的实现方式
在“RecordService”服务中,调用了两个NDI的JNI接口函数,分别为“initialNDI”和“publishNDI”,在“RecordService”服务中以JAVA方式的写法如下,分别表示初始化NDI流和推送NDI留,这类函数的用法,跟“RecordService”服务中其他函数的使用方法一样。
“nativie-lib.cpp”的C++文件中,对应有initialNDI和publishNDI的C++函数实现方式,在了解C++函数前,首先需要知道NDI SDK中规范的NDI流发送流程,具体步骤如下:
1、初始化NDI相关库(NDIlib_initialize),官方要求开始NDI之前一定要做初始化工作;
2、创建NDI发送端的配置指针(NDIlib_send_create_t ),根据实际情况,设定NDI发送流的名字、组等信息;
3、依据创建好的NDI配置指针,实例化对应的发送对象(NDIlib_send_instance_t),并返回一个发送对象的指针;
4、把图像数据动态分配给一个空间内,并返回一个指针,如果图像采用1920*1080的分辨率,且使用BGRA的颜色空间,那么实际给每一帧图像分配的内存空间为1920*1080*4,单位为Byte;
5、初始化NDI视频帧的指针(NDIlib_video_frame_v2_t),设置NDI视频帧对应的分辨率、色彩空间、宽高、帧率等,可根据项目实际情况具体设置;
6、将视频帧的指针,放入到NDI发送的缓存里(NDIlib_send_send_video_v2),NDI自动发送视频数据;
7、之后清空视频帧里对应的视频数据,重新放入新的数据并发送,并进入循环;
8、发送音频的原理跟发送视频一样,需要指定音频帧的指针(NDIlib_audio_frame_v3_t),分配空间,并放入发送音频的数据(NDIlib_send_send_audio_v3);
在Android Studio中使用JNI接口具体通过“nativie-lib.cpp”实现,在程序编译之初,“nativie-lib.cpp”本地就运行了一遍C++程序,因此“nativie-lib.cpp”本地的全局参数和对象,都可以直接被应用到函数中,使用JNI接口发送NDI留的具体实现方式如下:
1、“nativie-lib.cpp”本地实例化两个NDI发送端的配置指针(NDIlib_send_create_t )、NDI视频帧的指针(NDIlib_video_frame_v2_t)和发送对象(NDIlib_send_instance_t),主要用作正常视频和镜像视频的发送;
2、在“nativie-lib.cpp”的"initialNDI"函数中,初始化一些发送配置,如设定NDI流的名字,色彩空间信息;
3、在"RecordService"服务中,调用“initialNDI”的JNI接口后,实际跳转运行如下,初始化时,创建了两个发送对象指针“p_send”和“p_send2”;
4、在“nativie-lib.cpp”的"publishNDI2"函数中,设定好发送视频的分辨率,根据视频数据的指针,发送正常和镜像两个NDI流;
5、在"RecordService"服务中,调用“publishNDI”的JNI接口后,实际跳转运行如下,"RecordService"服务中,将已经生成的正常、镜像数据的数组发送给JNI接口,JNI接口里再将这些数组转成jbyte指针,就能供“publishNDI2”函数调用;
6、在"RecordService"服务中循环读取最新桌面的录屏图像,并转成byte数组,发送给“publishNDI”函数进行推流。
三、NDI SDK在使用中的分析
NDI SDK虽然支持苹果端和Android的开发应用,但在底层,套用的还是一套C++的库文件,到手机端需要通过不同的接口转换,因此在实际开发应用中,还会遇到不少的瓶颈和疑惑,我把优缺点进行了总结和分析。
NDI SDK的优点:
1、基于C++开发,对外只暴露一些接口调用函数,可通过特定接口(JNI)实现跨平台应用的开发,代码统一,更新迭代方便;
2、NDI的收发对流进行自适应匹配,发送端不管是什么分辨率、宽高比、帧率,在接收端都能收到并进行匹配,NDI SDK中,不需要对流的编解码,做深入的处理;
3、NDI SDK代码开源,有很好的收发流例子,低成本的特性,使得国内外开发应用案例比较多。
NDI SDK的缺点:(主要针对在Android端的开发)
1、NDI内部视频流的编解码不开放,NDI SDK里面只是把像素数据发送给NDI发送端,NDI 4的不支持H.264/265相关应用设置,NDI 5支持,但有限制,并且调用SDK只能发送HX的流;
2、NDI SDK中,不支持流的实施输入和输出,如Android端使用录屏接口“mediaProjection”的流,没法送给NDI,只能需要通过截屏的方法,获取像素信息后,在发送给NDI接收,这样效率比较低,延迟大,NDI 5中还没有给出很好的支持方法,只能依赖Android端“mediaProjection”的流的转换;
3、Android端本身由于安全性考虑,要把本地的音频流串流到NDI里面,需要改复杂的权限设置,目前暂时只支持麦克风的调用;
4、没有对于高清、4K流的一些详细规范,比如说4K制作中最常见的HLG和BT2020的设置,NDI为了降低应用没看,把很多涉及编解码的东西都放到了后台处理,这就使得我们在做一些专业视音频项目开发的时候很痛苦,得不到更好的支持;
四、总结
此次应用NDI SDK开发,主要是想用于NDI在便携式提词器上的一些应用,比如说摄像出去外拍,只需要带1太摄像机和Android平板,Android平板端为NDI接收,摄像手机端为NDI发送,通过无线的方式,把稿件信息直接串流到Android平板上,省去的复杂的提词器系统搭建和设置过程;因此在程序设计时,除了输出NDI主画面以外,还输出了一个镜像画面,使用人员可以根据实际应用,使用主画面或镜像画面。
NDI SDK开发的时候是在3月份,当时还只有NDI 4的支持,很多NDI的工具原理还不清楚,只能在开发过程中一步步尝试,开发出来的产品帧率也比较低,今后基于NDI 5,尤其是NDI Bridge和H265编解码方面,还要做更深入的研究,下图为实际的NDI输出效果。