
피아노 초견 실력을 높이기 위해 약 2년간 취미로 악보 엔진을 만든 경험을 소개한다. 이 엔진은 초견 연습 앱 Note Universe의 악보를 배치하고 화면에 그리며, 글쓴이는 설계부터 MusicXML 가져오기까지의 과정을 설명한다.
초견은 처음 보는 악보를 읽고 연주하는 능력이다. 시각적으로 악보를 해석하고 음악의 구조를 이해하는 동시에 손을 움직여야 하므로 피아노 연주자가 익히기 어려운 기술 중 하나다. 음악 이론과 화성 등 구조를 이해하는 능력은 오랜 훈련과 경험이 필요하지만, 시각적 해석과 손의 협응은 디지털 학습 도구로 연습할 수 있다고 봤다. 실수 없이 할 때까지 반복 훈련하고 간격 반복을 적용하면 초견의 기계적인 부분을 익히는 시간을 크게 줄일 수 있다는 구상으로 앱을 만들기 시작했다.
앱 전체에 대한 요약은 Note Universe 웹사이트에서 볼 수 있다. 이 글은 앱의 기반이 되는 악보 엔진을 어떻게 만들었는지 다룬다. 글쓴이는 본문을 AI 도움 없이 썼지만, 뒤에 나오는 렌더링 파이프라인 다이어그램을 만드는 데는 AI를 사용했다고 밝혔다. 엔진과 앱은 개발 첫해에는 직접 작성했고, 지난해 11월 무렵부터 이어진 개발은 모두 Claude Code를 이용해 진행했다고 설명한다. 음악을 잘 모르는 독자도 따라올 수 있도록 일부 용어의 뜻도 덧붙였다.
악보 기보는 음악을 기록된 형태로 충실히 표현하는 기술이자 예술이다. 핵심은 음의 높이인 음고와 한 음을 다음 음이 시작되기 전까지 얼마나 오래 유지하는지를 나타내는 길이를 기록하는 것이다. 스타카토나 레가토 같은 아티큘레이션, 연주 음량을 나타내는 셈여림도 있지만, 음고와 길이는 반드시 표현해야 한다. 아티큘레이션이나 셈여림을 빼도 곡을 알아볼 수는 있지만, 음고와 길이를 빼면 음악을 제대로 나타낼 수 없다.
현대 악보 기보에는 널리 쓰이는 모범 사례가 자리 잡았지만, 글쓴이가 아는 한 ISO 표준은 없다. 대신 읽기 쉬운 악보를 만드는 방법을 자세히 다루는 참고서가 여럿 있다. 그중 가장 중요한 책으로 꼽은 것은 일레인 굴드(Elaine Gould)의 Behind Bars다. 700쪽이 넘는 이 책은 음 하나를 그리는 방법부터 현대 악보 기보의 여러 요소를 체계적으로 설명한다. 글쓴이는 규칙만 선언하기보다 시각적 예시를 중심으로 설명하는 점을 특히 높이 평가했다.
Behind Bars의 지침을 코드로 옮긴 예시는 다음과 같다.
/**
* Horizontal space for a note of [durationTicks], relative to a
* [quarterNoteTicks]-long quarter note.
*/
fun spaceFor(durationTicks: Int, quarterNoteTicks: Int): Float {
if (durationTicks <= 0) return 0f
val ratio = durationTicks.toFloat() / quarterNoteTicks
// Assuming 480 ticks per quarter note:
// eighth (240) -> 1.32 (linear would give 1.00)
// quarter (480) -> 2.00 (linear would give 2.00)
// half (960) -> 3.03 (linear would give 4.00)
// whole (1920) -> 4.59 (linear would give 8.00)
return maxOf(MIN_PADDING, BASE_SPACE * ratio.pow(SPACING_EXPONENT))
}
글쓴이는 프로젝트 내내 이 책을 참고했다. 악보가 숙련된 음악가에게 어색하게 보이지 않도록 지켜야 할, 쉽게 알아채기 어려운 규칙이 많다. 이와 관련해 “프로그래머들이 음악 기보를 두고 믿는 잘못된 생각”이라는 별도 글도 준비 중이라고 밝혔다.
예를 들어 보표의 여러 줄 바깥에 놓인 음표는 줄기 길이를 보표 중앙선까지 늘려야 한다. 줄기를 일반적인 한 옥타브 길이로 그리면 음표가 보표와 동떨어져 보인다. 줄기의 방향도 음표가 중앙선보다 위에 있는지 아래에 있는지에 따라 정한다. 위쪽 음표의 줄기는 아래를 향하고, 아래쪽 음표의 줄기는 위를 향한다. 모든 줄기를 같은 방향으로 그리면 중앙선 위의 음표에서 줄기가 보표 밖으로 뻗을 수 있다.
음표의 간격도 음의 길이에 정확히 비례시키면 안 된다. 긴 음표에는 더 넓은 공간을 주지만, 그 간격은 음의 길이에 정비례하지 않는다. 따라서 온음표 하나로 된 마디는 4분음표 네 개로 된 마디보다 폭이 좁아질 수 있다. 간격을 길이에 정확히 비례시키면 온음표 하나가 넓은 마디에 홀로 놓이고 4분음표들은 서로 빽빽해진다.
엔진 구조와 악보 배치
처음에는 캔버스 코드 수백 줄로 간단한 시제품을 만들었다. 화면에 실제 악보가 나타나는 것은 만족스러웠지만, 표기 유형이 늘어나면서 성능이 나빠지고 코드가 복잡하게 얽혔다. 악보 기보 로직과 캔버스에 보내는 저수준 그리기 명령을 뒤섞은 점도 문제였다.
기존 라이브러리로 해결하지 않은 이유는 요구사항이 달랐기 때문이다. 예를 들어 Verovio 같은 도구가 있지만, 특히 무한 스크롤 요구사항에 맞는 것은 없었다. 직접 만드는 도전 자체를 원하기도 했다.
시제품을 버리고 제대로 된 시스템을 만들기로 하면서 다음과 같은 조건을 정했다.
- 악보 배치와 렌더링을 분리한다. 배치는 필요할 때 수행하고 초당 60회 반복하지 않도록 해 성능을 높인다. 복잡한 두 영역도 서로 분리한다.
- 모든 악보 요소를 표현하는
Renderable추상화를 둔다. 각 요소는 좌표와 경계 상자를 가지며,RenderableChord,RenderableAccidental,RenderableBeam,RenderableClefSymbol,RenderableRest등이 있다. 배치 단계의 출력이자 렌더링 단계의 입력이다. 렌더러는 기보 규칙을 알 필요 없이 지정된 위치에 각 기호를 그리기만 하면 된다. - 연습이 사실상 무한히 이어지므로 새 콘텐츠를 요청에 따라 생성할 수 있게 한다.
- 틀린 음을 강조하는 주석 등을 덧씌울 수 있어야 한다. 캔버스가 아닌 요소도 악보의 특정 위치에 고정할 수 있어야 한다. 앱의 이론 수업에서 기보의 각 부분을 가리키고 설명하는 데 이런 기능을 쓴다.
전체 파이프라인은 입력 음악을 배치 단계에서 Renderable로 바꾸고, 그리기 단계에서 화면 픽셀로 변환한다. 즉 배치와 렌더링을 두 단계로 나눈 구조다.
악보 배치에는 크게 두 가지 접근법이 있다. 가장 단순한 명령형 접근법은 단계별로 처리한다. 반면 Dorico 같은 완성도 높은 기보 소프트웨어는 제약 조건 기반 접근법을 쓴다. 이 방식에서는 요소들이 ‘스프링과 막대’처럼 상호작용한다. 임시표가 음표 머리와 겹치지 않도록 하는 조건은 최소 간격을 강제하는 막대에 해당한다. 두 화음 사이의 간격처럼 유연하게 조절할 수 있는 변수는 스프링에 해당한다. 각 요소에 에너지 함수를 부여하면 스프링이 자연 길이에서 벗어날수록 위치 에너지가 커진다. 이 에너지 함수를 최소화해 우선순위에 따라 가장 적절한 배치를 찾는다. 스프링의 강성을 높이면 해당 제약의 우선순위도 높일 수 있다.
글쓴이는 먼저 구현하기 쉽고 초기 기능 범위에 충분한 명령형 방식을 택했다. 처리 과정은 다음과 같다.
- 상태 설정: 현재 위치인
domainX와domainY, 활성 음자리표, 조표, 마디별 임시표 상태를 추적한다. 임시표 상태는 마디마다 초기화되므로 같은 마디에서는 임시표를 한 번만 표시한다. - 마디 경계:
startMeasure와endMeasure가 시작 X 좌표를 저장하고, 마디별 임시표를 조표 상태로 초기화한다. 이어 마디 너비를 담은RenderableMeasure를 출력한다. - 음자리표와 조표:
engraveClef가 음자리표 기호를 배치하고,engraveKeySignature가 5도권 순서에 따라 샤프와 플랫을 놓는다. 표준 조표 배열을 따르도록 일부 임시표는 한 옥타브 위로 옮긴다. - 박자표:
engraveTimeSignature가 여백을 더하고 분자와 분모 기호를 출력한다. - 화음 기보: 동시에 연주하는 둘 이상의 음으로 된 각 화음에서 음고와 음표를 계산한다. 평균 음고와 음자리표 중앙선을 비교해 줄기 방향을 정하고, 음표 머리가 겹치지 않도록 줄기의 반대쪽에 놓일 음표를 판단한다. 임시표가 서로 겹치지 않도록 왼쪽으로 얼마나 이동할지도 계산한다.
- 음표 머리와 덧줄, 임시표: 각 음의 세로 위치를 계산하고 보표 위아래에 필요한 덧줄을 만든다. 필요한 경우 임시표
Renderable을 붙이고 마디의 임시표 상태를 갱신한다. - 줄기와 꼬리: 줄기 길이를 계산한다. 중앙선에서 멀리 떨어진 음표는 길이를 점차 줄이고 보표 밖에 크게 벗어난 음표는 줄기를 중앙선까지 늘린다.
NoteDuration에 따라 줄기의 꼬리 개수를 정한다. - 쉼표:
engraveRest가RenderableRest를 출력하고 쉼표 너비와 기본 간격만큼 위치를 진행한다. - 옥타브 표기:
detectOctaveModifiers와engraveChordWithOctaveModifiers가 음역 기준을 벗어난 화음을 한 옥타브 위나 아래로 옮긴다. 음표가 보표에서 지나치게 멀리 떨어지는 대신 8va 또는 8vb 표기 아래에 놓이게 한다. 이 표기는 해당 음을 적힌 것보다 한 옥타브 높거나 낮게 연주하라는 뜻이다. - 측정 모드:
measureChord는 상태를 복사한 뒤 화음을 기보하고 원래 상태를 복원한다. 이를 통해 상태를 확정하지 않은 채 화음이 차지할 공간을 확인할 수 있다. 왼손과 오른손의 마디를 정렬하기 위해 측정 단계도 수행한다.
이 과정이 끝나면 여러 Renderable이 만들어지고, 이를 ScoreRenderer 클래스에 넘긴다. 렌더러는 각 요소를 순회하며 캔버스에 그린다. 초견 연습이 무한히 이어지면 Renderable도 제한 없이 누적되므로 최적화가 필요했다. 각 요소의 좌표와 경계 상자로 화면에 그릴 필요가 있는지 빠르게 확인하는 컬링을 적용했다. 글쓴이에 따르면 Renderable이 많아도 이 검사는 매우 빠르며, 매우 긴 연습에서도 그리기 시간을 꾸준히 16ms 미만으로 유지한다.
테스트와 렌더링
글쓴이는 기보 엔진과 렌더러를 대체로 병행해 개발했지만, 렌더링 파이프라인 전체에 의존하지 않고 기보 엔진만 단위 테스트하고 싶었다. 그래야 회귀 문제가 엔진 단계에서 생겼는지 렌더링 단계에서 생겼는지 구분하기 쉽다. ASCII 테스트 픽스처를 사용하면 테스트 자체가 어떤 악보를 기대하는지 설명해 주는 효과도 있다.
@Test
fun cDiminished7th_inKeyOfCMajor() {
val result = engraver.engraveChord(
PitchedChord(ChordTypes.DIMINISHED_SEVENTH, Note.C, Octave.FOUR)
)
assertNotes(result) {
"""
|-----|
| |
|-----|
| |
|bb0--|
| |
|-b0--|
| |
|-b0--|
-0-
"""
}
}
이 방식으로 모든 동작을 테스트할 수 있는 것은 아니다. 하지만 핵심 기보 경로 일부를 간단히 검사할 수 있고, 정확한 Renderable 좌표에 기대는 테스트보다 쉽게 깨지지 않는다. 하위 렌더러에 의존하지 않는다는 장점도 있다.
Renderable이 만들어진 뒤에는 지정된 위치에 그리면 되므로 렌더링 자체는 비교적 단순하다. 직사각형 모양의 쉼표처럼 일부 기호는 절차적으로 그릴 수 있다. 하지만 높은음자리표 같은 다른 기호는 위키백과에서 공개적으로 제공하는 자료에 크게 의존했다. 자료마다 스타일이 일관되지 않았고, 일부 기호는 아예 구할 수 없었다.
이후 Steinberg가 Dorico용으로 만든 Bravura 글꼴을 발견했다. 이 글꼴은 SIL 오픈 폰트 라이선스로 자유롭게 사용할 수 있어 빠르게 전환했다. 글쓴이는 이 프로젝트 덕분에 상당한 시간을 절약했고 악보의 시각적 품질도 크게 좋아졌다고 말한다. Bravura의 글리프는 음악 기보 전용 표준인 SMuFL에 맞춘 일관된 좌표계를 쓴다. 1em은 보표 간격 네 칸에 해당하므로 기호마다 크기를 추측해 조정할 필요가 없다. 이전에는 글리프를 처음 가져올 때마다 크기 보정값을 적용해야 했다.
전체 E2E 파이프라인을 테스트할 때는 스크린샷 테스트가 가장 적합했다. 테스트 코드가 명확하더라도 스크린샷을 보면 테스트의 맥락을 파악하기 쉽다. 글쓴이는 이를 위해 스크린샷 출력에 메타데이터도 추가했다. 기준 스크린샷은 시각적 회귀를 잡는 장치인 동시에 기보 엔진의 요구사항과 기능을 보여주는 문서 역할도 한다. 테스트 하네스는 엔진 출력이 콘텐츠 영역 안에 들어가도록 가운데 정렬하고 크기를 조정하는 번거로운 작업도 처리한다. 앱에는 이런 기준 스크린샷이 200개 넘게 있다.
MusicXML 가져오기
이 기보 파이프라인은 이제 Note Universe 앱 전체의 기반이 되어 생성된 초견 연습곡을 그린다. 처음에는 훨씬 나중에 다룰 것으로 생각했던 외부 음악 파일 가져오기도 추가했다. 악보 소프트웨어에서 널리 지원하는 표준 파일 형식인 MusicXML을 사용한다.
목표는 사용자가 임의의 악보를 가져오게 하는 것이 아니었다. 그런 용도라면 MuseScore 같은 도구를 쓸 수 있다. 언젠가 대규모 언어 모델(LLM)이 유효하고 어쩌면 음악적으로도 흥미로운 MusicXML을 만들 만큼 발전할 수 있다고 보고, 이를 가져오는 파이프라인을 구축하면 더 실제 악보에 가까운 초견 연습곡을 만들 수 있다고 생각했다.
몇 달 전 Claude Fable이 출시되자 궁금증이 커져 이 기능을 시도했다. MusicXML 파서는 두 단계로 만들었다. 먼저 Note Universe의 연습곡 생성기와 MusicXML 가져오기 도구가 모두 사용할 수 있도록 기보 엔진 API를 더 범용적으로 리팩터링했다. 기존 API 설계가 비교적 견고해 큰 작업은 필요하지 않았다. 다음으로 MusicXML 파서를 작성해 엔진 API에 연결했다. 엔진 API가 MusicXML 파일을 자연스럽게 순회할 수 있도록 하는 것이 리팩터링 요구사항 중 하나였기 때문에, 이 과정은 비교적 간단했다고 설명한다.
전체 파이프라인을 시험하기 위해 Claude Fable에 G장조의 짧은 왈츠를 작곡해 달라고 요청했다. 이를 태블릿 화면에서 확인한 뒤 직접 초견 연주를 녹음했다. 곡 제목은 ‘Little Waltz in G’이며 Claude Fable이 작곡한 곡이다.
Note Universe는 Play 스토어에서 이용할 수 있다. iOS 버전은 곧 출시될 예정이다. 글쓴이는 현재 앱이 초보자가 빠르게 기초를 익히는 데 도움이 되고 중급 연주자에게도 유용한 연습 도구라고 평가한다. 앞으로 몇 주 동안 기능을 계속 추가할 계획이며, 특히 MusicXML 기반 시스템으로 절차적으로 생성한 초견 연습곡에 다양한 짧은 곡을 더하는 기능을 기대하고 있다.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요