실시간 노래방 프로젝트
개요
주요 기능
주요 기술적 도전과 해결
음성 스트리밍 지연 극복:
네트워크 오프셋 계산 및 적용:
성과 및 배운 점
포스터
실시간 게임은 있는데 실시간 노래는 없는 것에 의문을 품으며
같이 부르는 노래방을 만들자는 아이디어로 시작했습니다.
노래를 넣으면 자동으로 노래방으로 만들어주는 편리한 서비스를 기획했습니다.
소개영상: https://youtu.be/eH1V3jkvpnQ
현장발표영상: https://youtu.be/B-ScB_yoydY
주차 | 진행 내용 |
---|---|
0주차 | 개발 기획 |
1주차 | 노래 추가 프로세스, 혼자 부르기 ui, 음정 시각화 개발 |
2주차 | 같이 부르기 ui, webRTC 시그널링, 방 관리, 채팅 등 개발 |
3주차 | 1, 2주차 내용 폴리싱(노래 추가 프로세스 속도 및 안정성 개선, 시각화 속도 개선, RTC 턴 서버 구축, 방관리 로직 개선(노래 부르는 중간에 입장), 모바일 레이아웃 개발) |
4주차 | 개발 마무리 및 버그 수정, 발표 시나리오 및 포스터 준비 |
유튜브 링크를 업로드하면 배경음악부터 가사까지 자동으로 추출합니다.
실시간으로 음정의 정확도를 눈으로 확인하며 부를 수 있습니다.
네트워크 지연이 느껴지지 않도록 음악재생시간에 오프셋을 적용합니다.
오프셋 적용 방식은 마이크를 켠 사람(부르는 사람)과 마이크를 끈 사람(듣는 사람)으로 구분되어 적용됩니다.
마이크를 켜고 끌 때 자동으로 전환되며 최소제곱법으로 최적의 오프셋을 계산합니다.
최초 목표는 초저지연 음성 스트리밍을 통해 같이 부를 수 있는 서비스였습니다.
음성 지연을 점점 늘려가며 시험해본 결과 약 40ms정도부터 지연의 존재를 느끼기 시작했고 100ms가 되면 모두가 지연을 느꼈습니다.
150ms를 넘어가면 확실한 지연을 느꼈습니다.
가능할 것이라고 예상한 이유
오디오를 wav 그대로 전송하는게 아닌 이상 인코딩을 해야하고, 인코딩 윈도우 때문에 일정 지연이 발생합니다.
가장 짧은 opus에서 최소로 지연하는 프레임사이즈는 2.5ms(보통 10ms)
따라서 프레임버퍼링으로 2.5ms, 알고리즘 지연시간 5ms로 잡고, 네트워크 지연으로는 유선 좋은 컨디션 기준 10ms이하라고 가정한다면
마이크 등 하드웨어 지연과 패킷화 등 전송 준비에 들어가는 지연을 감안해도 40ms 안쪽으로 가능할 것이라고 생각했습니다.
달랐던 실제 테스트 결과
하지만 브라우저 테스트 결과 코덱 설정에 한계가 있었습니다.
또 사전에 미처 예상하지 못 했던 오디오 컨텍스트 지연 때문에 네트워크를 제외하고도 약 150ms의 지연이 발생했습니다.
따라서 지터 버퍼로 인한 지연과 네트워크 지연이 없다고 가정해도 초저지연 음성스트리밍은 불가능 하다고 판단했습니다.
기획 수정을 고민하던 중 음성을 앞당길 수 없다면 음악소리를 미루면 되지 않을까하는 아이디어가 떠올랐고 개발해보기로 하였습니다.
목소리와 음악소리의 재생시점을 별도로 조절하기 위해서는 컨텍스트를 분리해야합니다.
또 정밀한 조정을 위해 음악을 가능한 동시에 재생해야하고, 동시 재생을 위해서는 단말들이 공통의 시계를 가져야 합니다.
송포유에서는 공통 시계로 서버 시계를 사용합니다.
구체적인 방법
서버시간을 알아내기 위해 방에 입장하면 클라이언트는 서버에 현재 시간을 요청합니다.
수신한 서버 시간을 이용해 아래와 같이 자신의 시계와 서버 시계와의 차이()를 추정하게 됩니다.
서버시간과 클라이언트시간의 차이
클라이언트 시간
서버 시간
(와 는 동일한 시점에서의 값)
클라이언트시간에 오차가 없다고 가정하면
(는 performance.now()
로 얻은 시간)
서버에서 시간을 받아올 때에도 지연이 발생하므로 발생한 지연()만큼 받아온 시간에 더해주어야 합니다.
(클라이언트가 수신한 서버 시간, 서버 응답이 클라이언트 도달하기까지 지연)
이제 를 어떻게 추정할 것인지 생각해야 합니다.
우선 이기 때문에 RTT가 가장 작은 표본을 대푯값으로 사용하는 방법을 생각해볼 수 있습니다.
즉, 을 사용하는 방법입니다.
이 방법에서 에 대한 잔차는
이고
성질상 이므로 이 됩니다.
즉 를 항상 미만으로 통제가 가능합니다.
하지만 이 방법은 네트워크 지연이 작은 환경에서만 을 낮게 통제할 수 있고 표본의 수를 결정하기 어렵다는 단점이 있었습니다.
을 정확히 알 수 있다면 또한 알 수 있습니다.
하지만 서버에서 클라이언트에 도착하는 시간은 측정하는 것이 어려우므로, 추정값을 사용해야 합니다.
따라서 의 추정문제는 아래와 같이 에 대한 추정문제로 변환할 수 있습니다.
,
서버로의 지연과 클라이언트로의 지연이 거의 같을 것이라고 가정하면 를 사용할 수 있고
따라서 입니다.
이제 표본 들로 를 추정해야 합니다.
의 분포(의 분포)를 가정하는 것은 어려웠습니다.
이기 때문에 표본의 개수가 많지 않은 이상 정규분포로 가정하기 어려웠습니다.
따라서 모수추정을 하지 않는 방법을 선택했습니다.
적당히 나쁜 네트워크 환경에서 를 추출하는 작업을 여러번 반복해 그래프를 그려 보았습니다.
(표본 50개를 정렬하여 표시한 그래프)
여러기기로 테스트 해보았을 때 항상 그래프 상 10-30구간과 같은 평평한 구간이 나타났고, 가장 정확한 서버 시간을 표시했습니다.
따라서 평평한 구간의 중앙값을 으로 사용했습니다.
를 모든 에서 만족할 때
RANGE는 5, MAXERROR는 7
처음에는 RANGE를 고정값으로 둘 경우 중앙에서 많이 벗어난 곳을 평평한 구간으로 판단할까 우려해서 RANGE를 N에 연동시켰습니다. (RANGE는 , MAXERROR는 10)
대체로 잘 작동하였으나 중간 발표 당시 T를 결정하는데 너무 오래 걸려 노래 시작이 안 되는 현상이 일어났습니다.
의 값이 널뛸 수록 표본의 수가 많이 필요할 것이라고 생각해 조건()을 만족하는 수열이 나타날 때까지 서버 시간을 받아오도록 한 것이 문제였습니다.
의 변동이 심하다는 것은 네트워크 상태가 좋지 않다는 것이고, 자연스럽게 의 값도 컸습니다.
그런 상황에서 RANGE가 N에 비례해서 늘어나니 를 계산하는데 시간이 오래 걸렸던 것입니다.
변동이 심한 상황에서는 어차피 평평한 구간이 잘 발생하지 않는다는 것을 확인하고 RANGE를 5로 고정시키고 MAXERROR를 7로 낮췄습니다.
50회 이상 측정이 필요했던 환경에서도 20회 미만에서 측정을 끝낼 수 있었습니다.
두 번째 문제는 각 사용자의 음악을 얼마만큼 미루고 앞당겨야(오프셋) 목소리에 지연(체감지연)이 없다고 느낄 수 있을 지였습니다.
아래와 같은 순서로 시도하였습니다.
발생한 지연 만큼 듣는 사람의 음악 재생을 미루기
듣는 사람이 여럿인 경우 여러명이 지연만큼 미뤄야한다는 단점,
음악과 목소리를 믹싱해서 전송하는 기존의 방식과 본질적으로 같아 특별한 장점이 없음
부르는 사람의 음악 재생을 앞당김
지연시간을 불러오는데 단계가 추가되지만 부르는 한 사람만 음악 재생이 바뀜
사용자 간 지연이 각자 달라 평균을 취하면 잔차가 남는 단점
부르는 사람에 오프셋을 적용하고 각 듣는 사용자에게 남는 잔차만큼 추가적으로 오프셋 적용
각 사용자들이 조금씩 보정을 적용해야 하지만 그 정도를 최소화(3.1.3.5. 최적 오프셋 계산)
하지만 부르는 사람이 둘 이상이 되면 이론상 지연을 0으로 만들 수 없는 상황이 발생
이론상 지연을 0으로 만드려면 인위적으로 지연을 삽입해 지연 차이를 부르는 사람마다 같게 만들어야 함
예시:
부르는 사람 A, B, 듣는 사람 C, D가 있을 때 A의 음성이 C에 도달하는데 걸리는 지연을 라고 하면, 일 때에만 0지연을 만들 수 있다.
그렇지 않으면 목소리 출력에 인위적인 지연을 추가시켜 조건을 만족시켜야 한다.
하지만 스트리밍 음성의 딜레이를 조절하는 것이 어려웠으며
일반적으로 지연 차가 유사하고 또 매우 크지 않은 이상 잔차도 느끼기 힘들기 때문에 큰 문제는 없을 것이라고 판단, 평균을 적용하는 것으로 결정
마이크를 켜는 시점에 듣는 사람/부르는 사람이 전환되며, 적용되는 오프셋이 0에 가까울 수록 불편함을 덜 겪게됩니다.
지연시간이 부르는 사람과 듣는 사람에 나누어 적용되기 때문에 듣는 사람에게 오프셋을 크게(절댓값) 적용하면 부르는 사람에게 적용되는 오프셋이 작아지고, 그 반대의 경우도 마찬가지입니다.
즉, 듣는사람/부르는사람의 오프셋 적용에 사용자 경험의 상충관계가 존재해 어떤 쪽을 더 우선시 할지 정책을 세워야 했습니다.
A가 부르는 사람, B, C가 듣는 사람이라고 할 때
이 프로젝트에서는 1번 방법(듣는 사람의 오프셋 적용을 최소화)을 선택했습니다.
부르는 사람은 오프셋 적용 시점을 예측하기 쉬운 반면, 듣는 사람은 그렇지 않기 때문에 듣는 사람의 경험을 더 우선시 하는 게 좋다고 판단했습니다.
모두를 동등하게 고려하는 경우의 계산 방법 예시입니다.(다른 경우도 계산 방식은 유사)
A가 노래를 부르는 시점부터 B가 A의 목소리를 듣는 시점까지 200ms가 걸린다고 했을 때()
A의 노래를 200ms만큼 앞당기면 B의 체감 지연은 0이 됩니다. ()
또는 A의 노래를 100ms만큼 앞당기고 B의 노래를 100ms만큼 미루면 체감 지연은 0이 됩니다.(, )
즉, 듣는 사람의 음악과 목소리 사이에 체감 지연이 0이되기 위해서는
을 만족해야 합니다.
A가 노래를 부르고 B,C가 듣는다고 했을 때, B, C의체감 지연을 0으로 만들면서도 A, B, C의 오프셋 적용을 최소화(0에 가깝게)하려면
를 만족하는 쌍 중에서
잔차제곱합 를 최소로 하는
즉, 를 만족하는 를 선택해야 함
일반화하면 아래와 같습니다.
챌린지 2에서 다룬 음성을 전송 간 발생하는 지연 외에 오디오 입출력 과정에서 발생하는 지연도 반영해야 합니다.
먼저 지연 발생 요소를 파악해야 했습니다.
노래 목소리가 전달되는 과정을 순서대로 생각해보면
여기서 환경마다 변동이 큰 요소는 5, 6입니다.
블루투스 이어폰 등 무선 기기를 사용한다면 1, 9도 커질 수 있습니다.
따라서 오프셋을 계산하기 위해 아래의 네 가지 state로 분류하여 사용했습니다.
이제 각 지연요소들로 음악 재생을 얼마나 늦출지에 대한 값(오프셋)을 계산해야 합니다.
오프셋이 양수이면 그 수치만큼 음악 재생이 늦춰지고
오프셋이 음수이면 앞당겨집니다.
if (isMicOn) {
setLatencyOffset(
-audioDelay - singerNetworkDelay - optionDelay - playoutDelay
);
} else {
setLatencyOffset(jitterDelay + listenerNetworkDelay);
}
오프셋이 변화할 때 재생시간을 조절해야 했습니다.
실제 재생시간과 목표 재생시간을 비교하여 재생배속을 조절하는 방식을 사용했습니다.
(실제 재생시간 - 목표 재생시간) / (1 - 전환속도) 동안 전환속도를 적용
노래를 시작하고 1초가 지난 상황을 가정해보겠습니다.
노래 시작 시간 = 12:03:00:000(12시 3분)
현재 시간 = 12:03:01:000
실제 재생시간 = 1초
이 때 사용자가 마이크를 켜서 -300ms의 오프셋이 적용되었습니다.
그렇다면
전환속도는 2배속 혹은 0.5배속만 사용
따라서 2배속을 동안 적용하면 오프셋 적용이 완료됩니다.
'노래방'이라는 서비스를 구현하기 위해서는 반주 음원과 가사 데이터가 필요합니다.
반주 음원은 유튜브 등에 많이 있고 가사데이터를 제공하는 서비스도 많습니다.
하지만 사용자는 변수들을 고려하지 않아도 되는 서비스를 원했기 때문에, 웹에서 반주와 가사를 찾아 가져오는 방식은 아래 단점들 때문에 배제했습니다.
반면 노래만 올리고 서버에서 반주와 가사를 모두 생성해준다면
장점은
단점은
하지만 단점들을 구현하면서 줄여볼 여지가 있었으므로 노래에서 자동으로 반주와 가사를 추출하는 서비스를 만들기로 하였다.
source separation 분야의 성능이 가장 높았기 때문에 선택했습니다.
다른 선택 가능한 모델들도 있었지만 테스트 결과 기대 이상으로 좋은 성능을 보여주어 모델 탐색에 더 시간을 쓰지 않았습니다.
Whisper 또한 ASR 분야의 SOTA 모델입니다.
아래와 같은 장점이 있습니다.
Whisper는 STFT를 통해 생성한 스펙트로그램(로그-멜스펙트로그램)을 인풋으로 받아 인코더 디코더를 거쳐 텍스트 토큰으로 전사되는 엔드투엔드 트랜스포머 구조를 사용하고 있습니다.
이 때 인풋으로 받는 스펙트로그램의 최대 길이는 30초인데 이 때문에 노래에서 가사를 추출하려고 하면 아래와 같은 종종 오류가 발생했습니다.
노래 전체를 입력으로 넣는 경우 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를 이용해서 음성이 있는 부분만 인풋으로 제공하여 해결했습니다.
Whisper는 디코딩 과정에서도 tranformer를 이용해 텍스트 토큰을 예측하기 때문에 다른 모델과 마찬가지로 hallucination 현상이 발생합니다.
hallucination현상을 완전히 없애려면 모델 자체를 수정해야 하기 때문에 자주 발생하는 상황을 파악하고 최대한 예방하는 것을 목표로 하였습니다.
아래와 같은 출력이 있었습니다.
10.36 10.88 이 노래는 제가 제일 좋아하는 곡입니다.
112.1 138.92 자막 제작에 협조해주신 모든 분들께 감사드립니다.
0.92 0.92 자막 제작: xxx(사람이름)
학습 데이터에 인터넷에 공유된 영화 자막 등을 사용하여 묵음 구간에 잘못된 학습이 발생하지 않았나 추측됩니다.
묵음에서 자주 발생하였으므로 4.2.2.2.1.에서와 마찬가지로 묵음 구간 타임스탬프를 제공하여 묵음이 아닌 구간만 인식하도록 하였습니다.
temperature를 0으로 고정시키고, 출력에서 세그먼트 텍스트의 길이에 비해 너무 짧은 발화시간 등 이상현상을 기계적으로 검출하여 삭제하는 작업을 추가하였습니다.
whisper는 출력의 confidence가 높지 않은 경우 temperature를 증가시켜 재시도합니다.
이는 인식에 실패하는 경우를 줄여주나, 높은 temperature에서 잘못된 출력을 하여 repeat loop에 빠지게 되면 전체 가사 전사작업이 엉망이 되는 경우가 있었습니다.
따라서 일부 인식에 실패하거나 오인식하는 상황이 있더라도 temperture는 0으로 고정하는 것이 좋겠다고 판단하였습니다.
자작곡 등 가사정보가 존재하지 않는 경우도 있지만 대부분의 노래는 인터넷에 가사 정보가 공개되어 있습니다.
정보가 있다면 사용하는 편이 좋다고 생각해 여러 시도를 해보았지만, 아래 이유로 최종적으로는 사용하지 않았습니다.
음정 추출에서 가장 중요한 이슈는 노이즈 강건성과 옥타브 오류 최소화입니다.
송포유에서는 노래에서 분리된 보컬만을 입력으로 사용하므로 옥타브 오류가 좀 더 중요하다고 볼 수 있습니다.
CREPE는 딥러닝을 이용해 단일 출력이 아닌 다양한 주파수 값 후보에 대한 활성화도를 제공하여 후처리가 더 용이했습니다.
(CREPE가 출력하는 활성화도. 가로축: 시간, 세로축: 주파수(로그스케일))
활성화도를 토대로 노래방화면에 표시해 줄 음정(각 시간별 주파수)을 추출하게 됩니다.
음성이 확실하지 않은 부분(숨소리, 무성음)에서 번져있는 모습 때문에, 단순히 특정 값 이상으로 필터링을 하게 되면 가사가 없는 구간에도 음정이 추출되거나 그래프가 튀는 현상이 나타났습니다.
frequency = np.max(activation, axis=1)
confidence = np.max(activation, axis=0)
filtered_frequency = np.where(confidence > 0.5, frequency, np.nan)
주로 오류가 발생하는 부분은 시간에 따라 일정하게 주파수가 유지되지 않는다는 점을 이용해 가우시안 필터를 적용한 활성화도를 사용하는 방법으로 개선했습니다.
가우시안 필터의 표준편차는 가로/세로 축에 따라 적절한 값을 적용해 음정 변화의 디테일을 살리면서 필터링할 수 있도록 하였습니다.
(가우시안필터를 적용한 활성화도(좌), 시간축 표준편차를 줄인 활성화도(우))
(원본 출력(좌), 가우시안 필터 및 보정 적용 출력(우))
송포유에서는 웹서버, api서버와 AI처리 부분이 분리되어 있는데, 이는 GPU인스턴스가 비싸기 때문에 유연한 인스턴스 관리를 위함입니다.
프로젝트 용으로 받은 aws 크레딧이 1000불이었으므로, 한 달 프로젝트를 진행하기에는 무리가 없었지만
고작 한 번에 2-3분 남짓한 노래 추출을 위해서 시간당 0.526 USD(g4dn.xlarge기준)짜리 인스턴스를 계속 켜놓아야 하는 것이 말도 안 된다고 생각했기 때문입니다.
결국 온디맨드 인스턴스를 계속 실행시켜 놓기는 했지만, 스팟 인스턴스 사용 또는 cpu-gpu 인스턴스간 전환 등 비용 절감 방법을 사용하기 위해서는 AI처리 인스턴스와 분리된 작업 관리자가 필요하다고 판단, CELERY를 사용하였습니다.
단일 GPU 인스턴스를 사용하였으므로 병렬 처리에 따른 이득을 기대하기 어려워 워커는 하나만 생성하도록 하고, 모델을 프리로딩하여 재사용하도록 해 작업 당 처리지연시간 약 80초를 단축하였습니다.
많지만 앞서 소개한 내용과 연관있는 몇 가지만 적어보겠습니다
로직상 허점이 있나
현재 구현은 3.1.3.4.에서 언급한 문제처럼 ①두 사람이 마이크를 켰으며 ②두 사람이 마이크를 끄고 있고 ③마이크를 끈 사람에 대한 지연 시간의 차가 큰 경우(대략 100ms 이상)에 두 사람의 목소리 타이밍에 차이가 있다고 느낄 수 있습니다.
하지만 시연에서는 마이크를 끄고 듣고 있는 사용자가 시연 단말 뿐이었으므로 로직상 지연이 없어야 합니다.
그렇다면 왜
오프셋 계산을 위해서 사용자간 마이크 켬/끔 상태를 공유하고 네트워크 RTT보고 등 여러 데이터를 주고받게 됩니다. 이 과정이 제대로 수행되지 않으면 오프셋이 정확하게 적용되지 않으므로 목소리가 동시에 나오지 않을 수 있습니다.
실제로 시연에서 한 사람의 점수와 음정그래프가 표시되지 않았는데 어떤 이슈로 RTC 데이터 채널에 문제가 생긴 것으로 보이며 때문에 네트워크 RTT나 마이크 상태가 교환되지 않았을 것으로 추정하고 있습니다. 이 부분은 향후 개선이 필요합니다.