나리
전시만드는 방식문의
←블로그 목록
개발 일지2026-07-07LevelUp Piano — MIDI 피아노 연습 앱

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

난이도·조성·기술을 지정하면 교육용 연습곡이 자동 생성·검증되는 파이프라인. LLM에게 ms 대신 마디·박으로 작곡시키고, 검증 실패를 그대로 되먹여 재생성하고, 사람 귀로만 잡히는 '음악성'은 규칙으로 누적한 P1 개발기입니다.

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) 으로 묶여 있습니다.

  • 음이름 → 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에게. 검증은 기계에게, 음악성은 사람 귀에게. 이 경계를 정확히 긋는 일이 파이프라인의 전부였습니다. 곡이 부족하다는 가장 큰 리스크를, 예술이 아니라 엔지니어링으로 푸는 방법이었습니다.

✗ fail → feed the error message straight back to the LLM✓ passspecdifficulty·key·skillLLMsymbolic generationIR (JSON)measures·beats·notesvalidatorenvelope·physics·keycompilerbeats→ms, deterministicNoteChartplay·grade formathuman approvejust listen & approve→ catalog
The AI composition pipeline — give a spec, the LLM writes IR, and only what the validator passes gets deterministically compiled into a NoteChart. Failed songs get their error message fed straight back for regeneration.
6P1 human-ear songs
3difficulty envelopes (Easy·Normal·Hard)
beatsLLM's unit (not ms)
loopvalidation fail → regenerate

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.

ApproachMusical qualityEducational controlCost/infraVerdict
LLM symbolic (notes as text)mid → fixed by a validation loophighest (natural-language spec)API only, cents/song✅ MVP
Dedicated music modelhighlow (conditional gen is hard)GPU hosting burdendeferred (overkill)
Rule-based (procedural)low (drills, but flat)perfectzeroskeleton only
Hybrid (rules + LLM)mid-highhighestAPI onlyfinal 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 }
    ] }
  ]
}
What the LLM writes — measures·beats·note names (human-readable, debuggable)R · E4 · beat 1 · len 2 G4 · beat 3 · len 1 L · C3 · beat 1 · len 4deterministic compile · beats→ms · names→MIDIWhat the code makes — NoteChart (ms·MIDI, machine units)note 64 · start 0ms · dur 1579ms note 67 · start 1579ms · dur 789ms
The same first measure — the top (the LLM's IR) is human-readable and debuggable; the bottom (the compiled result) is the precise time and number the game engine consumes.

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
}
💡 Insight

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 > 12
✅ Tip

A 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.

DifficultyNotes/sec (NPS)Chord ratioMax leapOut-of-keyHand crossing
Easy≤ 2.0≤ 10%≤ 7 semis0%forbidden
Normal≤ 4.5≤ 35%≤ 12 semis≤ 10%allowed
Hard≤ 7.0no 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.

SongStyleHuman-ear verdictLesson
Do-Re-Mi StrollEasy étudefineintro texture verified
Afternoon After RainEasy waltzfine3/4 + color verified
Alberti MorningNormal classicalfine (after fixes)starting point for the consonance check
Midnight GrooveHard funk-popgood rhythm, bad melodyrhythm idiom ≠ melody
Running MorningHard upbeat"an upbeat nursery rhyme"the style ceiling of piano-solo · 2 octaves
Summer FieldNormal Ghibli-esquenearly perfectthe add9 recipe becomes the standard template
🐞 The bug

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.

관람해 주셔서 고맙습니다

© 2026 나리인포메이션(Nari Information). All rights reserved.

사업자등록번호 746-08-03415 · hello@nariinfo.dev

LevelUp PianoMazzucchiVault