File size: 4,856 Bytes
daa0bdd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""
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