녹음 기능을 구현해보자

HBBB·2025년 4월 21일

Speechfy

목록 보기
2/2
post-thumbnail

이번 프로젝트에서

구현한 기능 중 녹음 기능에 대해 다루어 보려고 합니다.

녹음이 필요한 부분은 음성 데이터를 악기 소리로 변환하여 트랙으로 추가하는 기능이었습니다.
흐름은 다음과 같습니다.

  1. 사용자가 악기를 선택한다
  2. 메트로놈 재생과 함께 녹음을 시작한다. << 이번에 다루어볼 부분은 여기
  3. 녹음된 음성 데이터를 DDSP 모델을 활용해 변환한다

사실 Audio 태그를 사용해 보긴 했지만, 직접적으로 오디오 데이터를 다루어 본 적은 없어서 녹음 기능을 어떻게 구현해야 할지 많은 고민이 있었습니다.
구현해야 하는 부분은

  1. 녹음 시작
  2. 녹음 중지
  3. 녹음한 데이터 반환

입니다.

자료를 찾아본 결과 WebAudioAPI와 MedeiaREcorder를 활용해야 한다는 것을 알았습니다.

MediaRecorder란?

MediaRecorder는 웹 브라우저에서 오디오 및 비디오 콘텐츠를 손쉽게 녹화할 수 있게 해주는 Web API입니다.
마이크, 웹캠과 같은 미디어 입력 장치에서 제공하는 스트림(MediaStream)을 입력받아, 이를 다양한 포맷(WebM, Ogg, WAV 등)의 파일 형태로 녹화할 수 있게 해줍니다.

주로 웹에서 간단한 음성 녹음 기능, 화면 녹화 기능을 구현할 때 활용됩니다.

사용법

  1. MediaStream 확보
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });

사용자의 마이크나 웹캠에서 스트림(MediaStream)을 얻습니다.

  1. 미디어 레코더 인스턴스 생성
const recorder = new MediaRecorder(stream);

얻은 스트림을 기반으로 MediaRecorder 객체를 생성합니다.

  1. 녹음/녹화 시작 및 데이터 처리
recorder.ondataavailable = event => {
  chunks.push(event.data);
};
recorder.start();

녹화가 시작되면, 일정한 간격으로 이벤트를 통해 데이터가 전달됩니다.

  1. 중지 및 파일 생성
recorder.stop();

recorder.onstop = () => {
  const completeBlob = new Blob(chunks, { type: 'audio/webm' });
  const audioUrl = URL.createObjectURL(completeBlob);
};

녹화를 중지한 후, 저장된 데이터를 Blob으로 만들어 파일 형태로 저장하거나, URL로 생성합니다.

WebAudioAPI에 관한 내용은 여기에 정리했습니다.

구현

필요한 함수는 startRecording(), stopRecording()으로 구현 할 예정이며, 녹음이 종료되었을 때, <audio>에서 활용할 수 있도록 URL로 관리하도록 할 예정입니다.

추가적으로, 녹음 시작을 눌렀을 때, 녹음 중인 상태에 따라 컴포넌트를 변경해주어야 했기에 이를 확인하기 위한 isRecording 상태가 필요할 것 같습니다.

const [isRecording, setIsRecording] = useState<boolean>(false);
const [audio, setAudio] = useState<string>('');

또, 녹음에 필요한 MediaRecorder를 선언해줍니다

const mediaRecorderRef = useRef<MediaRecorder>(null); // MediaRecorder 객체를 저장하는 ref
const audioChunksRef = useRef<Blob[]>([]); // 생성된 오디오 청크를 담는 배열
const recordingPromiseRef = useRef<(value: string) => void>(() => {}); // 녹음 중지 이후, BlobURL이 준비되면, 이를 반환하기 위한 resolve함수
const streamRef = useRef<MediaStream>(null); // streamRef 녹음 종료시, 마이크 사용 권한을 종료하기 위해 사용

여기서 이를 state가 아니라 ref로 선언한 이유는 렌더링과 관련되어 있지 않지만, 지속적으로 관리를 해주어야 하고, DOM API와 연동을 해야 하는 데이터이기 때문입니다.

startRecording()

우선 녹음 시작을 위한 startRecording()입니다.

const startRecording = async () => {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: false,
      },
    });
    streamRef.current = stream;
    mediaRecorderRef.current = new MediaRecorder(stream);
    audioChunksRef.current = [];

    mediaRecorderRef.current.ondataavailable = (event: BlobEvent) => {
      if (event.data.size > 0) {
        audioChunksRef.current.push(event.data);
      }
    };

    mediaRecorderRef.current.onstop = () => {
      const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/wav' });
      const audioUrl = URL.createObjectURL(audioBlob);
      setAudio(audioUrl);
      recordingPromiseRef.current?.(audioUrl);
    };

    mediaRecorderRef.current.start();
    setIsRecording(true);
  } catch (e: unknown) {
    if (e instanceof Error) {
      throw new Error(e.message);
    } else {
      throw new Error('알 수 없는 에러 발생');
    }
  }
};

여기서

      audio: {
        echoCancellation: true, // 에코 제거
        noiseSuppression: true, // 잡음 제거
        autoGainControl: false, // 자동 게인 조절 비활성화
      },

를 통해 어느 정도 잡음을 제어하기 위한 옵션을 추가했습니다.

