Canvas, Web Audio API를 사용한 웹 오디오 플레이어 제작기
디지털 오디오 파형의 시각화
제작 동기 및 요구사항
오디오 파형(waveform)은 오디오, 영상 편집 툴 및 일부 오디오 플레이어에서 접할 수 있다. 업무에서 개발한 앱에서 오디오 재생 기능이 필요하여 wavesurfer.js를 사용하고 있었다. 그런데 Safari 브라우저에서 알 수 없는 버그로 인해 기능을 하지 않았고, 커스터마이징 지원을 위해 플러그인과 API 사용이 다소 복잡하다는 불편함을 느끼고 있었다. 그래서 새로운 서비스 개발을 시작할 때 기술적 도전도 할 겸 최소한의 기능을 가진 오디오 플레이어를 만들어 보기로 했다. 오디오 플레이어에 필요한 요구사항은 다음과 같다.
- 오디오 파일에서 만든 데이터를 기반으로 파형을 제공한다.
- 파형 하단에는 재생 시간을 파악할 수 있는 타임라인이 표시된다.
- 재생 중에는 파형 위에 현재 재생 위치를 나타내는 수직선이 표시된다.
- 오디오 재생, 멈춤, 탐색이 가능하다.
- 오디오 탐색을 위한 슬라이더 바를 제공한다.
- 오디오 파형 위를 클릭하면 해당하는 위치로 현재 재생 시간이 업데이트된다.
- 오디오 파형은 확대, 축소가 가능하다.
- 파형 위를 클릭 후 드래그하여 반복 재생 구간 설정이 가능하다.
확대, 축소 및 반복구간 설정은 부가 기능이라 서비스에 따라 필요하지 않을 수도 있다. 그것을 제외한 나머지가 오디오 플레이어로서의 최소 기능이라고 할 수 있다. 이런 요구사항에 기반한 오디오 플레이어를 아래 그림과 같은 형태로 개발했다.
소리의 디지털화에 대한 이야기
소리라는 것은 공기의 진동이다. 진동은 그래프로 표현이 가능하며 Y 축은 진폭으로 소리의 세기를 나타낼 수 있고 X축은 시간을 나타낸다. 그래프가 Y축 0에서 출발하여 +, -피크에 도달한 후 다시 0으로 돌아왔을 때 이를 한 파장(period)라고 하는데, 이 주기가 짧을수록 높은 소리, 길수록 낮은 소리가 된다. 1초에 1번의 파장이 발생했다면 그 소리는 1Hz(헤르츠)의 소리가 된다. 현재 국제적으로 표준 음높이로 쓰는 “라” 음은 440Hz로 1초에 440번 진동하는 소리다.
디지털 오디오는 이 그래프를 수치화한 것이다. 연속적인 값을 분산된 숫자로 저장하기 위해 1초당 몇개의 값을 기록(=샘플링)할 것인지 정해야 하는데, 그 값을 샘플링 레이트(Sampling rate)라고 한다. 인간의 가청 주파수는 대략 20Hz부터 20000hz(=20kHz)인데, 이에 기반해 사람이 자연스러운 소리로 인식할 수 있는 최소 샘플링 레이트가 40kHz라는 것을 계산해 내서 오디오 기술에 적용하고 있다. 현재 디지털 오디오에 사용하고 있는 샘플링 레이트로 44.1kHz(Compact Disc가 사용하는 수치), 48kHz, 88.2kHz, 192kHz 등이 있다.
Web Audio API를 사용한 샘플링 데이터 추출
Web Audio API는 디지털 오디오 소스로부터 샘플링된 값들을 가져오는 API를 제공한다. 오디오 파일로부터 샘플링 데이터를 가져오는 과정은 아래처럼 작성할 수 있다.
const response = await fetch('https://cdn.data.com/audio.mp3');
const arrayBuffer = await response.arrayBuffer();
const audioCtx = new AudioContext()
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
const rawData = audioBuffer.getChannelData(0)
AudioContext는 디지털 오디오 처리를 위한 인터페이스로서 한번 생성해서 여러 개의 오디오 소스 처리에 활용할 수 있다.
decodeAudioData
는 AudioContext의 부모 인터페이스인 BaseAudioContext에 있는 메소드로서 ArrayBuffer에 저장된 오디오 파일을 디코딩하여 AudioBuffer 데이터를 만든다.
AudioBuffer의 getChannelData
메소드를 사용하면 PCM 데이터를 얻을 수 있다. 배열에는 -1(=그래프 Y축의 음수 피크)부터 1(양수 피크) 사이의 32비트 실수가 들어 있고, 오디오의 길이(초 단위)에 샘플링 레이트를 곱한 수만큼의 값이 들어 있다. 예를 들어 샘플링 레이트가 48000인 오디오의 길이가 20초일 때, 샘플링된 데이터는 총 960,000개가 된다. 위의 코드에서 rawData
값을 콘솔에 출력해 보면 아래와 같은 값들이 나온다.
[
-0.000030517578125,
-0.000030517578125,
-0.000030517578125,
-0.000030517578125,
0.000030518509447574615,
0.00006103701889514923,
0.000030518509447574615,
0,
...
]
음수에서 양수로 변화하는 값들이다. 소리의 진동 그래프를 디지털로 수치화했음을 확인할 수 있다.
getChannelData
함수의 호출 파라미터로 0
을 사용한 것은 첫번째 채널에 저장된 데이터를 사용하겠다는 의미다. 일반적으로 사용하는 2채널 스테레오 오디오라면 전체 채널의 수는 2개, 5.1채널 오디오라면 채널이 6개가 있다. 양쪽 채널에서 나오는 소리를 모두 사용하지 않은 것은 구현의 편의를 위해서다. 실제 구현된 플레이어에서 표시된 파형을 통해 소리가 없는 부분, 소리가 큰 부분을 찾았을 때 기대했던 것과 특별히 다른 경우는 없었다.
샘플링 데이터의 리샘플링
샘플링 데이터는 가져왔지만 그 값을 그대로 쓰기에는 너무 많다. 1초당 대략 4만개의 데이터가 있으므로 1분만 되어도 240만개의 데이터를 얻게 된다. 하지만 캔버스의 넓이가 500px이라면 500개의 데이터만 있어도 1px당 1개의 점을 찍는 것으로 선을 표현할 수 있기에 그만큼 많은 데이터는 필요가 없다.
그래서 1초당 표시할 데이터의 수를 정한 후, 샘플링 데이터를 구간별로 잘라서 평균을 내는 방식을 사용해서 전체 데이터의 수를 줄인다.
본문에 실린 코드는 React로 구현한 코드에서 이 글을 읽는 데 도움이 될 정도로만 발췌하고 수정했기에 언급하지 않은 멤버 변수와 함수가 포함되어 있을 수 있다는 점 양해 바랍니다.
const samplesPerSec = 100 // 1초당 표시할 샘플의 수
const {
duration, // 오디오 길이 (초 단위)
sampleRate, // 샘플링 레이트. 보통 48000 또는 44100.
} = audioBuffer;
const rawData = audioBuffer.getChannelData(0); // 첫번쨰 채널의 AudioBuffer
const totalSamples = duration * samplesPerSec; // 구간 처리 후 전체 샘플 수
const blockSize = Math.floor(sampleRate / samplesPerSec); // 샘플링 구간 사이즈
const filteredData: number[] = [];
for (let i = 0; i < totalSamples; i++) {
const blockStart = blockSize * i; // 샘플 구간 시작 포인트
let blockSum = 0;
for (let j = 0; j < blockSize; j++) {
if (rawData[blockStart + j]) {
blockSum = blockSum + Math.abs(rawData[blockStart + j]);
}
}
filteredData.push(blockSum / blockSize); // 구간 평균치를 결과 배열에 추가
}
1초당 100개의 샘플 데이터만 가져오겠다고 한다면, 1초당 48000개의 샘플링을 100으로 나눠 480개의 구간을 만든 다음, 각 구간의 평균값을 구하면 된다.
다만 평균값을 구할 때 Math.abs
메소드를 사용해 샘플링 데이터의 절대값을 사용해야 한다. 샘플링 데이터는 소리의 세기를 나타내는 값이지만 파장 그래프 표현상 음수에서 양수로 변화하기 때문이다.
샘플링 데이터 정규화(normalizing)
리샘플링한 데이터를 사용하기 전에, 정규화 과정을 거쳐야 한다. 정규화 과정을 통해 샘플링된 데이터의 최대값이 1이 되도록 만든다.
오디오 파일마다 전체적으로 소리가 클 수도 있고 작을 수도 있다. 캔버스에 파형을 그릴 때 그런 값들을 정규화없이 그대로 반영하면 어떤 오디오는 파형이 너무 작게, 어떤 것은 너무 높게 표시될 것이다. 예를 들어 캔버스 높이가 300px일 때, 어떤 오디오든 그 안에서 가장 큰 소리가 (x,0)
좌표에, 가장 작은 소리는 (x,300)
좌표에 표시되게 할 필요가 있다(캔버스는 왼쪽 위 모서리의 좌표가 (0,0)
이다).
물론 작은 소리는 작은 파형으로 표시되길 원하는 서비스도 있을 수 있으므로 선택적으로 적용하면 되는 과정이다. 만약 오디오 파형을 통해 소리가 튀는 구간, 소리가 없는 구간 같은 정보를 직관적으로 얻기 위해서는 정규화를 거치는 편이 더 좋을 것이다.
const normalizeData = (filteredData: number[]) => {
const peak = Math.max(...filteredData);
const multiplier = Math.pow(peak, -1);
return filteredData.map((n) => n * multiplier);
};
샘플링 데이터에서 최대값을 peak
에 저장한 후 샘플링 데이터 배열을 샘플링 * (1 / peak)
공식으로 맵핑해주면 정규화가 완료된다.
이렇게 오디오 데이터는 모두 준비되었다. 이제 캔버스에 샘플링 데이터를 그리는 과정이 필요하다.
Canvas API를 사용한 도형 그리기
Canvas API는 HTML canvas 요소와 자바스크립트를 사용해서 그래픽을 표현할 수 있다. 애니메이션, 게임, 데이터 시각화, 사진 편집 등에 사용된다. 주로 2D 그래픽에 사용되고, 3D 그래픽 표현에는 WebGL API를 사용한다. 오디오 파형은 선, 사각형, 텍스트, 다각형에 색 칠하기 등만 가능하면 구현 가능하기에 Canvas API를 깊게 알 필요까지는 없고 기초 지식(튜토리얼의 1~4장) 정도만 공부한 후 API 레퍼런스를 찾아보며 작업을 하면 된다.
requestAnimationFrame 함수
오디오 재생이 시작되면 오디오 파형 위에 현재 재생 위치를 표시하는 수직선을 표시해줘야 한다. 그것은 재생이 진행됨에 따라 오른쪽으로 움직여야 한다.
캔버스에서 CSS의 transition
, @keyframes
등으로 만드는 것 같은 애니메이션 효과를 만드는 API는 별도로 없다. 캔버스에 그리고 지우는 것을 빠르게 반복함으로써 그런 효과를 만드는 방법을 사용한다. 그리고 그 반복 작업을 할 때 사용하는 것이 requestAnimationFrame
함수다.
window.requestAnimationFrame(callback);
이 함수는 브라우저의 리페인트(repaint)가 일어나기 전에 콜백을 실행한다. setInterval
같은 함수를 사용하지 않고, 재귀 호출을 사용해 requestAnimationFrame
을 반복 호출해도 그 실행 간격은 무한 루프에 빠진 것처럼 빠르게 실행되지 않으며 브라우저에 의해 디스플레이의 주사율에 맞춰진다. 즉 디스플레이가 60Hz의 주사율을 가지고 있다면 콜백의 호출 간격은 1/60초가 되는 셈이다. CPU 리소스를 무리하게 사용하지 않으면서도 사람의 눈에는 자연스러운 애니메이션 효과를 제공할 수 있게 된다.
파형을 그리는 drawWaveForm
이라는 함수가 있을 때, 애니메이션의 시작과 종료는 아래처럼 작성할 수 있다.
class AudioPlayer {
drawWaveForm = () = {
// Canvas API를 사용한 오디오 파형 그리기 구현
}
rafId = -1;
startDraw = () =
this.drawWaveForm();
this.rafId = requestAnimationFrame(() =>
this.startDraw(),
);
}
stopDraw = () =
cancelAnimationFrame(this.rafId);
}
}
requestAnimationFrame
함수는 정수로 된 고유한 콜백 아이디를 리턴한다. cancelAnimationFrame
함수에 그 아이디를 넣어서 호출하면 다음 리페인트 직전에 requestAnimationFrame
의 콜백이 실행되지 않으면서 캔버스를 업데이트하는 drawWaveForm
메소드 더 이상 실행되지 않게 된다. 실제 구현에서는 오디오 재생 버튼을 클릭했을 때 startDraw
를, 멈춤 버튼을 클릭할 때 stopDraw
를 호출하게 될 것이다.
오디오 파형 그리기
샘플링된 데이터가 있고 Canvas API가 있으니 이제 파형을 그리면 된다. 그려야 하는 것들에는 다음과 같은 것들이 있다.
- 백그라운드 박스
- 샘플링 데이터에 기반한 라인 그래프(또는 막대 그래프)
- 타임라인
- 현재 재생위치 표시 바
CanvasRenderingContext2D
인터페이스
실제로 도형을 그리는 데는 CanvasRenderingContext2D
인스턴스를 사용한다. 이것은 canvas 표면에 2D 렌더링 컨텍스트를 제공하며, 다각형, 텍스트, 이미지 등을 그릴 수 있다. 인스턴스는 canvas 요소를 통해 만들 수 있다.
<canvas id="my-house" width="300" height="300"></canvas>
const canvas = document.getElementById('my-house');
const ctx = canvas.getContext('2d');
devicePixelRatio
값에 맞춰서 scale 조정하기
디스플레이에 따라 가로 1920px 사이즈의 화면을 구성하는 픽셀의 수는 1920개일 수도 있고, 3840개일 수도 있다. 후자인 경우 어플리케이션이 사용하는 논리적 픽셀은 1개인데 실제로 사용하는 물리적 픽셀 수는 2개가 된다. 과거에는 이런 구분이 필요하지 않았지만, 아이폰 4S에서 레티나 디스플레이가 등장하면서 상황이 달라졌다. 웹사이트에 들어가는 캔버스를 사용할 때 화면의 DPI(Dots per Inch)에 맞춰서 업스케일링, 다시말해 논리적 픽셀 사이즈를 실제 디바이스의 픽셀 사이즈만큼 늘려줄 필요가 생긴 것이다.
하지만 고맙게도 브라우저의 API로 코드만 잘 작성해주면 업스케일링은 브라우저가 알아서 잘 해준다. 도형을 그리기 전에 아래 코드만 넣어주면 된다.
const ctx = canvasEl.getContext('2d');
const dpr = window.devicePixelRatio || 1;
ctx.scale(dpr, dpr);
scale
함수는 캔버스에서 사용하는 픽셀 단위를 늘려주는 역할을 한다. 그리고 devicePixelRatio
는 물리적 픽셀과 논리적 픽셀의 비율을 반환하는데, 저 값이 HD 모니터에서는 1
, 4K(=QHD) 모니터에서는 2
가 된다. devicePixelRatio
값을 사용하면 올바른 업스케일링 비율을 사용할 수 있다. 만약 업스케일링 없이 오디오 파형을 그리게 한 후 4K 모니터에서 확인하면 아래 스크린샷처럼 보이게 된다.
파형의 넓이와 높이가 캔버스의 그것에서 정확히 절반이 되어버렸다. 하지만 scale
함수로 업스케일링을 해두면 아래처럼 오디오 파형이 정상적으로 캔버스 사이즈에 맞게 표시된다.
CSS로 스타일링을 할 때는 업스케일링을 자동으로 해 주기 때문에 신경쓸 필요가 없지만 캔버스로 도형을 그릴 때는 꼭 필요한 과정이다.
배경 그리기
requestAnimationFrame
을 사용해서 캔버스에 계속 그림을 그릴 것이기에 매번 clearRect
를 사용해서 캔버스 위에 그려진 것을 지워줘야 한다. 안그러면 그렸던 도형들 위에 덧칠하는 형태가 되어 버린다. 예를 들어 좌표가 왼쪽에서 오른쪽으로 바뀌는 정사각형을 그리는 함수를 작성했다고 하자. 그런데 clearRect
를 빼먹었다면 canvas 위에는 오른쪽으로 움직이는 정사각형 대신 오른쪽으로 점점 늘어나는 직사각형이 보일 것이다.
아래 코드는 canvas 전체를 지우고 배경색을 입혀준다.
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.fillStyle = '#ccc'; // 캔버스 배경색
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
샘플링 데이터로 그래프 그리기
오디오 파형 시각화의 핵심인 샘플링 데이터로 그래프를 그리기를 구현해야 한다. 아래의 값을 계산하면 라인 그래프를 그릴 수 있다.
- 전체 샘플의 수
- canvas의 넓이
- 샘플 1개가 차지할 넓이 = canvas 넓이 / 샘플의 수
- 샘플의 x 좌표 = 샘플의 인덱스 * 샘플 1개가 차지할 넓이
- 샘플의 y 좌표 = 샘플 데이터(최대값 1로 정규화된 상태) * canvas의 높이
포인터를 캔버스의 좌하단으로 이동시킨 후 샘플 데이터 배열의 루프를 돌며 라인 그래프를 그린다.
// 샘플 1개가 차지할 넓이
const sampleWidth = waveAreaWidth / this.audioSamples.length;
let lastX = 0; // x축 좌표
ctx.beginPath(); // 선을 그리기 위해 새로운 경로를 만든다.
ctx.moveTo(lastX, canvasHeight);
ctx.strokeStyle = 'blue'; // 라인 컬러 설정
ctx.fillStyle = 'gray'; // 그래프 내부를 채울 컬러 설정
this.audioSamples.forEach((sample, index) => { // 샘플 데이터 배열 루프
const x = sampleWidth * index; // x 좌표
ctx.lineWidth = 2; // 라인 그래프의 두께
ctx.lineTo(
x,
canvasHeight - Math.abs(sample * waveAreaHeight), // y축 좌표
);
lastX = x;
});
// 라인 그래프의 하단을 선으로 연결해서 닫힌 형태로 만든 후, 색을 채운다
ctx.lineTo(lastX, canvasHeight;
ctx.moveTo(0, 0);
ctx.stroke();
ctx.fill();
ctx.closePath(); // 그래프가 완성되었으므로 경로를 닫는다.
위의 코드에서는 상하좌우 패딩을 반영하지 않았다. 라인 그래프의 시작과 끝이 canvas의 좌측 끝과 우측 끝에 위치하게 된다. 특히 아래쪽에 타임라인을 추가하고 포지션 바가 canvas 끝에 겹치지 않게 하려면 전후좌우 패딩을 고려해서 라인을 그려줘야 한다.
이 방식을 활용하면 일정한 넓이를 가진 막대 그래프 형태로 만들 수도 있을 것이다. 다만 막대 그래프를 위해선 샘플링 데이터의 수를 더 줄여야 할 필요가 있다. 앞서서 샘플링 데이터를 정리할 때 1초당 100개가 되도록 했는데, 캔버스에서 1초가 차지하는 넓이가 100px이라도 막대 그래프의 넓이는 1px밖에 되지 않는다. 그러면 스크린샷에서 보여준 라인 그래프와 별다른 차이가 없게 된다. 일정한 넓이의 막대 그래프를 제공하기 위해서는 앞서 구현한 것과 다른 방식으로 샘플링 데이터를 정리할 필요가 있을 것이다.
오디오 재생 기능 구현
오디오 재생을 위해서는 아래의 기능이 필수적으로 필요하다.
- 재생 / 멈춤
- 탐색 (seeking, 재생 위치 업데이트)
오디오 플레이어 시각화
오디오 소스를 audio 요소에 연결해서 재생에 활용했지만 화면에 표시하지는 않도록 했다. audio 요소에 controls
속성을 지정해서 브라우저 네이티브 오디오 컨트롤러를 표시하게 할 수도 있지만 브라우저마다 다른 형태로 제공되기에 일관적인 디자인의 플레이어를 제공하기는 어렵기 때문이다.
재생 위치 슬라이더는 range 타입의 input 요소를 활용했는데, 스타일링을 위해서는 생각보다 많은 양의 CSS 작성이 필요했다(링크 참조).
<audio id='waveAudio' src={this.props.audioUrl} loop>
Your browser does not support the
<code>audio</code> element.
</audio>
<input
id='seekSlider'
type='range'
className={'AudioPlayer__progressInput'}
disabled={this.isInputDisabled}
/>;
오디오 재생 / 멈춤
재생 / 멈춤 기능은 버튼에 이벤트 핸들러만 추가하면 간단히 구현 가능하다.
togglePlayAudioEl = async () => {
if (!this.state.isPlayingAudio) {
this.playAudioEl();
} else {
this.pauseAudioEl();
}
};
playAudioEl = () => {
if (this.audioEl) {
this.setState({ isPlayingAudio: true });
this.audioEl.play(); // audio 요소의 play 함수 호출
}
};
pauseAudioEl = () => {
if (this.audioEl) {
this.setState({ isPlayingAudio: false });
this.audioEl.pause(); // audio 요소의 pause 함수 호출
}
};
<button
onClick={this.togglePlayAudioEl}
>
<InlineIcon
icon={`${ // Iconify 라이브러리의 아이콘 활용
this.state.isPlayingAudio ? 'mdi:pause-circle' : 'mdi:play-circle'
}`}
></InlineIcon>
</button>;
캔버스 파형 클릭시 오디오 탐색
캔버스 파형 위를 클릭했을 때의 오디오 탐색 위치는 로딩된 오디오의 전체 길이, 캔버스의 넓이, 마우스 클릭 지점으로 계산할 수 있다.
getTimeByPositionOfCanvas = (e: MouseEvent) => { // 클래스의 멤버 함수
const rect = this.canvasEl.getBoundingClientRect(); // 캔버스의 포지션 정보
// 마우스 위치와 캔버스 위치를 사용해 x축 좌표를 계산한다.
const x = e.clientX - rect.left;
// (전체 시간 * x축 좌표 / 파형의 널이) 값이 이동할 위치가 된다.
return Math.max(0, this.duration * (x / this.getWaveAreaSize().width));
}
계산한 값을 오디오 요소의 currentTime
속성에 할당해주면 재생 위치가 업데이트된다.
this.audioEl.currentTime = this.getTimeByPositionOfCanvas(e)
나머지 구현 사항
여기까지가 오디오 파형의 시각화에 필수적으로 필요한 부분이다. 스크린샷에 제공한 플레이어가 가진 기능 중 이 글에서 설명하지 않은 부분은 다음과 같다.
-
파형 확대, 축소
- 화면을 벗어날 정도로 커진 캔버스가 좌우 스크롤 되도록 wrapper 요소 추가
- 줌 배율에 따른 그리기 값 계산
- 현재위치 바가 화면을 벗어났을 때 스크롤 이동시키기
-
구간 반복 기능
- mousedown, mousemove, mouseup 이벤트로 반복구간 설정
- 반복 구간 설정 중인 구간을 박스 형태로 canvas에 그리기
- 반복 구간 재생. AudioContext 사용해서 재생하기
파형 확대, 축소 기능이 들어가게 되면 파형 그래프 포지션의 계산, 오디오 탐색을 위한 공식에 확대/축소 상태를 고려해야 한다. 그리고 확대되었을 때 캔버스 사이즈를 늘리고 좌우 스크롤이 가능하게 할 것인지, 스크롤 없이 현재 포지션 바를 항상 가운데에 두게 하고 파형을 좌우로 움직이게 할 것인지에 따라 구현 방식이 달라지게 된다.
그리고 구간 반복을 위해서는 우선 구간 설정을 할 수 있는 이벤트 핸들러를 구현해야 하고, 구간 설정 중에 위치를 파악할 수 있도록 캔버스에 반투명 사각형 영역을 그려줘야 한다. 그리고 설정된 구간은 이동 및 시작/종료 지점 수정이 가능하도록 만들어야 한다. 구간 반복 오디오는 audio를 직접 재생하는 것이 아니라 AudioContext 인스턴스로 오디오 소스를 별도로 생성해서 재생했다.