从此

媒体/直播流、FFMPEG音视频开发、SRTP/RTP实时传输协议、libjitsi通话库

综合/最新

Java编程专题

通用

视频通话(连麦等)耐受度是400毫秒,单向直播是2秒,视频缓冲则为10秒。

RTP(Real-time Transport Protocol,实时传输协议) - IP电话(VoIP)和视频会议(如WebRTC)核心方案,非明文版为Secure Real-time Transport Protocol(SRTP),RTCP 为 RTP 的 QoS 控制通道。
LL-HLS / LL-DASH CMAF - 音视频低延迟分发方案,LL-HLS 文件后缀为 .m3u8, LL-DASH 文件后缀为 .mpd。
串流/推流技术(Streaming) - 成熟的 RTMP (Real-Time Messaging Protocol)、新型的 SRT (Secure Reliable Transport)。

音视频

综合:
  比特率=采样率 * 位 * 通道数
  RTP强制支持PCMU(μ-law)音频格式 而非PCMA(a-law)。
  FMJ规整URL斜杠:new MediaLocator(URLUtils.createUrlStr(new File("samplemedia/gulp2.wav")))
  音频接口:Windows保底为MME (Microsoft Multimedia Extensions),追求质量上DirectSound或独占模式的WASAPI (Windows Audio Session API)。


