앞서 LevelUp Piano가 LLM으로 연습곡을 만드는 방법을 글로 썼다. 그건 설계 이야기였다 — 파이프라인의 왜와 모양. 하지만 실제로 실행해볼 수 있는 코드는 없었다.
그래서 핵심을 오픈소스 도구로 떼어냈다: etude-gen. 명령어 한 줄이 사양(spec)을 검증된, 연주 가능한 MIDI 파일로 바꾼다. 작곡에는 OpenAI API를, 그걸 실체로 만드는 데엔 평범한 결정적 코드를 쓴다.
npx tsx src/cli.ts --difficulty easy --key C --technique "left-hand arpeggios"하지만 "실행 가능하다"는 것만으로 두 번째 글을 쓸 이유가 되진 않는다. 진짜 이유는, 피아노를 걷어내고 나니 남은 것이 음악과 아무 상관 없는 패턴이었기 때문이다 — 그리고 그건 언어 모델에서 신뢰할 수 있는 출력을 얻는 일과 전적으로 관련이 있다.
훔쳐 갈 가치가 있는 단 하나의 아이디어
LLM은 잘하는 상징적 언어로 일하게 하라. 정확해야 하는 모든 것은 결정적 코드로 밀어내라. 그 사이를 검증-재생성 루프로 이어라.
이게 전부다. 아래는 저 한 문장을 구체화한 것에 불과하다.
1부 — 모델은 스키마 아래에서 작곡하게
순진한 접근은 완성품을 바로 달라고 하는 것이다 — 밀리초, MIDI 번호, 전부. 그러면 산수 오류가 쏟아진다. "84 BPM에서 3박째가 몇 ms인가?"는 LLM이 매 토큰마다 가장 못하는 종류의 계산이다.
그래서 모델은 밀리초를 아예 보지 않는다. 사람 작곡가가 생각하는 방식대로 — 마디, 박, 음이름으로 — **중간 표현(IR)**을 쓰고, 나는 그 모양을 OpenAI Structured Outputs로 강제한다. Zod 스키마가 곧 계약서다:
const NoteSchema = z.object({
hand: z.enum(["L", "R"]),
pitch: z.string().describe('음이름, 예: "E4", "F#3"'),
beat: z.number().describe("1-기반 박 위치, 소수 허용"),
len: z.number().describe("박 단위 길이"),
});
const SongIRSchema = z.object({
title: z.string(),
key: z.string(),
tempo_bpm: z.number(),
time_signature: z.object({ beats: z.number().int(), unit: z.number().int() }),
difficulty: z.enum(["easy", "normal", "hard"]),
measures: z.array(z.object({ n: z.number().int(), notes: z.array(NoteSchema) })),
});
const completion = await client.beta.chat.completions.parse({
model: "gpt-5.5",
messages,
response_format: zodResponseFormat(SongIRSchema, "song"),
});
const song = completion.choices[0].message.parsed; // 타입 보장, 모양 보장파싱도, "JSON으로 답해주세요"도, 정규식 복구도 없다. 응답은 이미 타입에 맞춰 돌아온다. 덕분에 모델은 형식이 아니라 창의적인 부분 — 멜로디 — 에 힘을 쓸 수 있다.
2부 — 검증기가 곧 사양이고, 그 오류는 프롬프트다
Structured Outputs는 모양을 보장하지 결코 의미를 보장하지 않는다. 모델이 마디를 넘치는 음을 쓰거나, 어떤 손도 못 짚는 화음을 쓰거나, 조성을 벗어나 헤매는 것을 막는 건 아무것도 없다. 그래서 모든 IR은 신뢰되기 전에 검증기를 통과해야 한다.
연습곡 생성기에서 "올바르다"는 건 연주 가능하고 동시에 교육적으로 적절하다는 뜻이다. 그건 **난이도 봉투(envelope)**로 인코딩된다 — 출력을 단지 음악이 아니라 교육적으로 만드는 제약이다:
| easy | normal | hard | |
|---|---|---|---|
| 마디당 최대 음 수 | 6 | 12 | 24 |
| 한 손 동시 최대 음 수 | 1 | 2 | 4 |
| 최소 음 길이 (박) | 1 | 0.5 | 0.25 |
여기에 모든 난이도 공통으로: 손별 음역, 실제로 딱 맞아떨어지는 마디, 조성 이탈 음 20% 이하, 연주 가능한 템포 범위.
전체를 수렴시키는 결정적 한 수는 이것이다: 검증 오류는 사람이 아니라 모델을 위해 쓴다.
measure 1, note 1 (E4): beat 4.5 + len 1 overflows a 4/4 measure
measure 1: R hand plays 2 simultaneous notes, exceeding the easy limit of 1
measure 2, note 1 (H9): "H9" is not a valid pitch name각 줄이 어디서, 무엇이, 얼마나 어긋났는지를 말한다. 이건 로그가 아니라 지시문이다. 그대로 다음 프롬프트로 들어간다.
검증기의 오류 메시지를 사람이 읽을 로그가 아니라 모델이 읽을 지시문으로 써라. 실패가 스스로를 설명하면, 재생성 루프가 저절로 닫힌다 — 디버깅은 당신이 아니라 파이프라인이 한다.
3부 — 모든 산수를 결정적 코드로 밀어내기
IR이 통과한 뒤에야 결정적 절반이 돈다. 컴파일러는 모델이 결코 건드리지 못하게 한 모든 계산을 한다: 박은 틱이 되고, 틱은 밀리초가 되고, 음이름은 MIDI 번호가 된다.
const ticksPerBeat = TPQN * (4 / unit);
const startTick = Math.round(((measure.n - 1) * beats + (note.beat - 1)) * ticksPerBeat);
const startMs = Math.round(startTick * (60_000 / (tempoBpm * TPQN)));같은 IR을 넣으면 언제나 같은 바이트가 나온다. 제로 의존성 Standard MIDI File 인코더가 .mid를 쓰고, 나란히 만들어지는 노트차트가 게임 엔진이 소비할 밀리초 타이밍을 담는다.
모호하고 창의적인 일은 모델에게 그의 모국어로, 정확해야 하는 모든 것은 평범한 코드에게. 이 경계를 분명히 그으면 "LLM이 계산을 틀렸다"는 부류의 버그가 통째로 사라진다.
한 함수 안의 루프
Structured Outputs(모양) + 검증기(의미) + 결정적 컴파일러(정확성)는, 실패 경로를 모델로 되돌려 연결할 때 비로소 파이프라인이 된다:
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const song = (await client.beta.chat.completions.parse({ ... })).choices[0].message.parsed;
const errors = validate(song);
if (errors.length === 0) return { song, attempts: attempt };
messages.push(
{ role: "assistant", content: JSON.stringify(song) },
{ role: "user", content: `That song failed validation:\n${errors.map(e => `- ${e}`).join("\n")}\n\nRegenerate the complete song with every issue fixed.` },
);
}생성 → 검증 → 실패를 되먹임 → 재생성. 모델은 사람이 아니라 확고한 오라클(oracle)을 상대로 스스로를 교정한다.
30초 만에 돌려보기
결정적 절반이 도는 걸 보는 데엔 키조차 필요 없다 — 동봉된 예제 IR이 오프라인으로 컴파일된다:
git clone https://github.com/devyourown/etude-gen && cd etude-gen && npm install
npm run generate -- --from-ir examples/meadow-morning.json # API 키 불필요키가 있으면 전체 루프가 돈다. 방금 실제로 --difficulty easy --key C로 돌리니 Morning Brook이 나왔다 — 8마디, 47음, 걷는 듯한 오른손 멜로디 아래 왼손 C장조 분산화음 반주 — 검증을 통과하고 GarageBand에 바로 넣을 수 있는 MIDI 파일로 컴파일됐다.
왜 이것이 음악과 무관한가
도메인만 바꾸면 골격은 그대로다. 모델이 하드 제약을 동시에 만족해야 하는 창의적 산출물을 내야 하는 곳이라면 어디든, 같은 세 부분이 적용된다:
| 도메인 | 모델이 쓰는 상징적 IR | 검증기가 강제하는 것 | 컴파일러가 하는 일 |
|---|---|---|---|
| 음악 (이 글) | 마디, 박, 음이름 | 음역, 폴리포니, 조성 | 박 → ms, MIDI 바이트 |
| 일정 관리 | 상대 순서·소요시간을 가진 작업 | 겹침 없음, 마감 준수 | 실제 시각으로 해석 |
| UI 레이아웃 | 의도를 담은 컴포넌트 트리 | 그리드 적합, 대비, 접근성 | 픽셀 좌표 / CSS 출력 |
| 게임 레벨 | 방, 문, 열쇠 | 풀 수 있음, 도달 가능, 공정 | 타일맵 좌표 |
| SQL / 설정 | 제약된 DSL로 표현된 의도 | 스키마, 허용 컬럼, 안전성 | 정확한 쿼리 / 파일 렌더 |
교훈은 깔끔하게 일반화된다: 모델에게 정확하라고 요구하지 마라 — 당신이 검사할 수 있는 언어로 창의적이 되라고 하고, 정확함은 코드가 대신하게 하라.
마치며
원래 글은 실제 제품 문제 — 곡이 바닥나지 않게 하기 — 를 푼 이야기였다. 이 글은 그 아래에 있던 것을, 누구나 집어 쓸 수 있게 추출한 것이다 — 작고 MIT 라이선스인 OpenAI API 샘플, 그리고 내가 계속 다시 꺼내 쓰는 패턴. 밀리초는 코드에게, 멜로디는 모델에게, 검증은 스스로 닫히는 루프에게.
리포지토리: github.com/devyourown/etude-gen.
Earlier I wrote about how LevelUp Piano generates practice songs with an LLM. That was a design story — the why and the shape of the pipeline — but there was no code you could actually run.
So I pulled the core out into an open-source tool: etude-gen. One command turns a spec into a validated, playable MIDI file, using the OpenAI API to compose and plain deterministic code to make it real.
npx tsx src/cli.ts --difficulty easy --key C --technique "left-hand arpeggios"But a tool being runnable isn't why it deserves a second post. The reason is that once I stripped away the piano, what was left was a pattern that has nothing to do with music — and everything to do with getting trustworthy output out of a language model.
The one idea worth stealing
Let the LLM work in the symbolic vocabulary it's fluent in. Push everything that must be exactly right into deterministic code. Bridge the two with a validate-and-repair loop.
That's the whole thing. Everything below is just that sentence, made concrete.
Part 1 — Let the model compose, under a schema
The naive approach is to ask the model for the finished artifact directly: milliseconds, MIDI note numbers, the works. Do that and arithmetic mistakes pour out — "which millisecond is beat 3 at 84 BPM?" is exactly the kind of per-token math LLMs are worst at.
So the model never sees a millisecond. It writes an intermediate representation the way a human composer thinks — in measures, beats, and note names — and I enforce that shape with OpenAI Structured Outputs. The Zod schema is the contract:
const NoteSchema = z.object({
hand: z.enum(["L", "R"]),
pitch: z.string().describe('Scientific pitch name, e.g. "E4", "F#3"'),
beat: z.number().describe("1-based beat position; fractions allowed"),
len: z.number().describe("Duration in beats"),
});
const SongIRSchema = z.object({
title: z.string(),
key: z.string(),
tempo_bpm: z.number(),
time_signature: z.object({ beats: z.number().int(), unit: z.number().int() }),
difficulty: z.enum(["easy", "normal", "hard"]),
measures: z.array(z.object({ n: z.number().int(), notes: z.array(NoteSchema) })),
});
const completion = await client.beta.chat.completions.parse({
model: "gpt-5.5",
messages,
response_format: zodResponseFormat(SongIRSchema, "song"),
});
const song = completion.choices[0].message.parsed; // typed, guaranteed-shapedNo parsing, no "please respond in JSON," no regex salvage. The response comes back already matching the type. That frees the model to spend its effort on the creative part — the melody — instead of formatting.
Part 2 — The validator is your spec, and its errors are prompts
Structured Outputs guarantees the shape, not the meaning. Nothing stops the model from writing a note that overflows its measure, or a chord no hand can reach, or a piece that wanders out of key. So every IR runs through a validator before it's trusted.
For a practice-song generator, "correct" means playable and pedagogically appropriate. That's encoded as difficulty envelopes — the teaching constraints that make output educational rather than merely musical:
| easy | normal | hard | |
|---|---|---|---|
| max notes / measure | 6 | 12 | 24 |
| max simultaneous notes / hand | 1 | 2 | 4 |
| shortest note (beats) | 1 | 0.5 | 0.25 |
Plus, for every difficulty: per-hand pitch ranges, measures that actually add up, ≤ 20% out-of-key notes, tempo in a playable band.
Here's the move that makes the whole thing converge: a validation error isn't written for a human — it's written for the model.
measure 1, note 1 (E4): beat 4.5 + len 1 overflows a 4/4 measure
measure 1: R hand plays 2 simultaneous notes, exceeding the easy limit of 1
measure 2, note 1 (H9): "H9" is not a valid pitch nameEach line names where, what, and by how much. That's not a log entry — it's an instruction. It goes straight back into the next prompt.
Write your validator's error messages as instructions the model will read, not logs a human will read. When the failure is self-describing, the repair loop closes itself — you don't debug, the pipeline does.
Part 3 — Push all the arithmetic into deterministic code
Only once an IR passes does the deterministic half run. The compiler does every calculation the model was never allowed to touch: beats become ticks, ticks become milliseconds, note names become MIDI numbers.
const ticksPerBeat = TPQN * (4 / unit);
const startTick = Math.round(((measure.n - 1) * beats + (note.beat - 1)) * ticksPerBeat);
const startMs = Math.round(startTick * (60_000 / (tempoBpm * TPQN)));Same IR in, same bytes out, every time. A zero-dependency Standard MIDI File encoder writes the .mid; a parallel note chart carries the millisecond timings a game engine would consume.
Give the model the fuzzy, creative work in its native vocabulary; give ordinary code everything that has to be exactly right. Draw that line clearly and an entire class of "the LLM did bad math" bugs simply ceases to exist.
The loop, in one function
Structured Outputs (shape) + validator (meaning) + deterministic compiler (exactness) only become a pipeline when you wire the failure path back to the model:
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const song = (await client.beta.chat.completions.parse({ ... })).choices[0].message.parsed;
const errors = validate(song);
if (errors.length === 0) return { song, attempts: attempt };
messages.push(
{ role: "assistant", content: JSON.stringify(song) },
{ role: "user", content: `That song failed validation:\n${errors.map(e => `- ${e}`).join("\n")}\n\nRegenerate the complete song with every issue fixed.` },
);
}Generate → validate → feed failures back → regenerate. The model corrects itself against a hard oracle instead of a human.
Try it in 30 seconds
You don't even need a key to see the deterministic half work — a bundled example IR compiles offline:
git clone https://github.com/devyourown/etude-gen && cd etude-gen && npm install
npm run generate -- --from-ir examples/meadow-morning.json # no API key neededWith a key, the full loop runs. A real run just now, --difficulty easy --key C, produced Morning Brook — 8 measures, 47 notes, a left-hand C-major broken-chord accompaniment under a stepwise right-hand melody — passing validation and compiling to a MIDI file you can drop into GarageBand.
Why this has nothing to do with music
Swap out the domain and the skeleton is unchanged. Anywhere a model has to produce something creative that must also satisfy hard constraints, the same three parts apply:
| Domain | Symbolic IR the model writes | What the validator enforces | What the compiler does |
|---|---|---|---|
| Music (this) | measures, beats, note names | ranges, polyphony, key | beats → ms, MIDI bytes |
| Scheduling | tasks with relative order & duration | no overlaps, deadlines met | resolve to wall-clock times |
| UI layout | a component tree with intents | grid fits, contrast, a11y | emit pixel coordinates / CSS |
| Game levels | rooms, doors, keys | solvable, reachable, fair | tilemap coordinates |
| SQL / config | intent in a constrained DSL | schema, allowed columns, safety | render exact query / file |
The lesson generalizes cleanly: don't ask the model to be precise — ask it to be creative in a vocabulary you can check, and let code be precise for it.
Closing
The original post was the story of solving a real product problem: don't run out of songs. This one is what was underneath it, extracted so anyone can pick it up — a small, MIT-licensed OpenAI API sample and a pattern I keep reaching for. Milliseconds to the code, melody to the model, verification to a loop that closes on its own.