Nari

LevelUp Piano — MIDI 피아노 연습 앱 · 블로그

LLM을 이용한 곡 만들기 — 연습곡을 무한 생산하는 AI 작곡 파이프라인

블로그 목록
개발 일지2026-07-07
AILLM작곡파이프라인
✗ 실패 → 실패 메시지를 그대로 LLM에 되먹임✓ 통과사양난이도·조성·기술LLM심볼릭 생성IR (JSON)마디·박·음이름검증기봉투·물리성·조성컴파일러박→ms 결정적NoteChart게임 재생·채점 형식사람 승인듣고 승인만→ 카탈로그
AI 작곡 파이프라인 — 사양을 주면 LLM이 IR을 쓰고, 검증기가 통과시킨 것만 결정적으로 컴파일해 NoteChart로. 실패한 곡은 에러 메시지를 그대로 되먹여 재생성한다.
6P1 사람 귀 평가곡
3난이도 봉투 (Easy·Normal·Hard)
마디·박LLM 작곡 단위 (ms 아님)
루프검증 실패 → 재생성

왜 — 곡이 부족하면 다 무너진다

마디 단위 진단도, 간격 반복도, 티어 리그도, 칠 곡이 충분할 때만 의미가 있습니다. 연습할 곡이 수십 곡뿐이면 열심히 만든 학습 루프가 한 달 만에 바닥납니다. 수천 곡을 보유한 대형 학습 앱과 달리, 1인 제작으로 MIDI를 한 곡씩 만들어 올리는 파이프라인으로는 이 격차를 좁힐 수 없습니다.

그래서 목표를 이렇게 잡았습니다.

난이도·조성·기술 요소를 지정하면, NoteChart 호환 교육용 연습곡이 자동으로 생성·검증되고, 사람은 듣고 승인만 하는 파이프라인.

목표는 곡당 제작 비용을 수동 제작 몇 시간에서 생성 수십 초 + 짧은 검수로 낮추는 것입니다(P1은 아직 IR을 손으로 넣는 단계라, 이 절감은 측정값이 아니라 P2에서 검증할 목표입니다). 게다가 생성곡은 저작권 협상이 필요 없고, 손 라벨·마디 구조를 처음부터 정확히 갖고 태어나므로 진단 정확도 면에서도 유리합니다.

다섯 갈래에서 하나를 고르다

작곡을 자동화하는 길은 여럿입니다. 전용 음악 생성 모델(Music Transformer류), 순수 규칙 기반(코드진행+패턴), 오디오 생성(Suno류), 그리고 LLM 심볼릭 생성. 핵심 기준은 "더 좋은 곡"이 아니라 교육적 제어였습니다 — 우리 곡의 존재 이유는 예술이 아니라 특정 기술의 연습이니까요.

방식음악 품질교육 제어비용/인프라판정
LLM 심볼릭 (음표를 텍스트로)중 → 검증 루프로 보정최상 (자연어 지시)API만, 곡당 수 원✅ MVP 채택
전용 음악 모델하 (조건부 생성 난이도↑)GPU 호스팅 부담보류 (과잉)
규칙 기반(절차적)하 (드릴은 되나 밋밋)완벽0골격 생성에만
하이브리드 (규칙+LLM)중상최상API만최종 진화 형태
오디오 생성(Suno류)상(오디오로서)제외 (심볼릭 필요)

결론: LLM 심볼릭으로 MVP를 시작하고, 규칙이 골격을 보장하고 LLM이 그 안에서 멜로디를 채우는 하이브리드로 진화한다. MVP 기준에서는 전용 모델의 평균 음악 품질보다 원하는 난이도·기술 요소를 얼마나 안정적으로 통제하느냐가 더 중요했고, 제어 비용이 가장 낮은 쪽이 LLM 심볼릭이었습니다.

핵심: LLM에게 ms를 계산시키지 마라

가장 중요한 설계 결정입니다. LLM에게 곡을 직접 "밀리초와 MIDI 번호"로 뽑게 하면, 그 순간 산수 오류가 쏟아집니다. 76 BPM에서 3박째가 몇 ms인지, E4가 몇 번 노트인지를 매 음마다 정확히 계산하라는 건 LLM에게 가장 취약한 일을 시키는 것입니다.

그래서 중간 표현(IR)을 두었습니다. LLM은 사람 작곡가처럼 마디·박 단위로, 음은 **음이름("E4", "F#3")**으로 씁니다. ms 변환과 MIDI 매핑은 컴파일러가 뒤에서 결정적으로 처리합니다.

