프로젝트 소개 - 송포유

윤종성·2024년 11월 24일
0

포트폴리오

목록 보기
1/3
post-thumbnail

요약

실시간 노래방 프로젝트
개요

  • 기간: 2024년 10월 11일~11월 15일 (5주)
  • 인원: 4명
  • 기술 스택: WebRTC, Python, Celery, React, AI 모델(Demucs, Whisper)
  • 관리 도구: GitHub, 노션

주요 기능

  1. 노래 추가: 유튜브 링크 업로드 시 AI가 자동으로 배경음악과 가사를 분리하여 노래방 트랙을 생성.
  2. 실시간 피드백: 음정 시각화를 통해 사용자에게 실시간 채점 및 피드백 제공.
  3. 같이 부르기: 네트워크 지연을 보정하여 실시간 합창 경험 제공.

주요 기술적 도전과 해결

  1. 음성 스트리밍 지연 극복:

    • 브라우저 환경에서 초저지연 구현이 어려움을 깨닫고, 지연을 완전히 없애는 대신 사용자가 체감하지 못하도록 음악 재생 오프셋을 조정.
    • 서버-클라이언트 간 시간 동기화 알고리즘 개발 및 구현: 네트워크 지연 추정 및 중앙값 기반 보정으로 서버 시계를 사용해 음악 동기화.
  2. 네트워크 오프셋 계산 및 적용:

    • 사용자마다 다른 네트워크 지연을 최소화하기 위해 부르는 사람과 듣는 사람 간 지연을 동적으로 조정.
    • 지연 데이터를 기반으로 최소제곱법을 활용하여 최적의 오프셋 값 계산.

성과 및 배운 점

  • 초기 아이디어를 현실적이고 구현 가능한 방식으로 전환.
  • 서버 시간 동기화, 지터 버퍼 관리, 오프셋 적용 등 네트워크와 음성 처리 경험.

포스터


1. 개요

1.1. 배경

실시간 게임은 있는데 실시간 노래는 없는 것에 의문을 품으며
같이 부르는 노래방을 만들자는 아이디어로 시작했습니다.
노래를 넣으면 자동으로 노래방으로 만들어주는 편리한 서비스를 기획했습니다.

소개영상: https://youtu.be/eH1V3jkvpnQ
현장발표영상: https://youtu.be/B-ScB_yoydY

1.2. 개발 환경

  • 개발인원: 4인
  • 관리도구: github, 노션
  • 사용기술: WebRTC, python, celery, React, AI모델(demucs, whisper)
  • 개발기간: 2024. 10. 11.-11. 15.(기획포함 5주)

1.3. 주차별 진행 과정

주차진행 내용
0주차개발 기획
1주차노래 추가 프로세스, 혼자 부르기 ui, 음정 시각화 개발
2주차같이 부르기 ui, webRTC 시그널링, 방 관리, 채팅 등 개발
3주차1, 2주차 내용 폴리싱(노래 추가 프로세스 속도 및 안정성 개선, 시각화 속도 개선,
RTC 턴 서버 구축, 방관리 로직 개선(노래 부르는 중간에 입장), 모바일 레이아웃 개발)
4주차개발 마무리 및 버그 수정, 발표 시나리오 및 포스터 준비

2. 기능

2.1. 노래 추가

유튜브 링크를 업로드하면 배경음악부터 가사까지 자동으로 추출합니다.

2.2. 실시간 피드백(채점/점수 계산)

실시간으로 음정의 정확도를 눈으로 확인하며 부를 수 있습니다.

2.3. 같이부르기

네트워크 지연이 느껴지지 않도록 음악재생시간에 오프셋을 적용합니다.
오프셋 적용 방식은 마이크를 켠 사람(부르는 사람)과 마이크를 끈 사람(듣는 사람)으로 구분되어 적용됩니다.
마이크를 켜고 끌 때 자동으로 전환되며 최소제곱법으로 최적의 오프셋을 계산합니다.

3. 구현과정

3.1. 재생시간 조절을 통한 지연보상

3.1.1. 배경

최초 목표는 초저지연 음성 스트리밍을 통해 같이 부를 수 있는 서비스였습니다.
음성 지연을 점점 늘려가며 시험해본 결과 약 40ms정도부터 지연의 존재를 느끼기 시작했고 100ms가 되면 모두가 지연을 느꼈습니다.
150ms를 넘어가면 확실한 지연을 느꼈습니다.

가능할 것이라고 예상한 이유
오디오를 wav 그대로 전송하는게 아닌 이상 인코딩을 해야하고, 인코딩 윈도우 때문에 일정 지연이 발생합니다.
가장 짧은 opus에서 최소로 지연하는 프레임사이즈는 2.5ms(보통 10ms)
따라서 프레임버퍼링으로 2.5ms, 알고리즘 지연시간 5ms로 잡고, 네트워크 지연으로는 유선 좋은 컨디션 기준 10ms이하라고 가정한다면
마이크 등 하드웨어 지연과 패킷화 등 전송 준비에 들어가는 지연을 감안해도 40ms 안쪽으로 가능할 것이라고 생각했습니다.