해당 로직이 실행되면,

  1. 음성 데이터는 실시간으로 audioChunksRef에 저장되며
  2. 중지 시, 모든 음성 데이터 청크가 하나의 Blob으로 변환될 것입니다.

stopRecording()

const stopRecording = (): Promise<string> => {
  return new Promise<string>((resolve) => {
    recordingPromiseRef.current = resolve;

    if (streamRef.current) {
      streamRef.current.getTracks().forEach((track) => {
        track.stop();
      });
    }

    if (mediaRecorderRef.current) {
      mediaRecorderRef.current.stop();
    }
    setIsRecording(false);
  });
};

녹음 중지는 단순합니다. mediaRecorderRef.current.stop()를 호출하며 녹음 정지 이벤트를 발생시키며, streamRef.current.getTracks().forEach(track => track.stop())를 통해 마이크 입력을 확실히 종료시켜서 권한 관리를 명확하게 합니다.

mediaRecorderRef.current.onstop = () => {
  const audioBlob = new Blob(audioChunksRef.current, {
    type: 'audio/wav',
  });
  const audioUrl = URL.createObjectURL(audioBlob);
  setAudio(audioUrl);
  recordingPromiseRef.current?.(audioUrl);
};

위에서 보았던 녹음 중지에 사용되는 이벤트 핸들러입니다. Blob으로 변환 후, 무손실 음원인 wav로 파일을 저장합니다. wav는 용량이 mp3에 비해 크지만, 음원의 손실이 적어 이를 활용하기로 했습니다.

이후 해당 Blob을 URL로 변환하여 상태값으로 저장합니다.

최종 코드

최종 코드는 다음과 같습니다.

'use client';

import { useRef, useState } from 'react';

/**
 * useRecord 커스텀 훅은 브라우저에서 오디오 녹음을 시작하고, 녹음이 완료되면 오디오 Blob URL을 반환합니다.
 * @function useRecord
 * @returns {Object} 녹음 상태와 관련된 함수 및 데이터를 포함하는 객체.
 * @property {boolean} isRecording - 현재 녹음 중인지 여부를 나타냅니다. 녹음 중이면 true, 그렇지 않으면 false입니다.
 * @property {() => Promise<void>} startRecording - 녹음을 시작하는 비동기 함수입니다.
 *   사용자가 마이크 접근 권한을 허용해야 하며, 이 함수 호출 시 MediaRecorder가 초기화되고 녹음이 시작됩니다.
 * @property {string} audio - 녹음이 종료된 후 생성된 오디오 파일의 Blob URL입니다.
 *   이 URL은 `<audio>` 태그의 src 속성 등에서 사용하여 녹음된 오디오를 재생할 수 있습니다.
 * @property {() => void} stopRecording - 녹음을 중지하는 함수입니다.
 *   녹음이 중지되면, 현재까지 수집된 오디오 청크를 기반으로 Blob이 생성되고, 해당 Blob의 URL이 상태에 저장됩니다.
 */
export function useRecord(): {
  isRecording: boolean;
  startRecording: () => Promise<void>;
  audio: string;
  stopRecording: () => Promise<string>;
} {
  const [isRecording, setIsRecording] = useState<boolean>(false);
  const [audio, setAudio] = useState<string>('');
  const mediaRecorderRef = useRef<MediaRecorder>(null);
  const audioChunksRef = useRef<Blob[]>([]);
  const recordingPromiseRef = useRef<(value: string) => void>(() => {});
  const streamRef = useRef<MediaStream>(null);

  const startRecording = async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        audio: {
          echoCancellation: true,
          noiseSuppression: true,
          autoGainControl: false,
        },
      });
      streamRef.current = stream;
      mediaRecorderRef.current = new MediaRecorder(stream);
      audioChunksRef.current = [];
      mediaRecorderRef.current.ondataavailable = (event: BlobEvent) => {
        if (event.data.size > 0) {
          audioChunksRef.current.push(event.data);
        }
      };
      mediaRecorderRef.current.onstop = () => {
        const audioBlob = new Blob(audioChunksRef.current, {
          type: 'audio/wav',
        });
        const audioUrl = URL.createObjectURL(audioBlob);
        setAudio(audioUrl);
        recordingPromiseRef.current?.(audioUrl);
      };
      mediaRecorderRef.current.start();
      setIsRecording(true);
    } catch (e: unknown) {
      if (e instanceof Error) {
        throw new Error(e.message);
      } else {
        throw new Error('알 수 없는 에러 발생');
      }
    }
  };
  const stopRecording = (): Promise<string> => {
    return new Promise<string>((resolve) => {
      recordingPromiseRef.current = resolve;

      if (streamRef.current) {
        streamRef.current.getTracks().forEach((track) => {
          track.stop();
        });
      }

      if (mediaRecorderRef.current) {
        mediaRecorderRef.current.stop();
      }
      setIsRecording(false);
    });
  };

  return { isRecording, startRecording, audio, stopRecording };
}

정리

이번엔 useRecord훅을 구현하면서, 사용자의 음성을 녹음하는 방법에 대해 공부해 보았습니다.
MediaRecorder나 WebAudio API를 처음 공부해 본 거라 상당히 흥미로웠습니다.

profile
안녕하세요. 음악을 좋아하는 프론트엔드 개발자입니다

0개의 댓글