Case Study: The Sounds of Racer

HTML5 Rocks

소개

Racer Racer는 멀티플레이어 및 멀티디바이스로 동작하는 크롬 실험입니다. 여러개의 스크린에서 플레이하는 레트로 스타일의 슬롯 자동차 게임입니다. Android, iOS 폰 또는 태블릿에서 동작하며 누구나 참여할 수 있습니다. 앱이 아니며 다운로드도 없는 그저 모바일 웹일 뿐입니다.

14islands에서 Plan8과 함께 Giorgio Moroder의 구성을 기본으로 역동적인 음악과 사운드 경험을 만들었습니다. Racer는 반응형 엔진 사운드, 레이스 사운드 효과 기능을 가지지만 더 중요한 점은 음악 자체를 레이서로 참여한 여러대의 디바이스로 배포하여 역동적인 음악을 제공한다는 점입니다. 이는 여러대의 스마트폰으로 구성된 멀티-스피커와 같습니다.

여러대의 단말을 함께 연결하는 것은 잠시동안 친 장난과 같은 것이었습니다. 우리는 사운드를 여러개의 디바이스로 나누거나 디바이스 간으로 이동하였던 음악적 경험을 가지고 있으므로 이러한 아이디어를 레이서에 적용하기를 열망했습니다.

구체적으로 우리는 점점 더 많은 사람들이 게임에 참여하여 단말들 사이에서 (드럼과 베이스로 시작되어 기타와 신디사이저 등이 추가되는) 음악 트랙이 만들어지는 것을 테스트 하길 원했었습니다. 우리는 약간의 음악 데모를 진행했고 코딩을 시작했습니다. 멀티 스피커 이펙트는 정말 보람 있었습니다. 이 시점에서 동기화가 완벽하게 되지는 않았습니다만 단말들로 퍼져나가는 사운드 레이어를 들었을때 올바로 동작하는 것을 알 수 있었습니다.

사운드 만들기

구글 크리에이티브 랩은 사운드와 음악을 위한 창조적인 방향을 설명했습니다. 우리는 실제 사운드를 레코딩하거나 사운드 라이브러리에서 재분류한 것보다 사운드 이펙트를 만들기 위한 아날로그 신디사이저를 사용하길 원했습니다. 또한 대부분의 출력 스피커가 작은 폰 및 태블릿 스피커에서 사용되므로 왜곡을 피하기 위해 사운드의 주파수 스펙트럼이 한정되어야 한다는 것을 알았습니다. 이것은 큰 도전이 될 것을 의미합니다. 우리가 Giorgio로부터 처음 음악 초안을 받았을때 그의 조성은 우리가 만들어낸 사운드와 완벽하게 작동했기 때문에 안도 할 수 있었습니다.

엔진 사운드

사운드를 프로그래밍 할 때 가장 큰 도전은 최고의 엔진 사운드를 찾아 그것의 동작을 만드는 것이었습니다. F1 또는 나스카 트랙을 닮은 레이스 트랙에서 자동차는 빠르고 폭발적인 느낌을 가져야만 했습니다. 동시에 자동차들은 큰 엔진 사운드가 연결되기에는 정말 작았습니다. 우리는 모바일 스피커에서 울림이 큰 엔진소리를 재생할 수 없었습니다. 어찌 되었든 우리는 다른 어떤 것으로 표현해야 했습니다.

약간의 영감을 얻기 위해 우리는 Jon Ekstrand의 모듈식 신디사이저 콜렉션 일부를 엮은 것으로 시작하였습니다. 우리는 우리가 들은 것을 좋아했습니다. 이것은 두개의 오실레이터와 멋진 필터 그리고 LFO(Low-frequency oscillation:저주파 발진)와 같은 소리를 냈습니다.

아날로그 장비는 우리가 큰 희망을 가졌던 웹 오디오 API를 사용하여 성공적으로 리모델링 되었으며 웹 오디오에서 간단한 신디사이저를 만드는 것을 시작하였습니다. 생성된 사운드는 반응성이 매우 좋았으나 단말의 프로세싱 능력에 부담을 줄 것입니다. 영상이 부드럽게 재생되기 위해서는 모든 리소스를 저장하는 것을 극도로 줄일 필요가 있었습니다. 그래서 우리는 오디오 샘플을 재생하는 것으로 기술을 전환 했습니다.

Modular synth for engine sound inspiration