libjitsi:
  说明 - 
    支持本地收发,监听方可用通配地址 0.0.0.0,但目标地址则用 --remote-host=127.0.0.1 或实际 IP,或全部连至 Jitsi Videobridge (JVB) 服务端 IP。
    只发送/只接收:mediaStream.setDirection(MediaDirection.SENDONLY); // 会关闭 RTP 接收流;无论方向如何,RTCP 通道总会建立和同步状态,且无需对 RR 包进行确认。
    发送对象 - _RTPConnectorOutputStream.send(RawPacket packet) { 遍历 targets 并发送 }
    从 RTP 物理连接(ice4j)创建个 RTCP 包头过滤器(rtcpmux)- stream.getComponent(Component.RTP).getSocket().getSocket(RTCP_FILTER);
      Jabber IceUdpTransportManager 包装了 ice4j 的 Agent 打洞流程;未用到 DefaultStreamConnector.isRtcpmux()?
    服务端共用同一端口优化 - SinglePortUdpHarvester 基于“源地址”区分客户端,底层必须启用 rtcpmux 合并 RTP + RTCP。

    RTCP RR 类型会被 _StatisticsEngine.parseRTCPReport(...) 转换为 RTCPReceiverReport,SR 类型则为 RTCPSenderReport(RTCPSRPacket)。
      // 内部会自动填充至 MediaStreamStats2 对象属性。
      mediaStream.getMediaStreamStats().getRTCPReports().addRTCPReportListener(new RTCPReportAdapter() {
        @Override public void rtcpReportReceived(RTCPReport report) {
            if (report instanceof RTCPReceiverReport rr) { // or RTCPSenderReport
                // 通常 2 份 = 音频 x 1 + 视频 x 1
                rr.getFeedbackReports().forEach(System.out::println);
            }
        }
      });

    连上后更改目标地址:(通过 ice4j 打洞就直连,无需监听对端 IP 了)
      mediaStream.getMediaStreamStats().addRTCPPacketListener( // 注 - 想回调 RTCP RR Packet 需换用 addRTCPReportListener。
        new RTCPPacketListener() { ...srReceived(RTCPSRPacket srp) { ...setTarget...mediaStream.getMediaStreamStats()... } })
    音频走 AudioMediaStreamImpl,视频走 VideoMediaStreamImpl,通过 RTCP Sender Reports 进行音画同步。
    本地网络 mediaStream.setConnector(connector); 远端目标 mediaStream.setTarget(new MediaStreamTarget(isa,isa2));
    正常停止不了就强制关闭:if (mediaStream != null) { try { mediaStream.stop(); } finally { mediaStream.close(); mediaStreams[i] = null; } }
    媒体流收发停止后,不会关闭网络连接的传输或监听,故还应 connector.close();
    若没调用 LibJitsi.stop() 收尾,直接 kill 程序,可能导致远端收不到正常挂断通知而死等超时:
      Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { LibJitsi.stop(); } catch (Exception e) { } }));

    默认配置:var ms = LibJitsi.getMediaService(); var dc = ((MediaServiceImpl) ms).getDeviceConfiguration(); var as = dc.getAudioSystem();
      // 或 AudioSystem.getAudioSystem(LOCATOR_PROTOCOL_PORTAUDIO);
    遍历收音:var cdi2 = as.getDevices(AudioSystem.DataFlow.PLAYBACK).getFirst();
      // 或 LibJitsi.getMediaService().getDefaultDevice(MediaType.AUDIO, MediaUseCase.CALL)).getCaptureDeviceInfo(); // 无设备时也非 null!?
    指定放音:as.setDevice(AudioSystem.DataFlow.PLAYBACK, cdi2, true); // 或 new AudioMediaDeviceImpl(cdi2);
      // 或 new MediaDeviceImpl(((CaptureDevice) Manager.createDataSource(loc)).getCaptureDeviceInfo(), MediaType.AUDIO);
    反查放音:as.getSelectedDevice(AudioSystem.DataFlow.PLAYBACK); // 或收音用 dc.getAudioCaptureDevice()
      // as.getDevice(AudioSystem.DataFlow.PLAYBACK, new MediaLocator(cdi.getLocator().toExternalForm()));
    建媒体流:LibJitsi.getMediaService().createMediaStream(new MediaDeviceImpl(cdi2, MediaType.AUDIO));

    implementation("org.jitsi:libjitsi:1.1-34-gb93ce2ee") // 解压出 win32-x86-64 目录;已包含了fmj库
    //implementation("org.jitsi:jitsi-lgpl-dependencies:1.2-23-g7b49874:win32-x86-64")
    tasks.named("run") { 
        jvmArgs = listOf("-Djava.library.path=D:\\temp\\libjitsi-1.2-23-g7b49874\\win32-x86-64\\")
        args = listOf("--local-port-base=11111","--remote-host=localhost","--remote-port-base=22222")
        //args = listOf("--local-port-base=22222","--remote-host=localhost","--remote-port-base=11111")
        //args +=['--local-port-base=11111', '--remote-host=localhost', '--remote-port-base=22222'] // 接收入参;2分钟自动退出
        //args +=['--local-port-base=22222', '--remote-host=localhost', '--remote-port-base=11111'] // 发送入参;1分钟自动退出
    }
    检查 java.library.path 指定或自动解压原生库:
        try { System.loadLibrary("jnawtrenderer"); System.out.println("libjitsi 原生库加载成功!");
        } catch (UnsatisfiedLinkError e) { e.printStackTrace(System.err); }

  说明:
    注意 - LibJitsi.stop() 一旦被调用,LibJitsi.getMediaService() 就会卡住,故只在程序退出时调一次。
      PortAudioStream用于音频输入、PortAudioRenderer用于音频输出?
      示例 - https://github.com/jitsi/libjitsi/blob/master/src/main/java/org/jitsi/examples/AVTransmit2.java

  通用API:
    LibJitsi.start(); // 属性配置必须在库启动后设置;环境变量估计可以提前。
    LibJitsi.getConfigurationService().setProperty(DISABLE_VIDEO_SUPPORT_PNAME, true);
    //LibJitsi.getConfigurationService().setProperty("net.java.sip.communicator.impl.neomedia.echocancel.filterLengthInMillis", 10000);

    // macOS 系统中 Java_org_jitsi_impl_neomedia_portaudio_Pa_Initialize(...) 调 PortAudio 库 Pa_Initialize() 报 PortAudioException: Insufficient memory: errorCode= -9992;
    // 会自动移出 AudioSystem.getAudioSystems(),不影响功能;或规避下输出:System.setProperty("org.jitsi.impl.neomedia.device.PortAudioSystem.disabled", "true");
    MediaService ms = LibJitsi.getMediaService(); // NeomediaServiceUtils.getMediaServiceImpl();

    var cdi = ((MediaDeviceImpl) ms.getDefaultDevice(MediaType.AUDIO, MediaUseCase.CALL)).getCaptureDeviceInfo();
    if (cdi != null) { // 因getDefaultDevice匹配不到设备会返回一个非null实例,故只能用getCaptureDeviceInfo()来判null。
      var stream = ms.createMediaStream((MediaDevice) cdi); System.out.println("stream - " + stream);
    }

    MediaServiceImpl msi = (MediaServiceImpl)ms;
    if (msi != null) {
        var dc = msi.getDeviceConfiguration();
        if (dc != null) {
            var r = dc.getEchoCancelFilterLengthInMillis();
            System.out.println("r - " + r);
        }
    }

    WASAPISystem感知不到小部分USB声卡“ReSpeaker 4 Mic Array (UAC1.0)”,获取到的MediaDevice是个“inactive”实例,可改用能感知USB声卡的PortAudioSystem:
      //默认 AudioSystem 实现在 DeviceSystem.java 中 OSUtils.IS_WINDOWS ? ".WASAPISystem" : null 和 OSUtils.IS_ANDROID ? null : ".PortAudioSystem"。
      LibJitsi.start(); // 初始化、启动库;并暂时禁用依赖FFmpeg的视频支持。
      LibJitsi.getConfigurationService().setProperty(DISABLE_VIDEO_SUPPORT_PNAME, true);
      LibJitsi.getMediaService(); // 调用后getAudioSystem才不返回null。
      var as = AudioSystem.getAudioSystem(LOCATOR_PROTOCOL_PORTAUDIO); // 换成除 IS_ANDROID 操作系统外均支持的 PortAudioSystem。
      if(as!=null){
       var devices = as.getDevices(AudioSystem.DataFlow.CAPTURE);
       System.out.println(devices);
       if (!devices.isEmpty()) {
        var cdi = (CaptureDevice) Manager.createDataSource(devices.getFirst().getLocator()).getCaptureDeviceInfo();
        if (cdi != null) {
            var stream = ms.createMediaStream(new MediaDeviceImpl(cdi, MediaType.AUDIO));
            System.out.println("stream - " + stream);
        }
       }
      }


  JMF/FMJ:
    说明 - JMF已被JDK删除,FMJ官方成员也说停止维护和不推荐使用,故可换用 Jitsi libjitsi 库。
    Bug:
      Manager.createDataSink(ds, m).open() 中 rtpManager.initialize(new SessionAddress()) 入参未判空被调导致NPE:
        故应改写 DataSink 实现类 net.sf.fmj.media.datasink.rtp.Handler.open() 源码,传入 new SessionAddress(InetAddress.getLocalHost(), parsedRTPUrl.elements[0].port);
        完整用法 - var ds = new Handler(); ds.setSource(processor.getDataOutput()); ds.setOutputLocator(m); ds.open(); ds.start();

    rtp最简实例: 仅需 implementation("org.jitsi:fmj:1.0.2-jitsi")
      VLC media player 媒体 -> 打开网络串流 填入组播(全子网)网址 rtp://224.0.0.1:22222/audio/16
      RTP发送源码 https://github.com/jitsi/fmj/blob/master/src.examples.rtp/SimpleVoiceTransmiter.java  https://github.com/jitsi/fmj/blob/master/src.test/net/sf/fmj/rtp/rtpaudio.java
        String url = "rtp://224.0.0.1:22222/audio/16"; // 224.0.0.1 为组播IP,多个流则通过RTP头的SSRC或CNAME来区分;audio为媒体类型,16为可选的TTL值;无TTL写法 rtp://224.0.0.1:22222/audio。
      解决 Unable to transcode format - Audacity -> 打开文件(示例音频) -> 导出音频 -> 导出到计算机 -> 单声道、采样率 8000 Hz、编码 U-Law -> 导出。
      解决 com.sun.media.processor.unknown.Handler ConcurrentModificationException - 将 if blockingRealize() 块儿换为 processor.realize();,取消注释下方的 while (processor.getState() != Processor.Realized) 块儿。     

