우수사례 - The Sounds of Racer

소개

Racer는 멀티 플레이어 멀티 디바이스 Chrome 실험 버전입니다. 여러 화면에서 플레이되는 복고풍 슬롯카 게임입니다. 스마트폰, 태블릿, Android 또는 iOS 누구나 가입할 수 있습니다. 앱 없음 다운로드 없이 즐기세요. 모바일 웹만 있으면 됩니다.

Plan814islands의 친구들과 함께 조르조 모로더의 작곡을 기반으로 역동적인 음악과 사운드 경험을 만들었습니다. 레이서의 특징은 반응형 엔진 사운드, 레이스 사운드 효과는 물론 레이서가 참여하는 동안 여러 기기에 자체적으로 배포되는 역동적인 음악 믹스를 특징으로 합니다. 스마트폰으로 구성된 멀티 스피커 설치입니다.

여러 기기를 함께 연결하는 일은 한동안 만연한 것이었습니다. 사운드가 여러 기기에서 분리되거나 기기 간에 이동하는 음악 실험을 진행했기 때문에 이러한 아이디어를 Racer에 적용하고자 했습니다.

보다 구체적으로는 드럼과 베이스를 시작으로 기타 및 신디를 추가하는 등 점점 더 많은 사람들이 게임에 참여함에 따라 여러 기기에서 음악 트랙을 구축할 수 있는지 테스트하고 싶었습니다. 음악 데모를 진행하고 코딩을 배웠습니다. 멀티 스피커 효과는 정말 좋았어요. 이 시점에 모든 동기화가 제대로 이루어지지는 않았지만 기기 전체에 사운드가 흩어져 있다는 것을 들었을 때 뭔가 좋은 일을 할 수 있다는 것을 알았습니다.

사운드 만들기

Google Creative Lab은 사운드와 음악에 대한 창작 방향을 제시했습니다. 실제 사운드를 녹음하거나 사운드 라이브러리에 의존하기보다는 아날로그 신시사이저를 사용해 사운드 효과를 만들고 싶었습니다. 또한 출력 스피커는 대부분의 경우 작은 스마트폰 또는 태블릿 스피커일 것이므로 스피커가 왜곡되지 않도록 주파수 스펙트럼에서 사운드를 제한해야 했습니다. 이는 꽤 어려운 과제였습니다. Giorgio로부터 첫 번째 음악 드래프트를 받았을 때 그의 작곡은 저희가 만든 사운드에 딱 맞았기 때문에 안심이 되었습니다.

엔진음

사운드 프로그래밍에서 가장 어려운 과제는 최적의 엔진 사운드를 찾아 동작을 표현하는 것이었습니다. 레이싱 트랙은 F1 또는 Nascar 트랙과 비슷했기 때문에 자동차가 빠르고 폭발적인 느낌을 주어야 했습니다. 하지만 자동차는 매우 작아서 엔진의 큰 사운드로는 사운드를 영상과 실제로 연결하지 못했습니다. 모바일 스피커에서 웅장하고 요란한 엔진음이 나오지 못해서 다른 방법을 찾아야 했습니다.

아이디어를 얻기 위해 우리는 친구 Jon Ekstrand가 만든 모듈식 신디사이저 컬렉션을 집으로 불러와 흥미진진하기 시작했습니다. 전달해 주신 내용이 만족스러웠습니다. 두 개의 오실레이터, 몇 가지 멋진 필터와 LFO를 사용한 것 같군요.

아날로그 장비는 이전에 Web Audio API를 사용하여 큰 성공을 거두어 리모델링되어 왔기 때문에 큰 기대를 가지고 웹 오디오에서 간단한 신디를 만들기 시작했습니다. 생성된 사운드는 반응 속도는 가장 높지만 기기의 처리 능력에 부담을 줄 수 있습니다. 영상이 원활하게 실행되도록 하기 위해서는 가능한 모든 리소스를 아낄 수 있어야 했습니다. 그래서 우리는 오디오 샘플 재생으로 기법을 바꿨습니다.

엔진 사운드 영감을 위한 모듈식 신디사이저