엔진 소리를 만들기 위한 몇가지 기술들이 있습니다. 콘솔 게임을 위한 아주 일반적인 접근법은 여러 RPM에서의 엔진 소리들을(더 좋은 사운드를 위해) 여러 개의 레이어로 가지고(미리 로드) 그것들을 크로스페이드, 크로스피치로 처리하는 것입니다. 엔진 회전속도에 따라 동일 RPM이 되었을 때 엔진의 여러 사운드들의 레이어를 추가하고(미리 로드되지 않음) 크로스페이드, 크로스피치로 처리할수도 있습니다. 기어를 변속할때 이 레이어들간의 크로스페이딩을 적절히 한다면 매우 사실적인 소리가 나지만 사운드 파일로 큰 용량을 사용해야 합니다. 크로스피치는 너무 넓은 범위로 할 수 없거나 매우 인위적인 소리가 날 것입니다. 긴 로드 시간은 좋지 않으므로 피해야 합니다. 각 레이어에 5개 또는 6개의 사운드 파일들로 시도 했습니다만 사운드는 실망스러웠습니다. 적은 수의 파일들로 할수 있는 방법을 찾아야만 했습니다.

가장 효과적인 솔루션의 입증:

  • 가속과 기어변속 때의 사운드 파일은 최고 pitch/RPM에 반복되는 소리로 자동차의 시각적인 가속 끝부분에 동기화 되었습니다. 웹 오디오 API는 정확한 반복에 매우 좋으므로 우리는 문제점 없이 할 수 있었습니다.
  • 감속/엔진회전수 줄일 때의 사운드 파일
  • 마지막으로 루프에서 정지/idle상태에 재생되는 사운드 파일

아래와 같습니다.

Engine sound graphic

처음 터치 이벤트로 가속시키기 위해 우리는 시작시 처음 파일을 재생했습니다. 플레이어가 스로틀을 해제하면 재생중인 사운드 파일의 위치로부터 시간을 계산하며 스로틀이 다시 들어올 때 감속 사운드 파일을 계산 된 몇초간 재생 후에 가속 사운드 파일의 알맞은 위치로 이동하여 재생했습니다.

function throttleOn(throttle) {
    //startPosition을 현재 throttle의 크기에 의존하여 계산합니다.
    //0~3 초의 숫자를 throttle에 곱해서 startPosition을 얻을 수 있습니다.
    var startPosition = throttle * 3;

    var audio = context.createBufferSource();
    audio.buffer = loadedBuffers["accelerate_and_loop"];

    //버퍼 소스를 위해 loop position을 설정합니다.
    audio.loopStart = 5;
    audio.loopEnd = 9;

    //계산된 offset(startPosition)으로 버퍼 소스의 현재 시간에서 시작합니다.
    audio.start(context.currentTime, startPosition);
}

적용해 보기

엔진을 시작하고 "스로틀(Throttle)" 버튼을 누르세요.

단지 세 개의 사운드 파일들로 만든 좋은 엔진 소리로 우리는 다음 도전을 시작하기로 결정하였습니다.

동기화 하기

14islands의 David Lindkvist와 함께 완벽한 동기화로 재생을 위하여 단말을 더 깊게 알아보기 시작했습니다. 기본 이론은 간단합니다. 단말은 시간, 네트워크 레이턴시 값을 서버에 문의하여 로컬 클럭 오프셋을 계산합니다.

syncOffset = localTime - serverTime - networkLatency

각 연결된 장치들은 이 오프셋과 동일한 개념의 시간을 공유합니다. 쉽습니다. 맞나요? (이론상으로는)

네트워크 레이턴시 계산하기

레이턴시는 서버로 요청(request)하여 응답(response)받는데 걸리는 시간의 절반이라고 가정합니다.:

networkLatency = (receivedTime - sentTime) × 0.5

이 가정의 문제점은 서버로의 라운드 트립이 언제나 대칭적이지 않다는데 있습니다. 다시 말하면, 요청이 응답보다 더 오래 걸리거나 혹은 반대의 경우입니다. 더 높은 네트워크 레이턴시에서 더 큰 비대칭이 발생하며 이것은 지연된 사운드를 야기하여 서로 다른 단말들 간에 동기화 되지 못한 소리를 재생하게 합니다.