{
  "title": "여름 들판", "key": "C", "tempo_bpm": 76,
  "time_signature": [4, 4], "difficulty": "Normal",
  "measures": [
    { "n": 1, "notes": [
      { "hand": "R", "pitch": "E4", "beat": 1, "len": 2 },
      { "hand": "R", "pitch": "G4", "beat": 3, "len": 1 },
      { "hand": "R", "pitch": "A4", "beat": 4, "len": 1 },
      { "hand": "L", "pitch": "C3", "beat": 1, "len": 4 }
    ] }
  ]
}
LLM이 쓰는 것 — 마디·박·음이름 (사람이 읽고 고칠 수 있음)R · E4 · beat 1 · 2박 G4 · beat 3 · 1박 L · C3 · beat 1 · 4박결정적 컴파일 · 박→ms · 음이름→MIDI코드가 만드는 것 — NoteChart (ms·MIDI, 기계 단위)note 64 · start 0ms · dur 1579ms note 67 · start 1579ms · dur 789ms
같은 첫 마디 — 위(LLM의 IR)는 사람이 읽고 디버깅할 수 있고, 아래(컴파일 결과)는 게임 엔진이 쓰는 정밀 시간·번호다.

음이름은 MIDI 번호보다 LLM 오류율이 낮고, 무엇보다 사람이 읽고 디버깅할 수 있습니다. "3번 마디 왼손이 이상하다"를 눈으로 잡을 수 있죠. 음이름 → MIDI 변환은 LLM이 아니라 컴파일러가 결정적으로 합니다.