샘플에서 엔진 사운드를 내는 데 사용할 수 있는 몇 가지 기법이 있습니다. 콘솔 게임의 가장 일반적인 접근 방식은 각기 다른 RPM (로드 시)에서 엔진의 여러 사운드 (더 많을수록 좋음)를 레이어로 만든 다음 사운드 간에 크로스페이드 및 크로스피칭하는 것입니다. 그런 다음 동일한 RPM으로 (부하 없이) 회전하는 엔진의 여러 사운드를 추가하고 크로스페이드 및 크로스피치 사이에 추가합니다. 기어를 이동할 때 이러한 레이어 간에 크로스페이드하는 것이 제대로 이루어졌다면 매우 사실적으로 들리지만 사운드 파일이 많은 경우에만 가능합니다. 크로스 피치는 너무 넓거나 매우 합성적으로 들리면 안 됩니다. 긴 로드 시간을 피해야 했기 때문에 이 옵션은 좋지 않았습니다. 각 레이어에 대해 5~6개의 사운드 파일로 시도해 보았지만 음질이 만족스럽지 않았습니다. 파일 수를 줄인 방법을 찾아야 했습니다.

가장 효과적인 솔루션은 다음과 같습니다.

  • 최고 음조 / RPM에서 프로그래밍된 루프로 끝나는 자동차의 시각적 가속과 동기화된 가속 및 기어 변속이 포함된 사운드 파일 1개. Web Audio API는 정확한 루프를 잘 해내므로 오류나 튀는 문제 없이 루프를 처리할 수 있습니다.
  • 감속 / 엔진이 돌아가는 소리 파일 1개
  • 마지막으로 하나의 사운드 파일은 정지 / 유휴 사운드를 루프로 재생합니다.

모양

엔진 사운드 그래픽

첫 번째 터치 이벤트 / 가속의 경우 첫 번째 파일을 처음부터 재생하고 플레이어가 제한을 해제하면 사운드 파일의 해제 시점에 있던 시간을 계산하여 제한이 다시 작동하면 두 번째 (회전) 파일이 재생된 후 가속 파일의 올바른 위치로 이동하도록 합니다.

function throttleOn(throttle) {
    //Calculate the start position depending 
    //on the current amount of throttle.
    //By multiplying throttle we get a start position 
    //between 0 and 3 seconds.
    var startPosition = throttle * 3;

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

    //Sets the loop positions for the buffer source.
    audio.loopStart = 5;
    audio.loopEnd = 9;

    //Starts the buffer source at the current time
    //with the calculated offset.
    audio.start(context.currentTime, startPosition);
}

한번 해 보세요

엔진을 시작하고 '제한' 버튼을 누릅니다.

<input type="button" id="playstop" value = "Start/Stop Engine" onclick='playStop()'>
<input type="button" id="throttle" value = "Throttle" onmousedown='throttleOn()' onmouseup='throttleOff()'>

그래서 세 개의 작은 사운드 파일과 훌륭한 사운드 엔진만 가지고 다음 챌린지로 넘어가기로 했습니다.

동기화 가져오기

14islands의 David Lindkvist와 함께 우리는 기기가 완벽한 동기화로 작동할 수 있도록 더 깊이 알아보기 시작했습니다. 기본 이론은 간단합니다. 기기는 서버에 시간을 묻고 네트워크 지연 시간을 고려한 다음 로컬 시계 오프셋을 계산합니다.

syncOffset = localTime - serverTime - networkLatency

이 오프셋을 사용하면 연결된 각 기기가 동일한 시간 개념을 공유합니다. 정말 쉽죠? (이론적으로도 마찬가지입니다.)

네트워크 지연 시간 계산

지연 시간이 서버에 응답을 요청하고 수신하는 데 걸리는 시간의 절반이라고 가정할 수 있습니다.

networkLatency = (receivedTime - sentTime) × 0.5

이 가정의 문제는 서버로의 왕복이 항상 대칭적이지 않다는 것입니다. 즉, 요청이 응답보다 오래 걸릴 수 있고, 그 반대의 경우도 마찬가지입니다. 네트워크 지연 시간이 길수록 이 비대칭이 더 큰 영향을 미치므로 사운드가 지연되고 다른 기기와 동기화되지 않은 상태로 재생됩니다.

