강의

멘토링

로드맵

인프런 커뮤니티 질문&답변

성능측정달인123님의 프로필 이미지
성능측정달인123

작성한 질문수

웹에서 미디어를 다루는 방법 MediaStream API

오디오 Input -> Speaker 출력 Noise

작성

·

16

·

수정됨

0

안녕하세요.

이번에 프로젝트를 진행 하고 있는데 해결되지 않는 부분이 있어서 질문을 드립니다.

상황을 간단히 말씀드리면, 미팅룸 개설을 하고 참여한 인원중에 말을 하면 해당 음성을 다른 참여자의 스피커로 출력하는 방식입니다. (발화자 제외)

 

이때 Input Audio format은 16Khz, MONO, 32Float, 16,000 sample 로 지정되어 있습니다.

(음성 출력 뿐만 아니라, STT 서버에 보내서 텍스트를 반환하는데 이때 STT 서버의 오디오 요청스펙 입니다.)

 

그리고 Gemini의 도움을 받아 아래와 같이 옵션을 설정하였지만, 실제로 스피커 출력시 매우심한 Noise가 발생합니다. (STT 서버의 응답 텍스트는 정상 동작)

 

저는 백엔드 개발자인데, 프론트단에서 해결 방법을 잘 모르겠어서, 강의를 결제하게 되었습니다. 혹시 조언을 해주실수 있을까요?

아니면 강의에 몇강을 보면 관련 주제가 나오는지 알려주도 좋을거같습니다.

 

긴글 읽어주셔서 감사합니다.

 

  • Input audio data 관련 코드

audio: {
        echoCancellation: true,
        noiseSuppression: false,
        autoGainControl: false,
      }

this.highPassFilter = this.audioContext.createBiquadFilter();
    this.highPassFilter.type = 'highpass';
    // [튜닝] 목소리 뭉개짐을 피하기 위해 60Hz로 설정
    this.highPassFilter.frequency.value = 60;

    // 2. Compressor (안전장치/Limiter 역할 튜닝)
    this.compressor = this.audioContext.createDynamicsCompressor();
    // [튜닝] -6dB를 넘어가는 "정말 큰 기계음"만 잡는 '안전장치'로 사용
    this.compressor.threshold.value = -6;
    this.compressor.knee.value = 30;
    // [튜닝] 2:1로 최소한만 압축
    this.compressor.ratio.value = 2;
    // [튜닝] 순간적인 피크를 빠르게(3ms) 잡음
    this.compressor.attack.value = 0.003;
    this.compressor.release.value = 0.25;

    // 3. GainNode (전체 볼륨 증폭)
    this.gainNode = this.audioContext.createGain();
    // [튜닝] 압축을 거의 안 하므로 1.1배로 소폭만 증폭
    this.gainNode.gain.value = 1.1;

    // --- 7. 노드 체인 연결 ---
    this.audioSource.connect(this.highPassFilter); // 1. (마이크) -> 저주파 험 제거
    this.highPassFilter.connect(this.compressor);  // 2. -> "정말 큰 소리"만 방지
    this.compressor.connect(this.gainNode);        // 3. -> 전체 볼륨 소폭 증폭
    this.gainNode.connect(this.resamplerNode);     // 4. -> VAD 및 리샘플링
    this.resamplerNode.connect(this.audioContext.destination); // (워크렛 실행용)
  • 스피커 출력 관련 코드

