본문 바로가기
사이드 프로젝트

Web Audio API로 음성 변조 기능 구현하기

by NEMNE 2021. 12. 9.

 

들어가기 전에

지난 10월 25일부터 부스트캠프에서 6주 동안 그룹 프로젝트를 시작했고 12월 3일 부로 프로젝트가 끝났습니다.

구현한 프로젝트는 "노가리하우스"라는 프로젝트로 클럽하우스를 클론 한 음성채팅 서비스입니다.

 

 

GitHub - boostcampwm-2021/WEB27-NogariHouse: 🐟 쌍방향 음성 기반 SNS clubhouse 클론 프로젝트 🐟

🐟 쌍방향 음성 기반 SNS clubhouse 클론 프로젝트 🐟. Contribute to boostcampwm-2021/WEB27-NogariHouse development by creating an account on GitHub.

github.com

 

프로젝트를 진행하면 음성 채팅과 관련된 기능을 맡았고 WebRTC를 기반으로 실시간 음성 채팅을 구현했습니다.

그러나 WebRTC 자체는 많은 튜토리얼 자료가 많아 공부하는데에 있어 큰 어려움이 없었지만 음성 변조 기능 같은 경우에는 별도의 자료도 없을뿐더러 복잡한 지식을 요구하는 기능이었기 때문에 어떻게 음성 변조 기능을 구현하였는지 기록해보려고 합니다.

어떻게 음성변조를 구현하지?

음성변조 기능은 음성 데이터를 조작하는 일이였기 때문에 관련 경험이 없던 저는 구현하기 막막했습니다. 

그래서 구현 초기에는 기초적인 2가지 궁금증이 있었습니다.

  1. 음성변조는 어떤 원리로 동작하는가?
  2. 음성 데이터를 어떻게 조작할 것인가?

찾아본 결과 음성 변조의 원리는 음의 높낮이를 조절하면 우리가 생각하는 헬륨 마신 목소리처럼 변경할 수 있습니다.

 

음성 변조의 원리

2번째 역시 마이크 관련 공식 API는 없었지만 Web Audio API라는 공식 API가 있다는 것을 알게 됐습니다. Web Audio API를 통해 나오는 음성 데이터를 조작해주면 음성 변조가 가능하겠다는 생각이 들어 Web Audio API를 사용하기로 결정했습니다.

 

Web Audio API란?

Web Audio API는 웹에서 오디오를 제어할 수 있도록 해주는 API로 오디오에 이팩트를 추가하거나 시각화 할 수 있습니다.

 

 

보시는 것처럼 AudioContext 인터페이스를 통해 Input, Effect, Destination 이렇게 크게 3가지의 AudioNode로 나누어서 제어를 하게 됩니다.

 

Input에 오디오 소스를 등록해주면 음성을 변화시켜주는 Effect 단계를 거쳐 Destination으로 결과 음성을 내보내 주게 되는데, 

그래서 간단하게 Effect 단계에서 음의 높낮이를 조절해서 Destination으로 보내주면 되겠다고 생각을 했습니다.

 

export default class SoundMaster {
	...

    constructor(context: AudioContext) {
      this.context = context;
	  
      ...
    }

