최근 홀로렌즈 안에 있는 브라우저에서 WebRTC가 가능하냐고 물어보는 분이 있었고, 이때문에 홀로렌즈를 경험해 볼 기회가 있었습니다. 결론적으로는, 홀로렌즈에 탑재된 브라우저에서는 webrtc가 현재 불가능하며 홀로렌즈에서 webrtc를 구현하기 위해서는 MixedReality-WebRTC라는 라이브러리를 이용해야 합니다.

해당 포스트에서는 샘플용 UWP 어플리케이션을 제작함으로써 MixedReality-WebRTC를 이용해 어떻게 WebRTC 서비스를 구현할 수 있는지 단계별로 알아보도록 하겠습니다. 아래 단계를 모두 거치면, UWP 데스크탑 앱과 다른 피씨 또는 모바일 브라우저와 WebRTC로 화상 통신을 연결할 수 있을 것입니다.
아래 코드는 중요한 부분만 설명하고 있습니다. 전체 소스는 https://github.com/cryingnavi/webrtc-cshap에서 참고하세요.

시그널 서버는 자체 구현한 시그널 서버를 사용했으며 해당 서버안에는 자체 개발한 webrtc용 sdk를 포함하고 있습니다. 해당 시그널 서버는 https://github.com/cryingnavi/webrtc-server를 참조하시면 됩니다.

UWP가 무엇인지에 대하 자세히 알고 싶다면, https://docs.microsoft.com/ko-kr/windows/uwp/get-started/universal-application-platform-guide?OCID=VSClient_Ver17_UWPOverview_what-is-uwp를 참조하면 됩니다.

이 예제는 https://microsoft.github.io/MixedReality-WebRTC/manual/cs/helloworld-cs-setup-uwp.html 예제를 기반으로 하고 있습니다.

프로젝트 생성하기

유니버셜 프로젝트를 생성합니다. 저는 WebRTCEx02라는 이름으로 프로젝트를 생성했습니다.

프로젝트 생성하기

프로젝트 생성하기

MixedReality-WebRTC에 종속성 추가하기

MixedReality-WebRTC 종속성을 추가하기 위해서는 NuGet 패키지 도구를 이용합니다. NuGet은 Visual Studio에서 사용되는 무료 또는 오픈 소스 패키지를 관리해주는 도구로 GUI 환경에서 이를 다운로드 하고 관리할 수 있습니다.
MixedReality-WebRTC는 NuGet을 이용해 종속성을 추가합니다.

종속성추가

종속성추가

위와 같이 Microsoft.MixedReality.WebRTC를 검색해서 설치 합니다. 이번에는 반드시 Microsoft.MixedReality.WebRTC.UWP를 설치해야 합니다.

레이아웃 생성하기

MainPage.xaml 파일을 열고 아래와 같이 xml을 작성합니다.

<Page
    x:Class="WebRTCEX02.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:WebRTCEX02"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}" d:DesignWidth="3833.333" d:DesignHeight="2231.481">

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="5*"/>
            <ColumnDefinition Width="5*"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="5*"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <Border Grid.Row="0" Grid.Column="0" Background="#000"/>
        <Border Grid.Row="0" Grid.Column="1" Background="gray"/>

        <StackPanel Grid.Row="0" Grid.Column="0" Margin="20" VerticalAlignment="Center">
            <!-- 로컬 비디오 표시 -->
            <MediaPlayerElement x:Name="localVideoPlayerElement" />
        </StackPanel>
        <StackPanel Grid.Row="0" Grid.Column="1" Margin="20" VerticalAlignment="Center">
            <!-- 리모트 비디오 표시 -->
            <MediaPlayerElement x:Name="remoteVideoPlayerElement" />
        </StackPanel>
        <StackPanel Grid.Row="1" Grid.ColumnSpan="2" Orientation="Horizontal"
            HorizontalAlignment="Center" VerticalAlignment="Center">
            <TextBox Width="500" PlaceholderText="채팅방 아이디를 입력하세요" x:Name="roomIdEl"/>
        </StackPanel>

        <StackPanel Grid.Row="2" Grid.ColumnSpan="2" Orientation="Horizontal"
            HorizontalAlignment="Center" VerticalAlignment="Center">
            <Button Content="Start" Margin="10" Click="start" />
            <Button Content="Call" Margin="10" Click="call" />
            <Button Content="HangUp" Margin="10" Click="hangUp" />
        </StackPanel>
    </Grid>
