SolfegeScore-Singer-01 / backend /score_parser.py
JeffreyZhou798's picture
Update backend/score_parser.py
18c0204 verified
"""
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 (CORRECTED: 'sol' not 'so')
SOLFEGE_SYLLABLES = ['do', 're', 'mi', 'fa', 'sol', 'la', 'ti']
# Reference pitches (C4 octave)
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:
# Try music21 first
from music21 import converter
score = converter.parse(file_path)
duration = score.duration.quarterLength / 2 # Rough estimate (120 BPM)
voice_count = len(score.parts) if hasattr(score, 'parts') else 1
# Key detection
key_analysis = score.analyze('key')
key_name = f"{key_analysis.tonic.name} {key_analysis.mode}"
return {
'duration': max(duration, 10), # Minimum 10s
'voice_count': max(voice_count, 1),
'key': key_name
}
except Exception as e:
print(f"Error in quick_parse_score: {e}")
# Fallback
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)
# Detect key
key_analysis = score.analyze('key')
key_name = f"{key_analysis.tonic.name} {key_analysis.mode}"
key_fifths = key_analysis.sharps
# Extract voices
voices = []
solfege_table = []
for part_idx, part in enumerate(score.parts):
voice_notes = []
for element in part.flatten().notes:
if isinstance(element, note.Note):
# Get MIDI number
midi_num = element.pitch.midi
# Map to solfege
if mode == "movable":
solfege = midi_to_solfege_movable(midi_num, key_fifths)
else:
solfege = midi_to_solfege_fixed(midi_num)
# Get measure and beat
measure = element.measureNumber or 1
beat = element.beat or 1
# Duration in seconds (assume 120 BPM)
duration = element.duration.quarterLength * 0.5
voice_notes.append({
'midi': midi_num,
'solfege': solfege,
'start': element.offset,
'duration': duration,
'measure': measure,
'beat': beat
})
# Add to correction table (first 20 notes)
if len(solfege_table) < 20:
solfege_table.append([
len(solfege_table) + 1,
measure,
f"{beat:.1f}",
solfege,
"" # User correction
])
voices.append({
'id': part_idx,
'instrument': part.partName or f"Voice {part_idx + 1}",
'notes': voice_notes
})
# Total duration
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)
# Apply corrections if provided
if corrections is not None and len(corrections) > 0:
for row in corrections:
# Skip header or invalid rows
if not isinstance(row, list) or len(row) < 5:
continue
# Skip if row[0] is not a number (e.g., header row like "Measure")
try:
note_idx = int(row[0]) - 1
except (ValueError, TypeError):
continue
# Check if has correction
if row[4]:
corrected_solfege = str(row[4]).lower().strip()
if corrected_solfege in SOLFEGE_SYLLABLES:
# Apply to first voice (simplified)
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
# Map pitch class to solfege (Fixed Do)
PITCH_CLASS_TO_SOLFEGE = {
0: 'do', # C
1: 'do', # C#/Db -> do
2: 're', # D
3: 're', # D#/Eb -> re
4: 'mi', # E
5: 'fa', # F
6: 'fa', # F#/Gb -> fa
7: 'sol', # G
8: 'sol', # G#/Ab -> sol
9: 'la', # A
10: 'la', # A#/Bb -> la
11: 'ti' # B
}
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
"""
# Calculate tonic pitch class from fifths
tonic_pitch_class = ((key_fifths * 7) % 12 + 12) % 12
# Calculate scale degree
pitch_class = midi_num % 12
scale_degree = (pitch_class - tonic_pitch_class + 12) % 12
# Map scale degree to solfege (chromatic)
SCALE_DEGREE_TO_SOLFEGE = {
0: 'do', # Tonic
1: 'do', # Minor 2nd
2: 're', # Major 2nd
3: 're', # Minor 3rd
4: 'mi', # Major 3rd
5: 'fa', # Perfect 4th
6: 'fa', # Tritone
7: 'sol', # Perfect 5th
8: 'sol', # Minor 6th
9: 'la', # Major 6th
10: 'la', # Minor 7th
11: 'ti' # Major 7th
}
return SCALE_DEGREE_TO_SOLFEGE.get(scale_degree, 'do')