    connectToSource(isAnonymous: boolean, stream: MediaStream, callback: any) {
      try {
        this.mic = this.context.createMediaStreamSource(stream); // webRTC의 MediaStream와 Web Audio API 연결하기
        if(isAnonymous) { // 음성 변조를 허용할 경우
          const voiceChangerNode = this.context.createPitchChanger(); // 초기에 예상한 음의 높낮이를 변경 해주는 이팩트노드
          // voiceChangerNode 관련 추가 설정
          this.mic.connect(voiceChangerNode);
          voiceChangerNode.connect(this.context.destination);
		  
          ...	
    }
   ..
}

// InRoomUserBox.tsx

...

export function InRoomOtherUserBox({
  userDocumentId, isMicOn, stream, isAnonymous,
}: IParticipant) {
  const [userInfo, setUserInfo] = useState<any>();
  const ref = useRef<HTMLVideoElement>(null);
  const imageRef = useRef<HTMLImageElement>(null);
  const audioCtxRef = useRef(new (window.AudioContext)());

  ...

  // mic가 켜졌을 때 동작
  useEffect(() => {
    if (!ref.current || !isMicOn) return;
    const soundMaster = new SoundMaster(audioCtxRef.current);
    let meterRefresh: any = null;
    // WebRTC와 Web Audio API 연결하기
    soundMaster.connectToSource(isAnonymous as boolean, stream as MediaStream, 콜백함수);

    return () => {
      soundMaster.stop();
    };
  }, [isMicOn]);
  
  
  ...
}

 

그런데 안타깝게도 Web audio API에서 공식적으로 음의 높낮이를 조절해주는 AudioNode는 없었고, 결국 다른 방법을 생각해야했습니다.

대충 음의 높낮이를 변경해주는 걸 구현하기에는 까다롭다는 stackoverflow의 말

음성 변조 구현하기

그래서 여러 자료를 찾아본 결과 Effect에 음성 데이터를 직접 수정할 수 있는 ScriptProcessorNode가 있다는 것을 알게 되었습니다.

(나중에 알게 된 사실이지만 메인 스레드 사용 이슈 때문에 ScriptProcessorNode보다는 AudioWorklet을 사용하는 방식을 더 권장한다고 합니다.)

이 노드는  audioprocess라는 이벤트를 통해 음의 크기 정보가 담긴 음성 데이터를 배열 형태로 받아와서 음의 크기를 직접 바꿀 수 있게 도와주는 노드인데, 이벤트 핸들러 함수에 음의 크기를 조절하는 코드를 추가하여 음성 변조가 가능하도록 구현했습니다.

 

export default class SoundMaster {
    
    ...
    
    constructor(context: AudioContext) {
      this.context = context;
      this.pitchShifterProcessor = context.createScriptProcessor(256, 1, 1);
      this.pitchShifterProcessor.buffer = new Float32Array(256 * 2);
      this.pitchShifterProcessor.addEventListener('audioprocess', this.pitchShiftHandler.bind(this));
    }

    pitchShiftHandler(event: any) {
      const inputData = event.inputBuffer.getChannelData(0);
      const outputData = event.outputBuffer.getChannelData(0);
      let sum = 0.0;
      
      const newOutputData = new Float32Array(256 * 2);
      for (var i = 0; i < 256; i++) {
        const a = inputData[i];
        const b = (i !== 255) ? inputData[i + 1] : 0;
        newOutputData[i] += a * b * 8; // 이전 음성 데이터 * 현재 음성 데이터 * 8(음의 크기가 작아지는 걸 방지)
      }

      for (var i = 0; i < 256; i++) {
        outputData[i] = newOutputData[i]
      }
    }

    connectToSource(isAnonymous: boolean, stream: MediaStream, callback: any) {
      try {
        this.mic = this.context.createMediaStreamSource(stream);
        if(isAnonymous) { // 음성 변조 허용일 경우
          this.mic.connect(this.pitchShifterProcessor);
          this.pitchShifterProcessor.connect(this.context.destination);
        }else {
          ... 음성 변조가 아닐 경우
        }
        if (typeof callback !== 'undefined') {
          callback(null);
        }
      } catch (e) {
        console.error(e);
        if (typeof callback !== 'undefined') {
          callback(e);
        }
      }
    }

    stop() {
      this.mic.disconnect();
      this.pitchShifterProcessor.disconnect();
    }
}

 

해당 로직을 적용한 결과입니다.

 

 

그러나 보시는 것처럼 해당 로직은 음의 높낮이가 아닌 음의 크기를 바꿔주기 때문에 음질이 많이 깨지는 현상이 발생하게 됐습니다.

 

이를 개선하기 위해 시도한 것 중 첫 번째로는 DynamicCompressorNode를 사용하는 것입니다. 이 노드는 음의 가장 높은 부분은 낮춰주고 낮은 부분은 높여주어 음질을 안정화시켜주는 노드인데요. 해당 노드를 추가적으로 연결하면 음질을 개선해줄 수 있다고 기대했습니다.

 

    connectToSource(isAnonymous: boolean, stream: MediaStream, callback: any) {
      try {
        this.mic = this.context.createMediaStreamSource(stream);
        if(isAnonymous) { // 음성 변조 허용일 경우
          const compressor = this.context.createDynamicsCompressor();
          this.mic.connect(this.pitchShifterProcessor);
          this.pitchShifterProcessor.connect(compressor);
          compressor.connect(this.context.destination);
          
          ...

 

DynamicCompressorNode 적용 버전

 

하지만 해당 노드를 사용해도 아주 미세한 음질 개선만 있을 뿐 대화하기에 완벽한 음성 변조 기능은 아녔습니다.

 

그래서 이번에는 음의 높낮이를 조절해주는 알고리즘을 구현된 라이브러리를 사용하는 것이었습니다.

 

실제로 해당 알고리즘을 구현하기에는 오디오 엔지니어링에 대해 기본 배경지식이 없었기 때문에 Granular Syntensis나 Phase vo-coder 방식의 기법(음성 데이터를 아주 작은 단위로 나누고 재조합하여 다른 소리를 만드는 기법)을 구현하기에는 너무나 까다로웠습니다.

 

그래서 Web Audio API의 핵심 컨트리뷰터인 Chris wilson의 레포 중 Audio-Input-Effects에 있는 Jungle.js라는 파일을 사용하기로 결정했습니다. 

 

 

GitHub - cwilso/Audio-Input-Effects: Live input Web Audio effects

Live input Web Audio effects. Contribute to cwilso/Audio-Input-Effects development by creating an account on GitHub.

github.com

 

Jungle.js는 granular syntensis 기법을 이용하여 음의 높낮이를 제어할 수 있게 해주는 모듈이었습니다.

 

    connectToSource(isAnonymous: boolean, stream: MediaStream, callback: any) {
      try {
        this.mic = this.context.createMediaStreamSource(stream);
        if(isAnonymous) { // 음성 변조 허용일 경우
          let pitchChangeEffect = new Jungle( this.context );
          let compressor = this.context.createDynamicsCompressor();
          this.mic.connect(pitchChangeEffect.input);
          pitchChangeEffect.output.connect(compressor);
          pitchChangeEffect.setPitchOffset(0.7);      // pitch 조절     
          compressor.connect(this.context.destination);
          
          ...

 

 

해당 코드를 적용한 결과 만족한 음성 변조 결과가 나왔고 팀원들과 상의 끝에 이를 적용하기로 결정했습니다.

 

마무리

 

비록 음성 변조 관련 일부 로직은 외부 코드의 도움을 받긴 했지만,

 

음성 변조는 어떤 방식으로 구현하고 동작하는지 이해하게 되었고, 기술적으로 도전한다는 것이 어떤 건지 제대로 느낄 수 있었습니다.