Nari

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

기기 안에서 MIDI를 오디오로 굽기 — 온디바이스 렌더링 엔진

블로그 목록
기능 소개2026-07-02
iOS 네이티브오디오AVAudioEngine디버깅
3렌더 속도 (1x·0.75x·0.5x)
2-pass오프라인 PCM → AAC
44.1kHz샘플 단위 정확도
0서버 합성 비용

문제: 임의의 MIDI를 세 속도의 반주로

LevelUp Piano에서 선생님은 아무 MIDI 파일이나 올려 곡을 만들 수 있습니다. 그런데 연습 화면에는 함께 흐르는 반주 오디오가 필요합니다. 그것도 한 속도가 아니라 1x·0.75x·0.5x 세 가지로요. 느린 속도로 손에 익히고 점점 올리는 것이 연습의 기본이니까요.

문제는 "임의의"라는 단어입니다. 곡을 미리 만들어 넣을 수 없으니, 업로드되는 순간 그 자리에서 오디오를 만들어야 합니다.

왜 서버가 아니라 기기인가

서버에 신디사이저를 두는 방법도 있었습니다. 하지만:

  • 오디오 합성은 CPU를 꽤 먹고, 업로드마다 서버가 렌더링 큐를 감당해야 합니다.
  • 사운드폰트(악기 음색)와 렌더 엔진을 서버에 얹고 유지보수해야 합니다.
  • 아이패드에는 이미 훌륭한 오디오 엔진(AVAudioEngine)과 샘플러가 들어 있습니다.

그래서 렌더링을 기기 안에서 하기로 했습니다. 업로드하는 선생님의 아이패드가 곧 렌더 팜이 되는 셈입니다. 서버는 큐도 신디사이저도 운영하지 않고 파일 저장(Cloudflare R2)과 메타데이터만 맡습니다. 대신 그 대가로, 앱이 렌더 진행률·실패 복구·배터리 부담을 사용자 경험 안에서 책임져야 했습니다 — 공짜 점심은 아니었습니다.

2패스 아키텍처

MIDIPass 1 · Offline renderAVAudioEngine → .caf (PCM)Pass 2 · AAC encode.caf → .m4aCloudflare R2upload× 3 tempos — 1.0x · 0.75x · 0.5x
온디바이스 2패스 렌더링 — MIDI를 오프라인 PCM(.caf)으로 굽고 AAC(.m4a)로 인코딩해 업로드 (세 속도)

오프라인 렌더링을 파일로 바로 쓰려다 보면 버퍼 소유권·인코딩 타이밍에서 미묘한 문제가 생깁니다. 그래서 과정을 둘로 나눴습니다.

1패스 — 오프라인 PCM 렌더 (.caf)

  • AVAudioEngine을 오프라인(수동 렌더링) 모드로 돌립니다. 실시간 재생이 아니라, 원하는 만큼 빠르게 샘플을 뽑아냅니다.
  • 샘플러에 사운드폰트(피아노 음색)를 로드하고, MIDI 노트를 시간순 이벤트(누름/뗌)로 펼쳐 정렬합니다.
  • 렌더한 PCM을 곧바로 .caf 파일로 씁니다. 파일이 버퍼 소유권을 안전하게 관리해 줍니다.

2패스 — AAC 인코딩 (.m4a)

  • .cafAVAssetExportSession으로 .m4a(AAC)로 변환합니다.
  • 애플 인코더가 프라이밍·꼬리 패딩을 알아서 처리해, 타임스탬프 글리치나 잘린 끝음 문제가 없습니다.

세 속도는 노트의 시작·길이를 배율로 늘려 같은 파이프라인에 세 번 태우면 됩니다.

서브청크 렌더링 — 타이밍 뭉개짐을 피하는 법

처음엔 모든 MIDI 이벤트를 한꺼번에 스케줄한 뒤 큰 덩어리로 렌더했습니다. 그랬더니 음의 시작점이 미세하게 뭉개졌습니다. 큰 렌더 청크 안에서는 이벤트가 "청크 경계"로 반올림되기 때문입니다.

그래서 렌더 루프를 다음 이벤트가 일어나는 정확한 샘플 위치까지만 렌더하고, 거기서 이벤트를 발사한 뒤 이어가도록 잘게 쪼갰습니다. 44.1kHz에서 각 이벤트의 발사 프레임은 시각(ms) ÷ 1000 × 44100으로 정수 계산되므로, 음이 벽시계가 아니라 샘플 좌표에 놓입니다. 이벤트 사이만 렌더하니 각 음이 정확히 제 시각에 시작합니다.

이 구조의 부수 효과는 렌더가 결정적이라는 점입니다. 같은 노트 차트는 언제 돌려도 같은 파형을 냅니다. 보장되는 건 이벤트 스케줄링 정확도 — 각 이벤트가 계산된 샘플 프레임에 정확히 놓인다는 것입니다. (실제로 귀에 들리는 음향 onset은 샘플러 어택·SoundFont·인코딩 프라이밍의 영향을 받으니, 그것까지 샘플 단위로 정확하다고는 하지 않습니다.) 그래도 결정적이라, 회귀가 나면 파형 해시만 비교해도 드러납니다.

오래 헤맨 버그: 같은 음이 서로를 끊는다

BEFORE — 버그앞 음 off✗ 뒤 음까지 죽음AFTER — 수정off를 살짝 당김(트리밍)✓ 둘 다 유지time →
같은 음이 겹칠 때 — 앞 음의 note-off가 방금 시작한 뒤 음까지 죽인다(위). 오버랩을 살짝 트리밍하면 둘 다 살아난다(아래).

여기서 가장 오래 헤맨 버그가 나왔습니다. 어떤 곡에서 같은 음을 빠르게 반복하는 구간의 소리가 뚝뚝 끊겼습니다. 노트 판정은 멀쩡한데, 반주 오디오에서만 음이 사라졌습니다.

범인은 샘플러의 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가 음번호 단위라는 원리를 이해한 뒤에야 렌더러·라이브 두 경로에 같은 처방이 들어맞았습니다.

💡 Insight

증상(소리 끊김)과 원인(보이스 관리)의 거리가 멀수록 오래 헤매지만 — 원리를 이해하면 렌더러·라이브 두 경로에 같은 처방이 딱 들어맞는다.

마치며

온디바이스 렌더링은 "서버 비용 없이, 임의의 곡을, 그 자리에서" 반주로 만들어 주는 이 앱의 숨은 엔진입니다. 오프라인 AVAudioEngine, 2패스 인코딩, 서브청크 타이밍, 그리고 같은 음이 서로를 끊지 않게 하는 작은 트릭까지 — 화면에는 안 보이지만 연습의 질을 좌우하는 부분입니다.