/// "C4" → 60, "F#3" → 54, "Bb4" → 70. LLM이 아니라 이 함수가 한다.
int pitchToMidi(String pitch) {
  final m = RegExp(r'^([A-Ga-g])([#b]?)(-?\d+)$').firstMatch(pitch.trim());
  if (m == null) throw FormatException('잘못된 음이름: $pitch');
  const base = {'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11};
  var semis = base[m.group(1)!.toUpperCase()]!;
  if (m.group(2) == '#') semis += 1;
  if (m.group(2) == 'b') semis -= 1;
  return (int.parse(m.group(3)!) + 1) * 12 + semis; // C4 = 60
}
💡 Insight

LLM은 창의(멜로디·화성)에만 쓰고, 틀리기 쉬운 산수(ms·MIDI 변환)는 코드가 결정적으로 처리한다. 역할을 이렇게 나누는 것만으로 시간축 버그가 통째로 사라진다.

믿지 말고 검증하라 — 검증기와 재생성 루프

LLM 출력은 신뢰하지 않습니다. 생성된 IR은 검증기를 전수 통과해야 합니다. 검증기는 크게 세 가지를 봅니다.

  • 물리성 — 한 손이 동시에 누를 수 있는 건반 수(≤5)와 폭(≤9도), 건반 범위(C3~C5), Easy에서의 손 교차 금지.
  • 음악 정합 — 조성 밖 음 비율, 지속·반복되는 수직 불협(단2도·장7도 계열)의 비율.
  • 난이도 봉투 — 난이도별로 정해둔 상한(아래 표)을 넘는지.

여기서 중요한 건 실패를 처리하는 방식입니다. 검증 실패 메시지는 사람이 고치라고 있는 게 아니라, LLM에게 그대로 돌려주기 위해 있습니다. "어느 마디에서 무엇이 얼마나 벗어났는지"를 기계적으로 명시해, 그 문장을 다음 생성 프롬프트에 붙여 재생성시킵니다.

── 04_normal_pop.json ──
  metrics: nps=5.12 chord=41% leap=14 …
  ⚠ 음악성: 멜로디가 짧은 셀 위주(긴 음 8%) — 앵커가 되는 긴 음(≥1박)을 프레이즈마다 1개 이상
  ✗ 검증 실패 2건 (재생성 피드백):
    - 난이도(Normal) 위반: NPS 5.12 > 4.5
    - 난이도(Normal) 위반: 최대 도약 14반음 > 12
Tip

검증 실패는 사람의 일이 아니라 다음 프롬프트의 재료다. 에러 메시지를 사람이 읽을 로그가 아니라 LLM이 읽을 지시로 설계하면, 파이프라인이 스스로 수렴한다.

하나의 자로 잰다 — 난이도 봉투

난이도는 느낌이 아니라 측정값입니다. 초당 음 수(NPS), 동시음 비율, 최대 도약, 조성 밖 음 비율 같은 지표에 난이도별 상한을 두고, 이 상한을 생성 프롬프트의 제약과 검증기와 진급 게이트가 같은 상수로 공유합니다. 만드는 자와 채점하는 자가 같은 자를 쓰는 셈입니다.

난이도초당 음(NPS)동시음 비율최대 도약조성 밖 음손 교차
Easy≤ 2.0≤ 10%≤ 7반음0%금지
Normal≤ 4.5≤ 35%≤ 12반음≤ 10%허용
Hard≤ 7.0제한 없음≤ 19반음≤ 10%허용

표의 Easy 행은 코드에서 이런 상수입니다 — 생성 프롬프트·검증기·진급 게이트가 이 하나를 공유합니다.

static const easy = DifficultyEnvelope(
  maxNps: 2.0,              // 초당 ≤ 2음
  maxChordRatio: 0.10,      // 동시음 ≤ 10%
  maxLeapSemis: 7,          // 도약 ≤ 완전5도
  maxAccidentalRatio: 0.0,  // 조성 밖 음 금지
  forbidHandCrossing: true, // 손 교차 금지
);

기계가 못 잡는 것 — 음악성

여기서 파이프라인의 한계와 만납니다. 검증기는 "물리적으로 칠 수 있고 난이도에 맞는 곡"은 걸러내지만, 좋은 멜로디는 판정하지 못합니다. P1에서 6곡을 만들어 사람 귀로 들어봤을 때, 유일하게 반복된 약점이 정확히 그 지점이었습니다.

스타일사람 귀 평가교훈
도레미 산책Easy 에튀드문제없음입문 텍스처 검증
비 갠 오후Easy 왈츠문제없음3박·색채 검증
알베르티 아침Normal 고전문제없음(수정 후)수직 협화 검사의 출발점
심야 그루브Hard 펑크팝리듬 좋음·멜로디 나쁨리듬 어법 ≠ 멜로디
달리는 아침Hard 신나는"신나는 동요"피아노 솔로·2옥타브의 스타일 상한
여름 들판Normal 지브리풍거의 완벽add9 레시피를 표준 템플릿으로
🐞 The bug

심야 그루브는 리듬이 완벽했는데 멜로디가 "효과음 연속"으로 들렸다. 짧은 리듬 셀만 나열하면 노래가 아니다. 검증기는 이걸 통과시켰다 — 음악성은 기계가 못 잡는다.

그래서 두 갈래로 나눴습니다. 구조는 검증기가 기계적으로 막고, 음악적 품질은 사람 평가에서 얻은 규칙을 문서에 누적합니다. "노래로 부를 수 있어야 한다 — 프레이즈마다 긴 앵커 음(≥1박)을 하나 이상", "순차진행이 기본, 도약은 정점에 한두 번" 같은 규칙이 생성 프롬프트에 그대로 들어갑니다. 그리고 그 규칙의 기계적 그림자(긴 음 비율·순차 비율)를 경고로 띄워, 검수자가 반드시 들어봐야 할 곡을 표시합니다. 새 평가가 나올 때마다 이 규칙은 늘어납니다.

IR이라 테스트하기 쉬웠다

이 파이프라인의 숨은 이점은 테스트 가능성입니다. IR은 순수 데이터(JSON)이고 컴파일은 순수 함수라, 실기기나 오디오 없이 표 테스트로 전부 고정할 수 있습니다. 실제로 파이프라인은 43개 단정(assert) 으로 묶여 있습니다.

  • 음이름 → MIDIC4 = 60, 샤프·플랫·저음, 조성 다이어토닉(G장조에 F# 포함·F 미포함)을 단위 테스트로 고정.
  • 결정적 컴파일 — "4/4·tempo 70에서 마디2 박1 = 3429ms" 같은 값을 못박아, 시간 계산이 흔들리면 즉시 실패.
  • 검증기 음성(negative) 케이스 — 반드시 거부되어야 하는 것들을 테스트로 박아둡니다: 마디 넘침, 건반 범위 밖(D5=74 > 72), Easy 손 교차, Easy 조성 밖 음, Easy 도약 봉투(옥타브 점프), 수직 불협(F장화음 위 E 지속 = 장7도), 한 손 동시음 폭 9도 초과, 그리고 음악성 경고(짧은 셀 일변도).

검증기는 "무엇을 통과시키는가"보다 "무엇을 거부하는가"로 정의됩니다. 그 거부 목록을 테스트로 고정해두면, 규칙을 하나 바꿨을 때 의도치 않게 뚫린 구멍이 바로 드러납니다.

지금, 그리고 다음

지금까지가 P1입니다 — 오프라인 파이프라인(IR → 검증 → 컴파일 → NoteChart)이 돌고, 6곡을 사람 귀로 평가했고, 앱 안에는 dev 전용 미리듣기 화면으로 합성해 들어봤습니다.

  • P2 (다음) — 지금은 IR 파일을 손으로 넣기 때문에 1차 통과율·평균 재생성 횟수 같은 지표는 아직 없습니다. 여기에 Claude API 생성 단계를 CLI 앞단에 붙여 생성 → 검증 → (실패 시) 재생성 루프를 자동화합니다. 성공 기준은 명확합니다 — 사람 수정 없이 승인 가능한 곡의 1차 통과율곡당 평균 재생성 횟수를 측정해, CLI 한 번으로 카탈로그 후보가 나오게 하는 것.
  • 그 다음 (차별점) — 마디·손 단위 학습 진단과 연동해, 학생이 자주 틀리는 마디·기술을 겨냥한 맞춤 연습곡을 생성합니다. 생성곡은 손 라벨과 마디 구조를 처음부터 정확히 갖고 태어나므로, 업로드 곡보다 진단 정확도가 오히려 높습니다.

마치며

AI 작곡이라고 하면 "AI가 예술을 한다"를 떠올리기 쉽지만, 실제로 어려웠던 건 정반대였습니다 — LLM의 창의를 어디까지 풀어주고, 어디부터 코드로 가두느냐. ms는 코드에게, 멜로디는 LLM에게. 검증은 기계에게, 음악성은 사람 귀에게. 이 경계를 정확히 긋는 일이 파이프라인의 전부였습니다. 곡이 부족하다는 가장 큰 리스크를, 예술이 아니라 엔지니어링으로 푸는 방법이었습니다.