달랐던 실제 테스트 결과
하지만 브라우저 테스트 결과 코덱 설정에 한계가 있었습니다.
또 사전에 미처 예상하지 못 했던 오디오 컨텍스트 지연 때문에 네트워크를 제외하고도 약 150ms의 지연이 발생했습니다.
따라서 지터 버퍼로 인한 지연과 네트워크 지연이 없다고 가정해도 초저지연 음성스트리밍은 불가능 하다고 판단했습니다.

기획 수정을 고민하던 중 음성을 앞당길 수 없다면 음악소리를 미루면 되지 않을까하는 아이디어가 떠올랐고 개발해보기로 하였습니다.

3.1.2. 챌린지1(음악 동시 재생)

목소리와 음악소리의 재생시점을 별도로 조절하기 위해서는 컨텍스트를 분리해야합니다.
또 정밀한 조정을 위해 음악을 가능한 동시에 재생해야하고, 동시 재생을 위해서는 단말들이 공통의 시계를 가져야 합니다.
송포유에서는 공통 시계로 서버 시계를 사용합니다.

구체적인 방법
서버시간을 알아내기 위해 방에 입장하면 클라이언트는 서버에 현재 시간을 요청합니다.
수신한 서버 시간을 이용해 아래와 같이 자신의 시계와 서버 시계와의 차이(TT)를 추정하게 됩니다.

T=CTSTT=CT-ST
T=T=서버시간과 클라이언트시간의 차이
CT=CT=클라이언트 시간
ST=ST = 서버 시간
(CTCTSTST는 동일한 시점에서의 값)

클라이언트시간에 오차가 없다고 가정하면
CT=ctCT=ct (ctctperformance.now()로 얻은 시간)

서버에서 시간을 받아올 때에도 지연이 발생하므로 발생한 지연(ll)만큼 받아온 시간에 더해주어야 합니다.
ST=st+lST = st + l (st=st=클라이언트가 수신한 서버 시간, l=l=서버 응답이 클라이언트 도달하기까지 지연)

이제 TT를 어떻게 추정할 것인지 생각해야 합니다.

3.1.2.1. TT의 추정 1

우선 li<RTTil_i < RTT_i이기 때문에 RTT가 가장 작은 표본을 대푯값으로 사용하는 방법을 생각해볼 수 있습니다.
즉, T^=ctisti  (여기서  i=argmini  RTTi)\hat{T}=ct_{i^*}-st_{i^*}\; (여기서 \;i^* = argmin_i \; RTT_i)을 사용하는 방법입니다.

이 방법에서 TT에 대한 잔차eTe_T
eT=TT^=(CTST)(ctisti)=lie_T=T-\hat{T}=(CT-ST)-(ct_{i^*}-st_{i^*})=-l_i이고
성질상 li>0l_i > 0이므로 RTTi<eT<0-RTT_{i^*}<e_T<0이 됩니다.
eT|e_T|를 항상 RTTiRTT_{i^*}미만으로 통제가 가능합니다.
하지만 이 방법은 네트워크 지연이 작은 환경에서만 eTe_T을 낮게 통제할 수 있고 표본의 수를 결정하기 어렵다는 단점이 있었습니다.

3.1.2.2. TT의 추정 2

ll을 정확히 알 수 있다면 STST 또한 알 수 있습니다.
하지만 서버에서 클라이언트에 도착하는 시간은 측정하는 것이 어려우므로, 추정값을 사용해야 합니다.
따라서 TT의 추정문제는 아래와 같이 lil_i에 대한 추정문제로 변환할 수 있습니다.
ST^=sti+l^i\hat{ST}=st_i+\hat{l}_i,
Tl^i=ti=cti(sti+l^i)T-\hat{l}_i=t_i=ct_i-(st_i+\hat{l}_i)

서버로의 지연과 클라이언트로의 지연이 거의 같을 것이라고 가정하면 l^i=RTTi/2\hat{l}_i=RTT_i/2를 사용할 수 있고
따라서 ti=cti(sti+RTTi/2)t_i=ct_i-(st_i+RTT_i/2)입니다.

이제 표본 tit_i들로 T^\hat{T}를 추정해야 합니다.
T{T}의 분포(ll의 분포)를 가정하는 것은 어려웠습니다.
l>0l>0이기 때문에 표본의 개수가 많지 않은 이상 정규분포로 가정하기 어려웠습니다.
따라서 모수추정을 하지 않는 방법을 선택했습니다.

