| """ |
| Score Parser Module |
| Supports MIDI and MusicXML formats |
| Implements key detection and solfege mapping |
| """ |
|
|
| import os |
| from typing import Dict, List, Optional, Tuple |
| import tempfile |
|
|
| |
| SOLFEGE_SYLLABLES = ['do', 're', 'mi', 'fa', 'sol', 'la', 'ti'] |
|
|
| |
| REFERENCE_PITCHES = { |
| 'do': 261.63, 're': 293.66, 'mi': 329.63, 'fa': 349.23, |
| 'sol': 392.00, 'la': 440.00, 'ti': 493.88 |
| } |
|
|
|
|
| def quick_parse_score(file_path: str) -> Dict: |
| """ |
| Quick parse score to get basic info (duration, voice count). |
| Used for time estimation. |
| |
| Args: |
| file_path: Path to MIDI or MusicXML file |
| |
| Returns: |
| { |
| 'duration': float (seconds), |
| 'voice_count': int, |
| 'key': str |
| } |
| """ |
| try: |
| |
| from music21 import converter |
| |
| score = converter.parse(file_path) |
| duration = score.duration.quarterLength / 2 |
| voice_count = len(score.parts) if hasattr(score, 'parts') else 1 |
| |
| |
| key_analysis = score.analyze('key') |
| key_name = f"{key_analysis.tonic.name} {key_analysis.mode}" |
| |
| return { |
| 'duration': max(duration, 10), |
| 'voice_count': max(voice_count, 1), |
| 'key': key_name |
| } |
| |
| except Exception as e: |
| print(f"Error in quick_parse_score: {e}") |
| |
| return { |
| 'duration': 30, |
| 'voice_count': 1, |
| 'key': 'C major' |
| } |
|
|
|
|
| def parse_score_with_solfege(file_path: str, mode: str = "movable") -> Dict: |
| """ |
| Parse score and generate solfege mapping. |
| |
| Args: |
| file_path: Path to MIDI or MusicXML file |
| mode: "movable" or "fixed" |
| |
| Returns: |
| { |
| 'key': str, |
| 'duration': float, |
| 'voices': List[Dict], |
| 'solfege_table': List[List] # For Gradio Dataframe |
| } |
| """ |
| from music21 import converter, key, pitch, note |
| |
| try: |
| score = converter.parse(file_path) |
| |
| |
| key_analysis = score.analyze('key') |
| key_name = f"{key_analysis.tonic.name} {key_analysis.mode}" |
| key_fifths = key_analysis.sharps |
| |
| |
| voices = [] |
| solfege_table = [] |
| |
| for part_idx, part in enumerate(score.parts): |
| voice_notes = [] |
| |
| for element in part.flatten().notes: |
| if isinstance(element, note.Note): |
| |
| midi_num = element.pitch.midi |
| |
| |
| if mode == "movable": |
| solfege = midi_to_solfege_movable(midi_num, key_fifths) |
| else: |
| solfege = midi_to_solfege_fixed(midi_num) |
| |
| |
| measure = element.measureNumber or 1 |
| beat = element.beat or 1 |
| |
| |
| duration = element.duration.quarterLength * 0.5 |
| |
| voice_notes.append({ |
| 'midi': midi_num, |
| 'solfege': solfege, |
| 'start': element.offset, |
| 'duration': duration, |
| 'measure': measure, |
| 'beat': beat |
| }) |
| |
| |
| if len(solfege_table) < 20: |
| solfege_table.append([ |
| len(solfege_table) + 1, |
| measure, |
| f"{beat:.1f}", |
| solfege, |
| "" |
| ]) |
| |
| voices.append({ |
| 'id': part_idx, |
| 'instrument': part.partName or f"Voice {part_idx + 1}", |
| 'notes': voice_notes |
| }) |
| |
| |
| total_duration = score.duration.quarterLength * 0.5 |
| |
| return { |
| 'key': key_name, |
| 'duration': total_duration, |
| 'voices': voices, |
| 'solfege_table': solfege_table |
| } |
| |
| except Exception as e: |
| print(f"Error parsing score: {e}") |
| raise |
|
|
|
|
| def parse_score_with_correction(file_path: str, mode: str = "movable", corrections=None) -> Dict: |
| """ |
| Parse score with optional user corrections. |
| |
| Args: |
| file_path: Path to score file |
| mode: "movable" or "fixed" |
| corrections: Gradio Dataframe with corrections |
| |
| Returns: |
| Same as parse_score_with_solfege |
| """ |
| result = parse_score_with_solfege(file_path, mode) |
| |
| |
| if corrections is not None and len(corrections) > 0: |
| for row in corrections: |
| |
| if not isinstance(row, list) or len(row) < 5: |
| continue |
| |
| |
| try: |
| note_idx = int(row[0]) - 1 |
| except (ValueError, TypeError): |
| continue |
| |
| |
| if row[4]: |
| corrected_solfege = str(row[4]).lower().strip() |
| |
| if corrected_solfege in SOLFEGE_SYLLABLES: |
| |
| if result['voices'] and note_idx >= 0 and note_idx < len(result['voices'][0]['notes']): |
| result['voices'][0]['notes'][note_idx]['solfege'] = corrected_solfege |
| |
| return result |
|
|
|
|
| def midi_to_solfege_fixed(midi_num: int) -> str: |
| """ |
| Convert MIDI note to solfege using Fixed Do. |
| Based on pitch class, not letter name (simplified). |
| |
| Args: |
| midi_num: MIDI note number (0-127) |
| |
| Returns: |
| Solfege syllable |
| """ |
| pitch_class = midi_num % 12 |
| |
| |
| PITCH_CLASS_TO_SOLFEGE = { |
| 0: 'do', |
| 1: 'do', |
| 2: 're', |
| 3: 're', |
| 4: 'mi', |
| 5: 'fa', |
| 6: 'fa', |
| 7: 'sol', |
| 8: 'sol', |
| 9: 'la', |
| 10: 'la', |
| 11: 'ti' |
| } |
| |
| return PITCH_CLASS_TO_SOLFEGE.get(pitch_class, 'do') |
|
|
|
|
| def midi_to_solfege_movable(midi_num: int, key_fifths: int) -> str: |
| """ |
| Convert MIDI note to solfege using Movable Do. |
| Based on scale degree relative to key. |
| |
| Args: |
| midi_num: MIDI note number |
| key_fifths: Key signature fifths (0=C, 1=G, -1=F, etc.) |
| |
| Returns: |
| Solfege syllable |
| """ |
| |
| tonic_pitch_class = ((key_fifths * 7) % 12 + 12) % 12 |
| |
| |
| pitch_class = midi_num % 12 |
| scale_degree = (pitch_class - tonic_pitch_class + 12) % 12 |
| |
| |
| SCALE_DEGREE_TO_SOLFEGE = { |
| 0: 'do', |
| 1: 'do', |
| 2: 're', |
| 3: 're', |
| 4: 'mi', |
| 5: 'fa', |
| 6: 'fa', |
| 7: 'sol', |
| 8: 'sol', |
| 9: 'la', |
| 10: 'la', |
| 11: 'ti' |
| } |
| |
| return SCALE_DEGREE_TO_SOLFEGE.get(scale_degree, 'do') |
|
|