</Page>

그럼 아래와 같은 레이아웃이 생성 될 것입니다. 검은색 바탕은 로컬 비디오가 표현될 공간이고 회색 바탕은 리모트 비디오가 표현될 공간입니다.

그리고 세개의 버튼이 존재하는데 start버튼은 로컬 비디오를 표시합니다. call 버튼은 입력한 채팅방 아이디로 접속합니다. hangup 버튼은 통신을 종료합니다.

레이아웃

권한 허용하기

WebRTC 어플리케이션은 웹캠, 마이크, 인터넷 액세스에 대한 권한을 가지고 있어야 합니다. 웹캠과 마이크는 당연히 장치에 접근해야하기 때문이고 인터넷은 다른 피어와 연결해야하기 때문에 권한 허용이 필요한 부분입니다.

레이아웃

레이아웃

유틸리티 소스 포함하기

로컬 비디오를 표시하기 위해서는 VideoBridge라는 유틸리티가 필요합니다. 이는 캡처한 비디오 프레임을 수집해서 표시하는 역할을 수행합니다. 또한 VideoBridge는 StreamSamplePool 클래스를 사용합니다. 두 개의 소스는 각각 아래 주소에서 얻을 수 있습니다.
https://github.com/microsoft/MixedReality-WebRTC/blob/master/examples/TestAppUwp/Video/VideoBridge.cs
https://github.com/microsoft/MixedReality-WebRTC/blob/master/examples/TestAppUwp/Video/StreamSamplePool.cs

두개의 소스를 가져와 프로젝트에 포함 시킵니다.

webrtc

코딩하기

코딩할 게 상당히 많습니다. 일단 전체 소스는 아래 스샷과 같습니다.

webrtc

  1. LocalMedia, RemoteMedia 각각 미디어를 표시하기 위한 클래스입니다.
  2. Peer클래스는 PeerConnection 객체 생성합니다.
  3. Signaler는 시그널링 수행하기 위한 클래스로, Http로 채팅방을 생성하고 WebSocket으로 SDP와 Candidate를 교환합니다.

필요 객체 선언하기

먼저 MainPage.xaml.cs 클래스에 Loaded 이벤트를 추가합니다. Loaded 이벤트는 어플리케이션이 실행되고 페이지가 로드 되면 호출됩니다. 웹에선 DOM에 접근할 수 있는 onload와 같습니다.

그리고 아래와 같이 필요한 객체를 선언합니다. 필요한 객체는 위에서 기술한대로 시그널링을 위한 Signaler, 로컬 미디어(localMedia)와 리모트 미디어(remoteMedia) 객체입니다.

public MainPage()
{
    this.InitializeComponent();
    this.Loaded += OnLoaded;
}

private async void OnLoaded(object sender, RoutedEventArgs e) {
    this.signaler = new Signaler();
    this.signaler.RoomJoinEvent += onRoomJoin;

    this.localMedia = new LocalMedia(localVideoPlayerElement);
    this.remoteMedia = new RemoteMedia(remoteVideoPlayerElement);

    this.signaler.SetMedia(this.remoteMedia);
}

미디어 객체 선언시 UI 엘리먼트인 localVideoPlayerElement, remoteVideoPlayerElement를 넘겨줍니다. 각 엘리먼트는 적절한 시점에 클래스 안에서 비디오와 바인딩됩니다. 그리고 시그널링 객체에도 리모트 미디어 객체를 넘겨줍니다. 이는 시그널링 객체 안에서 PeerConnection 객체를 선언하고 PeerConnection객체에 리모트 미디어가 도착하면 UI인 remoteVideoPlayerElement와 연결해야하기 때문입니다.