적당히 나쁜 네트워크 환경에서 tit_i를 추출하는 작업을 여러번 반복해 그래프를 그려 보았습니다.

(표본 50개를 정렬하여 표시한 그래프)

  • 평균: -6761.334
  • 절사평균: -6757.0403
  • 중앙값: -6740.6501
  • 최빈값(kde mode): -6753.3704

여러기기로 테스트 해보았을 때 항상 그래프 상 10-30구간과 같은 평평한 구간이 나타났고, 가장 정확한 서버 시간을 표시했습니다.
따라서 평평한 구간의 중앙값을 T^\hat{T}으로 사용했습니다.

titj(i<j)t_i ≤ t_j (i < j)를 모든 i,ji,j에서 만족할 때
T^=median(ti,....,ti+k)(kRANGE,ti+ktiMAXERROR)\hat{T}=median(t_i, ...., t_{i+k}) (k≥RANGE, t_{i+k}-t_i ≤ MAXERROR)
RANGE는 5, MAXERROR는 7

기타(파라미터 선택 과정)

처음에는 RANGE를 고정값으로 둘 경우 중앙에서 많이 벗어난 곳을 평평한 구간으로 판단할까 우려해서 RANGE를 N에 연동시켰습니다. (RANGE는 max(5,N/2)max(5, N/2), MAXERROR는 10)
대체로 잘 작동하였으나 중간 발표 당시 T를 결정하는데 너무 오래 걸려 노래 시작이 안 되는 현상이 일어났습니다.
ll의 값이 널뛸 수록 표본의 수가 많이 필요할 것이라고 생각해 조건((k>RANGE,ti+ktiMAXERROR)(k>RANGE, t_{i+k}-t_i ≤ MAXERROR))을 만족하는 수열이 나타날 때까지 서버 시간을 받아오도록 한 것이 문제였습니다.
ll의 변동이 심하다는 것은 네트워크 상태가 좋지 않다는 것이고, 자연스럽게 ll의 값도 컸습니다.
그런 상황에서 RANGE가 N에 비례해서 늘어나니 T^\hat{T}를 계산하는데 시간이 오래 걸렸던 것입니다.

변동이 심한 상황에서는 어차피 평평한 구간이 잘 발생하지 않는다는 것을 확인하고 RANGE를 5로 고정시키고 MAXERROR를 7로 낮췄습니다.
50회 이상 측정이 필요했던 환경에서도 20회 미만에서 측정을 끝낼 수 있었습니다.

3.1.3. 챌린지2(네트워크 오프셋 계산법)

두 번째 문제는 각 사용자의 음악을 얼마만큼 미루고 앞당겨야(오프셋) 목소리에 지연(체감지연)이 없다고 느낄 수 있을 지였습니다.
아래와 같은 순서로 시도하였습니다.

3.1.3.1. 시도1

발생한 지연 만큼 듣는 사람의 음악 재생을 미루기
듣는 사람이 여럿인 경우 여러명이 지연만큼 미뤄야한다는 단점,
음악과 목소리를 믹싱해서 전송하는 기존의 방식과 본질적으로 같아 특별한 장점이 없음

3.1.3.2. 시도2

부르는 사람의 음악 재생을 앞당김
지연시간을 불러오는데 단계가 추가되지만 부르는 한 사람만 음악 재생이 바뀜
사용자 간 지연이 각자 달라 평균을 취하면 잔차가 남는 단점

3.1.3.3. 시도3

부르는 사람에 오프셋을 적용하고 각 듣는 사용자에게 남는 잔차만큼 추가적으로 오프셋 적용
각 사용자들이 조금씩 보정을 적용해야 하지만 그 정도를 최소화(3.1.3.5. 최적 오프셋 계산)
하지만 부르는 사람이 둘 이상이 되면 이론상 지연을 0으로 만들 수 없는 상황이 발생

3.1.3.4. 시도4

이론상 지연을 0으로 만드려면 인위적으로 지연을 삽입해 지연 차이를 부르는 사람마다 같게 만들어야 함

예시:
부르는 사람 A, B, 듣는 사람 C, D가 있을 때 A의 음성이 C에 도달하는데 걸리는 지연을 DelayACDelay_{AC}라고 하면, DelayACDelayAD=DelayBCDelayBDDelay_{AC}-Delay_{AD}=Delay_{BC}-Delay_{BD}일 때에만 0지연을 만들 수 있다.
그렇지 않으면 목소리 출력에 인위적인 지연을 추가시켜 조건을 만족시켜야 한다.

하지만 스트리밍 음성의 딜레이를 조절하는 것이 어려웠으며
일반적으로 지연 차가 유사하고 또 매우 크지 않은 이상 잔차도 느끼기 힘들기 때문에 큰 문제는 없을 것이라고 판단, 평균을 적용하는 것으로 결정

