문제: 임의의 MIDI를 세 속도의 반주로
LevelUp Piano에서 선생님은 아무 MIDI 파일이나 올려 곡을 만들 수 있습니다. 그런데 연습 화면에는 함께 흐르는 반주 오디오가 필요합니다. 그것도 한 속도가 아니라 1x·0.75x·0.5x 세 가지로요. 느린 속도로 손에 익히고 점점 올리는 것이 연습의 기본이니까요.
문제는 "임의의"라는 단어입니다. 곡을 미리 만들어 넣을 수 없으니, 업로드되는 순간 그 자리에서 오디오를 만들어야 합니다.
왜 서버가 아니라 기기인가
서버에 신디사이저를 두는 방법도 있었습니다. 하지만:
- 오디오 합성은 CPU를 꽤 먹고, 업로드마다 서버가 렌더링 큐를 감당해야 합니다.
- 사운드폰트(악기 음색)와 렌더 엔진을 서버에 얹고 유지보수해야 합니다.
- 아이패드에는 이미 훌륭한 오디오 엔진(AVAudioEngine)과 샘플러가 들어 있습니다.
그래서 렌더링을 기기 안에서 하기로 했습니다. 업로드하는 선생님의 아이패드가 곧 렌더 팜이 되는 셈입니다. 서버는 큐도 신디사이저도 운영하지 않고 파일 저장(Cloudflare R2)과 메타데이터만 맡습니다. 대신 그 대가로, 앱이 렌더 진행률·실패 복구·배터리 부담을 사용자 경험 안에서 책임져야 했습니다 — 공짜 점심은 아니었습니다.
2패스 아키텍처
오프라인 렌더링을 파일로 바로 쓰려다 보면 버퍼 소유권·인코딩 타이밍에서 미묘한 문제가 생깁니다. 그래서 과정을 둘로 나눴습니다.
1패스 — 오프라인 PCM 렌더 (.caf)
- AVAudioEngine을 오프라인(수동 렌더링) 모드로 돌립니다. 실시간 재생이 아니라, 원하는 만큼 빠르게 샘플을 뽑아냅니다.
- 샘플러에 사운드폰트(피아노 음색)를 로드하고, MIDI 노트를 시간순 이벤트(누름/뗌)로 펼쳐 정렬합니다.
- 렌더한 PCM을 곧바로
.caf파일로 씁니다. 파일이 버퍼 소유권을 안전하게 관리해 줍니다.
2패스 — AAC 인코딩 (.m4a)
.caf를AVAssetExportSession으로.m4a(AAC)로 변환합니다.- 애플 인코더가 프라이밍·꼬리 패딩을 알아서 처리해, 타임스탬프 글리치나 잘린 끝음 문제가 없습니다.
세 속도는 노트의 시작·길이를 배율로 늘려 같은 파이프라인에 세 번 태우면 됩니다.
서브청크 렌더링 — 타이밍 뭉개짐을 피하는 법
처음엔 모든 MIDI 이벤트를 한꺼번에 스케줄한 뒤 큰 덩어리로 렌더했습니다. 그랬더니 음의 시작점이 미세하게 뭉개졌습니다. 큰 렌더 청크 안에서는 이벤트가 "청크 경계"로 반올림되기 때문입니다.
그래서 렌더 루프를 다음 이벤트가 일어나는 정확한 샘플 위치까지만 렌더하고, 거기서 이벤트를 발사한 뒤 이어가도록 잘게 쪼갰습니다. 44.1kHz에서 각 이벤트의 발사 프레임은 시각(ms) ÷ 1000 × 44100으로 정수 계산되므로, 음이 벽시계가 아니라 샘플 좌표에 놓입니다. 이벤트 사이만 렌더하니 각 음이 정확히 제 시각에 시작합니다.
이 구조의 부수 효과는 렌더가 결정적이라는 점입니다. 같은 노트 차트는 언제 돌려도 같은 파형을 냅니다. 보장되는 건 이벤트 스케줄링 정확도 — 각 이벤트가 계산된 샘플 프레임에 정확히 놓인다는 것입니다. (실제로 귀에 들리는 음향 onset은 샘플러 어택·SoundFont·인코딩 프라이밍의 영향을 받으니, 그것까지 샘플 단위로 정확하다고는 하지 않습니다.) 그래도 결정적이라, 회귀가 나면 파형 해시만 비교해도 드러납니다.
오래 헤맨 버그: 같은 음이 서로를 끊는다
여기서 가장 오래 헤맨 버그가 나왔습니다. 어떤 곡에서 같은 음을 빠르게 반복하는 구간의 소리가 뚝뚝 끊겼습니다. 노트 판정은 멀쩡한데, 반주 오디오에서만 음이 사라졌습니다.
범인은 샘플러의 stopNote였습니다. stopNote(음번호)는 그 음번호로 울리는 보이스를 전부 끕니다 — 개별 타건을 지정할 수 없습니다. 그런데 레가토로 같은 음이 겹치면(앞 음의 길이가 다음 같은 음의 시작을 지나침), 앞 음의 "뗌"이 방금 시작한 다음 음까지 죽여버립니다. 어택 직후 뚝 끊기는 소리는 이렇게 나온 것이었습니다.
stopNote는 음번호 단위로만 끌 수 있다. 그래서 같은 음이 1ms라도 겹치면, 앞 음을 끄려던 명령이 방금 시작한 뒤 음까지 함께 죽인다.
같은 결함이 두 군데에 있었습니다.
- 오프라인 렌더러: 동일 프레임에서는 "뗌"을 "누름"보다 먼저 처리해 딱 맞닿은 경우는 막았지만, 1ms라도 겹치면 그대로 노출됐습니다. 해법은 이벤트를 만들기 전에 같은 음높이끼리의 오버랩만 골라, 앞 음의 뗌을 다음 같은 음의 시작보다 64프레임(44.1kHz에서 약 1.5ms) 앞으로 당기는 것입니다. 귀에는 안 들릴 만큼 짧지만 보이스가 서로를 끄지 않을 만큼은 확실한 간격이고, 다른 음높이는 건드리지 않습니다.
- 실시간 발음(터치/체험 연주): 같은 음을 짧은 시간 안에 다시 치면 앞 타건의 지연된 "뗌"이 뒤 타건을 죽였습니다. 여기선 세대 카운터로 막았습니다 — 각 타건에 번호를 붙이고, 예약된 "뗌"은 자신이 걸릴 때의 번호가 여전히 최신일 때만 실행합니다(그 사이 다시 쳤으면 건너뜀).
오프라인 렌더러의 트리밍은 이렇게 생겼습니다 — 같은 음높이 그룹에서 앞 음의 off가 다음 on을 지나치면, off를 64프레임 앞으로 당깁니다.
// 같은 음높이 그룹(onFrame 기준 정렬)에서 앞 음의 off가 다음 on을 지나치면
for i in 0..<(group.count - 1) {
let nextOn = group[i + 1].onFrame
guard group[i].offFrame >= nextOn else { continue }
// off를 다음 on 바로 앞(64프레임 ≈ 1.5ms)으로 당긴다 — 단, on보다 앞서지 않게
let safeOff = max(group[i].onFrame, nextOn - repeatedNoteTrimFrames)
group[i].offFrame = min(group[i].offFrame, safeOff)
}이 버그가 오래 걸린 건 증상(소리 끊김)과 원인(보이스 관리)의 거리가 멀어서입니다. 노트 판정은 정상이라 오디오 쪽을 늦게 의심했고, stopNote가 음번호 단위라는 원리를 이해한 뒤에야 렌더러·라이브 두 경로에 같은 처방이 들어맞았습니다.
증상(소리 끊김)과 원인(보이스 관리)의 거리가 멀수록 오래 헤매지만 — 원리를 이해하면 렌더러·라이브 두 경로에 같은 처방이 딱 들어맞는다.
마치며
온디바이스 렌더링은 "서버 비용 없이, 임의의 곡을, 그 자리에서" 반주로 만들어 주는 이 앱의 숨은 엔진입니다. 오프라인 AVAudioEngine, 2패스 인코딩, 서브청크 타이밍, 그리고 같은 음이 서로를 끊지 않게 하는 작은 트릭까지 — 화면에는 안 보이지만 연습의 질을 좌우하는 부분입니다.
The problem: arbitrary MIDI into three-tempo backing tracks
In LevelUp Piano, a teacher can upload any MIDI file to create a song. But the practice screen needs backing audio that plays along — and not at one speed, but three: 1x, 0.75x, and 0.5x. Learning a passage slowly and speeding it up is practice 101.
The tricky word is "any." Since you can't pre-bake the songs, you have to generate the audio on the spot, the moment it's uploaded.
Why the device, not the server
Hosting a synthesizer on the server was an option. But:
- Audio synthesis is fairly CPU-heavy, and every upload would put a render job on the server's queue.
- You'd have to ship and maintain a SoundFont (instrument voices) and a render engine on the backend.
- The iPad already ships with an excellent audio engine (AVAudioEngine) and a sampler.
So I render on the device. The uploading teacher's iPad effectively becomes the render farm; the server runs no queue and no synthesizer, handling only file storage (Cloudflare R2) and metadata. The trade-off is that the app now owns render progress, failure recovery, and battery cost inside the user experience — no free lunch.
A two-pass architecture
Writing an offline render straight to a file runs into subtle buffer-ownership and encode-timing issues. So I split the process in two.
Pass 1 — offline PCM render (.caf)
- Run AVAudioEngine in offline (manual-rendering) mode — not real-time playback, but pulling samples as fast as you like.
- Load the SoundFont (piano voice) into the sampler, and expand the MIDI notes into time-sorted events (note-on / note-off).
- Write the rendered PCM directly to a
.caffile, which manages buffer ownership safely.
Pass 2 — AAC encode (.m4a)
- Convert the
.cafto.m4a(AAC) withAVAssetExportSession. - Apple's encoder handles priming and tail padding internally, so there are no timestamp glitches or clipped final notes.
For the three tempos, you scale each note's start and duration and run the same pipeline three times.
Sub-chunked rendering — avoiding timing smear
At first I scheduled all the MIDI events up front and rendered in big chunks. Note onsets came out slightly smeared, because inside a large render chunk, events get rounded to the "chunk boundary."
So I broke the render loop down to render only up to the exact sample where the next event occurs, fire that event there, and continue. At 44.1kHz each event's fire frame is computed as an integer, time(ms) ÷ 1000 × 44100, so notes land on sample coordinates, not the wall clock. Rendering only between events means every note starts at exactly its right time.
A side effect of this structure is that the render is deterministic: the same note chart always produces the same waveform. What's guaranteed is event-scheduling accuracy — each event lands on exactly its computed sample frame. (The audible acoustic onset is still shaped by the sampler's attack, the SoundFont, and encoding priming, so I wouldn't claim that is sample-exact.) Being deterministic, though, means a regression shows up just by comparing a waveform hash.
The bug I chased longest: the same note cuts itself off
Here came the bug I chased longest. In songs with fast repeated notes, the audio stuttered — the notes dropped out. Judgment was fine; only the backing audio lost notes.
The culprit was the sampler's stopNote. stopNote(noteNumber) stops every voice ringing at that note number — you can't target an individual press. But when the same note overlaps in legato (the previous note's length runs past the next same note's start), the previous note's "off" kills the next note that just started. That cut-off right after the attack was exactly this.
stopNote can only stop by note number. So if the same note overlaps by even 1 ms, the command meant to stop the previous note also kills the next one that just started.
The same flaw lived in two places:
- The offline renderer: at the same frame it processed "off" before "on," which handled exactly-touching cases, but a 1 ms overlap slipped through. The fix is to trim only same-pitch overlaps before building events — pull the previous note's "off" to 64 frames (~1.5ms at 44.1kHz) before the next same note's start. Short enough to be inaudible, but a firm enough gap that voices don't stop each other, and it never touches other pitches.
- Live playback (touch / trial performance): pressing the same key again within a short window let the previous press's delayed "off" kill the new one. Here a generation counter fixes it — tag each press with a number, and a scheduled "off" only runs if its number is still the latest (skip it if the key was pressed again in the meantime).
The offline renderer's trim looks like this — within a same-pitch group, if a note's off runs past the next on, pull the off 64 frames earlier.
// In a same-pitch group (sorted by onFrame), if a note's off runs past the next on
for i in 0..<(group.count - 1) {
let nextOn = group[i + 1].onFrame
guard group[i].offFrame >= nextOn else { continue }
// pull the off just before the next on (64 frames ≈ 1.5ms), but never before its own on
let safeOff = max(group[i].onFrame, nextOn - repeatedNoteTrimFrames)
group[i].offFrame = min(group[i].offFrame, safeOff)
}What made this bug slow was the distance between the symptom (audio stutter) and the cause (voice management). Because judgment stayed correct, I suspected the audio path late — and only after grasping that stopNote works per note number did the same prescription drop neatly into both paths.
The bigger the gap between symptom (audio stutter) and cause (voice management), the longer the hunt — but once you get the principle, the same fix drops into both the renderer and live playback.
Closing
On-device rendering is this app's hidden engine — it turns "any song, on the spot, with no server cost" into backing audio. Offline AVAudioEngine, two-pass encoding, sub-chunk timing, and the little trick that keeps the same note from cutting itself off: invisible on screen, but decisive for the quality of practice.