아래는 시그널링 객체의 SetMedia입니다. 해당 메소드 안에서 Peer객체를 선언합니다.

public void SetMedia(LocalMedia localMedia, RemoteMedia remoteMedia) {
    this.localMedia = localMedia;
    this.remoteMedia = remoteMedia;
    
    this.peer = new Peer(this.remoteMedia);

    this.peer.GetSdpEvent += OnGetSdp;
    this.peer.GetCandidateEvent += OnGetCandidate;			
}

Start버튼을 클릭할때 로컬 미디어 표시

private async void start(object sender, RoutedEventArgs e) {
    localMedia.startMedia();
}

Start 버튼의 클릭 이벤트 핸들러를 선언합니다. 클릭시 localMedia.startMedia();를 호출합니다.

public async void startMedia() {  
    LocalAudioTrack _localAudioTrack;
    LocalVideoTrack _localVideoTrack;

    _webcamSource = await DeviceVideoTrackSource.CreateAsync();
    _microphoneSource = await DeviceAudioTrackSource.CreateAsync();

    var videoTrackConfig = new LocalVideoTrackInitConfig {
        trackName = "webcam_track"
    };
    _localVideoTrack = LocalVideoTrack.CreateFromSource(_webcamSource, videoTrackConfig);

    var audioTrackConfig = new LocalAudioTrackInitConfig {
        trackName = "microphone_track"
    };
    _localAudioTrack = LocalAudioTrack.CreateFromSource(_microphoneSource, audioTrackConfig);

    _webcamSource.I420AVideoFrameReady += LocalI420AFrameReady;

    this.localVideoTrack = _localVideoTrack;
    this.localAudioTrack = _localAudioTrack;
}

CreateAsync() 메소드는 미디어 소스를 얻어냅니다. 여기서는 인자로 아무것도 전달하지 않아 미디어 옵션이 기본값으로 사용됩니다.

_microphoneSource = await DeviceAudioTrackSource.CreateAsync();
_webcamSource = await DeviceVideoTrackSource.CreateAsync();

만약 미디어에 대한 설정을 해야한다면 LocalAudioDeviceInitConfig, LocalVideoDeviceInitConfig를 사용할 수 있습니다. 사용법은 다음과 같습니다.

_microphoneSource = await DeviceAudioTrackSource.CreateAsync(new LocalAudioDeviceInitConfig() {
	AutoGainControl = true
});
_webcamSource = await DeviceVideoTrackSource.CreateAsync(new LocalVideoDeviceInitConfig() {
	framerate = 30.0,
	width = 640,
	enableMrc = true
});
  • LocalAudioDeviceInitConfig
이름 설명
AutoGainControl AGC를 켜서 게인을 자동으로 조절합니다. 곧 강한 신호는 약하게 약한 신호는 강하게 증폭하게 됩니다.
  • LocalVideoDeviceInitConfig
이름 설명
enableMrc 홀로렌즈와 같은 혼합현실을 제공하는 장치에서 가상과 현실을 모두 캡쳐할지 여부를 지정함
enableMrcRecordingIndicator enableMrc가 true인경우, 카메라가 촬영하는 동안 적색원을 표시함
framerate 프레임레이트를 조절함
width 해상도 지정
height 해상도 지정
videoDevice 비디오 캡처 장치, 여러 비디오 장치가 있을 경우 그중 하나를 선택할 수 있다.
videoProfileId  
videoProfileKind  

이제 비디오 트랙과 오디오 트랙을 생성합니다.

var audioTrackConfig = new LocalAudioTrackInitConfig {
	trackName = "microphone_track"
};
_localAudioTrack = LocalAudioTrack.CreateFromSource(_microphoneSource, audioTrackConfig);