3.1.3.5. 최적 오프셋 계산

마이크를 켜는 시점에 듣는 사람/부르는 사람이 전환되며, 적용되는 오프셋이 0에 가까울 수록 불편함을 덜 겪게됩니다.
지연시간이 부르는 사람과 듣는 사람에 나누어 적용되기 때문에 듣는 사람에게 오프셋을 크게(절댓값) 적용하면 부르는 사람에게 적용되는 오프셋이 작아지고, 그 반대의 경우도 마찬가지입니다.
즉, 듣는사람/부르는사람의 오프셋 적용에 사용자 경험의 상충관계가 존재해 어떤 쪽을 더 우선시 할지 정책을 세워야 했습니다.

정책별 예시:

A가 부르는 사람, B, C가 듣는 사람이라고 할 때

  1. 듣는 사람의 오프셋 적용을 최소화
    offsetA=DelayAB+DelayAC2offset_A=-\frac{Delay_{AB}+Delay_{AC}}{2}(offsetB2+offsetC2offset_B^2 + offset_C^2를 최소로하는 값)
    offsetB=DelayABDelayAC2offset_B=\frac{Delay_{AB}-Delay_{AC}}{2}
    offsetC=DelayAB+DelayAC2offset_C=\frac{-Delay_{AB}+Delay_{AC}}{2}를 적용
  2. 부르는 사람의 오프셋 적용을 최소화
    offsetA=0offset_A=0
    offsetB=DelayABoffset_B=Delay_{AB}
    offsetC=DelayACoffset_C=Delay_{AC}를 적용
  3. 모두를 동등하게 고려
    offsetA=DelayAB+DelayAC3offset_A=-\frac{Delay_{AB}+Delay_{AC}}{3}
    offsetB=2DelayABDelayAC3offset_B=\frac{2Delay_{AB}-Delay_{AC}}{3}
    offsetC=DelayAB+2DelayAC3offset_C=\frac{-Delay_{AB}+2Delay_{AC}}{3}를 적용

이 프로젝트에서는 1번 방법(듣는 사람의 오프셋 적용을 최소화)을 선택했습니다.
부르는 사람은 오프셋 적용 시점을 예측하기 쉬운 반면, 듣는 사람은 그렇지 않기 때문에 듣는 사람의 경험을 더 우선시 하는 게 좋다고 판단했습니다.

계산 예시:

모두를 동등하게 고려하는 경우의 계산 방법 예시입니다.(다른 경우도 계산 방식은 유사)
A가 노래를 부르는 시점부터 B가 A의 목소리를 듣는 시점까지 200ms가 걸린다고 했을 때(DelayAB=200Delay_{AB}=200)
A의 노래를 200ms만큼 앞당기면 B의 체감 지연은 0이 됩니다. (offsetA=200offset_A=-200)
또는 A의 노래를 100ms만큼 앞당기고 B의 노래를 100ms만큼 미루면 체감 지연은 0이 됩니다.(offsetA=100offset_A=-100, offsetB=100offset_B=100)
즉, 듣는 사람의 음악과 목소리 사이에 체감 지연이 0이되기 위해서는
DelayAB+offsetAoffsetB=0Delay_{AB}+offset_{A}-offset{B}=0을 만족해야 합니다.

A가 노래를 부르고 B,C가 듣는다고 했을 때, B, C의체감 지연을 0으로 만들면서도 A, B, C의 오프셋 적용을 최소화(0에 가깝게)하려면
offsetB=offsetA+DelayABoffset_{B} = offset_{A}+Delay_{AB}
offsetC=offsetA+DelayACoffset_{C} = offset_{A}+Delay_{AC}를 만족하는 offsetoffset 쌍 중에서
잔차제곱합 SS=offsetA2+offsetB2+offsetC2SS=offset_A^2+offset_B^2 + offset_C^2를 최소로 하는 offsetAoffset_A
즉, dSSd(offsetA)=0,d2SSd(offsetA)2>0\frac{d SS}{d (offset_A)}=0, \frac{d^2 SS}{d (offset_A)^2}>0를 만족하는 offsetA,offsetB,offsetCoffset_A, offset_B, offset_C를 선택해야 함

SS=offsetA2+offsetB2+offsetC2=offsetA2+(DelayAB+offsetA)2+(DelayAC+offsetA)2=3offsetA2+2(DelayAB+DelayAC)offsetA+DelayAB2+DelayAC2dSSd(offsetA)=6offsetA+2(DelayAB+DelayAC)=0offsetA=DelayAB+DelayAC3d2SSd(offsetA)2=6>0\begin{aligned} SS &= offset_A^2+offset_B^2 + offset_C^2\\ &= offset_A^2+(Delay_{AB}+offset_A)^2 + (Delay_{AC}+offset_A)^2\\ &= 3offset_A^2+2(Delay_{AB}+Delay_{AC})offset_A+Delay_{AB}^2+Delay_{AC}^2\\ \\ \frac{d SS}{d (offset_A)} &= 6offset_A+2(Delay_{AB}+Delay_{AC})\\ &= 0\\ offset_A&=-\frac{Delay_{AB}+Delay_{AC}}{3}\\ \\ \frac{d^2 SS}{d (offset_A)^2} &= 6 >0 \end{aligned}

