Spaces:
Running
Running
| #!/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) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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() | |