Score_To_MML / convert_3part.py
Coconuttttt's picture
Initial deployment: Score to MML converter
daa0bdd
#!/usr/bin/env python3
"""
convert_3part.py
λ§ˆλΉ„λ…ΈκΈ° 1인 μ•…λ³΄μš© MusicXML β†’ 3파트 MML λ³€ν™˜κΈ°
μ ˆλŒ€ κ·œμΉ™:
1. μ ˆλŒ€ μ‹œκ°„μΆ• κΈ°μ€€
2. <duration>/<divisions> 둜 μ‹€μ œ 길이 계산
3. <chord> note = 직전 non-chord note와 같은 μ‹œμž‘μ 
4. <backup>/<forward> 반영
5. tie(start/stop) 동일 pitch 병합
6. <harmony> λ¬΄μ‹œ
7. tempo: <sound 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: <sound tempo> μš°μ„ , μ—†μœΌλ©΄ 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)
# <type> μš”μ†Œ 기반 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]:
"""<type> + <dot> μš”μ†Œλ‘œ 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]:
"""
단일 <part>λ₯Ό μ ˆλŒ€ μ‹œκ°„μΆ•μœΌλ‘œ νŒŒμ‹± (κ·œμΉ™ 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 μœ νš¨ν•˜λ©΄ 계산값 μ‚¬μš©, μ•„λ‹ˆλ©΄ <type> 기반 폴백."""
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()