다행히 우리의 뇌는 소리가 약간 지연되는지 알아차리지 못합니다. 연구에 따르면, 뇌에서 소리를 별개의 것으로 인식하는 데 20~30밀리초 (ms)의 지연이 발생한다고 합니다. 하지만 약 12~15ms쯤에는 완전히 '인식'할 수 없더라도 지연된 신호의 효과를 '느낀' 것 같습니다. 저희는 몇 가지 확립된 시간 동기화 프로토콜과 더 간단한 대안을 조사했고, 그 중 일부를 실제로 구현해 보았습니다. 결국 Google의 짧은 지연 시간 인프라 덕분에 우리는 단순히 요청 버스트를 간단하게 샘플링하고 지연 시간이 가장 짧은 샘플을 참조로 사용할 수 있었습니다.

시계 드리프트와 맞서 싸우기

잘 됐습니다! 5대 이상의 기기가 완벽한 동기화로 음악을 재생하지만 한동안은 안 돼요. 몇 분 동안 재생한 후에는 매우 정확한 Web Audio API 컨텍스트 시간을 사용하여 사운드를 예약했더라도 기기가 떨어질 수 있습니다. 지연이 한 번에 몇 밀리초만 느리게 누적되고 처음에는 감지할 수 없었지만, 장시간 재생한 후에는 음악 레이어가 전혀 동기화되지 않았습니다. 안녕하세요, 시계 드리프트.

해결책은 몇 초마다 다시 동기화하고 새로운 클록 오프셋을 계산한 다음 이를 오디오 스케줄러에 원활하게 피드하는 것이었습니다. 네트워크 지연으로 인한 음악의 현저한 변화 위험을 줄이기 위해 우리는 최신 동기화 오프셋의 기록을 유지하고 평균을 계산하여 이러한 변화를 원활하게 처리하기로 했습니다.

노래 예약 및 배치 변경

양방향 사운드 환경을 만든다는 것은 사용자 작업에 따라 현재 상태를 변경하기 때문에 더 이상 노래의 일부가 재생되는 시점을 제어할 수 없다는 의미입니다. 시의적절하게 곡의 편곡 간에 전환할 수 있어야 했습니다. 즉, 스케줄러는 다음 편곡으로 전환하기 전에 현재 재생 중인 소절의 남은 부분을 계산할 수 있어야 했습니다. 알고리즘의 결과는 다음과 같습니다.

  • Client(1)에서 노래를 시작합니다.
  • Client(n)는 첫 번째 고객에게 노래가 언제 시작되었는지 물어봅니다.
  • Client(n)는 웹 오디오 컨텍스트를 사용하여 노래가 시작된 시점의 기준점을 계산하고 syncOffset과 오디오 컨텍스트가 생성된 후 경과한 시간을 고려합니다.
  • playDelta = Date.now() - syncOffset - songStartTime - context.currentTime
  • Client(n)는 playDelta를 사용하여 노래가 실행된 시간을 계산합니다. 노래 스케줄러는 이를 사용하여 현재 배열의 어떤 소절을 다음에 재생해야 하는지 파악합니다.
  • playTime = playDelta + context.currentTime nextBar = Math.ceil((playTime % loopDuration) ÷ barDuration) % numberOfBars

건전함을 위해 항상 8소절로 하고 동일한 템포 (분당 박동수)를 유지하도록 했습니다.

전망

JavaScript에서 setTimeout 또는 setInterval를 사용할 때는 항상 미리 예약하는 것이 중요합니다. 이는 JavaScript 시계가 정밀하지 않고 레이아웃, 렌더링, 가비지 컬렉션 및 XMLHTTPRequests에 의해 예약된 콜백이 수십 밀리초 이상 쉽게 왜곡될 수 있기 때문입니다. 이 경우에는 모든 클라이언트가 네트워크를 통해 동일한 이벤트를 수신하는 데 걸리는 시간도 고려해야 했습니다.

오디오 스프라이트

