Dev Note

가사 싱크가 최대 16초 밀린 이유 — 세지 말고 대조하라

송빗 가라오케 가사 화면

송빗은 AI가 만든 곡을 가라오케처럼 한 줄씩 하이라이트하며 보여줍니다. 그런데 곡 앞부분은 잘 맞다가 뒤로 갈수록 가사가 밀리는 문제가 있었습니다. 그리고 이상하게도, 한국어 곡에서만 심했습니다.

원인은 타임스탬프가 부정확해서가 아니었습니다. 우리가 그 타임스탬프를 가사에 갖다 붙이는 방식이 틀려 있었습니다.

실측: 얼마나 어긋났나

한 곡씩 줄 단위로 오차를 재봤습니다.

언어1초 이상 어긋난 줄최대 오차
한국어40줄 중 24줄 (60%)16.71초
영어46줄 중 3줄

16.71초는 가라오케로서는 완전히 망가진 수준입니다. 그런데 이 표에서 가장 중요한 정보는 최대 오차가 아니라 언어에 따라 증상이 다르다는 사실입니다. 타임스탬프 자체가 부정확했다면 언어를 가리지 않아야 합니다. 언어를 탄다는 건 텍스트를 쪼개는 방식에 문제가 있다는 신호입니다.

원인 1 — 비례식으로 추정하고 있었다

기존 코드는 가사 줄의 시작 시각을 이렇게 구했습니다.

토큰_인덱스 = (지금까지_누적_단어수 / 전체_단어수) × 전체_토큰수

"전체의 30% 지점에 있는 단어라면 토큰도 30% 지점에 있겠지"라는 발상입니다. 이 식은 단어와 토큰이 1:1로 대응한다는 가정 위에서만 성립합니다.

그리고 그 가정을 검증하는 코드는 어디에도 없었습니다. 가정이 깨져도 예외가 나지 않고, 그냥 조금씩 어긋난 값이 나옵니다. 곡이 길수록 오차가 커지는 것도 이 때문입니다. 비율로 계산하니 뒤로 갈수록 벌어집니다.

원인 2 — 토큰 안에 태그가 섞여 있었다

음악 생성 API가 돌려주는 단어별 타임스탬프(alignedWords)를 실제로 열어보면 이렇게 생겼습니다.

{ word: '[Female ',        startS: 0.957 }
{ word: 'Vocalist]\n\n\n',   startS: 1.1   }
{ word: '[Intro]\n하늘을 ',   startS: 1.287 }   ← 섹션 태그 + 첫 가사가 한 토큰
{ word: '나는 ',             startS: 1.8   }

두 가지가 눈에 띕니다.

첫째, word에는 공백과 개행이 그대로 들어 있습니다. 깔끔하게 잘린 단어가 아닙니다.

둘째, [Female Vocalist][Intro] 같은 보컬·섹션 태그가 토큰에 섞여 있고, 심지어 태그와 첫 가사 단어가 같은 토큰에 들어옵니다.

우리 클라이언트는 화면에 표시할 때 이 태그들을 걷어내고 세고 있었습니다. 그래서 클라이언트는 145단어, API는 147토큰. 두 개 차이입니다. 그런데 그 두 개가 비례식을 타고 곡 전체에 퍼집니다.

한국어가 유독 심했던 것도 여기서 설명됩니다. 어절을 쪼개는 방식이 생성 모델의 토크나이저와 더 크게 어긋나서, 단어 수와 토큰 수의 격차가 영어보다 컸습니다.

해결 — 세는 걸 그만두고 대조한다

돌파구는 토큰 배열을 다시 들여다보다 나왔습니다. 토큰의 word들을 순서대로 그냥 이어붙이면 원본 가사가 그대로 복원됩니다.

aligned.map(t => t.word).join('')   // → 원본 가사 전체

