Score_To_MML / core /part_splitter.py
Coconuttttt's picture
Initial deployment: Score to MML converter
daa0bdd
"""
core/part_splitter.py
NoteEvent 리스트를 N개 파트로 분배.
현재 전략: Greedy 피치 범위 기반 분배
- Part 1: 가장 높은 피치 그룹 (주 멜로디)
- Part 2: 중간 피치 그룹 (화음/반주)
- Part 3: 가장 낮은 피치 그룹 (베이스)
교체 가능성: 이 함수의 시그니처를 유지하면서
더 정교한 알고리즘(staff/voice 기반, 음역대 분석 등)으로 교체 가능.
"""
from __future__ import annotations
from .models import NoteEvent
def _count_max_simultaneous(notes: list[NoteEvent]) -> int:
"""최대 동시 발음 수 계산 (part_count 자동 감지용)."""
if not notes:
return 1
events: list[tuple[float, int]] = []
for n in notes:
events.append((n.start, 1))
events.append((n.start + n.duration, -1))
events.sort()
max_count = current = 0
for _, delta in events:
current += delta
max_count = max(max_count, current)
return max(1, max_count)
def split_parts(
notes: list[NoteEvent],
part_count: int = 0,
) -> list[list[NoteEvent]]:
"""
NoteEvent 리스트를 part_count개 파트로 분배.
Args:
notes: 전체 NoteEvent 리스트
part_count: 분배할 파트 수 (기본 3)
Returns:
파트별 NoteEvent 리스트의 리스트.
길이는 항상 part_count이며, 비어있는 파트는 빈 리스트.
"""
if part_count <= 0:
part_count = _count_max_simultaneous(notes)
if not notes:
return [[] for _ in range(part_count)]
# part_hint가 지정된 경우 우선 사용
if any(n.part_hint is not None for n in notes):
return _split_by_hint(notes, part_count)
staffs = {n.staff for n in notes}
voices = {n.voice for n in notes}
# staff도 1개이고 voice도 1개면 단선율 → Part 1에 전부
if len(staffs) == 1 and len(voices) == 1:
parts = [[] for _ in range(part_count)]
parts[0].extend(sorted(notes, key=lambda n: n.start))
return parts
# staff 또는 voice가 여러 개면 staff+voice 기반 분배
return _split_by_staff_and_voice(notes, part_count)
# fallback: 피치 범위 기반 greedy 분배
return _split_by_pitch_range(notes, part_count)
def _split_by_hint(notes: list[NoteEvent], part_count: int) -> list[list[NoteEvent]]:
"""part_hint 값에 따라 분배."""
parts: list[list[NoteEvent]] = [[] for _ in range(part_count)]
for note in notes:
hint = note.part_hint
if hint is not None and 1 <= hint <= part_count:
parts[hint - 1].append(note)
else:
parts[0].append(note) # 미지정은 Part 1으로
return parts
def _split_by_staff_and_voice(notes: list[NoteEvent], part_count: int) -> list[list[NoteEvent]]:
"""
staff + voice 조합으로 파트 분배.
조합 순서 (높은 피치 우선): staff1/voice1, staff1/voice2, staff2/voice1, ...
"""
# (staff, voice) 조합을 평균 피치 기준으로 정렬 (높은 것이 Part 1)
combos: dict[tuple[int, int], list[NoteEvent]] = {}
for note in notes:
key = (note.staff, note.voice)
combos.setdefault(key, []).append(note)
def avg_pitch(note_list: list[NoteEvent]) -> float:
pitched = [n.pitch for n in note_list if n.pitch > 0]
return sum(pitched) / len(pitched) if pitched else 0.0
sorted_combos = sorted(combos.values(), key=avg_pitch, reverse=True)
parts: list[list[NoteEvent]] = [[] for _ in range(part_count)]
for i, group in enumerate(sorted_combos):
idx = min(i, part_count - 1)
parts[idx].extend(group)
# 파트 내 start 기준 재정렬
for part in parts:
part.sort(key=lambda n: n.start)
return parts
def _split_by_pitch_range(notes: list[NoteEvent], part_count: int) -> list[list[NoteEvent]]:
"""
피치 범위 기준 greedy 분배.
전체 피치를 part_count 구간으로 나누어 분배.
"""
pitched = [n for n in notes if n.pitch > 0]
rests = [n for n in notes if n.pitch == 0]
if not pitched:
parts: list[list[NoteEvent]] = [[] for _ in range(part_count)]
parts[part_count - 1].extend(rests)
return parts
min_pitch = min(n.pitch for n in pitched)
max_pitch = max(n.pitch for n in pitched)
pitch_span = max_pitch - min_pitch or 1
parts = [[] for _ in range(part_count)]
for note in pitched:
ratio = (note.pitch - min_pitch) / pitch_span
# 높은 피치 = 낮은 인덱스 (Part 1)
idx = part_count - 1 - int(ratio * (part_count - 0.01))
idx = max(0, min(idx, part_count - 1))
parts[idx].append(note)
# 쉼표는 Part 1에 배치
parts[0].extend(rests)
for part in parts:
part.sort(key=lambda n: n.start)
return parts