일반화하면 아래와 같습니다.
offsetA=ilistenersDelayAi/(N+1)offset_A = \sum_{i∈listeners}Delay_{Ai}/(N+1)

3.1.4. 챌린지3(오프셋 적용)

챌린지 2에서 다룬 음성을 전송 간 발생하는 지연 외에 오디오 입출력 과정에서 발생하는 지연도 반영해야 합니다.

3.1.4.1. 지연 요소

먼저 지연 발생 요소를 파악해야 했습니다.
노래 목소리가 전달되는 과정을 순서대로 생각해보면

  1. 사용자가 부른 노래소리가 마이크의 진동판을 울려 전기신호를 하드웨어로 전달한다.
  2. 하드웨어가 신호를 샘플링해 이산 데이터를 생성하고 프로세스에 전달
  3. 그 데이터를 특정 코덱으로 인코딩한다
  4. 인코딩한 바이너리 데이터를 UDP/TCP패킷으로 만든다
  5. 네트워크로 전송
  6. 상대방이 수신하면 지터 버퍼에 담아 둔다
  7. 일정 시간이 지나면 지터 버퍼에서 출력되어 디코딩
  8. 출력 버퍼에 담아두었다가
  9. 다시 아날로그 신호로 바뀌어 전기신호로 전달
  10. 스피커에서 출력된다

여기서 환경마다 변동이 큰 요소는 5, 6입니다.
블루투스 이어폰 등 무선 기기를 사용한다면 1, 9도 커질 수 있습니다.

따라서 오프셋을 계산하기 위해 아래의 네 가지 state로 분류하여 사용했습니다.

  • 5(networkDelay): RTC통계의 사용자별 RTT를 이용해 추정할 수 있음
  • 6(jitterDelay): RTC연결별 통계에서 (사후적으로) 알아낼 수 있음
  • 1, 9(playoutDelay):는 audioContext의 속성에서 추정값을 불러올 수 있음
  • 나머지(audioDelay): 나머지 요소는 추정이 힘들고 환경별 편차가 크지 않을 것이라고 기대하고 하나의 상수(150ms)를 사용

3.1.4.2. 오프셋 계산

이제 각 지연요소들로 음악 재생을 얼마나 늦출지에 대한 값(오프셋)을 계산해야 합니다.
오프셋이 양수이면 그 수치만큼 음악 재생이 늦춰지고
오프셋이 음수이면 앞당겨집니다.

사용자가 듣는 사람일 경우
  • networkDelay: 챌린지2에서 계산한 사용자별 네트워크 딜레이를 오프셋에 더함
  • jitterDelay: 이 값만큼 목소리 출력이 지연되므로 오프셋에 더함
  • playoutDelay, audioDelay: 음악과 목소리 공통으로 발생하는 지연이므로 고려하지 않음
사용자가 부르는 사람일 경우
  • networkDelay: 챌린지2에서 계산한 사용자별 네트워크 딜레이를 오프셋에 뺌
  • jitterDelay: 출력 지연에만 영향을 미치므로 고려하지 않음
  • playoutDelay, audioDelay: 음악을 재생하는데 걸리는 지연만큼 사용자가 노래를 부르는 시점도 늦춰지므로 이 값만큼 빨리 재생해야한다. 오프셋에서 뺌
적용 형태
  if (isMicOn) {
	setLatencyOffset(
	  -audioDelay - singerNetworkDelay - optionDelay - playoutDelay
	);
  } else {
	setLatencyOffset(jitterDelay + listenerNetworkDelay);
  }

3.1.4.3. 재생시점 조절

오프셋이 변화할 때 재생시간을 조절해야 했습니다.
실제 재생시간과 목표 재생시간을 비교하여 재생배속을 조절하는 방식을 사용했습니다.
(실제 재생시간 - 목표 재생시간) / (1 - 전환속도) 동안 전환속도를 적용

예시

노래를 시작하고 1초가 지난 상황을 가정해보겠습니다.
노래 시작 시간 = 12:03:00:000(12시 3분)
현재 시간 = 12:03:01:000
실제 재생시간 = 1초