다행히 우리의 뇌는 사운드가 살짝 지연되는 것을 알아채지 못합니다. 연구는 우리의 뇌가 구분된 사운드로 인식하기 위한 지연 시간이 20~30 ms(밀리세컨드) 임을 보여줍니다. 그러나 12~15 ms(밀리세컨드) 근처에서도 완전하게 인식할수는 없지만 지연된 신호의 영향을 느끼기 시작합니다. 우리는 확립된 시간 동기화 프로토콜, 간단한 대안 몇 가지를 조사하고 실제로 그들 중 몇 가지를 구현했습니다. 마지막으로 구글의 낮은 레이턴시 인프라 스트럭처에 고마움을 표시합니다. 우리는 구글의 낮은 레이턴시 인프라 스트럭처 참조하여 burst 요청(데이터 전송시 간격을 두고 중단 하면서 요청하는 방법)을 샘플링하고 그것을 기준으로 낮은 레이턴시로 샘플을 사용할 수 있었습니다.

클럭 드리프트 해결하기

동작되었습니다! 잠시동안 뿐이지만 우리는 완벽한 동기화로 5개 이상의 단말에서 펄스를 재생했습니다. 몇 분 동안의 재생 이후에 단말들은 높은 정밀도의 웹 오디오 API 컨텍스트 타임을 사용하여 사운드 스케줄링을 했음에도 불구하고 사이가 벌어지기 시작했습니다. 처음에는 감지되지 않던 한번에 몇 ms(밀리세컨드)의 지연은 천천히 누적되었습니다. 긴 시간 동안 재생 후에 음악 레이어에서 전체적으로 동기화가 틀어진 결과를 나타냈습니다. 클럭 드리프트 입니다.

해결방법은 수초마다 다시 동기화하는 것입니다. 새 클럭 오프셋을 계산하고 오디오 스케줄러에 이것을 균일하게 적용해야 합니다. 네트워크 지연동안 음악에 주목할 만한 변화들의 위험을 줄이기 위해 우리는 최근의 동기화 오프셋 이력을 유지하여 부드럽게 나오도록 하고 평균을 계산하기로 결정했습니다.

노래 스케줄링 및 배치 전환

상호작용 사운드 경험을 만드는 것은 노래의 부분이 재생될 때 더 이상 제어 되지 않는 것을 의미합니다. 여러분은 현재 상태를 바꾸기 위해 사용자 액션에 의존해야 합니다. 우리는 노래에서 적시에 배열을 전환할 수 있는지 확인했습니다. 이것은 우리의 스케줄러가 다음 배열로 전환하기 전에 현재 재생중인 파일의 남은 시간을 계산 할수 있어야 하는 것을 의미합니다. 우리의 알고리즘은 결국 아래와 같이 무언가를 찾는것으로 끝났습니다:

  • Client(1) 노래 시작
  • Client(n) 노래가 언제 시작되었는지 첫번째 클라이언트에 물어보기.
  • Client(n) 웹오디오 컨텍스트, syncOffset 값, 오디오 컨텍스트 생성 후 경과된 시간을 이용하여 노래가 시작된 때의 참조 지점을 계산하기.
  • playDelta = Date.now() - syncOffset - songStartTime - context.currentTime
  • Client(n) playDelta를 사용하여 노래가 얼마나 오래 재생되었는지 계산하기. 노래 스케줄러는 현재 배치에서 어느 마디(bar)가 다음번에 재생되어야 할 것을 알기 위해 이것을 사용합니다.
  • playTime = playDelta + context.currentTime nextBar = Math.ceil((playTime % loopDuration) ÷ barDuration) % numberOfBars

항상 8마디(eight bars) 길이가 되게 우리의 정렬을 제한하고 같은 템포(분당 박자)를 유지합니다.

돌아보기

자바스크립트의 setTimeout 또는 setInterval을 사용하는 경우 항상 미리 예약하는 것이 중요합니다. 이것은 자바스크립의 시계가 매우 정확치 않고 예약된 콜백들이 레이아웃, 렌더링, 가비지 콜렉션, XMLHTTPRequests 등으로 인해 수십 ms(밀리세컨드) 이상 왜곡될 수 있기 때문입니다. 우리의 경우 우리는 네트워크 전체에 동일한 이벤트를 모든 사용자가 시간내에 받을수 있도록 해야만 합니다.

오디오 스프라이트