var videoTrackConfig = new LocalVideoTrackInitConfig {
	trackName = "webcam_track"
};
_localVideoTrack = LocalVideoTrack.CreateFromSource(_webcamSource, videoTrackConfig);
_webcamSource.I420AVideoFrameReady += LocalI420AFrameReady;

생성한 미디어 트랙(localVideoTrack, localAudioTrack)은 피어 커넥션 객체의 Video Transceiver와 Audio Transceiver에 각각 지정될 것입니다. Transceiver는 로컬과 원격 미디어를 연결하는 파이프로 피어간에 미디어를 전송하는 역할을 수행합니다.

I420AVideoFrameReady 이벤트는 비디오 소스가 생성되고 첫번째 프레임이 도착하면 호출되는 이벤트입니다. 해당 이벤트 안에서 비디오 엘리먼트와 매칭하여 비디오를 재생합니다.

I420AVideoFrameReady 이벤트 작성

private void LocalI420AFrameReady(I420AVideoFrame frame) {
	lock (_localVideoLock) {
		if (!_localVideoPlaying) {
			_localVideoPlaying = true;

			// Capture the resolution into local variable useable from the lambda below
			uint width = frame.width;
			uint height = frame.height;

			// Defer UI-related work to the main UI thread
			RunOnMainThread(() => {
				// Bridge the local video track with the local media player UI
				int framerate = 30; // assumed, for lack of an actual value
				_localVideoSource = CreateI420VideoStreamSource(width, height, framerate);

				var localVideoPlayer = new MediaPlayer();
				localVideoPlayer.Source = MediaSource.CreateFromMediaStreamSource(_localVideoSource);

				localVideoPlayerElement.SetMediaPlayer(localVideoPlayer);
				localVideoPlayer.Play();
			});
		}
	}
	// Enqueue the incoming frame into the video bridge; the media player will
	// later dequeue it as soon as it's ready.
	_localVideoBridge.HandleIncomingVideoFrame(frame);
}

I420AVideoFrameReady 이벤트는 비디오 소스가 생성되고 첫번째 프레임이 도착하면 호출되는 이벤트입니다. 해당 이벤트 안에서 비디오 엘리먼트와 매칭하여 비디오를 재생합니다.

CreateI420VideoStreamSource 메소드는 WebRTC가 원시 비디오 프레임으로 제공하는 인코딩인 I420 형식으로 비디오 스트림을 반환하는 메소드입니다.

MediaPlayer를 생성하여 CreateI420VideoStreamSource에 의해 I420 형식으로 변환된 미디어 소스를 지정합니다.

그리고 마지막으로 xaml에서 생성한 MediaPlayerElement인 localVideoPlayerElement과 localVideoPlayer를 연결하여 재생합니다.

여기까지 레이아웃을 생성하고 기본 설정을 통해 로컬 미디어를 재생했습니다.

시그널 서버 실행하기

먼저 시그널 서버를 실행합니다. 시그널 서버는 제가 예전에 작성해놓은 Node.js 서버를 이용하겠습니다. 시그널 서버 깃 주소는 https://github.com/cryingnavi/webrtc-server와 같습니다. 프로젝트를 내려 받아 npm run start로 서버를 실행하면 localhost:11200으로 실행됩니다.
로컬로 실행은 가능하지만 정상적인 데모 실행을 위해서는 https가 필요합니다. https 도메인을 적용하길 추천합니다.

해당 서버는 한개의 REST API와 websocket을 이용해 P2P연결을 중계해줍니다.

제공하는 API들
  • REST API
    • http://localhost:11200/roomReady 채팅방을 생성하는 API입니다.
  • websocket
    • 접속한 user의 고유한 uuid 발행
    • roomReady로 생성한 채팅방에 접속
    • 같은 채팅방의 유저들간 SDP, Candidate 교환

REST API 요청 코드 작성

시그널 서버와 주고 받는 데이터 형식이 JSON이기 때문에 JSON 오브젝트 파싱을 위해 Nuget을 통해 Newtonsoft.Json를 다운 받아 설치합니다.

