본문으로 바로가기

TIL 20233-01-24 Wavesurfer스타일 오디오 재생기 만들기

category TIL 2023. 1. 24. 21:36

설날동안 간단하게 만들어볼거 없나 싶어서 생각해보다가 파형이 나오는 오디오 재생기를 만들기로 했다.

 

 

 

https://wavesurfer-js.org/

 

wavesurfer.js

Backward Play / Pause Forward Toggle Mute

wavesurfer-js.org

 

이것을 보면서 요구사항을 간단하게 정립했는데,

 

구현할 수 있는 내용들이야 끝도 없겠지만, 간단하게 만들어보기 위해 구현하고 싶었던 것의 핵심인 두가지만 뽑았다.

 

1. 음악에 대한 파형이 그려진다.

2. 파형을 클릭했을때 그 시점으로 음악을 이동한다.

 

 

1. 음악에 대한 파형을 그리기

 

Web Audio API를 통해서 이를 구할 수 있었다.

 

음악이 담긴 ArrayBuffer를 decodeAudioData 를 통해서 AudioBuffer로 변환할 수 있고, 이 AudioBuffer의 getChannelData를 통해서 채널에 대한 PCM 데이터를 가져올 수 있다.

 

PCM데이터는 Pulse Code Modulation의 줄임말로, 아날로그 데이터를 디지털 데이터로 변조한 데이터를 의미한다. 

AudioBuffer의 채널은 PCM 데이터를 들고 있는 채널을 의미하는데, 스테레오 채널이면 왼쪽, 오른쪽에 대한 채널을 들고 있으니 채널의 개수가 2개인 것이고, 모노 채널인 경우에는 채널이 하나인 것이다.

 

이렇게 가져온 PCM 데이터의 길이는, 샘플레이트 * 오디오의 길이와 동일하다. 

 

샘플레이트는 1초당 들리는 샘플의 개수를 의미한다. 소리라는 아날로그 데이터를, 디지털 신호로 변조할떄 이산적으로 몇개의 샘플로 나눠서 기록할 것인지를 의미하는 것이다.

 

Canvas의 너비는 고정값이고, Overflow하지 않게 오디오 플레이어를 만들 것이기 떄문에, 따라서 이 PCM 데이터 배열을 다시 조합할 필요가 있다.

 

위의 막대기의 너비와 캔버스의 너비가 고정값이기 떄문에, 하나의 막대기가 N개의 샘플을 의미하도록 재조합해주어야한다.

 

export function getAverageBlockList(
  totalCnt: number,
  blockSize: number,
  blockList: Float32Array
) {
  const filteredData = [];
  for (let i = 0; i < totalCnt; i++) {
    const blockStart = blockSize * i;
    let blockSum = 0;

    for (let j = 0; j < blockSize; j++) {
      if (blockList[blockStart + j]) {
        blockSum = blockSum + Math.abs(blockList[blockStart + j]);
      }
    }

    filteredData.push(blockSum / blockSize);
  }
  return filteredData;
}

 

export function normalizePeak(arr: number[]) {
  const maxPeak = Math.max(...arr);
  return arr.map((item) => item / maxPeak);
}

Canvas에 항상 일정하게 그려지기 위해 getAverageBlockList는 최대값이 1이 되게 정규화해주고 이제 이것을 Canvas에 그려주면 된다.

 

  drawBlocks({
    strokeColor,
    fillColor,
    blockList,
  }: {
    strokeColor?: string;
    fillColor?: string;
    blockList: number[];
  }) {
    if (!this.canvasRef || !this.canvasRef.current) return;
    const ctx = this.canvasRef.current.getContext("2d");
    if (!ctx) return;

    ctx.strokeStyle = strokeColor ?? "blue";
    ctx.fillStyle = fillColor ?? "cyan";
    blockList.forEach((data, index) => {
      const x = this.blockWidth * index;
      ctx.fillRect(
        x,
        this.getCanvasHeight(),
        this.blockWidth,
        this.getCanvasHeight() * data * -1
      );
    });
  }
  drawProgressBlocks({
    currentTime,
    duration,
  }: {
    currentTime: number;
    duration: number;
  }) {
    if (!this.canvasRef || !this.canvasRef.current) return;
    const ctx = this.canvasRef.current.getContext("2d");
    if (!ctx) return;
    const currentBarX =
      duration === 0 ? 0 : (currentTime / duration) * this.getCanvasWidth();
    const currentList = this.blockList.slice(
      0,
      Math.ceil(currentBarX / this.blockWidth)
    );
    this.drawBlocks({
      blockList: currentList,
      fillColor: "gray",
    });

    ctx.beginPath();
    ctx.moveTo(currentBarX, this.getCanvasHeight());
    ctx.lineWidth = this.blockWidth * 2;
    ctx.strokeStyle = "red";
    ctx.lineTo(currentBarX, 0);
    ctx.stroke();
    ctx.closePath();
  }

위와 같이 진행된 부분에 대해서는 다른 색으로 표현하기 위해, 먼저 drawBlock을 통해 전체적인 파형을 그리고 진행된 부분까지 계산을 해 이를 덮어쓰는 방식으로 구현했다.

 

 

 

2. 해당 파형 클릭시 해당 부분의 음악으로 이동하기

 

이것은 생각보다 간단했는데, 

  const handleClickCanvas = (e: MouseEvent) => {
    if (e.target instanceof HTMLCanvasElement) {
      if (!audioRef.current) return;
      const rect = e.target.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const seekTime = Math.max(
        0,
        audioRef.current.duration * (x / canvasWidth)
      );
      audioRef.current.currentTime = seekTime;
    }
  };

