#!/usr/bin/env python3 """ convert_3part.py 마비노기 1인 악보용 MusicXML → 3파트 MML 변환기 절대 규칙: 1. 절대 시간축 기준 2. / 로 실제 길이 계산 3. note = 직전 non-chord note와 같은 시작점 4. / 반영 5. tie(start/stop) 동일 pitch 병합 6. 무시 7. tempo: 우선, 없으면 metronome, 기본값 120 8. 동시 3음 이하 그대로 9. 동시 4음 이상 → 최상성+최하성+화성 대표 1음으로 축약 10. 출력 Part 1=Melody, Part 2=Chord1/Sub, Part 3=Chord2/Bass 고정 11. 내부 계산은 Fraction 고정밀, 최종 출력 직전에만 MML 토큰으로 변환 12. pitch/start/duration/tempo 보존 최우선; slur/layout/harmony text 무시 Usage: python convert_3part.py file1.mxl [file2.mxl ...] -o output.txt [--append] """ from __future__ import annotations import argparse import sys import zipfile import xml.etree.ElementTree as ET from fractions import Fraction from functools import lru_cache from typing import List, Tuple, Optional, Dict # ───────────────────────────────────────────────────────────────── # 상수 # ───────────────────────────────────────────────────────────────── QUARTER_TICKS = 12 # quarter note = 12 ticks WHOLE_TICKS = 48 # whole note = 48 ticks STEP_TO_SEMI: Dict[str, int] = { 'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11 } SEMI_TO_NOTE: Dict[int, str] = { 0: 'c', 1: 'c+', 2: 'd', 3: 'd+', 4: 'e', 5: 'f', 6: 'f+', 7: 'g', 8: 'g+', 9: 'a', 10: 'a+', 11: 'b' } # (tick, MML token) — 내림차순 정렬 (DP greedy용) MML_DUR_TABLE: List[Tuple[int, str]] = sorted([ (48, '1'), (36, '2.'), (24, '2'), (18, '4.'), (16, '3'), (12, '4'), (9, '8.'), (8, '6'), (6, '8'), (4, '12'), (3, '16'), (2, '24'), (1, '48'), ], reverse=True) MML_TICK_TO_TOKEN: Dict[int, str] = dict(MML_DUR_TABLE) # ───────────────────────────────────────────────────────────────── # MXL / XML 열기 # ───────────────────────────────────────────────────────────────── def open_mxl_or_xml(path: str) -> str: """MXL(zip) 또는 일반 XML/MusicXML 파일에서 MusicXML 문자열 반환""" if path.lower().endswith('.mxl'): with zipfile.ZipFile(path) as zf: # META-INF/container.xml 에서 rootfile 경로 찾기 try: container = zf.read('META-INF/container.xml') croot = ET.fromstring(container) for elem in croot.iter(): if '}' in elem.tag: elem.tag = elem.tag.split('}')[1] rf = croot.find('.//rootfile') xml_name = rf.attrib['full-path'] if rf is not None else '' except Exception: xml_name = '' if not xml_name: # fallback: 첫 번째 .xml 또는 .musicxml 파일 xml_name = next( (n for n in zf.namelist() if n.endswith('.xml') or n.endswith('.musicxml')), '' ) if not xml_name: raise FileNotFoundError(f'MXL 안에서 MusicXML을 찾지 못함: {path}') return zf.read(xml_name).decode('utf-8', errors='replace') else: with open(path, encoding='utf-8', errors='replace') as f: return f.read() # ───────────────────────────────────────────────────────────────── # MusicXML 파싱 # ───────────────────────────────────────────────────────────────── def strip_ns(root: ET.Element) -> None: """namespace 제거""" for elem in root.iter(): if '}' in elem.tag: elem.tag = elem.tag.split('}')[1] def parse_tempo(root: ET.Element) -> int: """규칙 7: 우선, 없으면 metronome, 기본 120""" for sound in root.iter('sound'): t = sound.attrib.get('tempo') if t: try: return int(float(t)) except ValueError: pass for metro in root.iter('metronome'): bpm = metro.findtext('per-minute') if bpm: try: return int(float(bpm)) except ValueError: pass return 120 def frac_tick(raw_dur: int, divisions: int) -> Fraction: """XML raw duration → Fraction ticks (규칙 2)""" if divisions <= 0: divisions = 1 return Fraction(raw_dur * QUARTER_TICKS, divisions) # 요소 기반 duration 폴백 테이블 (divisions 오류 시 사용) _TYPE_TICKS: dict = { 'breve': Fraction(96), 'whole': Fraction(48), 'half': Fraction(24), 'quarter': Fraction(12), 'eighth': Fraction(6), '16th': Fraction(3), '32nd': Fraction(3, 2), '64th': Fraction(3, 4), } def _dur_from_type(note_elem: ET.Element) -> Optional[Fraction]: """ + 요소로 duration(ticks) 계산. 없으면 None.""" t = note_elem.findtext('type') if t not in _TYPE_TICKS: return None ticks = _TYPE_TICKS[t] for _ in note_elem.findall('dot'): ticks = ticks * Fraction(3, 2) return ticks def parse_part(part_elem: ET.Element) -> Tuple[List[dict], Fraction]: """ 단일 를 절대 시간축으로 파싱 (규칙 1~6). Returns: (events, total_ticks) event = {'start': Fraction, 'dur': Fraction, 'midi': int, 'tie': frozenset} """ events: List[dict] = [] measure_start = Fraction(0) divisions = 1 divisions_valid = False # XML에서 유효한 divisions 값(>0)을 읽었는지 여부 for m in part_elem.findall('measure'): # attributes 우선 확인 attr = m.find('attributes') if attr is not None: d = attr.findtext('divisions') if d: divisions = int(d) if divisions > 0: divisions_valid = True # divisions=0 인 implicit 마디는 Audiveris 아티팩트 — 건너뜀 if not divisions_valid and m.get('implicit') == 'yes': continue def get_dur(raw_dur: int, note_elem: Optional[ET.Element] = None) -> Fraction: """divisions 유효하면 계산값 사용, 아니면 기반 폴백.""" if divisions_valid: return frac_tick(raw_dur, divisions) if note_elem is not None: t = _dur_from_type(note_elem) if t is not None: return t return frac_tick(raw_dur, max(divisions, 1)) cursor = Fraction(0) # 마디 내 raw cursor (frac ticks) max_cursor = Fraction(0) last_note_start: Optional[Fraction] = None for child in m: tag = child.tag if tag == 'backup': # 규칙 4: backup raw = int(child.findtext('duration', '0')) cursor -= get_dur(raw) elif tag == 'forward': # 규칙 4: forward raw = int(child.findtext('duration', '0')) cursor += get_dur(raw) max_cursor = max(max_cursor, cursor) elif tag == 'harmony': # 규칙 6: harmony 무시 pass elif tag == 'note': raw_dur = int(child.findtext('duration', '0')) dur_f = get_dur(raw_dur, child) is_chord = child.find('chord') is not None is_rest = child.find('rest') is not None is_grace = child.find('grace') is not None # 규칙: grace 기본 제거 if is_grace: if not is_chord: # grace는 duration=0 이 많지만 혹시 있으면 cursor += dur_f max_cursor = max(max_cursor, cursor) continue # 규칙 3: chord note는 직전 non-chord note와 같은 시작점 if is_chord: note_start = last_note_start if last_note_start is not None else cursor else: note_start = cursor last_note_start = cursor tie_types = frozenset(t.attrib.get('type') for t in child.findall('tie')) if not is_rest: p = child.find('pitch') if p is not None: step = p.findtext('step', 'C') octave = int(p.findtext('octave', '4')) alter = int(float(p.findtext('alter', '0'))) midi = (octave + 1) * 12 + STEP_TO_SEMI.get(step, 0) + alter abs_start = measure_start + note_start staff = int(child.findtext('staff', '1')) events.append({ 'start': abs_start, 'dur': dur_f, 'midi': midi, 'tie': tie_types, 'staff': staff, }) # cursor 갱신 (규칙 3: chord는 cursor 이동 안 함) if not is_chord: cursor += dur_f max_cursor = max(max_cursor, cursor) else: max_cursor = max(max_cursor, note_start + dur_f) measure_start += max_cursor return events, measure_start def parse_xml_string(xml_str: str) -> Tuple[List[dict], Fraction, int]: """MusicXML 문자열 → (모든 파트 이벤트 합산, 총 ticks, tempo)""" root = ET.fromstring(xml_str.encode('utf-8', errors='replace') if isinstance(xml_str, str) else xml_str) strip_ns(root) tempo = parse_tempo(root) all_events: List[dict] = [] total_dur = Fraction(0) for part in root.findall('part'): evs, part_dur = parse_part(part) all_events.extend(evs) total_dur = max(total_dur, part_dur) return all_events, total_dur, tempo # ───────────────────────────────────────────────────────────────── # 타이 병합 (규칙 5) # ───────────────────────────────────────────────────────────────── def merge_ties(events: List[dict]) -> List[dict]: """동일 pitch의 tie start→stop 연결을 지속음으로 병합""" out: List[dict] = [] # midi → index in out ongoing: Dict[int, int] = {} for e in sorted(events, key=lambda x: (x['start'], x['midi'])): midi = e['midi'] tie = e['tie'] if 'stop' in tie and midi in ongoing: prev = out[ongoing[midi]] # 연속(직전 note의 끝 == 현재 시작)이어야 병합 if prev['start'] + prev['dur'] == e['start']: prev['dur'] += e['dur'] if 'start' not in tie: del ongoing[midi] continue # 현재 이벤트를 별도 추가하지 않음 # 새 이벤트로 추가 new_ev = {k: v for k, v in e.items()} out.append(new_ev) if 'start' in tie: ongoing[midi] = len(out) - 1 return out # ───────────────────────────────────────────────────────────────── # 동시음 축약 (규칙 8~9) # ───────────────────────────────────────────────────────────────── def _best_middle(pitches: List[int], top: int, bot: int) -> Optional[int]: """ top/bot 제외 후보에서 화성 정체성을 가장 살리는 1음 반환. 제거 우선순위: 옥타브 중복 > 완전5도 중복 > 통과음 """ candidates = [p for p in pitches if p != top and p != bot] if not candidates: return None top_cls = top % 12 bot_cls = bot % 12 # 1단계: top/bot과 옥타브 중복 제거 f1 = [p for p in candidates if p % 12 not in (top_cls, bot_cls)] if f1: candidates = f1 # 2단계: top의 완전5도(7반음 아래) 음 제거 p5_cls = (top_cls - 7) % 12 f2 = [p for p in candidates if p % 12 != p5_cls] if f2: candidates = f2 # 3단계: 화음 내 우선 음정 (단3/장3/단7 등) 기준 선택 PREFERRED_INTERVALS = {3: 0, 4: 1, 10: 2, 9: 3, 7: 4, 8: 5, 5: 6, 2: 7} def harmony_score(p: int): interval = (top - p) % 12 return (PREFERRED_INTERVALS.get(interval, 10), -p) return min(candidates, key=harmony_score) def reduce_simultaneous(events: List[dict]) -> List[dict]: """ 규칙 8~9: 같은 start_tick에서 4음 이상 동시 시작이면 3음으로 축약. """ by_start: Dict[Fraction, List[dict]] = {} for e in events: by_start.setdefault(e['start'], []).append(e) result: List[dict] = [] for start in sorted(by_start): group = sorted(by_start[start], key=lambda x: -x['midi']) if len(group) <= 3: result.extend(group) else: top = group[0] bot = group[-1] pitches = [ev['midi'] for ev in group] mid_pitch = _best_middle(pitches, top['midi'], bot['midi']) kept = [top] if mid_pitch is not None: # group에서 해당 pitch의 이벤트 찾기 (top/bot 제외) for ev in group[1:-1]: if ev['midi'] == mid_pitch: kept.append(ev) break kept.append(bot) result.extend(kept) return result # ───────────────────────────────────────────────────────────────── # 3트랙 배정 (규칙 10) # ───────────────────────────────────────────────────────────────── def assign_3_tracks( events: List[dict], total_dur: Fraction ) -> Tuple[List[dict], List[dict], List[dict]]: """ 이벤트를 Melody(0) / Chord1(1) / Bass(2) 3트랙으로 배정. 동시 시작: 고음→Melody, 중간→Chord1, 저음→Bass. 단독음: 빈 트랙에 Melody 우선 배정. """ tracks: List[List[dict]] = [[], [], []] track_end = [Fraction(-1)] * 3 # 각 트랙의 현재 점유 끝 tick by_start: Dict[Fraction, List[dict]] = {} for e in events: by_start.setdefault(e['start'], []).append(e) for start in sorted(by_start): group = sorted(by_start[start], key=lambda x: -x['midi']) n = len(group) if n >= 3: # 3음: 고→0(Melody), 중→1(Chord1), 저→2(Bass) for tidx, ev in [(0, group[0]), (1, group[1]), (2, group[-1])]: tracks[tidx].append(ev) track_end[tidx] = ev['start'] + ev['dur'] elif n == 2: # 2음: 고→Melody, 저→Bass 시도 pairs = [(0, group[0]), (2, group[1])] for tidx, ev in pairs: if track_end[tidx] <= ev['start']: tracks[tidx].append(ev) track_end[tidx] = ev['start'] + ev['dur'] elif track_end[1] <= ev['start']: tracks[1].append(ev) track_end[1] = ev['start'] + ev['dur'] # 모두 겹치면 skip (손실 허용) else: # n == 1 ev = group[0] # 빈 트랙에 Melody 우선 assigned = False for tidx in [0, 1, 2]: if track_end[tidx] <= ev['start']: tracks[tidx].append(ev) track_end[tidx] = ev['start'] + ev['dur'] assigned = True break # 모두 busy면 pitch가 가장 가까운 트랙에 강제 배정 if not assigned: closest = min( range(3), key=lambda i: abs( (tracks[i][-1]['midi'] if tracks[i] else 60) - ev['midi'] ) ) tracks[closest].append(ev) track_end[closest] = max(track_end[closest], ev['start'] + ev['dur']) # 각 트랙 start 기준 정렬 for i in range(3): tracks[i].sort(key=lambda e: (e['start'], -e['midi'])) return tracks[0], tracks[1], tracks[2] # ───────────────────────────────────────────────────────────────── # 틱 → MML 토큰 분해 (규칙 11) # ───────────────────────────────────────────────────────────────── @lru_cache(maxsize=4096) def _decompose_dp(rem: int) -> Optional[tuple]: """DP로 rem 틱을 MML 토큰 틱 리스트로 분해 (최소 토큰 수)""" if rem == 0: return () best: Optional[tuple] = None for tick, _ in MML_DUR_TABLE: if tick <= rem: tail = _decompose_dp(rem - tick) if tail is not None: cand = (tick,) + tail if best is None or len(cand) < len(best): best = cand return best def decompose_duration(ticks: Fraction) -> List[Tuple[int, str]]: """Fraction 틱 → [(tick, MML_token), ...] (규칙 11)""" iticks = round(float(ticks)) iticks = max(0, iticks) if iticks == 0: return [] parts = _decompose_dp(iticks) if parts is None: raise ValueError(f'분해 불가: {ticks} → {iticks} ticks') return [(t, MML_TICK_TO_TOKEN[t]) for t in parts] # ───────────────────────────────────────────────────────────────── # MML 빌드 # ───────────────────────────────────────────────────────────────── def _midi_to_oct_note(midi: int) -> Tuple[int, str]: return midi // 12 - 1, SEMI_TO_NOTE[midi % 12] def build_track_mml(events: List[dict], total_dur: Fraction, optimize: bool = True) -> str: """이벤트 리스트 + 총 길이 → MML 문자열 (MML@ 와 ; 제외) optimize=True: - ±1 옥타브 이동: oN 대신 >/< 사용 - 같은 음표길이 3개 이상 연속: lN 설정 후 suffix 생략 """ # Phase 1: raw 토큰 생성 # ('oct', int) | ('note', str, str) | ('rest', str) | ('tie',) raw: List[tuple] = [] cur_time = Fraction(0) cur_oct: Optional[int] = None for e in events: if e['start'] > cur_time: for _, tok in decompose_duration(e['start'] - cur_time): raw.append(('rest', tok)) cur_time = e['start'] if e['start'] < cur_time: continue octave, note = _midi_to_oct_note(e['midi']) if cur_oct != octave: raw.append(('oct', octave)) cur_oct = octave dur_parts = decompose_duration(e['dur']) for i, (_, tok) in enumerate(dur_parts): raw.append(('note', note, tok)) if i < len(dur_parts) - 1: raw.append(('tie',)) cur_time = e['start'] + e['dur'] if cur_time < total_dur: for _, tok in decompose_duration(total_dur - cur_time): raw.append(('rest', tok)) # Phase 2: 연속 같은 duration run 길이 계산 (optimize 시에만 사용) n = len(raw) run_len = [1] * n if optimize: for i in range(n - 2, -1, -1): a, b = raw[i], raw[i + 1] if a[0] in ('note', 'rest') and b[0] in ('note', 'rest'): da = a[2] if a[0] == 'note' else a[1] db = b[2] if b[0] == 'note' else b[1] if da == db: run_len[i] = run_len[i + 1] + 1 # Phase 3: 출력 생성 pieces: List[str] = [] cur_oct = None cur_len: Optional[str] = None for i, t in enumerate(raw): if t[0] == 'oct': new_oct = t[1] if cur_oct is not None: diff = new_oct - cur_oct if optimize and diff == 1: pieces.append('>') elif optimize and diff == -1: pieces.append('<') else: pieces.append(f'o{new_oct}') else: pieces.append(f'o{new_oct}') cur_oct = new_oct elif t[0] == 'tie': pieces.append('&') elif t[0] == 'rest': suffix = t[1] if optimize and run_len[i] >= 3 and suffix != cur_len: pieces.append(f'l{suffix}') cur_len = suffix pieces.append('r' if suffix == cur_len else 'r' + suffix) elif t[0] == 'note': note, suffix = t[1], t[2] if optimize and run_len[i] >= 3 and suffix != cur_len: pieces.append(f'l{suffix}') cur_len = suffix pieces.append(note if suffix == cur_len else note + suffix) return ''.join(pieces) # ───────────────────────────────────────────────────────────────── # 변환 파이프라인 # ───────────────────────────────────────────────────────────────── def convert_files(file_paths: List[str], optimize: bool = True) -> Tuple[str, str, str, int]: """ 여러 파일(페이지 순서)을 연결해 3파트 MML로 변환. Returns: (melody_mml, chord1_mml, bass_mml, tempo) """ all_events: List[dict] = [] total_dur = Fraction(0) tempo = 120 for path in file_paths: xml_str = open_mxl_or_xml(path) evs, dur, bpm = parse_xml_string(xml_str) # 페이지 오프셋 적용 for e in evs: e['start'] += total_dur all_events.extend(evs) total_dur += dur tempo = bpm # 파이프라인 all_events = merge_ties(all_events) # 규칙 5 all_events = reduce_simultaneous(all_events) # 규칙 8~9 mel, ch1, bas = assign_3_tracks(all_events, total_dur) # 규칙 10 melody_mml = build_track_mml(mel, total_dur, optimize=optimize) chord1_mml = build_track_mml(ch1, total_dur, optimize=optimize) bass_mml = build_track_mml(bas, total_dur, optimize=optimize) return melody_mml, chord1_mml, bass_mml, tempo def format_output(melody: str, chord1: str, bass: str, tempo: int) -> str: """ 3파트 MML 결과를 텍스트 형식으로 포매팅. 마비노기 1인 악보용: MML@p1,p2,p3; 통합 포맷으로 출력. """ lines = [ 'Part 1', f'MML@{melody};', '', 'Part 2', f'MML@{chord1};', '', 'Part 3', f'MML@{bass};', ] return '\n'.join(lines) # ───────────────────────────────────────────────────────────────── # 가야금 모드 # ───────────────────────────────────────────────────────────────── def _clamp_midi_to_range(midi: int, lo: int, hi: int) -> int: """MIDI 번호를 lo~hi 범위 안으로 12반음 단위로 조정.""" while midi > hi: midi -= 12 while midi < lo: midi += 12 return midi def convert_files_gayageum(file_paths: List[str], optimize: bool = True) -> Tuple[str, str, str, int]: """ 가야금 모드 변환. Staff 1(오른손) → 농현(o5~o8, MIDI 72~107): MIDI +48 후 클램프 Staff 2(왼손) → 평음(o1~o4, MIDI 24~59): 범위 벗어나면 클램프 합산 후 3파트로 압축. Returns: (melody_mml, chord1_mml, bass_mml, tempo) """ all_events: List[dict] = [] total_dur = Fraction(0) tempo = 120 for path in file_paths: xml_str = open_mxl_or_xml(path) evs, dur, bpm = parse_xml_string(xml_str) for e in evs: e['start'] += total_dur all_events.extend(evs) total_dur += dur tempo = bpm all_events = merge_ties(all_events) staff_map = split_by_staff(all_events) mapped: List[dict] = [] # Staff 1: 오른손 → 농현 (o5~o8, MIDI 72~107) for e in staff_map.get(1, []): ne = dict(e) ne['midi'] = _clamp_midi_to_range(e['midi'] + 48, 72, 107) mapped.append(ne) # Staff 2: 왼손 → 평음 (o1~o4, MIDI 24~59) for e in staff_map.get(2, []): ne = dict(e) ne['midi'] = _clamp_midi_to_range(e['midi'], 24, 59) mapped.append(ne) mapped = reduce_simultaneous(mapped) mel, ch1, bas = assign_3_tracks(mapped, total_dur) melody_mml = build_track_mml(mel, total_dur, optimize=optimize) chord1_mml = build_track_mml(ch1, total_dur, optimize=optimize) bass_mml = build_track_mml(bas, total_dur, optimize=optimize) return melody_mml, chord1_mml, bass_mml, tempo # ───────────────────────────────────────────────────────────────── # staff 기반 분리 # ───────────────────────────────────────────────────────────────── def split_by_staff(events: List[dict]) -> Dict[int, List[dict]]: """이벤트를 staff 번호별로 분리. {staff_num: [events]}""" result: Dict[int, List[dict]] = {} for e in events: s = e.get('staff', 1) result.setdefault(s, []).append(e) return result def convert_files_by_staff(file_paths: List[str], optimize: bool = True) -> Tuple[Dict[int, str], Fraction, int]: """ Staff별로 MML을 분리해서 반환. Returns: ({staff_num: mml_str}, total_dur, tempo) """ all_events: List[dict] = [] total_dur = Fraction(0) tempo = 120 for path in file_paths: xml_str = open_mxl_or_xml(path) evs, dur, bpm = parse_xml_string(xml_str) for e in evs: e['start'] += total_dur all_events.extend(evs) total_dur += dur tempo = bpm all_events = merge_ties(all_events) staff_events = split_by_staff(all_events) staff_mmls: Dict[int, str] = {} for staff_num, evs in sorted(staff_events.items()): # staff 내 동시음을 높은음 순으로 n개 트랙으로 분리 tracks = assign_n_tracks(evs, total_dur) # 각 트랙을 MML로 변환 후 합산 (staff 하나 = 여러 트랙 가능) track_mmls = [build_track_mml(t, total_dur, optimize=optimize) for t in tracks] staff_mmls[staff_num] = track_mmls # 트랙 리스트로 저장 return staff_mmls, total_dur, tempo def format_output_by_staff(staff_mmls: Dict[int, list], total_dur: Fraction, tempo: int) -> str: """ Staff별 MML을 파트 형태로 출력. Staff 1 = 오른손(멜로디), Staff 2 = 왼손(베이스) 순. """ lines = [] part_num = 1 for staff_num, track_mmls in sorted(staff_mmls.items()): hand = '오른손' if staff_num == 1 else '왼손' if staff_num == 2 else f'Staff {staff_num}' for i, mml in enumerate(track_mmls, 1): lines.append(f'Part {part_num} [{hand} Track {i}]') lines.append(f'MML@{mml};') lines.append('') part_num += 1 return '\n'.join(lines) # ───────────────────────────────────────────────────────────────── # --all-notes 모드: 동시음 전부 포함, 파트 수 = 최대 동시음 수 # ───────────────────────────────────────────────────────────────── def assign_n_tracks(events: List[dict], total_dur: Fraction) -> List[List[dict]]: """ 동시음을 높은음 순으로 N개 트랙에 배정. 음 하나도 버리지 않음. N = 전체 악보에서 동시에 울리는 음의 최대 개수. Returns: N개 트랙 리스트 (각 트랙은 event dict 리스트) """ if not events: return [[]] # 각 시작 시점의 동시음 개수 파악 → 최대값 N from collections import defaultdict start_groups: dict = defaultdict(list) for e in events: start_groups[e['start']].append(e) max_poly = max(len(g) for g in start_groups.values()) n = max(max_poly, 1) tracks: List[List[dict]] = [[] for _ in range(n)] for start, group in sorted(start_groups.items()): # 높은음 순 정렬 sorted_group = sorted(group, key=lambda e: e['midi'], reverse=True) for i, ev in enumerate(sorted_group): tracks[i % n].append(ev) return tracks def format_output_n(tracks: List[List[dict]], total_dur: Fraction, tempo: int, optimize: bool = True) -> str: """ N파트 MML 결과를 텍스트 형식으로 포매팅. Part 1 ~ Part N 형식. """ lines = [] for i, track in enumerate(tracks, 1): mml = build_track_mml(track, total_dur, optimize=optimize) lines.append(f'Part {i}') lines.append(f'MML@{mml};') lines.append('') return '\n'.join(lines) def convert_files_all_notes(file_paths: List[str]) -> Tuple[List[List[dict]], Fraction, int]: """ 여러 파일을 연결해 N파트(최대 동시음 수)로 변환. Returns: (tracks, total_dur, tempo) """ all_events: List[dict] = [] total_dur = Fraction(0) tempo = 120 for path in file_paths: xml_str = open_mxl_or_xml(path) evs, dur, bpm = parse_xml_string(xml_str) for e in evs: e['start'] += total_dur all_events.extend(evs) total_dur += dur tempo = bpm all_events = merge_ties(all_events) tracks = assign_n_tracks(all_events, total_dur) return tracks, total_dur, tempo # ───────────────────────────────────────────────────────────────── # CLI # ───────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description='MusicXML → 3파트 MML 변환기') parser.add_argument('files', nargs='+', help='입력 MXL/XML 파일 (페이지 순서)') parser.add_argument('-o', '--output', help='출력 파일 경로') parser.add_argument('--append', action='store_true', help='기존 파일 하단에 구분선과 함께 추가') parser.add_argument('--all-notes', action='store_true', help='동시음 전부 포함, 파트 수=최대 동시음 수 모드') parser.add_argument('--gayageum', action='store_true', help='가야금 모드: 오른손→농현(o5~o8), 왼손→평음(o1~o4), 3파트 출력') args = parser.parse_args() print(f'변환 중: {args.files}', file=sys.stderr) if args.gayageum: melody, chord1, bass, tempo = convert_files_gayageum(args.files) result_text = format_output(melody, chord1, bass, tempo) print('가야금 모드: 오른손→농현(o5~o8), 왼손→평음(o1~o4)', file=sys.stderr) elif args.all_notes: tracks, total_dur, tempo = convert_files_all_notes(args.files) result_text = format_output_n(tracks, total_dur, tempo) print(f'파트 수: {len(tracks)}', file=sys.stderr) else: melody, chord1, bass, tempo = convert_files(args.files) result_text = format_output(melody, chord1, bass, tempo) if args.output: if args.append: SEP = '\n' + '-' * 40 + '\n' with open(args.output, 'a', encoding='utf-8') as f: f.write(SEP) f.write(result_text) f.write('\n') else: with open(args.output, 'w', encoding='utf-8') as f: f.write(result_text) f.write('\n') print(f'저장: {args.output}', file=sys.stderr) else: print(result_text) if __name__ == '__main__': main()