소개
Racer는 멀티플레이어, 멀티스크린 Chrome 실험입니다. 여러 화면에서 플레이할 수 있는 레트로 스타일의 슬롯 카 게임입니다. Android 또는 iOS 스마트폰/태블릿 누구나 참여할 수 있습니다. 앱 없음 다운로드 항목이 없습니다. 모바일 웹만
Plan8은 14islands의 친구들과 함께 조르조 모로더의 원곡을 바탕으로 역동적인 음악과 사운드 환경을 만들었습니다. Racer는 반응형 엔진 소리, 레이스 음향 효과를 제공하지만, 무엇보다도 레이서가 참여할 때 여러 기기에 자동으로 배포되는 동적 음악 믹스를 제공합니다. 스마트폰으로 구성된 멀티 스피커 설치입니다.
여러 기기를 연결하는 것은 오래전부터 시도해 왔던 일입니다. 사운드가 여러 기기에서 분할되거나 기기 간에 점프하는 음악 실험을 진행한 적이 있으므로 이러한 아이디어를 Racer에 적용하고자 했습니다.
구체적으로는 드럼과 베이스로 시작하여 기타와 신디사이저 등을 추가하는 등 점점 더 많은 사용자가 게임에 참여함에 따라 여러 기기에서 음악 트랙을 쌓을 수 있는지 테스트하고자 했습니다. 음악 데모를 진행하고 코딩에 대해 알아봤습니다. 멀티 스피커 효과가 정말 좋았습니다. 이 시점에서는 모든 동기화가 제대로 이루어지지 않았지만 기기에서 펼쳐지는 여러 겹의 사운드를 듣고 좋은 결과를 얻을 수 있겠다고 생각했습니다.
소리 만들기
Google Creative Lab에서 사운드와 음악의 창의적 방향을 제시했습니다. 실제 소리를 녹음하거나 음향 라이브러리를 사용하는 대신 아날로그 신디사이저를 사용하여 음향 효과를 만들고자 했습니다. 또한 출력 스피커가 대부분 작은 휴대전화나 태블릿 스피커이므로 스피커가 왜곡되지 않도록 주파수 스펙트럼에서 소리를 제한해야 했습니다. 이는 상당히 어려운 과제였습니다. 지오르지오로부터 첫 번째 음악 초안을 받았을 때는 안심했습니다. 그의 작곡이 우리가 만든 사운드와 완벽하게 어울렸기 때문입니다.
엔진 소리
사운드를 프로그래밍할 때 가장 큰 어려움은 가장 적합한 엔진 사운드를 찾고 그 동작을 조정하는 것이었습니다. 경마장은 F1 또는 NASCAR 트랙과 비슷하므로 자동차가 빠르고 폭발적인 느낌을 주어야 했습니다. 하지만 자동차가 너무 작아서 큰 엔진 소리는 시각적 효과와 잘 어울리지 않았습니다. 모바일 스피커에서 엔진 소리를 재생할 수는 없으므로 다른 방법을 찾아야 했습니다.
아이디어를 얻기 위해 친구인 존 에크스트랜드의 모듈식 신디사이저 컬렉션을 연결하고 놀기 시작했습니다. 좋은 소식을 들었습니다. 다음은 두 개의 오실레이터, 멋진 필터, LFO를 사용했을 때의 소리입니다.
이전에 Web Audio API를 사용하여 아날로그 장비를 리모델링하여 큰 성공을 거둔 적이 있으므로 큰 기대를 품고 Web Audio에서 간단한 신디사이저를 만들기 시작했습니다. 생성된 사운드는 반응성이 가장 높지만 기기의 처리 능력에 부담을 줍니다. 시각화가 원활하게 실행되도록 최대한 많은 리소스를 절약해야 했습니다. 따라서 대신 오디오 샘플을 재생하는 기법으로 전환했습니다.

