사람이 고치는 화면에서 정답 데이터 모으기
자동 배치를 선생님이 드래그로 고치는 화면은, 그대로 라벨링 도구이기도 했습니다. 조작 방식이 정답 데이터의 형태를 정한다는 것과, 이렇게 모은 데이터가 원리적으로 보지 못하는 축에 대한 기록입니다.
발음 교정 서비스의 피드백 카드는 학습자의 녹음에서 피치(목소리의 높낮이)를 추출해 곡선을 그리고, 그 위에 음절과 모라(일본어에서 한 박자로 세는 소리 단위)를 시간축에 배치합니다. 자동 배치는 초안이고, 원어민 선생님이 드래그로 보정한 것이 최종본입니다.
이 정렬 알고리즘을 고칠 때마다 나아졌는지 판정할 정답 데이터가 필요했는데, 따로 모을 필요가 없었습니다. 선생님이 매일 하고 있는 보정이 곧 원어민이 귀로 내린 판정이었습니다. 저장할 때마다 자동 초안(draft)과 사람 보정본(final)을 한 쌍으로 적재하도록 하자 정답 41건이 쌓였고, 그 위에 ms 단위 채점 하네스를 올렸습니다.
보정 화면을 만들 때 라벨링 도구를 의도하지는 않았습니다. 저장해둔 보정 기록을 나중에 정답 데이터로 썼습니다. 다만 라벨이 쓸 만한 형태로 남는지는 조작 방식이 결정합니다. 드래그를 어떻게 구현하느냐가 데이터 품질을 정하는 일이 되므로, 아래 세 가지는 UI 편의가 아니라 데이터를 위한 선택이었습니다.
시간축 보정에서 먼저 떠오르는 방식은 경계를 잡아 끄는 것입니다. 음절의 시작 지점을 오른쪽으로 밀면 그 음절은 짧아지고 앞 음절은 길어집니다. 문제는 여기서 끝나지 않습니다. 앞 음절이 길어지면 그 안의 배치가 다시 계산되고, 그 결과가 또 앞으로 번집니다.
데이터 쪽에서 보면 이게 더 곤란합니다. 선생님이 한 번 판단했는데 여러 유닛의 값이 같이 바뀌면, 무엇을 판정한 것인지가 기록에서 흐려집니다. 연쇄로 밀린 값까지 정답으로 취급하면 채점 기준이 오염됩니다.
그래서 경계가 아니라 구간을 옮기는 방식을 택했습니다. 드래그로 유닛의 새 중심을 정하고, 이동량만큼 시작과 끝을 같이 더합니다. 폭이 변하지 않으므로 이웃을 다시 나눌 이유가 없고, 저장된 기록에는 유닛 하나의 이동량만 남습니다. 판단 하나가 라벨 하나에 대응합니다.
const shift = nextCenter - centerOf(unit)
const moved = {
...unit,
tStart: unit.tStart + shift,
tEnd: unit.tEnd + shift,
}대가가 있습니다. 폭이 고정되므로 "이 음절이 실제로는 더 길다"는 종류의 보정은 이 조작으로 할 수 없습니다. 검수에서 실제로 요청된 것은 대부분 길이가 아니라 위치 밀림이었고, 길이 문제는 자동 정렬 쪽을 고쳐야 하는 사안으로 남겼습니다.
구간을 자유롭게 옮기게 하면 세 번째 음절이 두 번째 음절보다 앞에 오는 상태가 만들어집니다. 이런 데이터는 차트를 깨뜨리고, 정답셋에 섞이면 채점 결과까지 망칩니다.
검증으로 걸러내는 대신 만들 수 없게 했습니다. 새 중심을 이웃 유닛의 중심 사이로 클램프하고, 최소 간격을 두어 중심이 겹치는 것도 막습니다.
const MIN_GAP_SEC = 0.02
const lowerBound = prevUnit ? centerOf(prevUnit) + MIN_GAP_SEC : spanStart
const upperBound = nextUnit ? centerOf(nextUnit) - MIN_GAP_SEC : spanEnd
const nextCenter = clamp(draggedCenter, lowerBound, upperBound)드래그가 어떤 좌표로 들어와도 유닛의 순서는 유지됩니다. 정합성 검사를 따로 두지 않아도 되는 것이 이 방식의 이득입니다.
보정을 자동값에 대한 오프셋으로 저장할 수도 있었지만, 그렇게 하면 자동값이 바뀔 때마다 오프셋을 어디에 더해야 하는지가 다시 애매해집니다. 보정된 시각 자체를 절대값으로 저장하고, 그 유닛에 사람이 만졌다는 표시를 남겼습니다.
type Unit = {
text: string
tStart: number
tEnd: number
pitchSemitone: number
/** 사람이 드래그로 보정한 유닛. 재분석 시 자동값으로 덮지 않는다 */
edited?: boolean
}재분석은 이제 전체를 덮어쓰지 않고 병합합니다. 자동 계산 결과를 받되, 사람이 만진 자리에는 저장된 값을 그대로 둡니다.
const merged = aligned.map((auto, i) => (units[i]?.edited ? units[i] : auto))이 한 줄이 두 가지를 동시에 합니다. 알고리즘을 고치고 재분석을 돌려도 선생님이 이미 판정한 자리는 움직이지 않습니다. 그리고 정답셋에서 라벨이 있는 유닛과 아무도 손대지 않은 유닛을 구분할 수 있게 됩니다. 이 구분이 다음 이야기의 전제입니다.
정답 41건을 놓고 문장 첫머리의 정확도를 채점하려 했더니, 41건 전부 첫 유닛의 시작 지점 이동량이 0ms였습니다. 아무도 그 지점을 드래그하지 않았기 때문입니다.
이 결과를 "첫머리는 이미 정확하다"로 읽을 수는 없습니다. 선생님이 카드를 볼 때 첫 글자의 시작이 조금 밀린 것은 눈에 걸리지 않고, 문장 중간 글자가 소리와 어긋난 것은 바로 걸립니다. 고칠 이유가 없었던 것이 아니라, 고칠 이유가 눈에 띄지 않았을 뿐입니다.
사람이 고치는 화면에서 정답을 얻는 방식에는 이 편향이 구조적으로 따라옵니다. 라벨은 사람이 불편을 느낀 자리에만 생기고, 불편이 눈에 띄지 않는 축은 데이터에 아무 흔적을 남기지 않습니다. 그 축을 개선했는지는 이 하네스로 판정할 수 없어서, 검수 라운드의 지적 건수로 따로 판정해야 했습니다. 기계 채점과 사람 채점을 둘 다 유지하는 이유가 여기 있습니다.
- 폭 보정은 여전히 불가능합니다. 길이가 실제로 다른 경우는 자동 정렬 쪽을 고쳐야 합니다.
- 유닛 개수가 바뀌는 재분석에서는 인덱스 기준 병합이 성립하지 않습니다. 텍스트가 수정된 카드는 보정을 승계하지 않고 초기화하도록 두었습니다.
- 재생 위치 표시가 소리와 어긋나는 문제는 이 보정과 무관한 재생 동기화 쪽 사안으로 남아 있습니다.
정답 데이터를 모으려고 계획한 적은 없습니다. 보정 화면을 만들고 그 결과를 버리지 않았을 뿐입니다. 그 데이터가 어디를 보지 못하는지는 따로 알아내야 했습니다.