Canvas를 클릭했을떄 해당 부분의 X 좌표와, 캔버스의 왼쪽의 X 좌표를 통해 차를 구한 후 이를 캔버스 너비로 나누어 진행 비율을 구한다.

 

그리고 이것을 오디오의 Duration에 곱해, 현재 시간을 계산할 수 있었다.

 

 

 

구조적으로 고민한점

 

캔버스의 블럭들을 그려주는 코드가 Component와 같이 있으니 코드가 많이 산만해서, 이를 Class로 빼주었다.

Class는 딱 이런데 쓰면 좋은거 같다. 

 

처음에는 오디오에 대한 Ref와 섞여있어, 클래스로 빼기 애매한가 싶었는데, 생각을 해보면 drawProgressBlocks에서 현재 진행 상황을 위해서만 참고하고 있어 그냥 호출하는쪽에서 이를 인자로 받으면 되었다.

 

import { RefObject } from "preact";

export interface AudioCanvasDrawerOptions {
  canvasRef: RefObject<HTMLCanvasElement>;
  blockList: number[];
  blockWidth?: number;
}

export class AudioCanvasDrawer {
  blockList: number[];
  canvasRef: RefObject<HTMLCanvasElement>;
  blockWidth: number;

  constructor(options: AudioCanvasDrawerOptions) {
    this.canvasRef = options.canvasRef;
    this.blockList = options.blockList;
    this.blockWidth = options.blockWidth ?? 5;
  }

  getCanvasHeight() {
    return this.canvasRef.current?.height ?? 0;
  }

  getCanvasWidth() {
    return this.canvasRef.current?.width ?? 0;
  }
  drawProgressBlocks({
    currentTime,
    duration,
  }: {
    currentTime: number;
    duration: number;
  }) {
    if (!this.canvasRef || !this.canvasRef.current) return;
    const ctx = this.canvasRef.current.getContext("2d");
    if (!ctx) return;
    const currentBarX =
      duration === 0 ? 0 : (currentTime / duration) * this.getCanvasWidth();
    const currentList = this.blockList.slice(
      0,
      Math.ceil(currentBarX / this.blockWidth)
    );
    this.drawBlocks({
      blockList: currentList,
      fillColor: "gray",
    });

    ctx.beginPath();
    ctx.moveTo(currentBarX, this.getCanvasHeight());
    ctx.lineWidth = this.blockWidth * 2;
    ctx.strokeStyle = "red";
    ctx.lineTo(currentBarX, 0);
    ctx.stroke();
    ctx.closePath();
  }

  drawBlocks({
    strokeColor,
    fillColor,
    blockList,
  }: {
    strokeColor?: string;
    fillColor?: string;
    blockList: number[];
  }) {
    if (!this.canvasRef || !this.canvasRef.current) return;
    const ctx = this.canvasRef.current.getContext("2d");
    if (!ctx) return;

    ctx.strokeStyle = strokeColor ?? "blue";
    ctx.fillStyle = fillColor ?? "cyan";
    blockList.forEach((data, index) => {
      const x = this.blockWidth * index;
      ctx.fillRect(
        x,
        this.getCanvasHeight(),
        this.blockWidth,
        this.getCanvasHeight() * data * -1
      );
    });
  }
}

 

canvas 초기화 코드를 훅으로 빼기

import { useEffect, useRef } from "preact/hooks";

export const useCanvas = (
  canvasWidth: number,
  canvasHeight: number,
  animate: (ctx: CanvasRenderingContext2D) => void
) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");

    const initiateCanvas = () => {
      const dpr = window.devicePixelRatio ?? 1;
      if (!canvas || !ctx) return;
      canvas.width = canvasWidth;
      canvas.height = canvasHeight;

      ctx.scale(dpr, dpr);
    };

    initiateCanvas();

    let animateId: number;

    const requestAnimation = () => {
      animateId = requestAnimationFrame(requestAnimation);

      if (ctx) {
        animate(ctx);
      }
    };

    requestAnimation();

    return () => cancelAnimationFrame(animateId);
  }, [canvasHeight, canvasWidth, animate]);

  return canvasRef;
};

이 코드는 내가 작성한 것은 아니고, canvas API 관련해서 찾아보다가 이 분이 작성하신 코드를 가져온 것이다.

animate를 호출하는 부분, devicePixelRatio 기반으로 Canvas를 초기화하는 부분, requestAnimationFrame을 호출하는 부분을 훅으로 빼고, 이에 대한 Ref를 반환하는 식으로 훅을 작성했다.

현재는 캔버스를 재작성할 일은 없지만, 가뜩이나 복잡한 AudioPlayer 컴포넌트에서 복잡함과 책임을 덜어주는 의미있는 캡슐화인거 같다.

 

 

 

 

작성한 코드는 여기 있다.

https://github.com/Kanary159357/audio-wave-demo

 

 

 

조금만 코드를 다듬으면 열심히 쓸 블로그에 포스팅할만할지도?

 

 

 

작성할떄 코드 구조에 대해서 많이 생각해야겠다.

 

Canvas API로 계속해서 새로 그리는것이 생각보다는 그렇게 부담은 아니라는게 좀 신기했는데, SVG로 그리면 훨씬 가볍지 않을까 일단 이거를 먼저 비교해보려고 함.

 

그래서 더 뭐 구현해보려다가 멈춤.

 

 

 

티스토리에 글 쓰는데 확실히 마크다운 형식을 잘 지원하고 깔끔한 블로그를 따로 만드는게 좋을거 같다는 생각이 무럭무럭 든다.

 

여기는 일기장이고