Spaces:
Running
Running
| """ | |
| core/music_parser.py | |
| OMR ๊ฒฐ๊ณผ๋ฅผ NoteEvent ๋ฆฌ์คํธ๋ก ๋ณํํ๋ ํ์. | |
| ์ง์ ํ์: | |
| - "mock_events": MockOMRAdapter ์ถ๋ ฅ (dict ๋ฆฌ์คํธ) | |
| - "musicxml": MusicXML ๋ฌธ์์ด (score-partwise ํ์, stdlib xml.etree ํ์ฑ) | |
| MusicXML ํ์ฑ ์ฒ๋ฆฌ ๋ฒ์: | |
| - ๋จ์/ํ์(<chord>) ์ฒ๋ฆฌ | |
| - ์ผํ(<rest>) โ pitch=0 NoteEvent๋ก ๋ณํ (offset ํ๋ฆ ์ ์ง) | |
| - <backup>/<forward> ์ฒ๋ฆฌ (๋ค์ฑ๋ถ offset ์ ์ง) | |
| - <divisions> ๋ณ๊ฒฝ ์ถ์ | |
| - <key><fifths> ์กฐํ ํ์ฑ โ ์ํ ๋ฐ์ ๋ณด์ | |
| - <sound tempo=""> ํ ํฌ ํ์ฑ | |
| - voice/staff ์ ๋ณด ๋ณด์กด | |
| - namespace ์๋ ์ ๊ฑฐ | |
| - ํ์ด(<tie>) ์ฒ๋ฆฌ: ๊ฐ์ ํผ์น ์ํ duration ํฉ์ฐ | |
| ๋ฏธ์ง์: | |
| - score-timewise ํ์ | |
| - grace note (skip) | |
| - ์ฌ๋ฌ(articulation) | |
| - ๋ฐ๋ณต๊ธฐํธ ํผ์น๊ธฐ (D.S. / D.C. / Coda / Segno) | |
| """ | |
| from __future__ import annotations | |
| import xml.etree.ElementTree as ET | |
| from dataclasses import replace as dc_replace | |
| from typing import List | |
| from .models import NoteEvent | |
| class ParseError(Exception): | |
| """ํ์ฑ ์ค๋ฅ. ์ด๋ค ํ์ผ/๋จ๊ณ์์ ์คํจํ๋์ง ํฌํจ.""" | |
| pass | |
| # --------------------------------------------------------------------------- | |
| # ๊ณต๊ฐ ์ธํฐํ์ด์ค | |
| # --------------------------------------------------------------------------- | |
| def parse_omr_result(omr_result: dict) -> tuple[List[NoteEvent], dict]: | |
| """ | |
| OMR ๊ฒฐ๊ณผ dict๋ฅผ (NoteEvent ๋ฆฌ์คํธ, ๋ฉํ๋ฐ์ดํฐ dict)๋ก ๋ณํ. | |
| ๋ฉํ๋ฐ์ดํฐ ํค: | |
| - "tempo": int (BPM, 0์ด๋ฉด ๋ฏธ๋ฐ๊ฒฌ) | |
| Raises: | |
| ParseError: ์ ์ ์๋ ํ์์ด๊ฑฐ๋ ํ์ฑ ์คํจ ์ | |
| """ | |
| fmt = omr_result.get("format") | |
| if fmt == "mock_events": | |
| notes = _parse_mock_events(omr_result["data"]) | |
| return notes, {"tempo": 0} | |
| elif fmt == "musicxml": | |
| return _parse_musicxml(omr_result["data"]) | |
| else: | |
| raise ParseError(f"์ง์ํ์ง ์๋ OMR ๊ฒฐ๊ณผ ํ์: '{fmt}'") | |
| def parse_musicxml_file(xml_path: str) -> tuple[List[NoteEvent], dict]: | |
| """ | |
| MusicXML ํ์ผ ๊ฒฝ๋ก์์ ์ง์ ํ์ฑ. ํ ์คํธ ๋ฐ ์ง์ ์ฌ์ฉ์ ์ ์ฉ. | |
| Returns: | |
| (notes, metadata) โ parse_omr_result์ ๋์ผํ ํ์ | |
| Raises: | |
| ParseError: ํ์ผ ์ฝ๊ธฐ ์คํจ ๋๋ ํ์ฑ ์ค๋ฅ | |
| """ | |
| try: | |
| with open(xml_path, "r", encoding="utf-8", errors="replace") as f: | |
| xml_string = f.read() | |
| except OSError as e: | |
| raise ParseError(f"MusicXML ํ์ผ ์ฝ๊ธฐ ์คํจ ({xml_path}): {e}") | |
| return _parse_musicxml(xml_string, source_hint=xml_path) | |
| # --------------------------------------------------------------------------- | |
| # ๋ด๋ถ ๊ตฌํ | |
| # --------------------------------------------------------------------------- | |
| def _parse_mock_events(raw_notes: list) -> List[NoteEvent]: | |
| events = [] | |
| for raw in raw_notes: | |
| try: | |
| event = NoteEvent( | |
| pitch=int(raw["pitch"]), | |
| start=float(raw["start"]), | |
| duration=float(raw["duration"]), | |
| staff=int(raw.get("staff", 1)), | |
| voice=int(raw.get("voice", 1)), | |
| part_hint=raw.get("part_hint"), | |
| ) | |
| events.append(event) | |
| except (KeyError, ValueError) as e: | |
| raise ParseError(f"์ํ ๋ฐ์ดํฐ ํ์ฑ ์ค๋ฅ: {raw!r} โ {e}") | |
| events.sort(key=lambda n: (n.start, n.staff, n.voice)) | |
| return events | |
| # MIDI ์๊ณ ๋ฐ์ ์: C=0, D=2, E=4, F=5, G=7, A=9, B=11 | |
| _STEP_SEMITONE = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} | |
| # ์กฐํ ์คํ/ํ๋ซ ์์ | |
| _KEY_SHARPS = ["F", "C", "G", "D", "A", "E", "B"] # 1โฏ=F#, 2โฏ=F#C#, ... | |
| _KEY_FLATS = ["B", "E", "A", "D", "G", "C", "F"] # 1โญ=Bโญ, 2โญ=BโญEโญ, ... | |
| def _get_key_alters(fifths: int) -> dict[str, int]: | |
| """ | |
| ์กฐํ fifths ๊ฐ โ {์์ด๋ฆ: alter} ๋์ ๋๋ฆฌ. | |
| ์: fifths=2 (D์ฅ์กฐ) โ {"F": 1, "C": 1} | |
| fifths=-1 (F์ฅ์กฐ) โ {"B": -1} | |
| """ | |
| alters: dict[str, int] = {} | |
| if fifths > 0: | |
| for i in range(min(fifths, 7)): | |
| alters[_KEY_SHARPS[i]] = 1 | |
| elif fifths < 0: | |
| for i in range(min(-fifths, 7)): | |
| alters[_KEY_FLATS[i]] = -1 | |
| return alters | |
| def _parse_tempo(root: ET.Element) -> int: | |
| """ | |
| ๋ฃจํธ ์์์์ ์ฒซ ๋ฒ์งธ <sound tempo="N"/> ๊ฐ์ ๋ฐํ. | |
| ์์ผ๋ฉด 0 ๋ฐํ. | |
| """ | |
| for elem in root.iter("sound"): | |
| tempo_str = elem.get("tempo") | |
| if tempo_str: | |
| try: | |
| return int(float(tempo_str)) | |
| except (ValueError, TypeError): | |
| pass | |
| return 0 | |
| def _parse_musicxml(xml_string: str, source_hint: str = "") -> tuple[List[NoteEvent], dict]: | |
| """ | |
| MusicXML ๋ฌธ์์ด์ (NoteEvent ๋ฆฌ์คํธ, ๋ฉํ๋ฐ์ดํฐ)๋ก ๋ณํ. | |
| Args: | |
| xml_string: MusicXML XML ๋ฌธ์์ด | |
| source_hint: ์ค๋ฅ ๋ฉ์์ง์ ํฌํจํ ํ์ผ๋ช /๊ฒฝ๋ก (์ ํ) | |
| Returns: | |
| (events, metadata) โ metadata์ "tempo" ํฌํจ | |
| """ | |
| src = f" ({source_hint})" if source_hint else "" | |
| try: | |
| root = ET.fromstring(xml_string) | |
| except ET.ParseError as e: | |
| raise ParseError(f"MusicXML XML ๊ตฌ๋ฌธ ์ค๋ฅ{src}: {e}") | |
| # namespace ์ ๊ฑฐ (xmlns๊ฐ ์์ด๋ ๋์ผํ๊ฒ ์ฒ๋ฆฌ) | |
| for elem in root.iter(): | |
| if "}" in elem.tag: | |
| elem.tag = elem.tag.split("}")[1] | |
| root_tag = root.tag | |
| if root_tag != "score-partwise": | |
| raise ParseError( | |
| f"์ง์ํ์ง ์๋ MusicXML ๋ฃจํธ ์์{src}: '{root_tag}'\n" | |
| f" score-partwise ํ์๋ง ์ง์ํฉ๋๋ค. " | |
| f"score-timewise๋ MuseScore/Audiveris์์ ๋ณํ ๊ฐ๋ฅํฉ๋๋ค." | |
| ) | |
| # ํ ํฌ ์ถ์ถ | |
| tempo = _parse_tempo(root) | |
| events: List[NoteEvent] = [] | |
| # ํ์ด ์ถ์ : key=(part_id, pitch, voice, staff) โ events ๋ฆฌ์คํธ ์ธ๋ฑ์ค | |
| tie_pending: dict[tuple, int] = {} | |
| for part_idx, part_elem in enumerate(root.findall("part")): | |
| part_id = part_elem.get("id", f"P{part_idx + 1}") | |
| divisions = 1 # <divisions>: 4๋ถ์ํ๋น XML duration ๋จ์ | |
| measure_start = 0.0 # ํ์ฌ ๋ง๋์ ์์ beat | |
| key_alters: dict[str, int] = {} # ์กฐํ ๋ฐ์ ๋ณด์ (์์ด๋ฆ โ alter) | |
| for measure_elem in part_elem.findall("measure"): | |
| current_beat = 0.0 # ๋ง๋ ๋ด ํ์ฌ ์์น | |
| prev_note_beat = 0.0 # ์ง์ ๋น-chord ์ํ์ ์์ ์์น (chord ์ฒ๋ฆฌ์ฉ) | |
| max_beat = 0.0 # ๋ง๋ ๋ด ๋๋ฌํ ์ต๋ ์์น (backup ํ์๋ ์ ์ง) | |
| for child in measure_elem: | |
| tag = child.tag | |
| # attributes: divisions, key ์ ๋ฐ์ดํธ | |
| if tag == "attributes": | |
| div_elem = child.find("divisions") | |
| if div_elem is not None and div_elem.text: | |
| try: | |
| divisions = int(div_elem.text) | |
| except ValueError: | |
| pass | |
| key_elem = child.find("key") | |
| if key_elem is not None: | |
| fifths_elem = key_elem.find("fifths") | |
| if fifths_elem is not None and fifths_elem.text: | |
| try: | |
| key_alters = _get_key_alters(int(fifths_elem.text)) | |
| except ValueError: | |
| pass | |
| elif tag == "note": | |
| note_event = _parse_note( | |
| child, divisions, part_idx, | |
| measure_start, current_beat, prev_note_beat, | |
| part_id, source_hint, key_alters, | |
| ) | |
| if note_event is not None: | |
| # ํ์ด ์ฒ๋ฆฌ (์ผํ ์ ์ธ) | |
| if note_event.pitch != 0: | |
| tie_stop = any( | |
| t.get("type") == "stop" | |
| for t in child.findall("tie") | |
| ) | |
| tie_start = any( | |
| t.get("type") == "start" | |
| for t in child.findall("tie") | |
| ) | |
| tie_key = ( | |
| part_id, | |
| note_event.pitch, | |
| note_event.voice, | |
| note_event.staff, | |
| ) | |
| if tie_stop and tie_key in tie_pending: | |
| # ์ด์ ํ์ด ์ํ์ duration ํฉ์ฐ | |
| idx = tie_pending.pop(tie_key) | |
| old = events[idx] | |
| events[idx] = dc_replace( | |
| old, duration=old.duration + note_event.duration | |
| ) | |
| if tie_start: | |
| tie_pending[tie_key] = idx | |
| # ์ ์ด๋ฒคํธ๋ ์ถ๊ฐํ์ง ์์ | |
| else: | |
| events.append(note_event) | |
| if tie_start: | |
| tie_pending[tie_key] = len(events) - 1 | |
| else: | |
| events.append(note_event) | |
| # chord๊ฐ ์๋ ๊ฒฝ์ฐ์๋ง ์์น ์ ์ง | |
| is_chord = child.find("chord") is not None | |
| dur_beats = _get_duration_beats(child, divisions) | |
| if not is_chord: | |
| prev_note_beat = current_beat | |
| current_beat += dur_beats | |
| max_beat = max(max_beat, current_beat) | |
| elif tag == "backup": | |
| dur_beats = _get_duration_beats(child, divisions) | |
| current_beat = max(0.0, current_beat - dur_beats) | |
| elif tag == "forward": | |
| dur_beats = _get_duration_beats(child, divisions) | |
| current_beat += dur_beats | |
| max_beat = max(max_beat, current_beat) | |
| # backup์ด ์์ด๋ ๋ง๋ ๊ธธ์ด๋ ์ต๋ ๋๋ฌ ์์น ๊ธฐ์ค | |
| measure_start += max_beat | |
| if not events: | |
| return [], {"tempo": tempo} | |
| events.sort(key=lambda n: (n.start, n.staff, n.voice)) | |
| return events, {"tempo": tempo} | |
| def _parse_note( | |
| note_elem: ET.Element, | |
| divisions: int, | |
| part_idx: int, | |
| measure_start: float, | |
| current_beat: float, | |
| prev_note_beat: float, | |
| part_id: str, | |
| source_hint: str, | |
| key_alters: dict[str, int], | |
| ) -> NoteEvent | None: | |
| """ | |
| ๋จ์ผ <note> ์์๋ฅผ NoteEvent๋ก ๋ณํ. | |
| grace note์ฒ๋ผ duration์ด ์๋ ๊ฒฝ์ฐ๋ None ๋ฐํ (skip). | |
| key_alters: ์กฐํ์์ ํ์๋ {์์ด๋ฆ: alter} ๋์ ๋๋ฆฌ. | |
| <alter> ํ๊ทธ๊ฐ ์๋ ์ํ์ ๋ฐ์ ๋ณด์ ์ ์ฌ์ฉ. | |
| """ | |
| is_chord = note_elem.find("chord") is not None | |
| is_rest = note_elem.find("rest") is not None | |
| dur_beats = _get_duration_beats(note_elem, divisions) | |
| if dur_beats == 0.0: | |
| # grace note ๋๋ duration 0 โ skip | |
| return None | |
| note_beat = prev_note_beat if is_chord else current_beat | |
| abs_start = measure_start + note_beat | |
| voice_elem = note_elem.find("voice") | |
| voice = int(voice_elem.text) if voice_elem is not None and voice_elem.text else 1 | |
| staff_elem = note_elem.find("staff") | |
| if staff_elem is not None and staff_elem.text: | |
| staff_raw = int(staff_elem.text) | |
| else: | |
| # <staff> ์์ผ๋ฉด voice๋ฅผ staff ๋๋ฆฌ์๋ก ์ฌ์ฉ (Audiveris ๋ฑ) | |
| # voice๋ณ๋ก ํํธ๊ฐ ๋ถ๋ฆฌ๋๋๋ก ํจ | |
| staff_raw = voice | |
| # part ๊ฐ staff ๋ฒํธ๊ฐ ๊ฒน์น์ง ์๋๋ก ์ ์ญ ๊ณ ์ ๊ฐ์ผ๋ก ๋ณํ | |
| # ex) P1/staff1=1, P1/staff2=2, P2/staff1=11, P2/staff2=12 | |
| staff = part_idx * 10 + staff_raw | |
| if is_rest: | |
| return NoteEvent( | |
| pitch=0, | |
| start=abs_start, | |
| duration=dur_beats, | |
| staff=staff, | |
| voice=voice, | |
| ) | |
| pitch_elem = note_elem.find("pitch") | |
| if pitch_elem is None: | |
| return None | |
| step_elem = pitch_elem.find("step") | |
| octave_elem = pitch_elem.find("octave") | |
| alter_elem = pitch_elem.find("alter") | |
| step = step_elem.text.strip().upper() if step_elem is not None and step_elem.text else "C" | |
| octave = int(octave_elem.text) if octave_elem is not None and octave_elem.text else 4 | |
| # <alter> ๋ช ์ ์ ์ฐ์ ์ฌ์ฉ, ์์ผ๋ฉด ์กฐํ ๊ธฐ๋ณธ๊ฐ ์ ์ฉ | |
| if alter_elem is not None and alter_elem.text: | |
| alter = int(float(alter_elem.text)) | |
| else: | |
| alter = key_alters.get(step, 0) | |
| semitone = _STEP_SEMITONE.get(step, 0) | |
| pitch = (octave + 1) * 12 + semitone + alter | |
| pitch = max(0, min(127, pitch)) | |
| return NoteEvent( | |
| pitch=pitch, | |
| start=abs_start, | |
| duration=dur_beats, | |
| staff=staff, | |
| voice=voice, | |
| ) | |
| def _get_duration_beats(elem: ET.Element, divisions: int) -> float: | |
| """<duration> ์์๋ฅผ 4๋ถ์ํ ๊ธฐ์ค beats๋ก ๋ณํ.""" | |
| dur_elem = elem.find("duration") | |
| if dur_elem is None or not dur_elem.text: | |
| return 0.0 | |
| try: | |
| return int(dur_elem.text) / max(1, divisions) | |
| except (ValueError, ZeroDivisionError): | |
| return 0.0 | |