들어가며

Moyo는 아바타 기반 가상 오피스로, 사용자들은 로비나 회의실을 비롯한 모든 공간에서 화상회의를 할 수 있다. 최소 1시간, 길게는 4시간 이상 화상회의를 진행하기 때문에 오디오 품질이 협업 경험 전반에 영향을 준다. 따라서 서비스 개발 초기부터 LiveKit에서 제공하는 노이즈 제거 기능(Krisp)을 적용하고 있었다.

Krisp를 켜도 소음이 들리는 문제

하루, 10:00 ~ 19:00 동안 진행한 사용성 테스트에서 설문조사에 응해준 20명 중 9명이 화상회의에 불편함을 느꼈고, 그 중 6건의 노이즈 관련 피드백이 접수되었다.

소음 before 피드백

왜 Krisp를 적용해도 불편함을 느꼈을까

Krisp는 머신러닝 기반으로 사람의 음성과 소음을 분류해 소음만 제거한다. 브라우저 기본 WebRTC 옵션(noiseSuppression 등)이 규칙 기반 필터인 것과 달리 학습된 모델로 더 정밀하게 구분한다.

그러나 사람 목소리와 주파수 대역이 겹치는 소음에는 한계가 있었다. 카페의 주변 대화음, 키보드 타이핑 소리처럼 음성과 스펙트럼이 유사한 신호는 모델이 음성으로 오분류해 그대로 전달했다.


Krisp 위에 무엇을 더할 것인가

Krisp가 ML로 음성과 소음을 구분하는 데 한계가 있다면 같은 방식이 아닌 다른 레이어에서 작동하는 수단이 필요했다. 같은 방식끼리는 같은 지점에서 막히기 때문이다.

옵션처리 위치CPU 부담소음 제거 품질제어 가능성
WebRTC 기본 옵션브라우저 (하드웨어 레벨)낮음보통낮음
Krisp AISDK (소프트웨어 레벨)높음높음중간
서버 단 처리서버서버 부하높음높음

서버 단 처리는 다인원이 장시간 연결 상태를 유지하는 환경에서 부하와 비용이 누적될 수 있어 제외했다.

WebRTC 기본 옵션은 브라우저의 마이크 입력 단계에서 직접 적용되고 별도 서버 처리가 없어 지연과 성능 부담이 적다. 무엇보다 Krisp(소프트웨어 레벨 ML 처리)와 WebRTC(하드웨어 레벨 에코·게인 보정)는 작동하는 위치가 달라 서로 간섭 없이 병행할 수 있었기에 보완 방법으로 택했다.

어떤 옵션을 왜 켰나

옵션별로 적용 여부를 개별 결정했다.

echoCancellation은 스피커 출력이 마이크로 되먹임되는 에코를 차단한다. 헤드셋 없이 화상회의를 하는 경우 상대방 목소리가 자신의 마이크로 재입력되는 현상을 막는다. 화상회의 환경에서는 기본으로 활성화하는 것이 맞다고 봤다.

autoGainControl은 마이크 입력 볼륨을 자동으로 일정 수준으로 유지한다. 사용자마다 마이크 감도가 달라 발화 볼륨이 크게 차이 나는 경우를 보정하기 위해 활성화했다.

noiseSuppression은 오히려 비활성화했다. Krisp와 함께 켜면 같은 오디오를 두 단계에서 처리하게 되어 음성이 뭉개지는 부작용이 생기기 때문이다. 이후 Krisp 장애 시에만 폴백으로 활성화했다.

실제로 얼마나 달라졌을까

두 기술은 서로 다른 레이어에서 작동한다. Krisp가 ML로 음성과 소음을 구분하는 동안 echoCancellation은 스피커 출력이 마이크로 되먹임되는 에코를 차단하고, autoGainControl은 마이크 입력 게인을 안정적으로 유지한다. 이론상 두 레이어가 맞물리면서 억제 효과가 커질 것으로 보며 테스트해봤다.

시나리오

송신 측에서 카페 소음 YouTube 영상을 2분 재생하여 외부 소음을 주입시키는 방식으로 진행했다.

시나리오KrispechoCancellationautoGainControl
기본 (무처리)OFFOFFOFF
Krisp만ONOFFOFF
Krisp + WebRTC 기본 옵션ONONON

AudioContext로 측정하기

Claude Code를 활용해 AudioContextAnalyserNode 기반 코드를 짜고, 수신 측 탭에서 1초 간격으로 RMS·dB를 측정했다.

// 수신 측 <audio> 요소에서 MediaStream을 가져온다
const el = document.querySelector("audio");
const stream = el.srcObject;
 
// AudioContext 파이프라인: 스트림 소스 → 분석기
const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048; // 샘플 크기 (클수록 정밀, 처리 비용 증가)
source.connect(analyser);
 