public async void roomJoin() {
    var response = await client.GetAsync(HttpServerAddress + "/roomReady");

    string result = response.Content.ReadAsStringAsync().Result;
    dynamic data = JObject.Parse(result);

    RoomJoinEvent(data.body.roomId.ToString());
}

WebSocket 클라이언트 코드 작성

webrtc-server는 Peer간의 sdp, candidate를 교환하기 위해 웹소켓을 사용합니다.

public async void init() {
    client = new HttpClient();
    ws = new ClientWebSocket();
    await ws.ConnectAsync(serverUri, CancellationToken.None);

    while (ws.State == WebSocketState.Open) {
        int bufferSize = 1024;
        var buffer = new byte[bufferSize];
        var offset = 0;
        var free = buffer.Length;
        while (true) {
            ArraySegment<byte> bytesReceived = new ArraySegment<byte>(buffer, offset, free);
            WebSocketReceiveResult result = await ws.ReceiveAsync(bytesReceived, CancellationToken.None);
            offset += result.Count;
            free -= result.Count;
            if (result.EndOfMessage) break;
            if (free == 0) {
                var newSize = buffer.Length + bufferSize;
                var newBuffer = new byte[newSize];
                Array.Copy(buffer, 0, newBuffer, 0, offset);
                buffer = newBuffer;
                free = buffer.Length - offset;
            }
        }

        dynamic data = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(buffer.ToArray(), 0, offset));
        if (data != null) {
            string command = data.header.command;
            if (command == "connect") {
                token = data.body.token;
            } else if (command == "on_call_offer") {
                this.type = "offer";

                Thread t1 = new Thread(delegate () {
                    this.CreateTransveiver();
                    peer.CreateOffer();
                });
                t1.Start();
            } else if (command == "on_call_answer") {
                this.type = "answer";

                Thread t1 = new Thread(delegate () {
                    this.CreateTransveiver();
                });
                t1.Start();
            } else if (command == "on_offer_sdp") {
                var sdp = data.body.sdp;
                var json = JsonConvert.DeserializeObject(sdp.Value);
                var _sdp = json.sdp.Value;
            
                Thread t1 = new Thread(delegate () {
                    peer.SetOfferSDP(_sdp);
                    peer.CreateAnswer();
                });
                t1.Start();
            } else if (command == "on_answer_sdp") {
                var sdp = data.body.sdp;
                var json = JsonConvert.DeserializeObject(sdp.Value);
                var _sdp = json.sdp.Value;

                Thread t1 = new Thread(delegate () {
                    peer.SetAnswerSDP(_sdp);
                });
                t1.Start();
            } else if (command == "on_offer_candidate") {
                var candidate = data.body.candidate;
                var json = JsonConvert.DeserializeObject(candidate.Value);
                string _candidate = json.candidate.Value;
                string sdpMid = json.sdpMid.Value;
                int sdpMLineIndex = (int)json.sdpMLineIndex.Value;

                Thread t1 = new Thread(delegate () {
                    peer.AddIceCandidate(_candidate, sdpMid, sdpMLineIndex);
                });
                t1.Start();
            } else if (command == "on_answer_candidate") {
                var candidate = data.body.candidate;
                var json = JsonConvert.DeserializeObject(candidate.Value);
                string _candidate = json.candidate.Value;
                string sdpMid = json.sdpMid.Value;
                int sdpMLineIndex = (int)json.sdpMLineIndex.Value;

                Thread t1 = new Thread(delegate () {
                    peer.AddIceCandidate(_candidate, sdpMid, sdpMLineIndex);
                });
                t1.Start();
            }	
        }
    }
}

소스가 길지만, 요약하면 3가지 부분입니다. 웹소켓 클라이언트 객체를 선언하고 메시지를 받는 부분, 메시지를 JSON 객체로 선언하는 부분, 각 커맨드를 구분해 적절한 액션을 취하는 부분입니다.

UWP 앱이 Offer인 경우

