왜 — 곡이 부족하면 다 무너진다
마디 단위 진단도, 간격 반복도, 티어 리그도, 칠 곡이 충분할 때만 의미가 있습니다. 연습할 곡이 수십 곡뿐이면 열심히 만든 학습 루프가 한 달 만에 바닥납니다. 수천 곡을 보유한 대형 학습 앱과 달리, 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 }
] }
]
}음이름은 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
}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검증 실패는 사람의 일이 아니라 다음 프롬프트의 재료다. 에러 메시지를 사람이 읽을 로그가 아니라 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 레시피를 표준 템플릿으로 |
심야 그루브는 리듬이 완벽했는데 멜로디가 "효과음 연속"으로 들렸다. 짧은 리듬 셀만 나열하면 노래가 아니다. 검증기는 이걸 통과시켰다 — 음악성은 기계가 못 잡는다.
그래서 두 갈래로 나눴습니다. 구조는 검증기가 기계적으로 막고, 음악적 품질은 사람 평가에서 얻은 규칙을 문서에 누적합니다. "노래로 부를 수 있어야 한다 — 프레이즈마다 긴 앵커 음(≥1박)을 하나 이상", "순차진행이 기본, 도약은 정점에 한두 번" 같은 규칙이 생성 프롬프트에 그대로 들어갑니다. 그리고 그 규칙의 기계적 그림자(긴 음 비율·순차 비율)를 경고로 띄워, 검수자가 반드시 들어봐야 할 곡을 표시합니다. 새 평가가 나올 때마다 이 규칙은 늘어납니다.
IR이라 테스트하기 쉬웠다
이 파이프라인의 숨은 이점은 테스트 가능성입니다. IR은 순수 데이터(JSON)이고 컴파일은 순수 함수라, 실기기나 오디오 없이 표 테스트로 전부 고정할 수 있습니다. 실제로 파이프라인은 43개 단정(assert) 으로 묶여 있습니다.
- 음이름 → MIDI —
C4 = 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에게. 검증은 기계에게, 음악성은 사람 귀에게. 이 경계를 정확히 긋는 일이 파이프라인의 전부였습니다. 곡이 부족하다는 가장 큰 리스크를, 예술이 아니라 엔지니어링으로 푸는 방법이었습니다.
Why — if songs run out, everything collapses
Measure-level diagnosis, spaced repetition, tier leagues — they only matter when there are enough songs to play. With just a few dozen, a carefully built learning loop runs dry in a month. Unlike large learning apps with thousands of songs, a solo pipeline that uploads one hand-made MIDI at a time can't close that gap.
So the goal:
Specify a difficulty, key, and skill focus, and a NoteChart-compatible educational practice song is auto-generated and validated, with the human only there to listen and approve.
The goal is to drop the marginal cost per song from hours of production to tens of seconds of generation + a short review (P1 still feeds IR by hand, so this saving is a target to verify in P2, not a measured number). As a bonus, generated songs need no copyright negotiation, and they're born with accurate hand labels and measure structure — which even helps diagnosis accuracy.
Picking one of five paths
There are many ways to automate composition: a dedicated music model (Music Transformer et al.), pure rules (chord progressions + pattern libraries), audio generation (Suno-style), and LLM symbolic generation. The deciding criterion wasn't "the best song" but educational control — our songs exist not as art but as practice for a specific skill.
| Approach | Musical quality | Educational control | Cost/infra | Verdict |
|---|---|---|---|---|
| LLM symbolic (notes as text) | mid → fixed by a validation loop | highest (natural-language spec) | API only, cents/song | ✅ MVP |
| Dedicated music model | high | low (conditional gen is hard) | GPU hosting burden | deferred (overkill) |
| Rule-based (procedural) | low (drills, but flat) | perfect | zero | skeleton only |
| Hybrid (rules + LLM) | mid-high | highest | API only | final evolution |
| Audio generation (Suno-style) | high (as audio) | — | — | excluded (need symbolic) |
Conclusion: start the MVP with LLM symbolic, then evolve to a hybrid where rules guarantee the skeleton and the LLM fills in melody within it. For an MVP, what mattered more than a dedicated model's average musical quality was how reliably you can control the target difficulty and skill focus — and the lowest control cost was LLM symbolic.
The core: don't make the LLM do milliseconds
This is the most important design decision. Ask an LLM to emit a song directly in "milliseconds and MIDI numbers," and arithmetic errors pour out. Computing, for every note, exactly how many ms beat 3 is at 76 BPM or which number E4 is — that's asking the LLM to do the very thing it's worst at.
So there's an intermediate representation (IR). The LLM composes like a human would — in measures and beats, with pitches as note names ("E4", "F#3"). The ms conversion and MIDI mapping are handled deterministically, afterward, by the compiler.
{
"title": "Summer Field", "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 }
] }
]
}Note names have a lower LLM error rate than MIDI numbers and, above all, are human-readable and debuggable. You can spot "the left hand in measure 3 is off" with your eyes. The name → MIDI conversion is done deterministically by the compiler, not the LLM.
/// "C4" → 60, "F#3" → 54, "Bb4" → 70. This function does it, not the LLM.
int pitchToMidi(String pitch) {
final m = RegExp(r'^([A-Ga-g])([#b]?)(-?\d+)$').firstMatch(pitch.trim());
if (m == null) throw FormatException('bad pitch: $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
}Use the LLM only for creativity (melody, harmony), and let code handle the error-prone arithmetic (ms and MIDI conversion) deterministically. Splitting the roles this way makes an entire class of timing bugs disappear.
Don't trust — verify: the validator and the regeneration loop
LLM output is not trusted. Every generated IR must pass the validator in full. It checks three broad things:
- Physics — how many keys one hand can press at once (≤5) and how wide (≤a 9th), the key range (C3–C5), no hand-crossing on Easy.
- Musical fit — the ratio of out-of-key notes, and of sustained/repeated vertical dissonance (minor-2nd / major-7th family).
- Difficulty envelope — whether it exceeds the per-difficulty caps (table below).
The important part is how failures are handled. A validation-failure message isn't there for a human to fix — it's there to be handed straight back to the LLM. It states mechanically "which measure, what, and by how much it's off," and that sentence goes into the next generation prompt to regenerate.
── 04_normal_pop.json ──
metrics: nps=5.12 chord=41% leap=14 …
⚠ musicality: melody is mostly short cells (long notes 8%) — add ≥1 anchor long note (≥1 beat) per phrase
✗ 2 validation failures (regeneration feedback):
- difficulty(Normal) violation: NPS 5.12 > 4.5
- difficulty(Normal) violation: max leap 14 semitones > 12A validation failure isn't a human's task — it's material for the next prompt. Design the error message as an instruction the LLM will read, not a log a human will read, and the pipeline converges on its own.
Measured by one ruler — the difficulty envelope
Difficulty isn't a feeling; it's a measurement. Metrics like notes per second (NPS), chord ratio, max leap, and out-of-key ratio have per-difficulty caps, and those caps are shared — as the same constants — by the generation prompt's constraints, the validator, and the progression gate. The one who builds and the one who grades use the same ruler.
| Difficulty | Notes/sec (NPS) | Chord ratio | Max leap | Out-of-key | Hand crossing |
|---|---|---|---|---|---|
| Easy | ≤ 2.0 | ≤ 10% | ≤ 7 semis | 0% | forbidden |
| Normal | ≤ 4.5 | ≤ 35% | ≤ 12 semis | ≤ 10% | allowed |
| Hard | ≤ 7.0 | no limit | ≤ 19 semis | ≤ 10% | allowed |
The Easy row is this constant in code — the generation prompt, the validator, and the progression gate all share this one.
static const easy = DifficultyEnvelope(
maxNps: 2.0, // ≤ 2 notes/sec
maxChordRatio: 0.10, // ≤ 10% simultaneous
maxLeapSemis: 7, // ≤ a perfect fifth
maxAccidentalRatio: 0.0, // strictly in-key
forbidHandCrossing: true, // no hand crossing
);What the machine can't catch — musicality
Here we meet the pipeline's limit. The validator filters out "physically playable, difficulty-appropriate" songs, but it cannot judge a good melody. When I generated 6 songs in P1 and listened by ear, the one repeated weakness was exactly that.
| Song | Style | Human-ear verdict | Lesson |
|---|---|---|---|
| Do-Re-Mi Stroll | Easy étude | fine | intro texture verified |
| Afternoon After Rain | Easy waltz | fine | 3/4 + color verified |
| Alberti Morning | Normal classical | fine (after fixes) | starting point for the consonance check |
| Midnight Groove | Hard funk-pop | good rhythm, bad melody | rhythm idiom ≠ melody |
| Running Morning | Hard upbeat | "an upbeat nursery rhyme" | the style ceiling of piano-solo · 2 octaves |
| Summer Field | Normal Ghibli-esque | nearly perfect | the add9 recipe becomes the standard template |
Midnight Groove had perfect rhythm but a melody that sounded like "a string of sound effects." A sequence of short rhythmic cells isn't a song. The validator passed it — musical quality is something the machine can't catch.
So it split in two. The validator mechanically blocks structural problems, while musical quality is accumulated as rules distilled from human evaluation. Rules like "it must be singable — at least one anchor long note (≥1 beat) per phrase" and "stepwise motion is the default, leaps are for the peak, once or twice" go straight into the generation prompt. And the machine shadows of those rules (long-note ratio, stepwise ratio) surface as warnings that flag which songs a reviewer must listen to. Every new evaluation grows this ruleset.
The IR made it easy to test
A hidden benefit of this pipeline is testability. The IR is pure data (JSON) and the compile is a pure function, so it can all be pinned down with table tests — no device, no audio. The pipeline is in fact held together by 43 assertions.
- Note name → MIDI —
C4 = 60, sharps/flats/low notes, and the key's diatonic set (G major includes F#, not F), fixed as unit tests. - Deterministic compile — nailing down values like "at 4/4 tempo 70, measure 2 beat 1 = 3429ms," so any drift in the time math fails immediately.
- Validator negative cases — the things that must be rejected are pinned as tests: measure overflow, out-of-keyboard-range (D5 = 74 > 72), Easy hand-crossing, Easy out-of-key notes, the Easy leap envelope (an octave jump), vertical dissonance (a sustained E over an F-major chord = a major 7th), a one-hand span beyond a 9th, and the musicality warning (all-short-cell melodies).
A validator is defined less by "what it passes" than by "what it rejects." Pin that rejection list as tests, and the moment you change one rule, any hole you accidentally opened shows up at once.
Now, and what's next
Everything so far is P1 — the offline pipeline (IR → validate → compile → NoteChart) runs, 6 songs were evaluated by ear, and inside the app they were synthesized through a dev-only preview screen for listening.
- P2 (next) — right now IR files are fed in by hand, so metrics like first-pass rate and average regenerations per song don't exist yet. Attaching a Claude API generation step in front of the CLI automates the generate → validate → (on failure) regenerate loop. The success criteria are clear — measure the first-pass rate of songs approvable without human edits and the average regenerations per song, so a single CLI run yields catalog candidates.
- After that (the differentiator) — tie it to measure-and-hand diagnosis to generate personalized practice songs targeting the measures and skills a student misses most. Because generated songs are born with accurate hand labels and measure structure, their diagnosis accuracy is actually higher than uploaded songs.
Closing
"AI composition" tends to conjure "AI makes art," but the hard part was the opposite — how far to let the LLM's creativity run, and where to fence it in with code. Milliseconds to the code, melody to the LLM. Verification to the machine, musicality to the human ear. Drawing that boundary precisely was the whole of the pipeline. It was a way to solve the biggest risk — running out of songs — with engineering rather than art.