🌊 음성 데이터 시각화

사용자가 수심을 적는 동안 Web Audio API를 이용해 사용자가 이용하는 기기의 음성 데이터를 받아오고 이를 Canvas API를 이용해 시각화합니다. 해당 기능은 MainPage.tsxArchivePage.tsx에서 사용되기 때문에 재사용을 위해 LinearDataCanvas.tsx 컴포넌트로 분리하여 관리합니다.

수심적기.gif

전체적인 동작 설명

1️⃣ getUserMedia() 메서드로 사용자 음성 받아오기 및 소스생성과 분석기 연결

const getDomainData = useCallback(() => {
    if (analyser) analyser.getByteTimeDomainData(dataArray);

    return { bufferLength, dataArray };
  }, [analyser]);

...

const getMediaStream = useCallback(async () => {
    try {
      // <https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode>
      const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });

      if (mediaStream) {
        if (speechService.tts) handleText();
        audioCtx = new AudioContext();

        const analyser = audioCtx.createAnalyser();

        setAnalyser(analyser);

        analyser.fftSize = 512;
        analyser.smoothingTimeConstant = 0.85;
        bufferLength = analyser.frequencyBinCount;
        dataArray = new Uint8Array(bufferLength);
        analyser.getByteTimeDomainData(dataArray);

        **// 마이크로 소리를 받아와 source 생성**
        const source = audioCtx.createMediaStreamSource(mediaStream);
        
        **// 해당소스 분석노드와 연결**
        source.connect(analyser);
      }
    } catch (err) {
      console.log(`에러발생 ${err as string}`);

      if (speechService.tts) {
        speechService.synth.speak(
          "원활한 온라인도우 진행을 위해, 웹 브라우저 설정화면에서 마이크 사용 권한을 허용해주세요. 카드 선택 화면으로 돌아갑니다.",
          {
            blocking: true,
            endEvent: () => {
              navigate("/question");
            },
          }
        );
      } else {
        alert("원활한 온라인도우 진행을 위해, 웹 브라우저 설정화면에서 마이크 사용 권한을 허용해주세요.");
        navigate("/question");
      }
      musicPause();
    }
  }, [navigate, speechService, musicPause, handleText]);

...

	return (
		...
		{analyser && <LinearDataCanvas ref={canvasRef} getDomainData={getDomainData} />}
		...
	)

getUserMedia() 메서드를 이용하여 사용자의 미디어를 받아옵니다. MDN에서 제공하는 오디오 시각화 기본 사용법에 따라 반환값으로 들어온 MediaStreamcreateMediaStreamSource()메서드를 이용해 소스노드를 생성해주고, AudioContext를 이용해 생성한 분석기노드 Analyserconnect 시킵니다. 소스를 연결시킨 분석기노드의 getByteTimeDomainData() 메서드를 통해 TimeDomainData를 받아오는 함수를 생성해 LinearDataCanvas.tsx의 Props로 전달합니다.

AnalyserNode - Web APIs | MDN

2️⃣ TimeDomainData를 이용해 시각화하는 애니메이션 실행

사용자의 음성데이터(timeDomainData)로 256 길이의 Unit8Array 값이 들어옵니다. 해당 데이터를 Array.form()메서드를 이용하여 배열로 변경한 뒤, 이를 이용하여 파티클을 생성합니다. 파티클들의 집합은 선이되고, 선들이 모여 수심을 이룹니다. 따라서 모든 선은 256개의 데이터를 이용하여 만든 파티클, 적어도 256개 이상의 파티클을 가지고 있습니다. 적어도 256개 “이상”인 이유는 수심(흐르는듯한 물)을 표현하기 위해서 추가적으로 랜덤한 위치, 랜덤한 투명도, 랜덤한 확률로 파티클을 생성하기 때문입니다.

// 컨버스에 그려지는 파티클(하얀색 점)
type dotDataType = { x: number; y: number; opacity?: number };

// 파티클들이 모여 이루는 선
type lineType = dotDataType[];

// 선들의 집합
export type lineGroupType = lineType[];

// 선에 관한 정보
type lineInfoType = { yPos: number; per?: number; particle?: number; large?: boolean };

// 파티클을 저장하는 함수
const saveDot = useCallback( ... );

// 파티클을 그리는 함수
const drawDot = useCallback( ... );

// 수심(선의집합)을 그리는 함수
const currentDraw = useCallback(
	(arr?: lineGroupType, canvasInfo?: canvasInfoType, background = false): void => {
      if (!arr) arr = lineArr;
      if (!canvas) return;

      canvas.clearCanvas();

      let ratio;
			**// 수심을 적을때의 화면 비율 계산하기**
      if (canvasInfo) ratio = getCanvasRatio(canvasInfo);
      if (background) canvas.setBackground("000000");

      for (let i = 0; i < arr.length; i++) {
        drawDot(arr[i], lineInfoArr[i], ratio);
      }
    },
  [drawDot, getCanvasRatio, canvas]
);

이해를 돕기위해 선마다 색상값을 다르게 준 수심이미지를 첨부

이해를 돕기위해 선마다 색상값을 다르게 준 수심이미지를 첨부

수심을 그리는 함수인 currentDraw 함수는 애니메이션을위해 사용될 뿐만 아니라, ArchivePage.tsx에서 데이터베이스에 저장된 수심들을 불러와 일정한 크기의 카드 컴포넌트에 그려주기 위해서도 사용됩니다. 사용자가 수심을 적을 때의 화면보다 더 작은 화면에서 수심을 그려줘야 하기 때문에 저장된 파티클의 x, y값에 대해서 늘어나거나 줄어든 화면 비율만큼 값 조정이 필요하게 됩니다. 따라서 데이터베이스에는 사용자가 수심을 적을 때 화면 width, height 값도 함께 저장됩니다(canvasInfo).

*** 사용자의 화면이 100 * 100 인 상태에서 한가운데 파티클이 그려진다면 x, y값은 각각 50입니다. 데이터베이스에 x, y값이 50인 파티클 데이터가 저장되고, 이를 불러와 200 * 200인 화면에서 다시 그려줘야하는 일이 생긴다면 파티클은 늘어난 화면 비율에 따라 (100, 100) 위치에 그려져야합니다.

const lineInfoArr: lineInfoType[] = [
  { yPos: -122 },
  { yPos: -92 },
  { yPos: 0 },
  { yPos: 10, per: 66, particle: 5, large: true },
  { yPos: 30, per: 60, particle: 5, large: true },
  { yPos: 40, per: 55, particle: 5, large: true },
  { yPos: 70, per: 50, particle: 4, large: true },
  { yPos: 100, per: 40 },
  { yPos: 130, per: 40 },
  { yPos: 200, per: 20 },
  { yPos: 240, per: 10, large: true },
];