音视频
综合:
比特率=采样率 * 位 * 通道数
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();
}
}
}