이 때 사용자가 마이크를 켜서 -300ms의 오프셋이 적용되었습니다.
그렇다면
목표재생시간=(현재시간)(노래시작시간)(오프셋)=1000ms(300ms)=1300ms목표 재생시간 = (현재 시간) - (노래 시작 시간) - (오프셋) = 1000ms - (-300ms) = 1300ms
전환속도는 2배속 혹은 0.5배속만 사용
따라서 2배속을 (1000ms1300ms)/(12)=300ms(1000ms - 1300ms) / (1 - 2) = 300ms동안 적용하면 오프셋 적용이 완료됩니다.

3.2. AI 모델을 이용한 노래 추가

'노래방'이라는 서비스를 구현하기 위해서는 반주 음원과 가사 데이터가 필요합니다.
반주 음원은 유튜브 등에 많이 있고 가사데이터를 제공하는 서비스도 많습니다.
하지만 사용자는 변수들을 고려하지 않아도 되는 서비스를 원했기 때문에, 웹에서 반주와 가사를 찾아 가져오는 방식은 아래 단점들 때문에 배제했습니다.

  1. 자동수집은 오류 가능성이 있고
  2. 때문에 결국 사용자가 직접 적절한 데이터를 찾아와야 하는 경우가 생긴다
  3. 만약 반주 데이터가 존재하지 않는다면 이용할 수 없으며
  4. 존재 여부 또한 사용자가 별도로 확인해야 한다

반면 노래만 올리고 서버에서 반주와 가사를 모두 생성해준다면
장점은

  1. 노래 데이터는 반주에 비해 찾기 훨씬 쉽고
  2. 원곡에서 직접 추출된 반주를 사용하므로 미디반주보다 선호가 높다

단점은

  1. 반주와 가사 생성 작업에 시간이 소요되며
  2. 생성물에 오류가 있을 가능성이 있다.
  3. 반주보다는 찾기 쉽지만 어쨌든 사용자가 노래를 찾아와야 한다.

하지만 단점들을 구현하면서 줄여볼 여지가 있었으므로 노래에서 자동으로 반주와 가사를 추출하는 서비스를 만들기로 하였다.

3.2.1. 보컬 분리 - Demucs(Meta)

3.2.1.1. 선정이유

source separation 분야의 성능이 가장 높았기 때문에 선택했습니다.
다른 선택 가능한 모델들도 있었지만 테스트 결과 기대 이상으로 좋은 성능을 보여주어 모델 탐색에 더 시간을 쓰지 않았습니다.

3.2.2. 가사 인식 - Whisper(OpenAI)

3.2.2.1. 선정이유

Whisper 또한 ASR 분야의 SOTA 모델입니다.
아래와 같은 장점이 있습니다.

  1. 공개된 모델 중에서는 학습량이 제일 많아 노래와같이 다양한 억양이나 발음에도 강건
  2. 다국어를 감지할 수 있어 언어 지정 없이도 작동하며 여러 언어가 섞여도 문제 없으며
  3. 트랜스포머를 엔드투엔드로 사용하여 문맥을 고려한 토큰 생성 및 단어 선택으로 반복적인 가사 등장하는 노래에 적합하며
  4. 강제 정렬까지 지원(가사 타이밍 표시에 필요)

3.2.2.2. 출력 오류 개선

Whisper는 STFT를 통해 생성한 스펙트로그램(로그-멜스펙트로그램)을 인풋으로 받아 인코더 디코더를 거쳐 텍스트 토큰으로 전사되는 엔드투엔드 트랜스포머 구조를 사용하고 있습니다.
이 때 인풋으로 받는 스펙트로그램의 최대 길이는 30초인데 이 때문에 노래에서 가사를 추출하려고 하면 아래와 같은 종종 오류가 발생했습니다.

3.2.2.2.1. 가사를 일부 출력하지 않는 오류

노래 전체를 입력으로 넣는 경우 Whisper모델에서는 30초 단위로 분할하여 모델에 입력합니다.
입력 별로 발화 오디오가 아닐 확률(no_speech_prob)을 계산하여 임계값 이상일 경우 출력을 하지 않습니다.
이런 동작은 hallucination을 피하는데 도움을 줄 수 있지만 노래와 같이 전주/간주 중 가사가 없는 구간이 길 경우에 문제가 됩니다.

사례

아이유 - love wins all
전주 길이가 23초입니다. 앞 부분 30초에 해당하는 가사를 출력하지 않았습니다.

23.3 31.80 Dearest, darling, my universe 날 데려가줄래
(실제 출력은 여기부터)
31.80 40.78 나의 이 가난한 상상력으론 떠올릴 수 없는 곳으로
42.1 51.18 저기 멀리 from Earth to Mars 꼭 같이 가줄래
51.18 60.92 그곳이 어디든 오랜 외로움 그 반대말을 찾아서
60.92 72.88 어떤 실수로 이도록 우리는 함께일까

해결