const data = new Float32Array(analyser.fftSize);
 
setInterval(() => {
  // 현재 오디오 파형 데이터를 data에 채운다 (각 값: -1.0 ~ 1.0)
  analyser.getFloatTimeDomainData(data);
 
  // RMS: 각 샘플을 제곱 → 평균 → 제곱근 (신호의 평균 진폭)
  let sum = 0;
  for (let i = 0; i < data.length; i++) sum += data[i] * data[i];
  const rms = Math.sqrt(sum / data.length);
 
  // dB: RMS를 로그 스케일로 변환 (+1e-10은 log(0) 연산 방지)
  const db = 20 * Math.log10(rms + 1e-10);
  console.log(`RMS: ${rms.toFixed(6)}, dB: ${db.toFixed(2)}`);
}, 1000);

RMS와 dB

  • RMS(Root Mean Square): 오디오 신호의 평균 진폭. 0.0(무음) ~ 1.0(최대 볼륨).
  • dB(Decibel): RMS를 로그 스케일로 변환한 값. 20 * log10(RMS). 0dB이 최대이며 값이 낮을수록 조용하다.

+1e-10log(0) 연산 오류를 막기 위한 최솟값 보정이다.

결과

시나리오평균 dB기본 대비 억제량RMS 환산진폭 비율
기본 (무처리)-30.9 dB0.02851.0x
Krisp만-38.1 dB7.2 dB0.01240.44x (56% 감소)
Krisp + WebRTC 기본 옵션-80.9 dB50.0 dB0.000090.003x (99.7% 감소)

Krisp 단독으로는 7.2dB, 진폭 기준 56% 감소였다. 수치상 절반이 줄었지만 수신 측에서 여전히 배경 소음이 인지되는 수준이었다.

Krisp에 WebRTC 기본 오디오 옵션을 함께 적용하자 50.0dB 억제, 진폭 기준 99.7% 감소가 나왔다. 소음이 사실상 들리지 않는 수준이었다. 이론상 예상했던 상호 보완 효과가 수치로도 확인됐다.

직접 들으며 비교하기

수치 측정만으로 기준을 정하기 어려워 동일한 시나리오대로 팀원들이 직접 다양한 환경 조합에서 테스트를 진행했다.

구분테스트 환경
OSmacOS × 2, Windows × 2
오디오 기기AirPods, Galaxy Buds, 무선 이어폰, 기기 내장 마이크

각 조합에서 실제 발화 시와 침묵 시 값을 측정하고, 소음을 오탐하지 않으면서 실제 발화를 놓치지 않는 지점을 체감했다.

  • 억제가 너무 강할 때: autoGainControl이 침묵 구간의 미세 신호까지 증폭하고, 소음 제거 이후 남아있는 작은 신호까지 처리하면서 실제 음성도 함께 잘리는 현상이 발생했다. 상대방에게 말소리가 끊겨 들리는 체감 품질 저하가 나타났다.
  • 억제가 너무 약할 때: 카페 대화음처럼 사람 음성과 주파수 대역이 비슷한 소음은 필터를 통과해 그대로 전달됐다.

결국 감도 설정은 소음 억제 품질과 음성 보존 사이의 트레이드 오프임을 깨닫고, 의논 결과 WebRTC의 기본 옵션을 사용하되 Krisp 최종 감도를 medium으로 결정했다.


소음 제거 강화 이후 드러난 발화 감지 문제

감도를 조정하던 중 새로운 문제가 드러났다. 발화 중인 참여자에게 표시되던 하이라이팅이 제대로 작동하지 않게 된 것이다.

서버의 VAD 미감지

Moyo는 LiveKit의 useIsSpeaking 훅으로 발화 여부를 판단해 UI 하이라이팅을 적용하고 있었다. useIsSpeaking은 LiveKit 서버가 오디오 신호를 분석해 발화 여부를 boolean으로 제공하는 서버 사이드 판단이다.

원인은 과도한 소음 제거였다. Krisp와 WebRTC 기본 옵션을 병행 적용하면서 오디오 진폭이 99.7% 줄어들었고, 그 결과 LiveKit 서버의 VAD(Voice Activity Detection)가 임계값을 넘지 못해 실제 발화를 감지하지 못하는 상황이 발생했다. 다시 말해 서버가 발화 이벤트를 내려주는 방식이라 입력 음량이나 필터링 수준에 따라 서버의 감지 기준과 실제 오디오 상태 사이에 불일치가 생겨 감지 자체가 누락될 수 있었다.

소음 제거 전 발화 신호: ████████████ (충분히 강함)
소음 제거 후 발화 신호: ▌           (서버 임계값 미달 → 발화 미감지)