// --- [수정] 오디오 출력(Playback) 로직 (심리스 스케줄링) ---

  private handleIncomingAudio(audioData: ArrayBuffer): void {
    if (audioData.byteLength === 0 || !this.playbackAudioContext) return;

    if (this.playbackAudioContext.state === 'suspended') {
      this.playbackAudioContext.resume().catch((err) => {
        console.error('Playback AudioContext 재개 실패:', err);
      });
    }

    this.audioQueue.push(audioData);

    // [수정] 재생 루프가 멈춰있을 때(!this.isPlaying)만 새로 시작
    if (!this.isPlaying) {
      this.isPlaying = true;
      // 현재 시간을 기준으로 스케줄링을 다시 시작합니다.
      this.nextChunkTime = this.playbackAudioContext.currentTime;
      this.playNextChunk();
    }
  }

  private playNextChunk(): void {
    if (this.audioQueue.length === 0) {
      this.isPlaying = false; // 큐가 비면 재생 중지
      return;
    }
    if (!this.playbackAudioContext || this.playbackAudioContext.state === 'closed') {
      this.isPlaying = false;
      this.audioQueue = [];
      return;
    }

    const audioData = this.audioQueue.shift()!;

    try {
      const float32Data = new Float32Array(audioData);
      const audioBuffer = this.playbackAudioContext.createBuffer(
        PLAYBACK_CHANNELS,
        float32Data.length,
        this.playbackAudioContext.sampleRate
      );
      audioBuffer.copyToChannel(float32Data, 0);

      const source = this.playbackAudioContext.createBufferSource();
      source.buffer = audioBuffer;
      source.connect(this.playbackAudioContext.destination);

      // --- [수정] 심리스 스케줄링 로직 ---

      // 1. 랙(Lag)으로 인해 예약 시간이 이미 지났는지 확인
      const currentTime = this.playbackAudioContext.currentTime;
      if (this.nextChunkTime < currentTime) {
        // 지연이 발생했으면, 갭(Gap)이 생기지 않도록 현재 시간으로 리셋
        this.nextChunkTime = currentTime;
      }

      // 2. 계산된 nextChunkTime에 재생을 '예약'합니다. (갭 제거)
      source.start(this.nextChunkTime);

      // 3. 다음 청크가 시작될 시간을 미리 계산합니다.
      this.nextChunkTime += audioBuffer.duration;

      // 4. [수정] onended에서 다음 청크를 비동기적으로 호출합니다. (버그 수정)
      source.onended = () => {
        // 큐에 다음 데이터가 있으면, 딜레이 없이 바로 다음 청크를 스케줄링합니다.
        if (this.audioQueue.length > 0) {
          this.playNextChunk();
        } else {
          this.isPlaying = false; // 큐가 비었으면 재생 종료
        }
      };

      // 5. [삭제] 즉각적인 재귀 호출을 삭제합니다. (이것이 버그였습니다)
      // if (this.audioQueue.length > 0) {
      //   this.playNextChunk();
      // }

    } catch (e) {
      console.error('오디오 청크 재생 중 오류:', e);
      this.isPlaying = false; // 오류 발생 시 재생 루프 중지
    }
  }

답변 1

0

이사님은님의 프로필 이미지
이사님은
지식공유자

안녕하세요.

저의 강의에 관심을 보여주셔서 감사드립니다.

 

각종 필터도 사용하시고, 오디오 재생 시에는 직접 버퍼를 사용하시는 것으로 보이네요.

저의 강의에는 Raw 미디어 데이터를 다루기 보다는, 미디어를 다루는 기본적인 내용과 함께 MediaStream 클래스를 기반으로 관련 클래스 혹은 인터페이스의 메서드를 소개해 나가는 강의라고 보시면 됩니다.

강의 내에서는 해당 문제의 답을 찾기에는 아마 기초적인 내용이라고 여기실 가능성이 커 보입니다.

 

다만, 강의 내용과 별개로 보여주신 소스의 제일 처음의 audio 속성에 할당하는 객체는 아마도 오디오를 얻기 위해 사용한 Constraints 인 듯한데, echoCancellation 의 값도 일단은 false로 놓고, 각종 필터들도 제거한 상태에서 먼저 테스트 해보기를 권장 드립니다.

만약 오디오 재생에 버퍼를 사용하는 특별한 이유가 없다면 MediaStream 객체 단위에서 다루고, WebRTC를 사용하여 전송하는 기본적인 방법의 사용도 권해 드립니다. WebRTC는 오디오, 비디오 전송에 편리하도록 만들어져 있기 때문이며 성능과 안정성도 뛰어납니다.

다른 곳에서는 인식이 되는 raw 데이터(AudioBuffer에 사용하는)라고 하더라도 웹 상의 AudioBuffer에서 사용하는 데이터와는 다른 형태일 수도 있습니다. (보통 input 쪽의 decodeAudioData를 통해 추출한 데이터를 사용합니다.) raw 데이터를 직접 다루는 것은 정말 난이도가 높은 작업이라고 생각합니다.

 

하지만, 지금 보여주신 소스 코드 기준으로 한 가지 말씀드리고 싶은 것은 있습니다.

playNextChunk 함수는 아마도 반복적으로 호출되는 함수로 보여집니다. 만약 그렇다면 playNextChunk 함수에서 생성하는 AudioBufferSourceNode(createBufferSource를 통해 생성한)가 계속해서 AudioContext의 destination에 연결되는 듯 보입니다. 즉, 여러 개의 Source가 반복해서 연결되는 것 처럼 보입니다.

이전에 사용한 Source가 있다면 disconnect 한 후 새로 생성한 Source 를 연결해 보시기 바랍니다.

 

저의 의견이 도움이 되었길 바라며, 진행하시는 프로젝트를 성공적으로 마무리하시길 기원하겠습니다.

감사합니다.

 

성능측정달인123님의 프로필 이미지
성능측정달인123

작성한 질문수

질문하기