개발/Audio

[Audio] 오디오를 눈으로 보는 2가지 방법(peaks와 wavesurfer)

핸디(Handy) 2023. 8. 13. 21:04

들어가며

이번 글에서는 오디오를 그리는 방법에 대해 알아보고 간단한 구현 방법을 설명합니다.

그다음으로는 라이브러리 Wavesurfer에 대해 간단히 소개하고 알아보도록 하겠습니다.

다루는 내용은 아래와 같습니다.

  • 오디오를 그리는 방법
  • Wavesurfer를 다루는 방법
  • v6 -> v7로 바뀌면서 달라진 점

 

오디오를 그리는 방법

일단 오디오를 다루는 회사에서 근무하고 있는 저에게 오디오를 그리는 기능은 필연적이었습니다.

가우디오 스튜디오에서 waveform를 그린 모습

그래서 처음에는 직접 waveform를 그렸습니다. 그리는 방법은 생각보다 간단한데요.

직접 구현하기

직접 구현하는 방법은 Canvas를 이용하는 방법입니다. ( 다른 라이브러리도 같습니다만)

데이터 가져오기

로컬에 있는 파일이던 서버에 있는 파일이던 일단 데이터를 가져오는 것부터 시작합니다.

useEffect(() => {
  if (src === undefined) {
    return;
  }
  const loadAudioFile = async () => {
    try {
      const result = await axios.get(src, {
        responseType: "arraybuffer",
      });

      if (result.status !== 200) {
        return;
      }
      const audioContext = new AudioContext();
      const decodeAudio = await audioContext.decodeAudioData(result.data);
      setAudioFile(decodeAudio);
    } catch (e) {
      console.error(e);
    }
  };
  loadAudioFile();
}, [src]);

실제 불러오는 코드 중에 중요 부분만 남긴 코드인데요.

arraybuffer 형태로 가져와서 브라우저의 audioContext에서 decodeAudioData를 통해 arraybuffer를 audiobuffer로 변환하는 과정입니다.

그다음으로는 audiobuffer의 데이터를 실제 그릴 수 있는 peaks로 바꾸는 로직입니다.

peaks 계산하기

peaks를 계산하는 건 다음의 과정을 겪습니다.(js에서는)

  1. 전체 오디오 데이터에서 하나의 peak를 구할 영역을 구분한다.
  2. 해당 영역에서 가장 큰 값과 가장 작은 값을 찾는다.
    1. 하지만 상황에 따라 큰 값을 구하고 위아래로 복사해 주는 경우도 있습니다. 제 경우가 그랬거든요.
  3. 원하는 수치로 정규화한다.
const normalizeAudioData = useMemo(() => {
  if (audioFile === undefined) {
    return [];
  }
  return normalizeData(filterMinMaxData(audioFile));
}, [audioFile]);

코드로 표현하면 다음과 같습니다. (2.1의 상황에 맞춘 코드입니다)

const normalizeData = (filteredData) => {
  const multiplier = Math.pow(Math.max(...filteredData), -1);
  return filteredData.map((n) => n * multiplier);
};

const filterMinMaxData = (audioBuffer: AudioBuffer, sampleRate: number) => {
  const rawData = audioBuffer.getChannelData(0);
  const samples = sampleRate;
  const blockSize = Math.floor(rawData.length / samples);

  const minMaxData = [];
  for (let i = 0; i < samples; i++) {
    const blockStart = blockSize * i;
    let sum = 0;
    for (let j = 0; j < blockSize; j++) {
      sum = sum + Math.abs(rawData[blockStart + j] || 0);
    }
    minMaxData.push(sum / blockSize);
  }

  return minMaxData;
};

세부 코드들은 다음과 같죠.

마지막으로 그리기

이제 나온 peaks를 가지고 canvas에 그리면 되겠습니다.

useEffect(() => {
  drawPeaks(
    waveformRef.current, // 일반적으로 div에 ref로 넣어준 값
    normalizeAudioData,
    option?.defaultWaveformColor || "black"
  );
}, [waveformRef.current]);

/* 중략 */

const drawPeaks = (canvas, normalizedData, waveColor) => {
  if (!canvas) {
    return;
  }

  const dpr = window.devicePixelRatio || 1;
  const padding = 10;
  canvas.width = canvas.offsetWidth * dpr;
  canvas.height = (canvas.offsetHeight + padding * 2) * dpr;
  const ctx = canvas.getContext("2d");
  ctx.scale(dpr, dpr);
  ctx.translate(0, canvas.offsetHeight / 2 + padding);
  // draw the line segments

  const width = canvas.offsetWidth / normalizedData.length;
  for (let i = 0; i < normalizedData.length; i++) {
    const x = width * i;
    let height = normalizedData[i] * canvas.offsetHeight - padding / 2;
    if (height < 0) {
      height = 0;
    } else if (height > canvas.offsetHeight / 2) {
      height = height - padding * 2;
    }
    drawLineSegment(ctx, x, height, waveColor);
  }
};

