WebRTC의 미디어 서버 계열은 대체로 수신되는 미디어 영상을 조작하고 전송하는 MCU 와 수신된 미디어를 그대로 포워딩 해주는 SFU 로 나눠진다고 할 수 있다. MCU와 SFU의 장/단점은 이전 포스팅 [WebRTC] MediaServer(MCU/SFU) 정리 에서 확인 가능하다. 이번 포스팅은 SFU 계열의 미디어 서버 인 mediasoup SFU 이용에 대한 것이다.
mediasoup 은 core library는 c++로 이루어져 있고, node.js 기반 자바스크립트로 server를 구성할 수 있도록 해주는 node.js api 이다. 최근에 v3 으로 업데이트 되면서 DataChannel 등 흥미로운 기능들이 추가되었다. (https://mediasoup.org/documentation/v3/differences-between-v2-and-v3/) 설치도 무척 쉬운데 다음과 같이 node.js 의 npm 으로 설치 가능하다.
$npm install mediasoup@3 --save
다만, mediasoup 의 경우 서버에서 일반적인 webrtc sdp/icecandidate 등의 절차를 따르지 않고 ortc 기반으로 커스텀하게 사용해야 되서 ortc 등에 익숙하지 않는 경우 사용하기가 쉽지 않다. 물론 브라우저에서 사용할 수 있는 library를 제공하기는 하지만 일반적인 webrtc 절차를 따르지 않는 경우에 안드로이드 등 모바일 native 로 개발하기에는 전용 라이브러리가 필요하고 현재까지는 모바일 플랫폼용 라이브러리는 제공되고 있지 않다. 그렇다고 브라우저 상에서 뭔가 특별한 것을 설치하거나 그런 것은 아니다. 즉 브라우저의 webrtc 표준상에서 동작은하지만 만 webrtc 의 sdp 교환과 icecandidate 설정이라는 과정에서 ortc 로 변환하는 과정들이 들어가는데 이런 부분에 대해서 기존 web rtc에만 익숙해저 있는 개발자에게는 뭔가 혼란스럽게 된다.
이번/이후 포스팅은 안드로이드 등 모바일 native를 개발하기 위한 첫 걸음으로, mediasoup 예제를 가지고 크롬 브라우저에서 mediasoup 의 client library를 사용하지 않고 webrtc api들을 이용해서 분리하는 과정을 설명한다.
다음과 같이 대상 예제를 받고 설치한다.
$mkdir mediasoup_ex1
$cd mediasoup_ex1
$git clone https://github.com/mkhahani/mediasoup-sample-app
$cd mediasoup-sample-app
$cp config.example.js config.js
$vi config.js (이 부분에서 ssl 패스, 포트 등을 설정해 준다)
$npm install
$npm start
크롬 브라우저를 열고 https://localhost:3000 을 url 창에 입력하면
connect 버튼을 누르고 publishing 부분에 [Start Webcam] 을 클릭하면 로컬 영상을 미디어 서버로 전송하는 상태가 된다. 미디어 서버로 영상을 전송하는 상태가 되면, 브라우저 다른 창을 열고 동일한 URL에서 [connect] 버튼을 누르면 [Subscribe]가 활성화되어 있다. [Subscriber]를 클릭하면 전송 영상이 보이는 것을 확인할 수 있다.
상기 URL에 대해서 크롬의 개발자 모드를 열어보고 소스들을 확인해 보면 index 페이지와 app-bundle.js 만 보인다. app-bundle.js 는 npm start할때 browserify 로 필요한 js 파일들을 번들링한 것이다. app-bundle.js 를 보면 connect 버튼과 publish, subscribe 버튼에 매핑된 함수를 확인할 수 있다.
1. Signaling 분석
이 예제는 socket.io 의 websocket을 이용해서 signaling 을 처리하도록 되어 있다. connect 버튼을 처리하는 connect 함수에는 접속하는 서버와 이벤트 처리에 대한 함수가 있다. app-bundle.js 에서 socket.request 에서 클라이언트에서 전달하는 signaling 메시지에 대해서 확인 가능하고 서버쪽 처리는 server.js 의 runWebSocketServer 함수에서 확인 가능하다. 간단한 예제여서 Signaling 은 다음의 9개 메시지로 구성된다.
Message | 설명 |
newProducer getRouterRtpCapabilities createProducerTransport createConsumerTransport connectProducerTransport connectConsumerTransport produce consume resume |
미디어 전송 채널이 생성되어 있음을 알려줌(서버->클라이언트) MediaSoup Server(Router)에서 제고 가능한 RTP Capability 조회 미디어 전송 채널 생성 미디어 수신 채널 생성 미디어 전송 채널 연결 미디어 수신 채널 연결 미디어 전송 채널 전송 미디어 수신 채널 전송 미디어 수신 채널 resume |
newProducer 메시지를 제외하고는 모드 클라이언트에서 서버로 요청하는 메시지이다.
2. Publishing 분석
웹브라우저에서 main 으로 실행되는 javascript는 샘플 폴더의 client.js에 들어 있다. 각 버튼에 매핑된 함수가 정의되어 있다. Publishing 의 경우 [Connect] 버튼을 누르고 [Start Webcam] 버튼을 누르면 미디어 전송 채널로 미디어를 전송하게 된다.
connect 처리는 간단하다. Signaling 서버 URL로 socket.io websocket 접속하고 connect 가 되면 getRouterRtpCapabilities 메시지를 보내고 해당 응답으로 loadDevice 함수를 호출한다. loadDevice 함수에서는 mediasoup client library의 Device 객체를 생성하고(new mediasoup.Device()) load 함수를 호출하는데, 이때 getRouterRtpCapabilities 의 응답 데이터를 넣어서 호출하게 된다.
getRouterRtpCapabilities는 서버에서 mediasoup의 worker 로 생성한 router 객체에서 RtpCapabilities를 조회한다. Worker는 하나의 프로세스로 서버의 core 한개를 점유한다. router는 Audio/Video RTP 를 교환하는 단위로 producers와 consumers로 구별된다. router의 경우 하나의 worker에 속해 있어서 하나의 core를 점유하게 되고 동일 worker의 다른 router와 해당 core를 공유하게 된다. 다음은 서버에서 router를 생성하는 코드이다.
(router 생성 pseudo 코드) const mediasoup=require('mediasoup'); const worker=await mediasoup.createWorker({ logLevel:-config-,logTags:-config-,rtcMinPort:-config-,rtcMaxPort:-config- }); const mediaCodecs=-config-; const router=await worker.createRouter({mediaCodecs}); |
[getRouterRtpCapabilities]
getRouterRtpCapabilities 메시지는 서버의 router 객체의 rtpCapabilities 멤버 변수에 기술된 내용을 클라이언트로 전달해 주는 것이다. (router.rtpCapabilities) rtpCapabilities 는 다음으로 구성된다. (https://mediasoup.org/documentation/v3/mediasoup/rtp-parameters-and-capabilities/#RtpCapabilities)
다음은 ubuntu 18.04에서 테스트해본 rtpCapabilities 값을 json viewer로 본 것이다..
[loadDevice]
loadDevice 에서는 mediasoup client library의 Device 객체를 생성(Device.js)하고(new mediasoup.Device()) rtpCapabilities 를 매개로 해서 load함수를 호출하도록 되어 있다. 생성자에서는 클라이언트 브라우저 종류를 판단하여 해당 브라우저에 맞는 Handler를 설정한다. (예를 들어 Chrome70.js)
load 함수에서는 브라우저의 RTP Capabilities 를 가져오고 앞의 getRtpCapabilities 메시지 응답으로 받은 미디어 서버의 rtpCapabilities 이용해서 extendedRtpCapabilities를 생성한다. 브라우저의 RTP Capabilities는 Handler의 getNativeRtpCapabilities() 에서 조회할 수 있는데, Handler(Chrome70.js)에서는 브라우저의 RTCPeerConnection 을 생성하고 createOffer한 데이터를 sdpTransform 객체와 sdpCommonUtils 객체로 RtpCapabilities를 추출해서 전달한다.
(브라우저 RtpCapabilities 조회 Pseudo Code) const pc=new RTCPeerConnection({--Options--}); pc.addTransceiver('audio'); pc.addTransceiver('video'); const offer=await pc.createOffer(); const sdpObject=sdpTransform.parse(offer.sdp); const nativeRtpCapabilities=sdpCommonUtils.extractRtpCapabilities({sdpObject}); |
extendedRtpCapabilities는 ortc 객체를 이용한다. ortc.getExtendedRtpCapabilities(nativeRtpCapabilities,routerRtpCapabilities);
다음은 크롬 브라우저에서 조회된 nativeRtpCapabilities, 라우터의 RtpCapabilities, 이 둘로 생성한 extendedRtpCapbilities 예이다. extendedCapabilities는 브라우저의 RtpCapabilities 와 라우터의 RtpCapabilities 를 보고 교환 가능한 미디어를 채크해서 설정하는 것 같다.
(nativeRtpCapabilities) {"codecs": [{"mimeType":"audio/opus","kind":"audio","clockRate":48000,"preferredPayloadType":111,"channels":2,"rtcpFeedback":[{"type":"transport-cc"}],"parameters":{"minptime":10,"useinbandfec":1}}, {"mimeType":"audio/ISAC","kind":"audio","clockRate":16000,"preferredPayloadType":103,"channels":1,"rtcpFeedback":[],"parameters":{}},{"mimeType":"audio/ISAC","kind":"audio","clockRate":32000,"preferredPayloadType":104,"channels":1,"rtcpFeedback":[],"parameters":{}}, ------------------------중략----------------------------- [],"parameters":{}},{"mimeType":"audio/telephone-event","kind":"audio","clockRate":32000,"preferredPayloadType":112,"channels":1,"rtcpFeedback":[],"parameters":{}},{"mimeType":"audio/telephone-event","kind":"audio","clockRate":16000,"preferredPayloadType":113,"channels":1,"rtcpFeedback":[],"parameters":{}},{"mimeType":"audio/telephone-event","kind":"audio","clockRate":8000,"preferredPayloadType":126,"channels":1,"rtcpFeedback":[],"parameters":{}}, {"mimeType":"video/VP8","kind":"video","clockRate":90000,"preferredPayloadType":96,"rtcpFeedback":[{"type":"goog-remb"},{"type":"transport-cc"},{"type":"ccm","parameter":"fir"},{"type":"nack"},{"type":"nack","parameter":"pli"}],"parameters":{}},{"mimeType":"video/rtx","kind":"video","clockRate":90000,"preferredPayloadType":97,"rtcpFeedback":[],"parameters":{"apt":96}},{"mimeType":"video/VP9","kind":"video","clockRate":90000,"preferredPayloadType":98,"rtcpFeedback":[{"type":"goog-remb"},{"type":"transport-cc"},{"type":"ccm","parameter":"fir"},{"type":"nack"},{"type":"nack","parameter":"pli"}],"parameters":{"profile-id":0}}, ------------------------중략----------------------------- {"mimeType":"video/rtx","kind":"video","clockRate":90000,"preferredPayloadType":120,"rtcpFeedback":[],"parameters":{"apt":124}},{"mimeType":"video/ulpfec","kind":"video","clockRate":90000,"preferredPayloadType":123,"rtcpFeedback":[],"parameters":{}}],
"headerExtensions":[ {"kind":"audio","uri":"urn:ietf:params:rtp-hdrext:ssrc-audio-level","preferredId":1}, {"kind":"audio","uri":"http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01","preferredId":2},{"kind":"audio","uri":"urn:ietf:params:rtp-hdrext:sdes:mid","preferredId":3}, {"kind":"audio","uri":"urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id","preferredId":4}, {"kind":"audio","uri":"urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id","preferredId":5}, {"kind":"video","uri":"urn:ietf:params:rtp-hdrext:toffset","preferredId":14}, {"kind":"video","uri":"http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time","preferredId":13}, {"kind":"video","uri":"urn:3gpp:video-orientation","preferredId":12}, {"kind":"video","uri":"http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01","preferredId":2},{"kind":"video","uri":"http://www.webrtc.org/experiments/rtp-hdrext/playout-delay","preferredId":11},{"kind":"video","uri":"http://www.webrtc.org/experiments/rtp-hdrext/video-content-type","preferredId":6},{"kind":"video","uri":"http://www.webrtc.org/experiments/rtp-hdrext/video-timing","preferredId":7},{"kind":"video","uri":"http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07","preferredId":8},{"kind":"video","uri":"http://www.webrtc.org/experiments/rtp-hdrext/color-space","preferredId":9}, {"kind":"video","uri":"urn:ietf:params:rtp-hdrext:sdes:mid","preferredId":3}, {"kind":"video","uri":"urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id","preferredId":4}, {"kind":"video","uri":"urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id","preferredId":5}],
"fecMechanisms":[]} |
(routeRtpCapabilities) {"codecs": [{"kind":"audio","mimeType":"audio/opus","clockRate":48000,"channels":2,"preferredPayloadType":100,"parameters":{},"rtcpFeedback":[]},
{"kind":"video","mimeType":"video/VP8","clockRate":90000,"rtcpFeedback":[{"type":"nack"},{"type":"nack","parameter":"pli"},{"type":"ccm","parameter":"fir"},{"type":"goog-remb"},{"type":"transport-cc"}],"preferredPayloadType":101,"parameters":{"x-google-start-bitrate":1000}},{"kind":"video","mimeType":"video/rtx","preferredPayloadType":102,"clockRate":90000,"rtcpFeedback":[],"parameters":{"apt":101}}],
"headerExtensions":[ {"kind":"audio","uri":"urn:ietf:params:rtp-hdrext:sdes:mid","preferredId":1,"preferredEncrypt":false,"direction":"recvonly"},{"kind":"video","uri":"urn:ietf:params:rtp-hdrext:sdes:mid","preferredId":1,"preferredEncrypt":false,"direction":"recvonly"},{"kind":"video","uri":"urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id","preferredId":2,"preferredEncrypt":false,"direction":"recvonly"},{"kind":"video","uri":"urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id","preferredId":3,"preferredEncrypt":false,"direction":"recvonly"},{"kind":"audio","uri":"http://www.webrtc.org/--중략--abs-send-time","preferredId":4,"preferredEncrypt":false,"direction":"sendrecv"}, {"kind":"video","uri":"http://www.webrtc.org/--중략--/abs-send-time","preferredId":4,"preferredEncrypt":false,"direction":"sendrecv"}, {"kind":"audio","uri":"http://www.ietf.org/id/--중략---cc-extensions-01","preferredId":5,"preferredEncrypt":false,"direction":"inactive"}, {"kind":"video","uri":"http://www.ietf.org/id/--중략---extensions-01","preferredId":5,"preferredEncrypt":false,"direction":"inactive"}, {"kind":"video","uri":"http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07","preferredId":6,"preferredEncrypt":false,"direction":"sendrecv"},{"kind":"video","uri":"urn:ietf:params:rtp-hdrext:framemarking","preferredId":7,"preferredEncrypt":false,"direction":"sendrecv"},{"kind":"audio","uri":"urn:ietf:params:rtp-hdrext:ssrc-audio-level","preferredId":10,"preferredEncrypt":false,"direction":"sendrecv"},{"kind":"video","uri":"urn:3gpp:video-orientation","preferredId":11,"preferredEncrypt":false,"direction":"sendrecv"},{"kind":"video","uri":"urn:ietf:params:rtp-hdrext:toffset","preferredId":12,"preferredEncrypt":false,"direction":"sendrecv"}],
"fecMechanisms":[]} |
(extendedRtpCapabilities) {"codecs":[{"mimeType":"audio/opus","kind":"audio","clockRate":48000,"localPayloadType":111,"localRtxPayloadType":null,"remotePayloadType":100,"remoteRtxPayloadType":null,"channels":2,"rtcpFeedback":[],"localParameters":{"minptime":10,"useinbandfec":1},"remoteParameters":{}}, {"mimeType":"video/VP8","kind":"video","clockRate":90000,"localPayloadType":96,"localRtxPayloadType":97,"remotePayloadType":101,"remoteRtxPayloadType":102,"rtcpFeedback":[{"type":"goog-remb"},{"type":"transport-cc"},{"type":"ccm","parameter":"fir"},{"type":"nack"},{"type":"nack","parameter":"pli"}],"localParameters":{},"remoteParameters":{"x-google-start-bitrate":1000}}],
"headerExtensions":[ {"kind":"audio","uri":"urn:ietf:params:rtp-hdrext:sdes:mid","sendId":3,"recvId":1,"direction":"sendonly"}, {"kind":"video","uri":"urn:ietf:params:rtp-hdrext:sdes:mid","sendId":3,"recvId":1,"direction":"sendonly"}, {"kind":"video","uri":"urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id","sendId":4,"recvId":2,"direction":"sendonly"},{"kind":"video","uri":"urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id","sendId":5,"recvId":3,"direction":"sendonly"},{"kind":"video","uri":"http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time","sendId":13,"recvId":4,"direction":"sendrecv"},{"kind":"audio","uri":"http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01","sendId":2,"recvId":5,"direction":"inactive"},{"kind":"video","uri":"http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01","sendId":2,"recvId":5,"direction":"inactive"},{"kind":"video","uri":"http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07","sendId":8,"recvId":6,"direction":"sendrecv"},{"kind":"audio","uri":"urn:ietf:params:rtp-hdrext:ssrc-audio-level","sendId":1,"recvId":10,"direction":"sendrecv"},{"kind":"video","uri":"urn:3gpp:video-orientation","sendId":12,"recvId":11,"direction":"sendrecv"}, {"kind":"video","uri":"urn:ietf:params:rtp-hdrext:toffset","sendId":14,"recvId":12,"direction":"sendrecv"}],"fecMechanisms":[]} |
extendedRtpCapabilities 가 생성되면 미디어 수신을 위한 _recvRtpCapabilities를 생성한다.
[publishing]
publish 버튼을 누르게 되면 client.js 의 publish 함수가 호출된다. 브라우저에서는 서버에 createProducerTransport라는 메시지를 보내는데, 서버에서는 mediaRouter 객체에 WebRtc Transport를 생성하기 위하여 createWebRtcTransport 함수를 호출하게 된다.
createWebRtcTransport 함수를 호출하면 호출 결과로 transport 객체가 전달되는데 이 객체의 transport.id, transport.iceParameters, transport.iceCandidates, transport.dtlsParameters 등을 클라이언트로 전달한다.
(WebRtcTransport) {"id":"d6093491-4c2b-475a-a121-e3725ae11b61", "iceParameters":{"iceLite":true,"password":"3lyo6o7cta1tdwuh0e3lvdkxu7a2qdbe","usernameFragment":"qglfxbav05cgojx8"}, "iceCandidates":[{"foundation":"udpcandidate","ip":"192.168.0.119","port":10049,"priority":1076558079,"protocol":"udp","type":"host"},{"foundation":"tcpcandidate","ip":"192.168.0.119","port":10018,"priority":1076302079,"protocol":"tcp","tcpType":"passive","type":"host"}], "dtlsParameters":{"fingerprints":[{"algorithm":"sha-1","value":"55:EC:CD:AF:82:3F:95:4C:37:87:14:E2:FD:1C:BB:3C:0B:8E:47:00"},{"algorithm":"sha-224","value":"3F:4B:36:3B:EB:99:FF:EF:6A:FE:76:F5:31:62:A3:68:7E:01:5B:04:6F:03:FB:B2:B4:B2:0C:92"},{"algorithm":"sha-256","value":"B1:51:19:69:0C:B4:84:45:77:E1:F5:50:33:47:A2:94:1B:F3:9B:C6:6E:71:E7:F9:AB:03:78:A3:F4:8F:56:BC"},{"algorithm":"sha-384","value":"F7:A6:F3:9C:41:3D:2C:84:5A:70:D8:35:30:EF:1A:8D:A2:10:BF:54:5B:13:FE:8C:5E:AF:4D:D9:E2:B8:D3:E6:05:55:E1:2E:90:38:A5:5D:92:BE:3E:F6:B6:A1:0B:1D"},{"algorithm":"sha-512","value":"A7:FF:F5:09:79:47:0B:B7:2F:95:2E:63:C1:C2:02:6F:56:2F:CE:44:24:46:1F:DC:15:37:22:5A:7E:58:7C:0B:7F:A2:D5:24:79:21:0D:17:0B:B6:B4:C1:7C:12:F1:63:8D:28:37:78:6C:7F:4A:B8:B5:3A:6B:82:8A:9D:84:C7"}],"role":"auto"}} |
브라우저에서는 createWebRtcTransport 결과로 id, iceParameters, iceCandidates, dtlsParameters 등을 이용해서 Transport 객체(Transport.js)를 생성한다. 이후 브라우저에서는 getUserMedia 로 웹캠의 비디오/오디오를 획득하고 Transport의 produce 함수를 호출하게 된다.
(getUserMedia Pseudo Code) const stream=await navigator.mediaDevices.getUserMedia({video:true}); const track=stream.getVideoTracks()[0]; const params={track}; const producer=await transport.produce(params); |
Transport.js의 produce 함수에서는 Chrome70.js 의 send함수를 호출한다.
Chrome70.js 의 send 함수에서는 SendHandler의 send 함수가 호출되며 이 함수에서 PeerConnection에서 sendonly 로 addTransceiver를 호출하고 PeerConnection의 createOffer() 함수로 local sdp 를 가져오고 몇가지 처리한 다음에 setLocalDescription 을 설정한다.
중간에 _setupTransport 함수를 호출하고 이 _setupTransport 함수에서는 connect 이벤트를 전송한다. 이 connect 이벤트는 Transport.js 의 이벤트 핸들러를 호출하게 되고 이 이벤트 핸들러에서는 connect 이벤트를 client.js 로 전달한다.
client.js 에서는 이 이벤트를 받아서 서버로 connectProducerTransport 메시지를 전달하게 된다. connectProducerTransport 메시지를 받게 되면 서버는 webRtcTransport 의 connect 함수를 호출한다. 이떄 클라이언트에서 받은 dtlsParameter를 이용해서 매게변수로 전달한다.
그리고 remoteSdp 개채의 send 함수를 호출하고 이 객체에서 remoteSdp 를 가져온다. 그리고 브라우 저의 PeerConnection에 setRemoteDescription 을 호출한다.
이와 동시에 client.js 에서는 Transport.js 의 produce event를 받아서 서버로 produce 메시지를 보낸다.
produce 메시지를 받게 되면 서버는 webRtcTransport 의 produce 를 호출한다. 여기까지 완료 되면 브라우저에서 웹캠의 영상을 서버로 전송하게 된다.
다음은 connect -> publish 과정을 정리한 플로우이다.