UWP가 Offer이고 다른 디바이스의 크롬 브라우저가 Answer인 경우, CreateTransveiver()를 수행하고 CreateOffer()를 수행합니다. 곧 선언된 PeerConnection 객체를 통해 offer sdp를 선언합니다.
선언한 sdp는 곧바로 웹소켓을 통해 Answer인 크롬 브라우저에게 전달됩니다.

Offer sdp를 생성하면, candidate또한 생성되기 시작합니다. 이 또한, 곧장 웹소켓을 통해 상대방에게 전달됩니다.

이 부분은 Peer.cs 클래스에 존재합니다.

this._peerConnection = new PeerConnection();

this._peerConnection.LocalSdpReadytoSend += Peer_LocalSdpReadytoSend;
this._peerConnection.IceCandidateReadytoSend += Peer_IceCandidateReadytoSend;

PeerConnection 객체를 선언하고 sdp와 canidate가 생성되면 호출되는 이벤트 핸들러를 지정합니다.

상대방측이 Offer SDP를 PeerConnection에 저장하고 Answer sdp를 전달해주면 UWP앱은 이를 자신의 PeerConnection 객체에 저장합니다.

정리하면, Offer SDP를 전달하고 Answer SDP를 받아서 저장합니다. 저장하는 부분은 아래와 같습니다.

public void SetOfferSDP(string content) {
    var message = new SdpMessage { Type = SdpMessageType.Offer, Content = content };

    this._peerConnection.SetRemoteDescriptionAsync(message);
}

UWP 앱이 Answer인 경우

UWP가 Answer이면 상대방이 채팅방에 들어왔다는 신호가 오면 CreateTransveiver()를 하고 상대방의 Offer SDP가 도착하면 SetOfferSDP(_sdp)로 Offser를 저장하고 CreateAnswer()를 통해 Answer SDP를 생성합니다.

Answer sdp를 생성하면, candidate또한 생성되기 시작합니다. 이 또한, 곧장 웹소켓을 통해 상대방에게 전달합니다.

정리하면, Offser SDP를 자신의 PeerConnection객체에 저장하고 Answer를 생성해서 전달합니다.

CreateTransveiver메소드

아래는 CreateTransveiver 메소드의 코드입니다. videoTransveiver와 audioTransveiver를 선언하고 각각에 로컬 비디오 트랙과 로컬 오디오 트랙을 지정합니다.

private void CreateTransveiver()  {
    Transceiver videoTransveiver = peer.AddTransceiver(MediaKind.Video);
    videoTransveiver.LocalVideoTrack = localMedia.localVideoTrack;
    videoTransveiver.DesiredDirection = Transceiver.Direction.SendReceive;

    Transceiver audioTransveiver = peer.AddTransceiver(MediaKind.Audio);
    audioTransveiver.LocalAudioTrack = localMedia.localAudioTrack;
    audioTransveiver.DesiredDirection = Transceiver.Direction.SendReceive;
}

실행

한쪽은 UWP앱을 실행하고 다른 디바이스에서 크롬을 실행합니다. 그리고 다음과 같은 순서로 실행해 볼 수 있습니다.

  1. UWP앱에서 Start버튼을 클릭해서 로컬미디어를 표시한다.
  2. Call 버튼을 클릭해서 채팅방을 생성한다.

  3. 다른 디바이스 크롬에서 11200로 접속하고 테스트 박스에 UWP앱에서 생성한 채팅방 번호를 입력한다.
  4. 크롬에서 Start버튼으로 로컬 미디어를 표시한다.
  5. Call 버튼으로 채팅방에 접속한다.

에러 없이 코딩 하고 정상적으로 접속 했다면, 아래와 같이 실행됨을 확인할 수 있을 것입니다.

webrtc

웹브라우저의 실행 모습은 아래와 같습니다.

webrtc

전체 소스

이 예제 전체 소스는 https://github.com/cryingnavi/webrtc-cshap에서 참조할 수 있습니다.