소음 제거 품질은 개선되었지만 VAD의 감지 기준과의 충돌은 useIsSpeaking의 구조적 한계임을 느꼈다.

발화 감지를 브라우저로 옮기다

서버 판단에 의존하는 대신 브라우저에서 오디오 트랙의 실제 볼륨을 직접 측정하는 useTrackVolume 훅으로 전환했다.

useIsSpeakinguseTrackVolume
판단 주체LiveKit 서버브라우저
반환값boolean0 ~ 1 (실제 볼륨)
장점모든 클라이언트가 동일 기준 공유서버 이벤트 누락 시에도 즉시 감지
단점노이즈 필터링에 따라 발화 미감지 가능임계값 설정에 따른 오탐 우려

useTrackVolume은 브라우저의 AudioContext가 트랙을 실시간으로 분석해 볼륨을 반환하므로 발화 여부 판단의 책임 위치를 서버에서 클라이언트로 이동시켰다.

클라이언트가 오디오 신호를 직접 분석하면 서버 이벤트와 무관하게 현재 오디오 상태를 반영할 수 있고, 필터링 파이프라인이 바뀌어도 UI가 흔들리지 않는다는 장점이 있다.

이미 소음이 99.7% 제거된 신호를 기준으로 임계값을 잡았고, 발화 감도도 마찬가지로 팀원들이 다양한 환경에서 직접 확인하며 결정했다.


Krisp 실패 대비 폴백 구조 설계

Krisp는 외부 라이브러리 의존 특성상 장애가 발생할 수 있다. Krisp 실패 시 브라우저 내장 noiseSuppression으로 자동 전환하는 다단계 폴백 구조를 구현했다. echoCancellationautoGainControl은 두 경로 모두에서 항상 적용된다.

noiseSuppression은 왜 기본 OFF인가

브라우저 내장 noiseSuppression을 Krisp와 동시에 켜면 소음 제거가 이중으로 처리되어 음성이 뭉개지는 부작용이 생긴다. 정상 경로에서는 noiseSuppression: false로 설정하고, Krisp 장애 시에만 폴백으로 활성화한다.

// 마이크 트랙 생성 — WebRTC 기본 옵션 명시 적용
const localTrack = await createLocalAudioTrack({
  echoCancellation: true, // 스피커 → 마이크 에코 제거
  autoGainControl: true, // 마이크 감도 자동 보정
  noiseSuppression: false, // Krisp와 중복 처리 시 음성 왜곡 → 기본 OFF
});
 
// Krisp를 적용하고, 실패하면 브라우저 내장 noiseSuppression으로 폴백
try {
  if (isKrispNoiseFilterSupported()) {
    const krisp = KrispNoiseFilter();
    await localTrack.addProcessor(krisp); // Krisp를 오디오 처리 파이프라인에 추가
  } else {
    throw new Error("Krisp not supported");
  }
} catch {
  // Krisp 초기화 실패 시 폴백
  // echoCancellation + autoGainControl은 restartTrack 이후에도 유지됨
  await localTrack.restartTrack({ noiseSuppression: true });
}

마치며

성과

지표결과
소음 관련 불편 피드백적용 전 6건 → 적용 후 0건
소음 억제량기본 대비 50.0 dB (진폭 99.7% 감소)
폴백 안정성Krisp 장애 시에도 noiseSuppression으로 소음 제거 유지

소음 after 피드백

50dB 억제 수치는 단순 예측이 아니라 AudioContext로 수신 측 오디오를 직접 측정한 결과다.

한계와 보완 방향

테스트를 하면서 기기마다 마이크 감도와 소음 환경이 달라 고정 임계값으로는 모든 사용자를 커버하기 어렵다는 것을 깨달았다. 특히 음소거 판단과 발화 하이라이팅 모두 오디오 볼륨 임계값에 기반하는 만큼 사용자가 자신의 환경에 맞게 감도를 직접 조절할 수 있으면 더 정밀한 경험을 할 수 있다는 생각이 들었다.

이를 위한 보완 방향으로는 감도 단계를 UI에 직접 노출하는 방식이 있다.

  • Krisp: 소음 억제 강도(예: low · medium · high)를 사용자가 선택할 수 있도록 단계를 노출한다. 강도를 낮추면 음성 손실을 줄이고 높이면 소음 억제를 강화할 수 있다.
  • 발화 감도: 하이라이팅 임계값을 슬라이더 형태로 노출한다. 내장 마이크와 외부 기기 간 볼륨 차이가 크기 때문에, 사용자가 자신의 환경에 맞는 감도를 직접 찾을 수 있다.

현재는 팀원 테스트로 결정한 고정값을 사용하고 있으며, 사용자 레벨의 감도 조절은 남은 과제로 인식하고 있다.


참고

PR

Wiki

공식 문서