Hum to Song은 하나의 약속을 합니다. 바로 당신이 흥얼거린 멜로디가 완성된 노래에 실제로 담긴다는 것입니다. "영감을 받은" 것이 아니며, 같은 분위기의 새로운 멜로디도 아닙니다. 오직 당신의 음표입니다.
파이프라인은 서류상으로는 간단해 보였습니다:
- 브라우저에서 흥얼거린 소리를 노트(notes)와 박자(tempo), 키(key), 그리고 마디별 코드(chord)로 변환합니다 (Spotify의 오픈소스 Basic Pitch 모델 사용).
- 텍스트-투-뮤직 모델(Google의 Lyria 사용)을 사용하여 정확히 그 키, 박자, 코드 루프에 맞는 반주를 요청합니다.
- 그 위에 멜로디를 입힙니다.
문제는 2단계였습니다. 이 모델은 오디오 입력을 받지 못하기 때문에, 유일하게 제어할 수 있는 방법은 프롬프트(prompt)뿐이었습니다. 저희 테스트 시트("Twinkle", A major, 96 BPM)에서 실제 반주를 확인했을 때 다음과 같은 문제들이 나타났습니다:
- 박자: 보통은 정확했지만 항상 그런 것은 아니었습니다 (95와 100을 얻기도 했습니다).
- 키: 3번 중 1번만 따랐습니다.
- 코드: 루프가 종종 자체 마디에서 시작했습니다. 한 반주는 IV 코드로 본체를 열었습니다.
- 인트로: 4마디를 요청했지만, 2마디, 3마디, 심지어 8마디짜리 인트로가 나왔으며, 마지막에는 드럼 필(drum fill)이 포함되는 경우가 많았습니다.
5번째 마디에 A 키, 96 BPM으로 멜로디를 입히자 예상했던 그대로의 결과물이 나왔습니다: 반주와 싸우는 듯한 멜로디였습니다. 그래서 저희는 프롬프트를 신뢰하는 것을 멈추고, 대신 되돌아온 오디오 자체를 측정하기 시작했습니다. 아래 내용은 브라우저의 OfflineAudioContext 내에서 실행되며, 다운로드할 샘플이 없고 매번 동일한 결과를 제공합니다.
1. 박자(Tempo): 요청된 박자 근처의 콤 필터(comb filter)
우리는 온셋 엔벨로프(~10ms 호프)를 계산한 다음, 모든 위상에 대해 모든 비트 위치에서 엔벨로프를 합산하여 후보 박자를 점수화합니다. 검색 범위는 요청된 BPM의 ±8% 이내로 유지하는데, 이는 팝 음악에 대한 자유로운 박자 탐색이 반박자 또는 두 배 속도로 쉽게 고정되기 때문입니다.
function findTempo(f: Features, targetBpm: number) {
const evalBpm = (bpm: number) => {
const period = 60 / bpm / f.frameSec;
...
비트당 점수를 정규화하는 것이 결정적인 해결책이었습니다. 이것 없이는 거의 항상 두 배 속도가 승리했습니다.
2. 본체는 어디서 시작하는가?
강박(downbeat)은 킥(kick)과 코드 변화를 담고 있는 비트(4분음표 기준)이므로, 각 후보 오프셋은 저음 에너지와 그 구간에 걸쳐 크로마가 얼마나 변하는지를 기준으로 점수가 매겨집니다.
인트로의 끝을 찾는 것이 더 어려웠습니다. 명백한 규칙("레벨의 가장 큰 점프")은 실제 반주에서는 실패했습니다. 마지막 인트로 마디에 있는 드럼 필인(drum fill)이 가장 큰 점프였는데, 이는 한 마디 일찍 발생했습니다. 필인은 크지만 저음이 없습니다. 따라서 어떤 마디가 본체(body)의 시작으로 간주되려면 레벨 그리고 킥/베이스 에너지가 노래의 대부분 길이 동안 유지되는 수준에 도달해야 합니다:
if (level(k) >= 0.8 && barLow(k) >= 0.75 * lowRef
&& quietSoFar / k < 0.65 && level(k + 1) >= 0.7) {
introBars = k;
...
lowRef와 레벨 참조는 노래 중간 부분의 마디들에 대한 중앙값(medians)이므로, 크고 화려한 마지막 코러스가 이 값들을 왜곡하지 않습니다.
3. 키(Key): 모든 12가지 방식으로 피치 클래스 상관관계 분석
음표 길이에 가중치를 부여한 멜로디의 피치 클래스(pitch classes)를 가져와 −6…+5 반음(semitones)을 거쳐 회전시키고, 각 회전을 본체에 걸친 반주의 평균 크로마와 상관관계 분석합니다. 요청된 키는 동점자 처리(tie-breaker)를 위해 아주 작은 0.02 보너스를 받습니다. 가장 좋은 회전이 바로 조바꿈(transposition)입니다.
이것이 완성된 노래가 당신이 흥얼거린 것과 몇 반음 정도 차이가 날 수 있는 이유입니다. 밴드와 충돌하는 것보다는 당신의 곡을 조바꿈 시키는 것을 선호합니다.
4. 코드(Chords): 반음당 하나의 Goertzel 필터
모델은 우리가 요청한 코드 대신 자체 코드를 연주하는 경우가 많습니다. 곡이 충돌하지 않도록 하려면 밴드가 매 반마디마다 무엇을 연주하는지 알아야 하는데, 여기서 분석 FFT(Fast Fourier Transform)가 우리를 실망시켰습니다. 11 kHz에 1024 샘플 프레임으로 설정했을 때, 하나의 빈(bin)은 약 11 Hz 폭이며, 이는 A3 근처 아래 이웃 음 사이의 간격보다 넓었습니다.
그래서 하모니 맵(harmony map)은 E2부터 C6까지 반음당 하나의 Goertzel 필터를 사용합니다. Goertzel은 단일 빈 이산 푸리에 변환(DFT): 약 45개의 특정 주파수만 필요할 때 저렴하며, 각 음에 정확하게 조정될 수 있습니다.
45개의 음을 12개 피치 클래스로 접고(Fold), 최대값 = 1로 정규화하여 하프 바당 크로마 벡터를 얻습니다.
5. 구절 이동 후 실제 불협화음만 수정하기
마지막 두 가지 움직임:
- 구절 이동 (Phrase shift): 멜로디가 밴드가 등장하는 마디나 그로부터 최대 3마디 뒤에서 시작하도록 시도합니다. 멜로디의 지속 시간 중 얼마나 많은 부분이 밴드가 연주하는 피치 클래스에 놓이는지를 점수화합니다. 최소 5% 더 좋을 때만 이동시키고, 그렇지 않으면 자연스러운 진입을 유지합니다.
- 불협화음 수정 (Clash fix): 음이 움직이는 경우는 해당 음의 피치 클래스가 밴드에서 거의 부재하고 (< 0.3) 그리고 한 반음 떨어진 음이 강하게 울릴 때 (≥ 0.55)입니다. 그러면 그 음은 한 반음 방향으로 이동합니다. 온음계 이웃(Whole-tone neighbours)은 그대로 두기 때문에 지나가는 음(passing notes)은 살아남습니다. 단지 단2도 간의 마찰(minor-second rubs)만 수정됩니다.
실제 배경 음악을 사용했을 때, 이러한 단계들은 멜로디와 밴드 사이의 협화성(consonance)을 0.50에서 0.63으로 끌어올렸습니다. 60개의 검사를 포함하는 단위 테스트 스크립트가 수학적 계산을 고정하여 다음 수정이 조용히 이를 무효화하지 못하도록 합니다.
생성형 오디오(generative audio)를 기반으로 작업할 사람에게 전하고 싶은 말
- 모델의 출력을 계약서가 아닌 측정 데이터로 취급하세요. 프롬프트는 요청일 뿐입니다. 오디오가 진실입니다.
- 간단한 프롬프트가 기발한 프롬프트보다 낫습니다. 상세한 섹션별 타임라인보다 단순한 코드 루프(