Spaces:
Running
Running
| """ | |
| 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 | |