사운드를 하나의 파일로 결합하면 HTML 오디오와 웹 오디오 API에 대한 HTTP 요청을 모두 줄일 수 있습니다. 또한 오디오 객체를 사용하여 사운드를 반응형으로 재생하는 가장 좋은 방법이기도 합니다. 재생 전에 새 오디오 객체를 로드할 필요가 없기 때문입니다. 우리가 출발점으로 삼는 좋은 구현이 이미 몇 가지 있습니다. iOS와 Android 모두에서 안정적으로 작동하고 기기가 절전 모드로 전환되는 일부 이상한 사례를 처리하도록 스프라이트를 확장했습니다.

Android에서는 기기를 절전 모드로 전환해도 오디오 요소가 계속 재생됩니다. 절전 모드에서는 배터리를 보존하기 위해 JavaScript 실행이 제한되며 requestAnimationFrame, setInterval 또는 setTimeout를 사용하여 콜백을 실행할 수 없습니다. 오디오 스프라이트는 자바스크립트를 사용하여 재생을 중지해야 하는지 계속 확인하기 때문에 이 문제가 발생합니다. 오히려 오디오가 계속 재생 중이지만 오디오 요소의 currentTime이 업데이트되지 않는 경우도 있습니다.

Chrome Racer에서 웹 오디오 이외의 대체 수단으로 사용한 AudioSprite 구현을 확인하세요.

오디오 요소

Racer 관련 작업을 시작할 때 Android용 Chrome은 아직 Web Audio API를 지원하지 않았습니다. 어떤 기기에서는 HTML 오디오를 사용하고 다른 기기에는 Web Audio API를 사용하고, 우리가 달성하고자 했던 고급 오디오 출력을 결합하는 방식으로 몇 가지 흥미로운 과제를 해결할 수 있었습니다. 고맙게도, 지금은 모두 역사입니다. Web Audio API는 Android M28 베타에서 구현되었습니다.

  • 지연/타이밍 문제 오디오 요소가 재생 명령 시 정확하게 재생되지 않을 수도 있습니다. JavaScript는 단일 스레드이므로 브라우저의 사용량이 많아 최대 2초의 재생 지연이 발생할 수 있습니다.
  • 재생 지연은 매끄러운 반복이 불가능할 때도 있음을 의미합니다. 데스크톱에서는 이중 버퍼링을 사용하여 어느 정도 간격이 없는 루프를 얻을 수 있지만, 휴대기기에서는 이 옵션을 사용할 수 없습니다. 그 이유는 다음과 같습니다.
    • 대부분의 휴대기기는 한 번에 두 개 이상의 오디오 요소를 재생하지 않습니다.
    • 고정 볼륨. Android와 iOS에서는 모두 Audio 객체의 볼륨을 변경할 수 없습니다.
  • 미리 로드할 수 없습니다. 휴대기기에서는 touchStart 핸들러에서 재생이 시작되지 않는 한 오디오 요소가 소스 로드를 시작하지 않습니다.
  • 문제 찾기. 서버가 HTTP 바이트 범위를 지원하지 않으면 duration 가져오기 또는 currentTime 설정이 실패합니다. 앞서 했던 것처럼 오디오 스프라이트를 빌드하고 있다면 이 작업에 유의하세요.
  • MP3에서 기본 인증에 실패했습니다. 사용 중인 브라우저에 관계없이 일부 기기는 기본 인증으로 보호되는 MP3 파일을 로드하지 못합니다.

결론

웹용 사운드를 처리하기 위한 최선의 옵션으로 음소거 버튼을 누른 이후 많은 발전이 있었습니다. 하지만 이는 시작에 불과하며 웹 오디오는 이제 곧 열광하게 될 것입니다. 지금까지 여러 기기 동기화와 관련하여 할 수 있는 일의 일부만 살펴보았습니다. 스마트폰과 태블릿에는 신호 처리와 효과 (예: 에코)를 자세히 알아보기 위한 처리 능력이 없었지만 기기 성능이 향상됨에 따라 웹 기반 게임도 이러한 기능을 활용할 것입니다. 사운드의 가능성을 계속해서 끌어올리는 흥미진진한 시기입니다.