메뉴
HN
Hacker News • 36일 전

1억 2,500만 파라미터 피아노 자동완성 온디바이스 모델 공개

IMP
6/10
핵심 요약

한 개발자가 1억 2,500만 파라미터 트랜스포머를 학습시켜 피아노 연주를 실시간으로 자동완성하는 앱 'RollTab'을 무료 공개했습니다. iPhone 15에서 초당 약 108개의 노트를 생성하며, MIDI 표현 방식 최적화, 학습 데이터 정제, DPO 후속 학습이 성능 향상의 핵심이었다고 합니다. 온디바이스에서 실시간 음악 생성을 구현한 사례로 모바일 AI 응용에 참고할 만합니다.

번역된 본문

요약: 저는 1억 2,500만 파라미터 트랜스포머를 학습시켜 피아노 연주를 실시간으로 자동완성하도록 만들었습니다(iPhone 15에서 초당 약 108개 노트 처리). 가장 큰 성능 향상은 적절한 MIDI 표현 방식을 찾은 것, 학습 데이터를 공격적으로 정제한 것, DPO 후속 학습을 추가한 것에서 나왔습니다.거의 1년 전, 이런 아이디어로 프로젝트를 시작했습니다: MIDI 피아노를 휴대폰에 연결하고, 무언가를 연주하면 AI가 곡을 자동완성해주는 것. 피아노를 위한 GitHub Copilot 같은 것이죠. 예상보다 훨씬 깊은 토끼굴이었습니다. 14번의 실험 끝에 이제 글로 쓸 만큼 만족할 수준에 도달했습니다. RollTab이라는 앱으로, MIDI 키보드와 iPhone/iPad가 있다면 무료로 사용할 수 있습니다.1. 사운드 샘플몇 가지각 오디오는 짧은 프롬프트로 시작해 모델이 이어서 연주합니다.- 포켓몬, 팔레트 타운 (8음 프롬프트)- 파이널 판타지 6, 테마 테마 (16음 프롬프트)- 엘리제를 위하여 (16음 프롬프트)MIDI 파일이란?MIDI 파일은 MP3나 다른 오디오 형식과는 상당히 다릅니다. 녹음된 소리를 저장하는 대신, 음악을 이벤트의 연속으로 저장합니다: 특정 음높이(pitch)와 세기(velocity)로 건반을 누르고, 건반에서 손을 떼고, 서스테인 페달 상태가 바뀌는 등의 이벤트입니다. 악기 전환이나 볼륨 변경 같은 이벤트도 있습니다.이런 이벤트들은 보통 여러 트랙으로 구성됩니다. 팝이나 게임 MIDI라면 멜로디, 코드, 베이스, 드럼, 스트링, 여러 신디사이저 파트가 있을 수 있습니다. 이 프로젝트는 피아노 연속 연주에 집중하기 때문에 피아노에 가까운 소재를 남기고 나머지는 제거하거나 축소했습니다.음악을 어떻게 토큰화하는가?이런 연주를 트랜스포머로 학습시키려면 먼저 MIDI 이벤트를 모델이 읽고 예측할 수 있는 이산적인 시퀀스로 변환해야 했습니다.가장 직관적인 매핑은 모든 MIDI 이벤트마다 토큰을 만드는 것입니다:NOTE_ON_60_80 # {음높이}_{세기}NOTE_OFF_60 # {음높이}TIME_SHIFT_12 # {시간 스텝}음높이와 세기를 NOTE_ON 토큰에 직접 포함하면 어휘가 빠르게 늘어납니다. MIDI에는 128개의 음높이와 128개의 세기 값이 있어서, 단순 결합 시 최대 128 * 128 + 128 = 16,512개 토큰이 note-on/note-off만으로 필요합니다. 실무에서는 세기를 버킷팅하겠지만 근본적인 문제는 남습니다: 많은 조합이 드물게 나타나고, 모델이 희소한 토큰에서 많은 구조를 학습해야 합니다.흔한 개선 방법은 문법으로 표현을 분해하는 것입니다:[NOTE_ON, PITCH, VELOCITY] | [NOTE_OFF, PITCH] | [TIME_SHIFT, DURATION]이제 출력 공간이 작아집니다:NOTE_ON / NOTE_OFF / TIME_SHIFTPITCH: 128개 값VELOCITY: 약 16개DURATION: 약 100개생성 중에 유효하지 않은 다음 토큰을 마스킹하여 문법을 강제할 수 있습니다. NOTE_ON 다음에는 음높이 토큰만 유효하고, 음높이 다음에는 세기 토큰만 유효합니다. 이렇게 하면 구문적으로 유효한 출력이 보장됩니다.저는 note-on/note-off 방식 표현을 시도했지만, 모델이 흐트러지는 경향이 있었습니다. note-off를 내보내지 않아 노트가 매달려 있거나, 활성 상태를 잃어버리는 문제가 있었죠. 특히 제 목표인 노트북이나 휴대폰에서 실시간에 가깝게 동작하는 소형 모델에는 치명적이었습니다.또 다른 표현 방식으로 시도한 것은 이렇습니다:[NOTE, PITCH, VELOCITY, DURATION] | [TIME_SHIFT, DURATION]이 방식은 노트 길이가 명시적으로 포함되어 note-off 드리프트를 피할 수 있습니다. TIME_SHIFT 토큰은 노트가 연주되지 않을 때 재생 헤드를 진행시킵니다. 음악적으로는 더 나았지만 느렸습니다. 음표 하나에 대략 4번의 자기회귀 트랜스포머 스텝이 필요했고, 컨텍스트 윈도우도 빠르게 소모했습니다.최종 표현 방식최종적으로 정착한 표현은 다음과 같습니다:NOTE(음높이, delta_onset, 길이, 세기)최종 버전에는 별도의 TIME_SHIFT 이벤트가 없습니다. 침묵은 다음 노트의 delta_onset, 즉 이전 노트 시작 이후 경과 시간으로 표현됩니다. 예를 들어:NOTE(C4, delta=0, duration=12, velocity=80)NOTE(D4, delta=24, duration=12, velocity=80)이것은 C4를 연주하고, 다음 노트 시작 전까지 24 타임 스텝을 기다린 후 연주하라는 의미입니다.