const drawLineSegment = (ctx, x, y, waveColor) => {
  ctx.lineWidth = 2;
  ctx.strokeStyle = waveColor;
  ctx.beginPath();
  ctx.moveTo(x, -y);
  ctx.lineTo(x, y);
  ctx.stroke();
};

이 과정이 라이브러리 없이 그리는 방법입니다.

전체 코드를 간략화하였으나 인터넷을 찾아보시면 많은 예시들이 있으니 해당 문서를 보시는데 더 상세하니 참고 바랍니다.

이렇게 만들어놓고 프로젝트에서 사용하고 있었는데, 점차 요구조건이 많아집니다. 

단순히 그리는 것을 넘어 실제 재생이 되고 여러 가지 부가 옵션이 딸린 기능으로 업그레이드할 필요성이 생긴 것입니다.

그래서 더 이상 직접 만들기를 거부하고 라이브러리를 가져다가 쓰기로 결심합니다.

Wavesurfer 

그래서 찾아본 결과 wavesurfer와 peaks.js가 유명했습니다.

peaks.js&nbsp;vs&nbsp;wavesurfer

제가 라이브러리를 선택하는 기준은 일단 사용자수입니다.

그래프를 보면 사용자수가 peaks이 많은데도 wavesurfer를 제 프로젝트 상황 때문이었는데요.

waveform-playlist라는 라이브러리르 사용하고 있었는데요. 여기서 사용하고 있는 것이 바로 wavesurfer였기 때문이었습니다.

그래서 코드를 살펴보니 익숙하고, 또 프로젝트에 같은 기능의 라이브러리를 2종이나 들고 있을 수 이유가 없어서 wavesurfer를 선택하였습니다.

다시 wavesurfer로 돌아가서

https://wavesurfer-js.org/

공식문서를 살펴보면 이렇게 간단히 표시되어 있습니다.

실질적으로 peaks와 기능적인 차이는 거의 없습니다. 다만 사용하기가 wavesurfer가 더 편한 감이 있습니다.

유저가 바로 사용해 볼 수 있도록 playground를 친절하게 열어둔 것도 사용성에 한몫을 하고 있습니다. + 공식문서도 잘 되어있어요.

 

wavesurfer.js | audio waveform player JavaScript library

 

wavesurfer-js.org

기능 살펴보기

기능은 오디오 waveform으로 할 수 있는 거의 모든 것이 있습니다. 옵션을 같이 살펴보면 좋을 거 같은데요.

https://wavesurfer-js.org/docs/#md:wavesurfer-options

옵션을 보면 너무 많아 보이지만 카테고리를 나눠보면 

오디오에 기본적으로 필요한 정보들 container,  url 등이 있고,

꾸미기 요소들 height, waveColor,... barAlign 등이 있습니다.

그다음으로는 부가기능들 plugins, normalize, media 가 있습니다.

이렇게 크게 3종류의 옵션들이 있습니다.

플러그인 살펴보기

플러그인에는 공식적으로 7개가 있습니다. 하지만 유저가 직접 만들 수도 있는데요. 그 부분은 이곳에선 생략하겠습니다.

https://wavesurfer-js.org/docs/#md:plugins

주로 사용하게 될 것은 Regions, Timeline, Hover 정도일 겁니다.

 

wavesurfer.js | audio waveform player JavaScript library

 

wavesurfer-js.org

이것 또한 이곳에서 확인하실 수 있습니다.

Regions
Timeline
Hover

추가 기능

그밖에 제가 wavesurfer를 좋아하는 이유는 타입스크립트도 공식적으로 지원한다는 점인데요.

그리고 눈여겨볼 기능은 여러 가지의 waveform를 보여주는 multi-track 지원입니다.

제가 wavesurfer를 살펴봤을 때는 v6이었고 이번에 v7이 되면서 멀티트랙 플러그인이 추가되었습니다.

이게 좀 더 빨리 나왔다만 waveform-playlist 라이브러리를 가져와서 삽질을 안 했을 텐데.. 뭔가 타이밍이 아쉬운 순간이었습니다.

결국 라이브러리를 fork해와서 이리저리 수정한 끝에 다음과 같은 모습으로 재탄생했습니다. 언젠가 오픈소스로 내보낼 일이 있을지도 모르겠네요.

waveform-playlist로부터 파생된 나만읜 multi waveform

FAQ

문서를 보다 보면 이런 대목도 있습니다.

오디오( + 비디오), 즉 미디어 데이터를 개발자로 다루다 보면 몇 가지 당황 포인트들이 있습니다.