NAT穿透、P2P打洞

Ice4J:
  亲测 - 标准 ICE 打洞耗时约 2 分钟。
  无打洞流程的 RTP C/S,必须 Server 端先监听,否则报 ICMP Port Unreachable,而打洞 COMPLETED 时,其实 Agent 早就已经 bind 了该端口,因此 RTP 两端的启动顺序就不重要了。
  Offer/Answer 两端 SDP 描述无明确类型区分,需额外包装指定下,但通常是 Offer 能力列表较多,而 Answer 则最终选取共有能力(媒体属性和收发方向等);网络候选均应全量列出,agent.startConnectivityEstablishment() 打洞时 agent.setControlling(true) 说了算。
  不启用增量回调:agent.setTrickling(false); // SDP 内的 a=ice-options:trickle 只是表明能力支持,并不影响 Agent 对象行为。

  SDP 包装:
    用JSON交换:WebRTC JSEP 格式 Offer/Answer - {"type": "offer/answer", "sdp": "..."};Trickle Candidate - {"type": "candidate", "candidate": "..."}。
    程序内交换:localComponent.addRemoteCandidate(new RemoteCandidate(...));

  打洞结果:
    测试页 - https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
    host:从本机网卡上获取到的地址。
    srflx(server reflexive):从STUN服务器获取到的地址。
    relay:从TRUN服务器获取到的地址。
  
  免费 stun/turn Server:
    https://gist.github.com/mondain/b0ec1cf5f60ae726202e
    搭建 - https://github.com/pion/turn
    {
	"urls": [
		"turn:stun.evan-brass.net",
		"turn:stun.evan-brass.net?transport=tcp",
		"stun:stun.evan-brass.net"
	],
	"username": "guest",
	"credential": "password"
    }

  打洞后不能关闭已打通端口的 Socket:
    agent.addStateChangeListener(PropertyChangeListener pcl);
    // listener.wait() + notifyAll() 或 pcl 内回调后直接用(Component全部打通才回调):
    if (event.getNewValue() == IceProcessingState.COMPLETED) {
        var stream = agent.getStream(streamName);
        if (stream != null) {
            var component = stream.getComponent(Component.RTP);
            if (component != null) {
                return component.getSocket();
            }
        }
    }

其他

JGroups:
  自身 IP: jc.getAddressAsString();