STILLCODING

STILL CODING / NOTES

텍스트 악보를 마우스로 고치려면 — Songnote가 ABC를 문서 모델로 바꿔 편집하는 법

JH Kim

Songnote는 ABC라는 텍스트 악보를 입력하면 오선보로 그려 주고 피아노로 연주해 주는 앱입니다. 이전 글에서는 ABC 문법과 소리 만드는 법을 다뤘습니다.

오선보에서 음표를 반음 올리거나 세 번째 마디 뒤에 음을 넣으려면, ABC 텍스트에서는 해당 글자 위치부터 찾아야 합니다. 음 길이를 잘못 쓰면 마디도 어긋납니다. Songnote의 시각 편집기는 클릭한 음표를 문서 모델의 이벤트에 연결해 고칩니다. 편집 결과는 다시 ABC로 내보냅니다.

클릭한 음표를 문서의 이벤트로 찾기

가장 단순한 방법은 마우스로 음표를 클릭하면 ABC 텍스트의 해당 글자를 찾아 문자열을 바꾸는 것입니다. 하지만 이 방법은 금방 막힙니다. 음을 하나 지우면 마디의 나머지 박이 어긋나고, 화음을 추가하려면 문자열 안에서 대괄호를 정확히 다뤄야 하며, 실행 취소는 문자열 조각 단위의 차이(diff)를 관리해야 합니다.

편집기는 ABC를 마디와 이벤트로 나누어 다룹니다. 시각 편집 명령을 실행할 때마다 문서를 ABC 텍스트로 되돌리고, 상위 화면에 초안 변경을 알립니다. 저장이나 재생 시점까지 변환을 미루는 구조는 아닙니다.

ABC 텍스트  ──parseScore──▶  문서 모델  ──serializeScore──▶  ABC 텍스트
                              ▲    │
                        편집 명령   └── 오선보 렌더링, 재생, 검증

src/editor/model.js의 문서 모델은 대략 이런 모양입니다.

{
  header:   { title, composer, meter: '4/4', unitLength: '1/4', tempo: 100, key: 'C' },
  voices:   [{ id: 'RH', clef: 'treble' }, { id: 'LH', clef: 'bass' }],
  measures: [
    { id, number: 1, eventsByVoice: {
        RH: [{ id, kind: 'note' | 'chord' | 'rest', onset, duration, pitches, tieStart, articulations }],
        LH: [ /* ... */ ]
    } }
  ]
}

음 길이를 ABC 접미사로 되돌리기

frac는 분자와 분모를 최대공약수로 약분합니다. 기본 음 길이를 읽을 때 이 함수를 쓰지만, 이벤트의 duration과 onset은 fracValue로 변환한 숫자입니다. 모든 계산을 분수로 유지하는 구현은 아닙니다. 3잇단음표처럼 이진 부동소수점으로 정확히 표현되지 않는 길이가 있고, 비교에는 1e-6의 허용 오차를 둡니다.

ABC 텍스트로 되돌릴 때는 길이를 ABC의 접미사로 바꾸는 함수가 분모를 1부터 16까지 시도해서 딱 떨어지는 분수를 찾습니다.

const durationSuffix = (duration, unit) => {
  const ratio = duration / fracValue(unit);
  if (Math.abs(ratio - 1) < 1e-6) return '';
  // ABC accepts a numerator/denominator suffix. Keep compound values such
  // as 3/2 exact; emitting /1 changes how renderers interpret the duration.
  for (let denominator = 1; denominator <= 16; denominator += 1) {
    const numerator = Math.round(ratio * denominator);
    if (Math.abs(ratio - numerator / denominator) > 1e-6) continue;
    if (denominator === 1) return String(numerator);
    if (numerator === 1) return `/${denominator}`;
    return `${numerator}/${denominator}`;
  }
  // ...
};

코드의 주석이 이유를 밝혀 둡니다(번역). “3/2 같은 복합 값은 정확히 유지한다. /1을 내보내면 렌더러가 길이를 다르게 해석한다.”

문서를 복제해 실행 취소 기록 남기기

편집 하나하나는 commit이라는 함수를 거칩니다.

