├── .gitignore ├── play-stream.sh ├── play-stream.bat ├── rtp-forwarder.sdp ├── go.mod ├── LICENSE ├── README.md ├── go.sum └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | __debug_bin 3 | *.exe 4 | *.lnk -------------------------------------------------------------------------------- /play-stream.sh: -------------------------------------------------------------------------------- 1 | ffplay -fflags nobuffer -flags low_delay -i rtp-forwarder.sdp -protocol_whitelist file,udp,rtp -------------------------------------------------------------------------------- /play-stream.bat: -------------------------------------------------------------------------------- 1 | ffplay -fflags nobuffer -flags low_delay -i rtp-forwarder.sdp -protocol_whitelist file,udp,rtp -------------------------------------------------------------------------------- /rtp-forwarder.sdp: -------------------------------------------------------------------------------- 1 | v=0 2 | o=- 0 0 IN IP4 127.0.0.1 3 | s=Pion WebRTC 4 | c=IN IP4 127.0.0.1 5 | t=0 0 6 | m=audio 4000 RTP/AVP 111 7 | a=rtpmap:111 OPUS/48000/2 8 | m=video 4002 RTP/AVP 125 9 | a=rtpmap:125 H264/90000 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/my/ue-rtp-forwarder 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/gorilla/websocket v1.4.2 7 | github.com/pion/rtcp v1.2.6 8 | github.com/pion/rtp v1.6.2 9 | github.com/pion/webrtc/v3 v3.0.4 10 | ) 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 TensorWorks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Unreal Engine -> Pion WebRTC -> RTP Receiver 2 | 3 | This is a proof of concept demonstrating pixel streaming from Unreal Engine to Pion's WebRTC, with Pion then forwarding RTP video/audio to FFPlay. 4 | 5 | ## Running the proof of concept 6 | 7 | There are number of moving pieces to run this demo. We tested this proof of concept using **FFPlay 4.3.1**, **NodeJS 15.5.1**, **GoLang 1.15.6**, and **Unreal Engine 4.25** - we recommend using similar version numbers to reproduce our results. 8 | 9 | 1. Get the ["Pixel Streaming Demo"](https://docs.unrealengine.com/en-US/Resources/Showcases/PixelStreamingShowcase/index.html) and run that in Unreal Engine. 10 | 2. Run the Cirrus signalling server bundled with Unreal Engine by calling `run.bat` or `sudo node cirrus.js` in `Samples\PixelStreaming\WebServers\SignallingWebServer`. 11 | 3. Run this RTP forwarder, `go run main.go` in this repository. 12 | 4. Play the forwarded video/audio streams in FFPlay, run `play-stream.bat` or `play-stream.sh` 13 | 14 | ## Configuring the forwarder 15 | There are a number of flags that can be passed to `main.go` that may be of interest to those using this proof of concept: 16 | 17 | ```go 18 | // CirrusPort - The port of the Cirrus signalling server that the Pixel Streaming instance is connected to. 19 | var CirrusPort = flag.Int("CirrusPort", 80, "The port of the Cirrus signalling server that the Pixel Streaming instance is connected to.") 20 | 21 | // CirrusAddress - The address of the Cirrus signalling server that the Pixel Streaming instance is connected to. 22 | var CirrusAddress = flag.String("CirrusAddress", "localhost", "The address of the Cirrus signalling server that the Pixel Streaming instance is connected to.") 23 | 24 | // ForwardingAddress - The address to send the RTP stream to. 25 | var ForwardingAddress = flag.String("ForwardingAddress", "127.0.0.1", "The address to send the RTP stream to.") 26 | 27 | // RTPVideoForwardingPort - The port to use for sending the RTP video stream. 28 | var RTPVideoForwardingPort = flag.Int("RTPVideoForwardingPort", 4002, "The port to use for sending the RTP video stream.") 29 | 30 | // RTPAudioForwardingPort - The port to use for sending the RTP audio stream. 31 | var RTPAudioForwardingPort = flag.Int("RTPAudioForwardingPort", 4000, "The port to use for sending the RTP audio stream.") 32 | 33 | // RTPAudioPayloadType - The payload type of the RTP packet, 111 is OPUS. 34 | var RTPAudioPayloadType = flag.Uint("RTPAudioPayloadType", 111, "The payload type of the RTP packet, 111 is OPUS.") 35 | 36 | // RTPVideoPayloadType - The payload type of the RTP packet, 125 is H264 constrained baseline 2.0 in Chrome, with packetization mode of 1. 37 | var RTPVideoPayloadType = flag.Uint("RTPVideoPayloadType", 125, "The payload type of the RTP packet, 125 is H264 constrained baseline in Chrome.") 38 | 39 | // RTCPIntervalMs - How often (ms) to send RTCP messages (such as REMB, PLI) 40 | var RTCPIntervalMs = flag.Int("RTCPIntervalMs", 2000, "How often (ms) to send RTCP message such as REMB, PLI.") 41 | 42 | //Whether or not to send PLI messages on an interval. 43 | var RTCPSendPLI = flag.Bool("RTCPSendPLI", true, "Whether or not to send PLI messages on an interval.") 44 | 45 | //Whether or not to send REMB messages on an interval. 46 | var RTCPSendREMB = flag.Bool("RTCPSendREMB", true, "Whether or not to send REMB messages on an interval.") 47 | 48 | // Receiver-side estimated maximum bitrate. 49 | var REMB = flag.Uint64("REMB", 400000000, "Receiver-side estimated maximum bitrate.") 50 | ``` 51 | 52 | ## Configuring FFPlay 53 | You may need to download FFPlay if it is not on your system already: https://ffmpeg.org/ffplay.html 54 | Currently FFPlay is passed details about the RTP streams using the `rtp-forwarder.sdp` file. 55 | Additionally, FFPlay is passed the `-fflags nobuffer -flags low_delay` flags to reduce latency; however, these may not be suitable in all cases. 56 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 4 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 5 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 6 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 7 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 8 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 9 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 10 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 11 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 12 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 13 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 14 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 15 | github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= 16 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 17 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 18 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 19 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 20 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 21 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 22 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 23 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 24 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 25 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 26 | github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= 27 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 28 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 29 | github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= 30 | github.com/pion/datachannel v1.4.21 h1:3ZvhNyfmxsAqltQrApLPQMhSFNA+aT87RqyCq4OXmf0= 31 | github.com/pion/datachannel v1.4.21/go.mod h1:oiNyP4gHx2DIwRzX/MFyH0Rz/Gz05OgBlayAI2hAWjg= 32 | github.com/pion/dtls/v2 v2.0.4 h1:WuUcqi6oYMu/noNTz92QrF1DaFj4eXbhQ6dzaaAwOiI= 33 | github.com/pion/dtls/v2 v2.0.4/go.mod h1:qAkFscX0ZHoI1E07RfYPoRw3manThveu+mlTDdOxoGI= 34 | github.com/pion/ice/v2 v2.0.14 h1:FxXxauyykf89SWAtkQCfnHkno6G8+bhRkNguSh9zU+4= 35 | github.com/pion/ice/v2 v2.0.14/go.mod h1:wqaUbOq5ObDNU5ox1hRsEst0rWfsKuH1zXjQFEWiZwM= 36 | github.com/pion/interceptor v0.0.9 h1:fk5hTdyLO3KURQsf/+RjMpEm4NE3yeTY9Kh97b5BvwA= 37 | github.com/pion/interceptor v0.0.9/go.mod h1:dHgEP5dtxOTf21MObuBAjJeAayPxLUAZjerGH8Xr07c= 38 | github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= 39 | github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= 40 | github.com/pion/mdns v0.0.4 h1:O4vvVqr4DGX63vzmO6Fw9vpy3lfztVWHGCQfyw0ZLSY= 41 | github.com/pion/mdns v0.0.4/go.mod h1:R1sL0p50l42S5lJs91oNdUL58nm0QHrhxnSegr++qC0= 42 | github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= 43 | github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= 44 | github.com/pion/rtcp v1.2.6 h1:1zvwBbyd0TeEuuWftrd/4d++m+/kZSeiguxU61LFWpo= 45 | github.com/pion/rtcp v1.2.6/go.mod h1:52rMNPWFsjr39z9B9MhnkqhPLoeHTv1aN63o/42bWE0= 46 | github.com/pion/rtp v1.6.2 h1:iGBerLX6JiDjB9NXuaPzHyxHFG9JsIEdgwTC0lp5n/U= 47 | github.com/pion/rtp v1.6.2/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko= 48 | github.com/pion/sctp v1.7.10/go.mod h1:EhpTUQu1/lcK3xI+eriS6/96fWetHGCvBi9MSsnaBN0= 49 | github.com/pion/sctp v1.7.11 h1:UCnj7MsobLKLuP/Hh+JMiI/6W5Bs/VF45lWKgHFjSIE= 50 | github.com/pion/sctp v1.7.11/go.mod h1:EhpTUQu1/lcK3xI+eriS6/96fWetHGCvBi9MSsnaBN0= 51 | github.com/pion/sdp/v3 v3.0.4 h1:2Kf+dgrzJflNCSw3TV5v2VLeI0s/qkzy2r5jlR0wzf8= 52 | github.com/pion/sdp/v3 v3.0.4/go.mod h1:bNiSknmJE0HYBprTHXKPQ3+JjacTv5uap92ueJZKsRk= 53 | github.com/pion/srtp/v2 v2.0.1 h1:kgfh65ob3EcnFYA4kUBvU/menCp9u7qaJLXwWgpobzs= 54 | github.com/pion/srtp/v2 v2.0.1/go.mod h1:c8NWHhhkFf/drmHTAblkdu8++lsISEBBdAuiyxgqIsE= 55 | github.com/pion/stun v0.3.5 h1:uLUCBCkQby4S1cf6CGuR9QrVOKcvUwFeemaC865QHDg= 56 | github.com/pion/stun v0.3.5/go.mod h1:gDMim+47EeEtfWogA37n6qXZS88L5V6LqFcf+DZA2UA= 57 | github.com/pion/transport v0.8.10/go.mod h1:tBmha/UCjpum5hqTWhfAEs3CO4/tHSg0MYRhSzR+CZ8= 58 | github.com/pion/transport v0.10.0/go.mod h1:BnHnUipd0rZQyTVB2SBGojFHT9CBt5C5TcsJSQGkvSE= 59 | github.com/pion/transport v0.10.1/go.mod h1:PBis1stIILMiis0PewDw91WJeLJkyIMcEk+DwKOzf4A= 60 | github.com/pion/transport v0.12.0/go.mod h1:N3+vZQD9HlDP5GWkZ85LohxNsDcNgofQmyL6ojX5d8Q= 61 | github.com/pion/transport v0.12.2 h1:WYEjhloRHt1R86LhUKjC5y+P52Y11/QqEUalvtzVoys= 62 | github.com/pion/transport v0.12.2/go.mod h1:N3+vZQD9HlDP5GWkZ85LohxNsDcNgofQmyL6ojX5d8Q= 63 | github.com/pion/turn/v2 v2.0.5 h1:iwMHqDfPEDEOFzwWKT56eFmh6DYC6o/+xnLAEzgISbA= 64 | github.com/pion/turn/v2 v2.0.5/go.mod h1:APg43CFyt/14Uy7heYUOGWdkem/Wu4PhCO/bjyrTqMw= 65 | github.com/pion/udp v0.1.0 h1:uGxQsNyrqG3GLINv36Ff60covYmfrLoxzwnCsIYspXI= 66 | github.com/pion/udp v0.1.0/go.mod h1:BPELIjbwE9PRbd/zxI/KYBnbo7B6+oA6YuEaNE8lths= 67 | github.com/pion/webrtc v1.2.0 h1:3LGGPQEMacwG2hcDfhdvwQPz315gvjZXOfY4vaF4+I4= 68 | github.com/pion/webrtc/v3 v3.0.4 h1:Tiw3H9fpfcwkvaxonB+Gv1DG9tmgYBQaM1vBagDHP40= 69 | github.com/pion/webrtc/v3 v3.0.4/go.mod h1:1TmFSLpPYFTFXFHPtoq9eGP1ASTa9LC6FBh7sUY8cd4= 70 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 71 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 72 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 73 | github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= 74 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 75 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 76 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 77 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 78 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 79 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 80 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 81 | golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E= 82 | golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 83 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 84 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 85 | golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 86 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 87 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 88 | golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 89 | golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 90 | golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 91 | golang.org/x/net v0.0.0-20201201195509-5d6afe98e0b7 h1:3uJsdck53FDIpWwLeAXlia9p4C8j0BO2xZrqzKpL0D8= 92 | golang.org/x/net v0.0.0-20201201195509-5d6afe98e0b7/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 93 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 94 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 95 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 96 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 97 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 98 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 99 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 100 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 101 | golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 102 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA= 103 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 104 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 105 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 106 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 107 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 108 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 109 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 110 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 111 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 112 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 113 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 114 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 115 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 116 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 117 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 118 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 119 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 120 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 121 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 122 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 123 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 124 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 125 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // This program forwards WebRTC streams from Unreal Engine pixel streaming over RTP to some arbitrary receiever. 2 | // This program uses websockets to connect to Unreal Engine pixel streaming through the intermediate signalling server ("cirrus"). 3 | // This program then uses Pion WebRTC to receive video/audio from Unreal Engine and the forwards those RTP streams 4 | // to a specified address and ports. This is a proof of concept that is designed so FFPlay can receive these RTP streams. 5 | // This program is a heavily modified version of: https://github.com/pion/webrtc/tree/master/examples/rtp-forwarder 6 | 7 | package main 8 | 9 | import ( 10 | "encoding/json" 11 | "flag" 12 | "fmt" 13 | "log" 14 | "net" 15 | "net/url" 16 | "time" 17 | 18 | "github.com/gorilla/websocket" 19 | "github.com/pion/rtcp" 20 | "github.com/pion/rtp" 21 | "github.com/pion/webrtc/v3" 22 | ) 23 | 24 | // CirrusPort - The port of the Cirrus signalling server that the Pixel Streaming instance is connected to. 25 | var CirrusPort = flag.Int("CirrusPort", 80, "The port of the Cirrus signalling server that the Pixel Streaming instance is connected to.") 26 | 27 | // CirrusAddress - The address of the Cirrus signalling server that the Pixel Streaming instance is connected to. 28 | var CirrusAddress = flag.String("CirrusAddress", "localhost", "The address of the Cirrus signalling server that the Pixel Streaming instance is connected to.") 29 | 30 | // ForwardingAddress - The address to send the RTP stream to. 31 | var ForwardingAddress = flag.String("ForwardingAddress", "127.0.0.1", "The address to send the RTP stream to.") 32 | 33 | // RTPVideoForwardingPort - The port to use for sending the RTP video stream. 34 | var RTPVideoForwardingPort = flag.Int("RTPVideoForwardingPort", 4002, "The port to use for sending the RTP video stream.") 35 | 36 | // RTPAudioForwardingPort - The port to use for sending the RTP audio stream. 37 | var RTPAudioForwardingPort = flag.Int("RTPAudioForwardingPort", 4000, "The port to use for sending the RTP audio stream.") 38 | 39 | // RTPAudioPayloadType - The payload type of the RTP packet, 111 is OPUS. 40 | var RTPAudioPayloadType = flag.Uint("RTPAudioPayloadType", 111, "The payload type of the RTP packet, 111 is OPUS.") 41 | 42 | // RTPVideoPayloadType - The payload type of the RTP packet, 125 is H264 constrained baseline 2.0 in Chrome, with packetization mode of 1. 43 | var RTPVideoPayloadType = flag.Uint("RTPVideoPayloadType", 125, "The payload type of the RTP packet, 125 is H264 constrained baseline in Chrome.") 44 | 45 | // RTCPIntervalMs - How often (ms) to send RTCP messages (such as REMB, PLI) 46 | var RTCPIntervalMs = flag.Int("RTCPIntervalMs", 2000, "How often (ms) to send RTCP message such as REMB, PLI.") 47 | 48 | //Whether or not to send PLI messages on an interval. 49 | var RTCPSendPLI = flag.Bool("RTCPSendPLI", true, "Whether or not to send PLI messages on an interval.") 50 | 51 | //Whether or not to send REMB messages on an interval. 52 | var RTCPSendREMB = flag.Bool("RTCPSendREMB", true, "Whether or not to send REMB messages on an interval.") 53 | 54 | // Receiver-side estimated maximum bitrate. 55 | var REMB = flag.Uint64("REMB", 400000000, "Receiver-side estimated maximum bitrate.") 56 | 57 | type udpConn struct { 58 | conn *net.UDPConn 59 | port int 60 | payloadType uint8 61 | } 62 | 63 | type ueICECandidateResp struct { 64 | Type string `json:"type"` 65 | Candidate webrtc.ICECandidateInit `json:"candidate"` 66 | } 67 | 68 | // Allows compressing offer/answer to bypass terminal input limits. 69 | const compress = false 70 | 71 | func writeWSMessage(wsConn *websocket.Conn, msg string) { 72 | err := wsConn.WriteMessage(websocket.TextMessage, []byte(msg)) 73 | if err != nil { 74 | log.Println("Error writing websocket message: ", err) 75 | } 76 | } 77 | 78 | func createOffer(peerConnection *webrtc.PeerConnection) (string, error) { 79 | offer, err := peerConnection.CreateOffer(nil) 80 | if err != nil { 81 | log.Println("Error creating peer connection offer: ", err) 82 | return "", err 83 | } 84 | 85 | if err = peerConnection.SetLocalDescription(offer); err != nil { 86 | log.Println("Error setting local description of peer connection: ", err) 87 | return "", err 88 | } 89 | 90 | offerStringBytes, err := json.Marshal(offer) 91 | if err != nil { 92 | log.Println("Error unmarshalling json from offer object: ", err) 93 | return "", err 94 | } 95 | offerString := string(offerStringBytes) 96 | return offerString, err 97 | } 98 | 99 | func createPeerConnection() (*webrtc.PeerConnection, error) { 100 | // Create a MediaEngine object to configure the supported codec 101 | m := webrtc.MediaEngine{} 102 | 103 | // This sets up H.264, OPUS, etc. 104 | m.RegisterDefaultCodecs() 105 | 106 | // Create the API object with the MediaEngine 107 | api := webrtc.NewAPI(webrtc.WithMediaEngine(&m)) 108 | 109 | // Prepare the configuration 110 | // UE is using unified plan on the backend so we should too 111 | config := webrtc.Configuration{SDPSemantics: webrtc.SDPSemanticsUnifiedPlan} 112 | 113 | // Create a new RTCPeerConnection 114 | peerConnection, err := api.NewPeerConnection(config) 115 | 116 | if err != nil { 117 | log.Println("Error making new peer connection: ", err) 118 | return nil, err 119 | } 120 | 121 | // Allow us to receive 1 audio track, and 1 video track in the "recvonly" mode 122 | if _, err = peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RtpTransceiverInit{ 123 | Direction: webrtc.RTPTransceiverDirectionRecvonly, 124 | }); err != nil { 125 | log.Println("Error adding RTP audio transceiver: ", err) 126 | return nil, err 127 | } else if _, err = peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RtpTransceiverInit{ 128 | Direction: webrtc.RTPTransceiverDirectionRecvonly, 129 | }); err != nil { 130 | log.Println("Error adding RTP video transceiver: ", err) 131 | return nil, err 132 | } 133 | 134 | return peerConnection, err 135 | } 136 | 137 | // Pion has recieved an "answer" from the remote Unreal Engine Pixel Streaming (through Cirrus) 138 | // Pion will now set its remote session description that it got from the answer. 139 | // Once Pion has its own local session description and the remote session description set 140 | // then it should begin signalling the ice candidates it got from the Unreal Engine side. 141 | // This flow is based on: 142 | // https://github.com/pion/webrtc/blob/687d915e05a69441beae1bba0802e28756eecbbc/examples/pion-to-pion/offer/main.go#L90 143 | func handleRemoteAnswer(message []byte, peerConnection *webrtc.PeerConnection, wsConn *websocket.Conn, pendingCandidates *[]*webrtc.ICECandidate) { 144 | sdp := webrtc.SessionDescription{} 145 | unmarshalError := json.Unmarshal([]byte(message), &sdp) 146 | 147 | if unmarshalError != nil { 148 | log.Printf("Error occured during unmarshaling sdp. Error: %s", unmarshalError.Error()) 149 | return 150 | } 151 | 152 | // Set remote session description we got from UE pixel streaming 153 | if sdpErr := peerConnection.SetRemoteDescription(sdp); sdpErr != nil { 154 | log.Printf("Error occured setting remote session description. Error: %s", sdpErr.Error()) 155 | return 156 | } 157 | fmt.Println("Added session description from UE to Pion.") 158 | 159 | // User websocket to send our local ICE candidates to UE 160 | for _, localIceCandidate := range *pendingCandidates { 161 | sendLocalIceCandidate(wsConn, localIceCandidate) 162 | } 163 | } 164 | 165 | // Pion has received an ice candidate from the remote Unreal Engine Pixel Streaming (through Cirrus). 166 | // We parse this message and add that ice candidate to our peer connection. 167 | // Flow based on: https://github.com/pion/webrtc/blob/687d915e05a69441beae1bba0802e28756eecbbc/examples/pion-to-pion/offer/main.go#L82 168 | func handleRemoteIceCandidate(message []byte, peerConnection *webrtc.PeerConnection) { 169 | var iceCandidateInit webrtc.ICECandidateInit 170 | jsonErr := json.Unmarshal(message, &iceCandidateInit) 171 | if jsonErr != nil { 172 | log.Printf("Error unmarshaling ice candidate. Error: %s", jsonErr.Error()) 173 | return 174 | } 175 | 176 | // The actual adding of the remote ice candidate happens here. 177 | if candidateErr := peerConnection.AddICECandidate(iceCandidateInit); candidateErr != nil { 178 | log.Printf("Error adding remote ice candidate. Error: %s", candidateErr.Error()) 179 | return 180 | } 181 | 182 | fmt.Println(fmt.Sprintf("Added remote ice candidate from UE - %s", iceCandidateInit.Candidate)) 183 | } 184 | 185 | // Starts an infinite loop where we poll for new websocket messages and react to them. 186 | func startControlLoop(wsConn *websocket.Conn, peerConnection *webrtc.PeerConnection, pendingCandidates *[]*webrtc.ICECandidate) { 187 | // Start loop here to read web socket messages 188 | for { 189 | 190 | messageType, message, err := wsConn.ReadMessage() 191 | if err != nil { 192 | log.Printf("Websocket read message error: %v", err) 193 | log.Printf("Closing Pion websocket control loop.") 194 | wsConn.Close() 195 | break 196 | } 197 | stringMessage := string(message) 198 | 199 | // We print the recieved messages in a different colour so they are easier to distinguish. 200 | colorGreen := "\033[32m" 201 | colorReset := "\033[0m" 202 | fmt.Println(string(colorGreen), fmt.Sprintf("Received message, (type=%d): %s", messageType, stringMessage), string(colorReset)) 203 | 204 | // Transform the raw bytes into a map of string: []byte pairs, we can unmarshall each key/value as needed. 205 | var objmap map[string]json.RawMessage 206 | err = json.Unmarshal(message, &objmap) 207 | 208 | if err != nil { 209 | log.Printf("Error unmarshalling bytes from websocket message. Error: %s", err.Error()) 210 | continue 211 | } 212 | 213 | // Get the type of message we received from the Unreal Engine side 214 | var pixelStreamingMessageType string 215 | err = json.Unmarshal(objmap["type"], &pixelStreamingMessageType) 216 | 217 | if err != nil { 218 | log.Printf("Error unmarshaling type from pixel streaming message. Error: %s", err.Error()) 219 | continue 220 | } 221 | 222 | // Based on the "type" of message we received, we react accordingly. 223 | switch pixelStreamingMessageType { 224 | case "playerCount": 225 | var playerCount int 226 | err = json.Unmarshal(objmap["count"], &playerCount) 227 | if err != nil { 228 | log.Printf("Error unmarshaling player count. Error: %s", err.Error()) 229 | } 230 | fmt.Println(fmt.Sprintf("Player count is: %d", playerCount)) 231 | case "config": 232 | fmt.Println("Got config message, ToDO: react based on config that was passed.") 233 | case "answer": 234 | handleRemoteAnswer(message, peerConnection, wsConn, pendingCandidates) 235 | case "iceCandidate": 236 | candidateMsg := objmap["candidate"] 237 | handleRemoteIceCandidate(candidateMsg, peerConnection) 238 | default: 239 | log.Println("Got message we do not specifically handle, type was: " + pixelStreamingMessageType) 240 | } 241 | 242 | } 243 | } 244 | 245 | // Send an "offer" string over websocket to Unreal Engine to start the WebRTC handshake. 246 | func sendOffer(wsConn *websocket.Conn, peerConnection *webrtc.PeerConnection) { 247 | 248 | offerString, err := createOffer(peerConnection) 249 | 250 | if err != nil { 251 | log.Printf("Error creating offer. Error: %s", err.Error()) 252 | } else { 253 | // Write our offer over websocket: "{"type":"offer","sdp":"v=0\r\no=- 2927396662845926191 2 IN IP4 127.0.0.1....." 254 | writeWSMessage(wsConn, offerString) 255 | fmt.Println("Sending offer...") 256 | fmt.Println(offerString) 257 | } 258 | } 259 | 260 | // Send our local ICE candidate to Unreal Engine using websockets. 261 | func sendLocalIceCandidate(wsConn *websocket.Conn, localIceCandidate *webrtc.ICECandidate) { 262 | var iceCandidateInit webrtc.ICECandidateInit = localIceCandidate.ToJSON() 263 | var respPayload ueICECandidateResp = ueICECandidateResp{Type: "iceCandidate", Candidate: iceCandidateInit} 264 | 265 | jsonPayload, err := json.Marshal(respPayload) 266 | 267 | if err != nil { 268 | log.Printf("Error turning local ice candidate into JSON. Error: %s", err.Error()) 269 | } 270 | 271 | jsonStr := string(jsonPayload) 272 | writeWSMessage(wsConn, jsonStr) 273 | fmt.Println(fmt.Sprintf("Sending our local ice candidate to UE...%s", jsonStr)) 274 | } 275 | 276 | func createUDPConnection(address string, port int, payloadType uint8) (*udpConn, error) { 277 | 278 | var udpConnection udpConn = udpConn{port: port, payloadType: payloadType} 279 | 280 | // Create remote addr 281 | var raddr *net.UDPAddr 282 | var resolveRemoteErr error 283 | if raddr, resolveRemoteErr = net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", address, port)); resolveRemoteErr != nil { 284 | return nil, resolveRemoteErr 285 | } 286 | 287 | // Dial udp 288 | var udpConnErr error 289 | if udpConnection.conn, udpConnErr = net.DialUDP("udp", nil, raddr); udpConnErr != nil { 290 | return nil, udpConnErr 291 | } 292 | return &udpConnection, nil 293 | } 294 | 295 | func setupMediaForwarding(peerConnection *webrtc.PeerConnection) (*udpConn, *udpConn) { 296 | 297 | // Prepare udp conns 298 | // Also update incoming packets with expected PayloadType, the browser may use 299 | // a different value. We have to modify so our stream matches what rtp-forwarder.sdp expects 300 | videoUDPConn, err := createUDPConnection(*ForwardingAddress, *RTPVideoForwardingPort, uint8(*RTPVideoPayloadType)) 301 | 302 | if err != nil { 303 | log.Println(fmt.Sprintf("Error creating udp connection for video: " + err.Error())) 304 | } 305 | 306 | audioUDPConn, err := createUDPConnection(*ForwardingAddress, *RTPAudioForwardingPort, uint8(*RTPAudioPayloadType)) 307 | 308 | if err != nil { 309 | log.Println(fmt.Sprintf("Error creating udp connection for audio: " + err.Error())) 310 | } 311 | 312 | peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { 313 | 314 | var trackType string = track.Kind().String() 315 | fmt.Println(fmt.Sprintf("Got %s track from Unreal Engine Pixel Streaming WebRTC.", trackType)) 316 | 317 | var udpConnection *udpConn 318 | switch trackType { 319 | case "audio": 320 | udpConnection = audioUDPConn 321 | case "video": 322 | udpConnection = videoUDPConn 323 | default: 324 | log.Println(fmt.Sprintf("Unsupported track type from Unreal Engine, track type: %s", trackType)) 325 | } 326 | 327 | // Send RTCP message on an interval to the UE side. a PLI on an interval so that the publisher is pushing a keyframe every rtcpPLIInterval 328 | go func() { 329 | ticker := time.NewTicker(time.Millisecond * 2000) 330 | for range ticker.C { 331 | 332 | // Send PLI (picture loss indicator) 333 | if *RTCPSendPLI { 334 | if rtcpErr := peerConnection.WriteRTCP([]rtcp.Packet{&rtcp.PictureLossIndication{MediaSSRC: uint32(track.SSRC())}}); rtcpErr != nil { 335 | fmt.Println(rtcpErr) 336 | } 337 | } 338 | 339 | // Send REMB (receiver-side estimated maximum bandwidth) 340 | if *RTCPSendREMB { 341 | if rtcpErr := peerConnection.WriteRTCP([]rtcp.Packet{&rtcp.ReceiverEstimatedMaximumBitrate{Bitrate: *REMB, SSRCs: []uint32{uint32(track.SSRC())}}}); rtcpErr != nil { 342 | fmt.Println(rtcpErr) 343 | } 344 | } 345 | } 346 | }() 347 | 348 | b := make([]byte, 1500) 349 | rtpPacket := &rtp.Packet{} 350 | for { 351 | // Read 352 | n, _, readErr := track.Read(b) 353 | if readErr != nil { 354 | panic(readErr) 355 | } 356 | 357 | // Unmarshal the packet and update the PayloadType 358 | if err = rtpPacket.Unmarshal(b[:n]); err != nil { 359 | panic(err) 360 | } 361 | rtpPacket.PayloadType = udpConnection.payloadType 362 | 363 | // Marshal into original buffer with updated PayloadType 364 | if n, err = rtpPacket.MarshalTo(b); err != nil { 365 | panic(err) 366 | } 367 | 368 | // Write 369 | if _, err = udpConnection.conn.Write(b[:n]); err != nil { 370 | // For this particular example, third party applications usually timeout after a short 371 | // amount of time during which the user doesn't have enough time to provide the answer 372 | // to the browser. 373 | // That's why, for this particular example, the user first needs to provide the answer 374 | // to the browser then open the third party application. Therefore we must not kill 375 | // the forward on "connection refused" errors 376 | if opError, ok := err.(*net.OpError); ok && opError.Err.Error() == "write: connection refused" { 377 | continue 378 | } 379 | panic(err) 380 | } 381 | } 382 | 383 | }) 384 | 385 | return videoUDPConn, audioUDPConn 386 | } 387 | 388 | func main() { 389 | flag.Parse() 390 | 391 | // Setup a websocket connection between this application and the Cirrus webserver. 392 | serverURL := url.URL{Scheme: "ws", Host: fmt.Sprintf("%s:%d", *CirrusAddress, *CirrusPort), Path: "/"} 393 | wsConn, _, err := websocket.DefaultDialer.Dial(serverURL.String(), nil) 394 | if err != nil { 395 | log.Fatal("Websocket dialing error: ", err) 396 | return 397 | } 398 | 399 | defer wsConn.Close() 400 | 401 | peerConnection, err := createPeerConnection() 402 | if err != nil { 403 | panic(err) 404 | } 405 | 406 | // Store our local ice candidates that we will transmit to UE 407 | pendingCandidates := make([]*webrtc.ICECandidate, 0) 408 | 409 | // Setup a callback to capture our local ice candidates when they are ready 410 | // Note: can happen at random times so might be before or after we have sent offer. 411 | peerConnection.OnICECandidate(func(localIceCandidate *webrtc.ICECandidate) { 412 | if localIceCandidate == nil { 413 | return 414 | } 415 | 416 | desc := peerConnection.RemoteDescription() 417 | if desc == nil { 418 | pendingCandidates = append(pendingCandidates, localIceCandidate) 419 | fmt.Println("Added local ICE candidate that we will send off later...") 420 | } else { 421 | sendLocalIceCandidate(wsConn, localIceCandidate) 422 | } 423 | }) 424 | 425 | // Set the handler for ICE connection state 426 | // This will notify you when the peer has connected/disconnected 427 | peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) { 428 | 429 | colorPurple := "\033[35m" 430 | colorReset := "\033[0m" 431 | 432 | fmt.Printf("Connection State has changed %s \n", connectionState.String()) 433 | 434 | if connectionState == webrtc.ICEConnectionStateConnected { 435 | fmt.Println(string(colorPurple), "Connected to UE Pixel Streaming!", string(colorReset)) 436 | } else if connectionState == webrtc.ICEConnectionStateFailed || connectionState == webrtc.ICEConnectionStateDisconnected { 437 | fmt.Println(string(colorPurple), "Disconnected from UE Pixel Streaming.", string(colorReset)) 438 | } 439 | }) 440 | 441 | videoUDP, audioUDP := setupMediaForwarding(peerConnection) 442 | defer videoUDP.conn.Close() 443 | defer audioUDP.conn.Close() 444 | 445 | sendOffer(wsConn, peerConnection) 446 | startControlLoop(wsConn, peerConnection, &pendingCandidates) 447 | 448 | } 449 | --------------------------------------------------------------------------------