원문 보기
원문 보기 (영어)
TL;DR: I trained a 125M-parameter transformer to autocomplete piano performances in real time (~108 notes/sec on an iPhone 15). The biggest improvements came from finding the right MIDI representation, cleaning the training data aggressively, and adding DPO post-training. Almost a year ago, I started tinkering with an idea: connect my MIDI piano to my phone, play something, and have AI autocomplete the song for me. Think GitHub Copilot, but for piano. It turned out to be a deeper rabbit hole than I expected. Fourteen experiments later, it is finally at a point where I am happy enough with it to write about. The app, RollTab, is available for free here if you have a MIDI keyboard and an iPhone/iPad. 1 A few sound samples Each audio starts with a short prompt, followed by the model's continuation. Pokémon, Pallet Town (8-note prompt) Your browser does not support the audio tag. Final Fantasy VI, Terra's Theme (16-note prompt) Your browser does not support the audio tag. Für Elise (16-note prompt) Your browser does not support the audio tag. What’s in a MIDI File? A MIDI file is quite different from an MP3 or other audio formats. Rather than storing recorded sound, it stores music as a sequence of events: a key is pressed at a certain pitch and velocity, a key is released, the sustain pedal changes state, and so on. Other events include switching instruments or changing volume. These events are often organised into multiple tracks. A pop or game MIDI might have melody, chords, bass, drums, strings, and several synth parts. This project is focused on piano continuation, so I mostly kept piano-like material and removed or reduced the rest. How Do You Tokenize Music? To train a transformer on these performances, I first needed to turn the MIDI events into a discrete sequence the model could read and predict. The most obvious mapping is to make a token for every MIDI event: NOTE_ON_60_80 # {pitch}_{velocity} NOTE_OFF_60 # {pitch} TIME_SHIFT_12 # {time step} If you include pitch and velocity directly in a NOTE_ON token, the vocabulary can grow quickly. There are 128 MIDI pitches and 128 velocity values, so the naive combined note-on vocabulary has up to: 128 * 128 + 128 = 16,512 tokens just for note-on and note-off. In practice you would probably bucket velocity, but the basic issue remains: many combinations are rare, and the model has to learn a lot of structure from sparse tokens. A common improvement is to factor the representation with a grammar: [NOTE_ON, PITCH, VELOCITY] | [NOTE_OFF, PITCH] | [TIME_SHIFT, DURATION] Now the output spaces are smaller: NOTE_ON / NOTE_OFF / TIME_SHIFT PITCH: 128 values VELOCITY: ~16 DURATION: ~100 You can enforce the grammar during generation by masking invalid next tokens. After NOTE_ON , only pitch tokens are valid. After pitch, only velocity tokens are valid. This guarantees syntactically valid output. I tried note-on/note-off style representations, but my models tended to drift. They would forget to emit note-off, leave hanging notes, or lose track of active state. That was especially bad for my target: a small model running close to real time on a laptop or phone. Another representation I tried was closer to: [NOTE, PITCH, VELOCITY, DURATION] | [TIME_SHIFT, DURATION] This avoids note-off drift because note duration is explicit. The time shift token advances the playhead when no note is played. This worked better musically, but it was slow. One musical note took roughly four autoregressive transformer steps. It also burns through the context window quickly. The final representation The representation I eventually settled on was: NOTE(pitch, delta_onset, duration, velocity) There is no separate TIME_SHIFT event in the final version. Silence is represented by delta_onset on the next note: the time since the previous note onset. For example: NOTE(C4, delta=0, duration=12, velocity=80) NOTE(D4, delta=24, duration=12, velocity=80) means: play C4, wait 24 time steps before the next note onset, then play D4. Chords are represented as multiple notes with delta_onset = 0 , sorted by pitch 2 : NOTE(C4, delta=24, duration=24, velocity=80) NOTE(E4, delta=0, duration=24, velocity=78) NOTE(G4, delta=0, duration=24, velocity=82) It's also not a flat token stream like: NOTE, PITCH, DELTA, DURATION, VELOCITY Instead of spending four transformer passes generating the attributes of a note, the transformer advances the music by one complete note at a time. In practice, this gets the large model to about 108 notes/second on an iPhone, well above anything a human would need for live playing. Internally each note has five categorical fields, each with its own vocabulary 3 , with timing quantized to fixed steps. 4 [event_type, pitch_id, delta_id, duration_id, velocity_id] Each field gets its own embedding. The note token is the sum of all the embeddings: note = event_type_embedding [ NOTE ] + pitch_embedding [ C4 ] + delta_embedding [ 12 ] + duration_embedding [ 24 ] + velocity_embedding [ 80 ] The model then has separate output heads: pitch, delta, duration, and so on. There is a small nested decoder between the fields, so later fields can condition on earlier predicted fields. But the expensive transformer backbone runs only once per note, not once per field. Sustain Pedal As you might know, pressing down the sustain pedal on a piano makes notes play even after you release them. I didn't want to muddy the implementation with adding sustain pedal events. Instead, sustain is baked into note duration during preprocessing. If the key is released while the sustain pedal is down, the note is extended to the pedal-up time. If the same pitch is played again first, the earlier note is cut off at the retrigger. The result is a note duration that approximates the actual sounding duration. This loses the explicit pedal gesture, but it makes the modeling problem much simpler: the model only has to predict pitch, onset, duration, and velocity. Dataset I searched through a lot of publicly available datasets and collections, focusing mostly on older classical music in the public domain. The quality varied wildly, so I ended up writing quite a few cleaning scripts. The final dataset contained a few hundred thousand MIDI files, representing roughly 300 million note events. The final pipeline: selected piano-focused material removed or reduced pathological multi-track mixtures filtered by density and pitch/time coverage deduplicated by fingerprints that ignore global transposition and uniform tempo changes grouped alternate versions of the same composition into the same split I tried scaling the dataset to roughly 5x the size, hoping it would improve performance, but the resulting models were worse. Cleaning and selecting the data mattered more than simply adding more of it. Training Initially, training is just cross-entropy over the five output heads, summed together: type_loss + pitch_loss + delta_loss + duration_loss + velocity_loss This makes it easy to track pitch, duration, and velocity accuracy separately, rather than relying on a single aggregate next-token loss. Still, the training objective has an important limitation: music continuation does not have a single correct answer. A held-out song only gives the model one "correct" next note, even though there are often many continuations that would work musically. Cross-entropy is useful for learning the mechanics of music, but not a great proxy for how good a full continuation sounds. Augmentation Augmentation was important because the live input is not a pristine MIDI file. It is me playing piano, badly enough that notes might be slightly early, late, too hard, etc. In the end I settled on the following augmentations: global transposition uniform tempo scaling duration/velocity jitter dropped prompt notes Model The architecture is essentially a fairly standard decoder-only transformer: RMSNorm, rotary positional embeddings, causal self-attention, SwiGLU/MLP blocks, and autoregressive generation. I m