샘플에서 엔진 소리를 만드는 데 사용할 수 있는 몇 가지 기법이 있습니다. 콘솔 게임의 가장 일반적인 접근 방식은 엔진의 여러 사운드 레이어 (많을수록 좋음)를 서로 다른 RPM (부하 포함)으로 만든 다음 서로 교차 페이드 및 교차 피치를 적용하는 것입니다. 그런 다음 동일한 RPM으로 부하 없이 회전하는 엔진의 여러 사운드 레이어를 추가하고 두 사운드 간에 크로스페이드 및 크로스피치를 적용합니다. 기어를 변속할 때 이러한 레이어 간에 크로스페이딩을 올바르게 수행하면 매우 사실적인 사운드를 얻을 수 있지만, 사운드 파일이 많아야 합니다. 크로스피치가 너무 넓으면 매우 합성적인 소리가 납니다. 로드 시간이 길어지지 않도록 해야 했으므로 이 옵션은 적합하지 않았습니다. 레이어당 5~6개의 사운드 파일을 사용해 보았지만 사운드가 만족스럽지 않았습니다. 파일 수를 줄이는 방법을 찾아야 했습니다.
가장 효과적인 해결 방법은 다음과 같습니다.
- 자동차의 시각적 가속과 동기화된 가속 및 기어 변속이 포함된 사운드 파일 1개가 가장 높은 피치 / RPM의 프로그래밍된 루프로 끝납니다. Web Audio API는 정확하게 루핑하는 데 매우 능숙하므로 글리치나 팝이 발생하지 않습니다.
- 감속 / 엔진 회전이 느려지는 소리 파일 1개
- 마지막으로 루프로 스틸 이미지 / 유휴 상태 사운드를 재생하는 사운드 파일 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()'>
그래서 작은 사운드 파일 3개와 좋은 사운드 엔진만으로 다음 과제로 넘어가기로 했습니다.
동기화 가져오기
YouTube는 14islands의 데이비드 린드크비스트와 함께 기기를 완벽하게 동기화하여 재생하는 방법을 자세히 살펴보기 시작했습니다. 기본 이론은 간단합니다. 기기는 서버에 시간을 요청하고 네트워크 지연 시간을 고려한 후 로컬 시계 오프셋을 계산합니다.
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과 오디오 컨텍스트가 생성된 후 경과된 시간을 고려하여 Web Audio 컨텍스트를 사용하여 노래가 시작된 시점의 참조점을 계산합니다.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 오디오와 Web Audio API 모두에서 HTTP 요청을 줄일 수 있습니다. 또한 재생하기 전에 새 오디오 객체를 로드할 필요가 없으므로 Audio 객체를 사용하여 반응형 사운드를 재생하는 가장 좋은 방법입니다. 이미 좋은 구현이 많이 있으며 이를 출발점으로 삼았습니다. iOS와 Android에서 안정적으로 작동하도록 스프라이트를 확장하고 기기가 절전 모드로 전환되는 몇 가지 이상한 사례를 처리했습니다.
Android에서는 기기를 절전 모드로 전환해도 오디오 요소가 계속 재생됩니다. 절전 모드에서는 배터리를 절약하기 위해 JavaScript 실행이 제한되며 requestAnimationFrame
, setInterval
또는 setTimeout
를 사용하여 콜백을 실행할 수 없습니다. 오디오 스프라이트는 JavaScript를 사용하여 재생을 중지해야 하는지 계속 확인하기 때문에 이는 문제가 됩니다. 설상가상으로 오디오가 계속 재생 중인데도 오디오 요소의 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
핸들러에서 재생이 시작되지 않으면 Audio 요소가 소스 로드를 시작하지 않습니다. - 문제를 찾습니다. 서버에서 HTTP 바이트 범위를 지원하지 않으면
duration
를 가져오거나currentTime
를 설정할 수 없습니다. 저희와 같이 오디오 스프라이트를 빌드하는 경우 이 점에 유의하세요. - MP3의 기본 인증이 실패합니다. 일부 기기에서는 사용 중인 브라우저와 관계없이 기본 인증으로 보호된 MP3 파일을 로드하지 못합니다.
결론
웹에서 소리를 처리하는 가장 좋은 방법으로 음소거 버튼을 누르던 시절부터 많은 발전이 있었지만, 아직 시작에 불과하며 웹 오디오가 곧 큰 폭으로 성장할 것입니다. 여러 기기 동기화와 관련하여 할 수 있는 작업은 이 정도만 살펴봤습니다. 휴대전화와 태블릿에는 신호 처리 및 효과 (예: 리버브)를 살펴볼 만큼의 처리 성능이 없었지만 기기 성능이 향상됨에 따라 웹 기반 게임에서도 이러한 기능을 활용할 수 있게 되었습니다. 사운드의 가능성을 계속해서 탐색할 수 있는 흥미로운 시대입니다.