const commit = updater => {
  const before = cloneDocument(doc);      // 지금 상태를 통째로 복제해 기록
  const next = cloneDocument(doc);
  updater(next);                          // 복제본에만 수정을 적용
  next.revision += 1;
  setHistory(h => [...h.slice(-49), before]);   // 실행 취소 기록 (최대 50단계)
  setFuture([]);                                // 새 편집을 하면 '다시 실행'은 비운다
  setDoc(next);
  const text = serializeScore(next);
  lastLocal.current = text;
  onDraftChange?.(text);                  // ABC 텍스트로 되돌려 초안으로 저장
};

실행 취소 기록에는 문서 전체의 복제본이 들어갑니다. 이전 문서를 꺼내면 되므로 명령별 반대 동작을 작성할 필요는 없습니다. 문서가 커지면 복제 비용과 기록의 메모리 사용량도 늘어납니다. 이 코드만으로 긴 곡에서의 반응 속도를 판단할 수는 없습니다.

텍스트 입력과 시각 편집이 만나는 시점

이 편집기에는 같은 곡을 나타내는 표현이 두 개입니다. 사용자가 ABC 텍스트를 직접 고치는 칸과, 시각 편집기의 문서 모델입니다. 한쪽이 바뀔 때마다 다른 쪽을 갱신하면 서로를 덮어쓰는 무한 루프가 생기기 쉽습니다. 그래서 편집기가 마지막으로 스스로 만든 텍스트를 기억해 둡니다.

// 밖에서 온 텍스트가 내가 방금 만든 것과 다를 때만 다시 읽는다
useEffect(() => {
  if (source !== lastLocal.current) {
    const next = parseScore(source);
    setDoc(next);
    setSelected(null); setHistory([]); setFuture([]);   // 곡이 바뀌었으니 실행 취소도 초기화
    lastLocal.current = source;
  }
}, [source]);

마디가 넘칠 때와 모자랄 때

마디의 박 수가 맞지 않으면 어떻게 할까요? 음을 하나씩 입력하는 도중에는 마디가 잠시 비어 있거나 넘치는 것이 당연합니다. 검증 함수는 상태를 두 단계로 나눕니다.

if (total > doc.measureLength + 1e-6)      // 마디 길이를 넘음
  diagnostics.push({ level: 'error', code: 'diag.over', /* ... */ });
else if (total > 0 && total < doc.measureLength - 1e-6)   // 부족함
  diagnostics.push({ level: 'warning', code: 'diag.under', /* ... */ });

렌더링된 음표와 소스 위치 잇기

문서 모델은 악보를 그리지는 않습니다. 오선보 그림은 abcjs 라이브러리가 ABC 텍스트로부터 그립니다. 그렇다면 재생 중인 음표를 강조하거나, 클릭한 음표가 무엇인지 알려면 그림 속 요소와 소스를 이어야 합니다. abcjs는 렌더링된 각 요소가 소스 텍스트의 어느 위치(startChar)에서 왔는지를 알려 줍니다.

selectables.current = rendered[0]?.getSelectableArray?.() || [];
// ...
for (const item of selectables.current) {
  const start = item.absEl?.abcelem?.startChar;
  item.svgEl?.classList.toggle('is-sounding', chars.has(start));   // 지금 울리는 음표만 강조
}

재생용 음표 목록의 각 음이 자기 startChar를 가지고 있어서, 지금 재생 위치에 걸친 음들의 startChar 집합을 만들고, 같은 값을 가진 SVG 요소에 클래스를 붙입니다. 선택한 음표의 테두리는 abcjs가 붙여 주는 클래스(abcjs-v{성부}, abcjs-m{마디})로 요소를 찾아 그립니다.

브라우저에 남는 초안과 버전

이 편집기에서 시각 명령은 문서 모델을 바꾸고, 텍스트 입력은 초안을 먼저 바꿉니다. 버전 저장 시점에 두 표현이 다시 맞춰집니다. 실행 취소 기록은 그 모델 안에서 유지되며, 외부 텍스트를 다시 읽을 때 초기화됩니다.