pydub의 detect_nonsilent를 이용해서 음성이 있는 부분만 인풋으로 제공하여 해결했습니다.

3.2.2.2.2. hallucination 현상

Whisper는 디코딩 과정에서도 tranformer를 이용해 텍스트 토큰을 예측하기 때문에 다른 모델과 마찬가지로 hallucination 현상이 발생합니다.
hallucination현상을 완전히 없애려면 모델 자체를 수정해야 하기 때문에 자주 발생하는 상황을 파악하고 최대한 예방하는 것을 목표로 하였습니다.

사례

아래와 같은 출력이 있었습니다.
10.36 10.88 이 노래는 제가 제일 좋아하는 곡입니다.
112.1 138.92 자막 제작에 협조해주신 모든 분들께 감사드립니다.
0.92 0.92 자막 제작: xxx(사람이름)
학습 데이터에 인터넷에 공유된 영화 자막 등을 사용하여 묵음 구간에 잘못된 학습이 발생하지 않았나 추측됩니다.

해결

묵음에서 자주 발생하였으므로 4.2.2.2.1.에서와 마찬가지로 묵음 구간 타임스탬프를 제공하여 묵음이 아닌 구간만 인식하도록 하였습니다.
temperature를 0으로 고정시키고, 출력에서 세그먼트 텍스트의 길이에 비해 너무 짧은 발화시간 등 이상현상을 기계적으로 검출하여 삭제하는 작업을 추가하였습니다.

temperature를 0으로 고정한 이유

whisper는 출력의 confidence가 높지 않은 경우 temperature를 증가시켜 재시도합니다.
이는 인식에 실패하는 경우를 줄여주나, 높은 temperature에서 잘못된 출력을 하여 repeat loop에 빠지게 되면 전체 가사 전사작업이 엉망이 되는 경우가 있었습니다.
따라서 일부 인식에 실패하거나 오인식하는 상황이 있더라도 temperture는 0으로 고정하는 것이 좋겠다고 판단하였습니다.

3.2.2.3. 가사 스크래핑 시도

자작곡 등 가사정보가 존재하지 않는 경우도 있지만 대부분의 노래는 인터넷에 가사 정보가 공개되어 있습니다.
정보가 있다면 사용하는 편이 좋다고 생각해 여러 시도를 해보았지만, 아래 이유로 최종적으로는 사용하지 않았습니다.

  1. 우선 가사가 존재하지 않는 노래도 있음
  2. 가사를 가져오더라도 진짜 원하는 노래의 가사인지 한 번 더 확인이 필요함(중요한 건 아님 충분히 기술적으로 해결 가능. 인식 버전과 유사도를 비교한다든가)
  3. 그러나 가져오더라도 강제정렬이 필요하며, 어차피 강제정렬도 모델의 인식률에 결과물의 퀄리티가 의존함
  4. 모델에 프롬프트로 원본가사를 제공(존재하는 경우)하는 방식은 오히려 언어모델의 오류만 키웠으며, whisper는 텍스트를 제공하여 강제정렬을 할 수 없음
  5. 결론적으로 주어진 정보를 이용할 수 있는 적절한 다른 모델을 찾지 못 해 음성인식으로 가사를 추출하도록 함

3.2.3. 음정 추출 - CREPE

3.2.3.1. 선정이유

음정 추출에서 가장 중요한 이슈는 노이즈 강건성과 옥타브 오류 최소화입니다.
송포유에서는 노래에서 분리된 보컬만을 입력으로 사용하므로 옥타브 오류가 좀 더 중요하다고 볼 수 있습니다.
CREPE는 딥러닝을 이용해 단일 출력이 아닌 다양한 주파수 값 후보에 대한 활성화도를 제공하여 후처리가 더 용이했습니다.

3.2.3.2. 후처리

(CREPE가 출력하는 활성화도. 가로축: 시간, 세로축: 주파수(로그스케일))

활성화도를 토대로 노래방화면에 표시해 줄 음정(각 시간별 주파수)을 추출하게 됩니다.
음성이 확실하지 않은 부분(숨소리, 무성음)에서 번져있는 모습 때문에, 단순히 특정 값 이상으로 필터링을 하게 되면 가사가 없는 구간에도 음정이 추출되거나 그래프가 튀는 현상이 나타났습니다.

frequency = np.max(activation, axis=1)
confidence = np.max(activation, axis=0)

filtered_frequency = np.where(confidence > 0.5, frequency, np.nan)

주로 오류가 발생하는 부분은 시간에 따라 일정하게 주파수가 유지되지 않는다는 점을 이용해 가우시안 필터를 적용한 활성화도를 사용하는 방법으로 개선했습니다.
가우시안 필터의 표준편차는 가로/세로 축에 따라 적절한 값을 적용해 음정 변화의 디테일을 살리면서 필터링할 수 있도록 하였습니다.
(가우시안필터를 적용한 활성화도(좌), 시간축 표준편차를 줄인 활성화도(우))