첫 번째로는 미디어 데이터를 다루는 레퍼런스가 생각보다 적다는 것입니다.

두 번째로는 생각보다 미디어의 용량은 커다란 점인데요.

일반적으로 오디오 데이터의 포맷은 wav를 많이 사용합니다. mp3이 비해 몇 배 정도 용량이 큽니다.

그래서 여러 가지 트릭과 최적화가 필요한데요. FAQ도 같이 이슈를 말하고 있습니다.

다른 서비스를 비롯한 저의 경우에도 원본 오디오 데이터 + pre-decoded peaks를 만들어서 저장을 합니다. 

그리고 이를 사용해서 유저에게 표현하고 있습니다. ( 이 부분에 대한 내용도 추후에 따로 다뤄보도록 하겠습니다. 필요하다면요 ㅎㅎ)

 

Audio Decode time

번외로 성능 최적화를 고민하다가 크롬의 decodeAudioData의 구현체가 극악이라는 글을 보았습니다.

여기서 깊게 다루기엔 내용이 방대하나, 요약해서 말해보자면

w3c 스펙에는 맞췄으나( 대충 오디오 정보 주면 audiobuffer로 디코딩해라 ) 여러 가지 이슈때문에 성능이 느려졌다는 것인데요.

저도 이를 확인해 보고자 데모사이트를 만들어서 테스트를 진행했습니다.

 

Audio Decode Compare

 

audio-decoder-compare-speed.vercel.app

크롬 기준
사파리 기준

살펴보면 크롬일 경우 mp3를 기본 브라우저 decoder로 불면 18초가 걸립니다. (91MB, 1시간 20분 기준)

하지만 decoder 성능을 끌어올린 다른 라이버러리 Decode-audio-data-fast에서는 3초가 걸립니다.

여러 파일로 테스트해 본 결과 대략 4~6배 정도의 성능 개선이 있었습니다.

근데 크롬이 아닌 사파리에서 보면 사파리 기본 decoder의 성능이 가장 빨랐습니다. (역시 소프트웨어는...)

참고로 해당 라이브러리를 바로 사용하진 못했습니다.

Decode-audio-data-fast가 가장 빨랐지만 audiobuffer에서 데이터 유실이 생기는 이슈가 있었거든요. 

추가로 용량이 큰 wav보다 용량이 적은 mp3의 decode 시간이 더 오래 걸린 것은 압축에 따른 시간차이입니다.

decode 시간을 줄이려면 용량이 큰 파일을 다운을 받아야 하는 트레이트오프 관계에 있었습니다.

그래서 저는 네트워크 용량을 줄이고 유저의 디바이스에서 성능 개선을 할 목적으로 mp3로 사용하고 있긴 합니다.

컨트리뷰터가 되어보자

마지막으로 wavesurfer에 기여했던 경험 + 컨트리뷰터가 된 경험으로 글을 마무리하겠습니다.

v6에서 v7로 버전업을 하고 대응을 하는 와중에 기능 이상을 발견합니다.(사용료는 못내더라도 버그수정은 가능하지!!)

빨간색 영역, 초록색 영역(위에서 설명한 Regions 플러그인)이 겹쳐서 있는 상황이었는데요.

wavesurfer를 보면 트리거 되는 이벤트가 있는데 그중에 region-out이라는 이벤트가 있습니다.

이름 그대로 region이 끝나게 되면 호출되는 이벤트인데요.

위에 console.log에서 처럼 겹쳐지는 부분이 잘 못 호출되고 있었습니다.

red 영역은 26초~40초, green영역은 29초~43초로 되어있습니다.

하지만 이벤트를 보면 red영역의 region-out 이벤트가 green영역의 시작되는 29초에 트리거되는 버그가 있었습니다.

코드를 살펴보니 region이 여러 개가 있을 경우를 생각하지 않고 계산되어 발생한 이유였는데요.

버그 픽스는 어렵지 않았다.

버그 픽스는 어렵지 않게 금방 구현할 수 있었습니다.

버그픽스되니 제대로 이벤트 터짐

근데 오픈소스에 풀리퀘는 처음이라.. 당황했지만..

여차저차 가이드라인 문서 읽고 수정했고 7.1.3 버전에 반영되었습니다~!~!

(중간에 리뷰받아서 chore : Improved code readability 추가 커밋 올린 건 안 비밀..)

https://github.com/katspaugh/wavesurfer.js/pull/3100

 

마무리

이번 글에서 오디오를 직접 그리는 방법과 wavesurfer를 이용하는 방법에 대해 살펴보았습니다.

그리고 눈여겨볼만한 기능, 그리고 컨트리뷰터가 된 경험으로 글을 마무리했습니다.

p.s 조만간 프론트 뽑을 거 같은데.. 혹시 공고 뜨면 함께 일해주십쇼..

끝.