하나의 파일로 사운드를 연결하는 것은 HTML 오디오와 웹 오디오 API 모두에서 HTTP 요청들을 줄이기위한 훌륭한 방법입니다. 또한 오디오 오브젝트를 사용하여 반응적으로 사운드를 재생하기 위한 최적의 방법이 될수 있는 일입니다. 이미 좋은 구현 사례들이 있으며 이것들로부터 시작 할 수 있습니다. 우리는 단말이 슬립 모드로 진입 할때 발생하는 몇몇 이상한 경우를 다루는 것 뿐만 아니라 iOS와 Android에서도 안정적으로 동작하도록 스프라이트를 확장했습니다.

Android에서 오디오 엘리먼트는 단말이 슬립 모드로 진입해도 재생이 유지됩니다. 슬립모드에서는 배터리 보호를 위해 자바스크립트 실행이 제한되고 콜백을 발생시키기 위해 requestAnimationFrame, setInterval 또는 setTimeout에 의존할 수 없습니다. 오디오 스프라이트가 재생이 멈춰야 하는지의 확인을 자바스크립트에 의존하는 것이 문제가 됩니다. 설상가상으로 오디오가 재생되고 있음에도 불구하고 경우에 따라서 오디오 엘리먼트의 currentTime이 업데이트 되지 않습니다.

웹오디오가 아닌 것으로 대체하여 Chrome Racer 에서 사용하기 위한 오디오스프라이트 구현을 참고 하세요.

오디오 엘리먼트

우리가 Racer 작업을 시작했을때 Android 용 Chrome은 아직 웹 오디오 API를 지원하고 있지 않았었습니다. 몇가지 단말을 위한 HTML 오디오를 사용하는 로직, 다른 단말을 위한 웹 오디오 API, 진보된 오디오 출력과 함께 몇가지 흥미로운 도전들을 이루는 것을 원했습니다. 고맙게도 이것은 여기에 모든 이력이 있습니다. 웹 오디오 API는 Android M28 beta.에 구현되어 있습니다.

  • 지연/타이밍 이슈들. 오디오 엘리먼트는 재생이 필요할 때 항상 정확하게 재생되지 않습니다. 자바스크립트는 싱글 쓰레드로 동작하는 브라우저가 분주해지는 경우 2초까지의 재생 지연 발생을 야기합니다.
  • 재생 지연은 부드러운 반복이 항상 가능한 것은 아닙니다. 데스크탑에서 여러분은 끊어진 곳이 없는 루프를 만들기 위해 더블 버퍼링을 사용할 수 있습니다. 그러나 모바일 단말에서 이것은 옵션이 될 수 없습니다. 이유는:
    • 대부분의 모바일 단말은 동시에 하나 이상의 오디오 엘리먼트를 재생할 수 없습니다.
    • 고정된 볼륨. Android 뿐만 아니라 iOS도 오디오 오브젝트의 볼륨을 변경하는 것을 허락하지 않습니다.
  • 미리 로딩하기가 없습니다. 모바일 단말들에서 오디오 엘리먼트는 touchStart 핸들러에서 초기화되어 재생하는 것 외의 소스 로딩을 시작할 수가 없습니다.
  • 이슈들을 찾는 것. duration 또는 currentTime 설정 하기는 서버가 HTTP Byte-Range를 지원하지 않는다면 실패할 것입니다. 우리가 한 것과 같이 오디오 스프라이트를 만든다면 이것 한 가지를 검토하세요.
  • MP3의 기본 인증 실패. 몇몇 디바이스는 여러분이 어떤 브라우저를 사용하는지와 상관없이 기본적인 인증에 의해 보호되는 MP3 파일들의 로딩이 실패됩니다.

결론

우리는 웹을 위한 사운드를 다루기 위한 가장 좋은 옵션으로 음소거 버튼을 누르는 것으로부터 먼 길을 왔습니다. 하지만 이것은 시작일 뿐이며 웹오디오는 좀 더 견고해지려 합니다. 우리는 여러개 단말을 동기화하는 것에서 무엇을 할 수 있는지 표면적인 부분을 확인했습니다. 폰과 태블릿에서 신호처리와 사운드 이펙트(리버브와 같은)를 하기 위한 프로세싱 파워가 없었습니다. 하지만 단말 성능이 증가함에 따라 웹 기반 게임도 이 기능들을 활용할 수 있게 될 것입니다. 사운드의 가능성을 계속 추진하기 위한 흥미로운 시간입니다.

Comments

0