(원본 출력(좌), 가우시안 필터 및 보정 적용 출력(우))

3.2.4. CELERY

3.2.4.1. 사용 배경

송포유에서는 웹서버, api서버와 AI처리 부분이 분리되어 있는데, 이는 GPU인스턴스가 비싸기 때문에 유연한 인스턴스 관리를 위함입니다.
프로젝트 용으로 받은 aws 크레딧이 1000불이었으므로, 한 달 프로젝트를 진행하기에는 무리가 없었지만
고작 한 번에 2-3분 남짓한 노래 추출을 위해서 시간당 0.526 USD(g4dn.xlarge기준)짜리 인스턴스를 계속 켜놓아야 하는 것이 말도 안 된다고 생각했기 때문입니다.
결국 온디맨드 인스턴스를 계속 실행시켜 놓기는 했지만, 스팟 인스턴스 사용 또는 cpu-gpu 인스턴스간 전환 등 비용 절감 방법을 사용하기 위해서는 AI처리 인스턴스와 분리된 작업 관리자가 필요하다고 판단, CELERY를 사용하였습니다.

단일 GPU 인스턴스를 사용하였으므로 병렬 처리에 따른 이득을 기대하기 어려워 워커는 하나만 생성하도록 하고, 모델을 프리로딩하여 재사용하도록 해 작업 당 처리지연시간 약 80초를 단축하였습니다.

4. 아쉬운 점

많지만 앞서 소개한 내용과 연관있는 몇 가지만 적어보겠습니다

  1. 로직 상 화음 등 실시간으로 상대방의 목소리를 듣고 맞춰 부르는 것은 불가능.
    사용한 로직은 듣는 사람/부르는 사람을 마이크 켬 여부로 구분해 두 그룹 간의 지연만 고려하는 방식입니다.
    여러 사용자가 마이크를 켜게 되면 그 사용자들 간에는 지연이 느껴집니다.
    방식 자체의 한계로 지연 자체를 제거하지 않는 이상 개선이 매우 힘듭니다.
  2. 오프셋 변경 시 자연스러운 배속 조절(fade in/out)이 아닌 고정배속을 사용한 것
    배속 재생 시 발생하는 피치 시프트를 정확하게 보정하는 코드 구현이 지연되어, 저속 빨리감기를 할 경우 피치 시프트가 너무 오랜시간 느껴지는 상황이었습니다.(1.1 배속 사용 시 200ms 오프셋을 적용하기 위해 2초가 필요)
    피치 보정 없이는 차라리 트랜지션 시간을 줄여버리는 게 낫다고 판단하여 고정배속을 하였습니다.
  3. 중복노래 필터링을 하지 못 한 것
    오디오 핑거프린트, 제목 유사도 판단 등 중복 검사를 하지 않기 때문에 중복된 노래 업로드 시 스토리지 등 리소스 낭비가 발생합니다.
  4. 지터딜레이도 네트워크에 포함시켰어야
    지터딜레이도 RTC 연결마다 별도로 발생하고 있으므로 네트워크처럼 데이터 채널로 주고 받으며 네트워크 딜레이처럼 부르는/듣는 사람을 별도로 계산해야 합니다.
    프로젝트 당시 미처 고려하지 못 했습니다.

4.1. 현장 발표 영상에서 멀티 시연 시 두 목소리의 지연차가 느껴지는데요

로직상 허점이 있나
현재 구현은 3.1.3.4.에서 언급한 문제처럼 ①두 사람이 마이크를 켰으며 ②두 사람이 마이크를 끄고 있고 ③마이크를 끈 사람에 대한 지연 시간의 차가 큰 경우(대략 100ms 이상)에 두 사람의 목소리 타이밍에 차이가 있다고 느낄 수 있습니다.
하지만 시연에서는 마이크를 끄고 듣고 있는 사용자가 시연 단말 뿐이었으므로 로직상 지연이 없어야 합니다.

그렇다면 왜
오프셋 계산을 위해서 사용자간 마이크 켬/끔 상태를 공유하고 네트워크 RTT보고 등 여러 데이터를 주고받게 됩니다. 이 과정이 제대로 수행되지 않으면 오프셋이 정확하게 적용되지 않으므로 목소리가 동시에 나오지 않을 수 있습니다.
실제로 시연에서 한 사람의 점수와 음정그래프가 표시되지 않았는데 어떤 이슈로 RTC 데이터 채널에 문제가 생긴 것으로 보이며 때문에 네트워크 RTT나 마이크 상태가 교환되지 않았을 것으로 추정하고 있습니다. 이 부분은 향후 개선이 필요합니다.

profile
알을 깬 개발자

0개의 댓글