즉 토큰 배열은 별개의 데이터가 아니라 가사 문자열을 잘라놓은 것일 뿐입니다. 그렇다면 "이 줄이 몇 번째 단어인가"를 셀 이유가 없습니다. "이 줄이 문자열의 몇 번째 글자에서 시작하는가"를 찾으면 됩니다. 문자 오프셋만 알면 토큰은 역산됩니다.

세는 방식은 앞에서 하나만 어긋나도 뒤가 전부 밀립니다. 대조 방식은 각 줄이 독립적으로 자기 위치를 찾으므로 오차가 누적되지 않습니다.

구현에서 걸린 지점은 셋이었습니다.

1. 공백 정규화와 오프셋 역매핑

토큰의 개행·연속 공백을 하나로 접어야 화면의 가사 줄과 문자열 비교가 됩니다. 그런데 접고 나면 원본 위치를 잃어버립니다. 그래서 정규화하면서 정규화된 인덱스 → 원본 인덱스 지도를 같이 만듭니다.

function normalizeWithOffsets(text: string):
  { text: string; originalOffsets: number[] }

2. 반복되는 후렴

후렴이 세 번 나오는 곡에서 그냥 indexOf를 쓰면 세 줄 모두 첫 번째 위치를 가리킵니다. 그래서 이전 매칭이 끝난 지점을 커서로 들고, 그 뒤에서만 찾습니다.

let matchIndex = normalizedTokens.text.indexOf(line, cursor)
if (matchIndex < 0) matchIndex = normalizedTokens.text.indexOf(line)  // 폴백
...
cursor = matchIndex + line.length

3. 오프셋 → 토큰 인덱스

토큰별 누적 끝 오프셋 배열을 만들어두고 이진 탐색으로 찾습니다. 그 토큰의 startS가 그 줄의 시작 시각입니다.

여기에 방어 두 가지를 더 얹었습니다. 타임스탬프가 뒤집혀 오는 경우가 있어 마지막에 단조 증가로 보정하고, 매칭에 실패한 줄은 전체를 버리지 않고 그 줄만 폴백 값을 유지합니다.

결과

수정 전수정 후
한국어40줄 중 24줄 오차40 / 40 매칭
영어46줄 중 3줄 오차46 / 46 매칭

정렬 로직은 부수 효과 없는 순수 함수로 분리했습니다. 웹은 lib/lyrics-alignment.ts, 앱은 같은 로직을 Dart로 포팅했습니다. 덕분에 같은 입력에 대해 웹과 앱이 같은 값을 내는지를 테스트로 확인할 수 있습니다(첫 줄 1.287초로 양쪽 일치).

테스트는 실제로 문제가 됐던 형태를 그대로 케이스로 만들었습니다 — 태그가 섞인 토큰, 반복 후렴, 역순 타임스탬프, 타임스탬프가 아예 없는 경우.

남는 교훈

"비례해서 추정"은 숨은 가정이다. 두 시퀀스의 길이가 같다는 가정이 식 안에 감춰져 있습니다. 이런 코드는 가정이 깨져도 예외를 던지지 않고 조용히 틀린 값을 냅니다. 최소한 두 길이가 다를 때 로그라도 남겼다면 훨씬 일찍 찾았을 겁니다.

외부 API의 토큰은 내 토크나이저와 다르다. 당연한 말 같지만, 양쪽 개수를 실제로 찍어보기 전까지는 대체로 같다고 가정하고 짭니다. 이어붙였을 때 원본이 복원되는지부터 확인하면 세는 방식 대신 대조 방식으로 갈 길이 열립니다.

언어마다 증상이 다르면 텍스트 분할을 의심하라. 이번 건에서 가장 빨랐던 단서가 "한국어만 심하다"였습니다. 로직이 언어를 탈 이유가 없는데 타고 있다면, 문제는 로직이 아니라 로직에 들어가는 텍스트가 쪼개진 방식입니다.

송빗 — 장르·분위기·키워드만 고르면 AI가 가사부터 보컬 완곡까지 만들어 줍니다. Google Play에서 받기
← 블로그 목록으로