parser.py (12240B)
1 """FluidStudio notation parser — .flsp files and .json headers. 2 3 Chromatic semitone system: notes 0-11 relative to octave in header. 4 0 = C at that octave. Modes used for chord validation only. 5 """ 6 7 import json 8 import re 9 from dataclasses import dataclass, field 10 from pathlib import Path 11 12 13 # ── Mode / Chord tables (for validation) ───────────────────────────── 14 15 MODES = { 16 "ionian": [0, 2, 4, 5, 7, 9, 11], 17 "dorian": [0, 2, 3, 5, 7, 9, 10], 18 "phrygian": [0, 1, 3, 5, 7, 8, 10], 19 "lydian": [0, 2, 4, 6, 7, 9, 11], 20 "mixolydian": [0, 2, 4, 5, 7, 9, 10], 21 "aeolian": [0, 2, 3, 5, 7, 8, 10], 22 "locrian": [0, 1, 3, 5, 6, 8, 10], 23 "misheberak": [0, 2, 3, 6, 7, 9, 10], 24 "freygish": [0, 1, 4, 5, 7, 8, 10], 25 "hungarian_minor": [0, 2, 3, 6, 7, 8, 11], 26 "hungarian_major": [0, 3, 4, 6, 7, 9, 10], 27 "persian": [0, 1, 4, 5, 6, 8, 11], 28 "whole_tone": [0, 2, 4, 6, 8, 10], 29 } 30 31 CHORDS = { 32 "M": [0, 4, 7], 33 "M6": [0, 4, 7, 9], 34 "M69": [0, 4, 7, 9, 14], 35 "M7m": [0, 4, 7, 10], 36 "M7M": [0, 4, 7, 11], 37 "M9": [0, 4, 7, 10, 14], 38 "Madd2": [0, 2, 4, 7], 39 "Madd9": [0, 4, 7, 14], 40 "m": [0, 3, 7], 41 "m6": [0, 3, 7, 9], 42 "m7m": [0, 3, 7, 10], 43 "m7M": [0, 3, 7, 11], 44 "m9": [0, 3, 7, 10, 14], 45 "m69": [0, 3, 7, 9, 14], 46 "madd2": [0, 2, 3, 7], 47 "madd9": [0, 3, 7, 14], 48 "sus2": [0, 2, 7], 49 "sus26": [0, 2, 7, 9], 50 "sus27": [0, 2, 7, 10], 51 "sus27M": [0, 2, 7, 11], 52 "sus4": [0, 5, 7], 53 "sus46": [0, 5, 7, 9], 54 "sus47": [0, 5, 7, 10], 55 "sus47M": [0, 5, 7, 11], 56 "dimb3": [0, 2, 6], 57 "dim": [0, 3, 6], 58 "dim7": [0, 3, 6, 9], 59 "b5": [0, 4, 6], 60 "aug": [0, 4, 8], 61 "aug7": [0, 4, 8, 10], 62 "p4": [0, 5], 63 "TT": [0, 6], 64 "p5": [0, 7], 65 } 66 67 68 # ── Data structures ────────────────────────────────────────────────── 69 70 @dataclass 71 class VoiceHeader: 72 """Parsed metadata from a .json header file.""" 73 project: str = "" 74 bpm: float = 120 75 key: int = 0 # key root (0=C, 7=G, etc.) — for chord suggestions 76 octave: int = 4 # base octave (0=C at this octave) 77 mode: str = "ionian" # for chord suggestions only 78 channels: list[dict] = field(default_factory=list) 79 80 # Derived from filename 81 channel_name: str = "" 82 channel_config: dict = field(default_factory=dict) 83 84 @property 85 def sf2(self) -> str: 86 return self.channel_config.get("sf2", "") 87 88 @property 89 def channel_octave(self) -> int: 90 """Per-channel octave override (falls back to header octave).""" 91 return self.channel_config.get("octave", self.octave) 92 93 94 @dataclass 95 class GondryEvent: 96 """A single parsed musical event.""" 97 beat_position: float # absolute beat position from start 98 duration_beats: float # note duration in beats 99 midi_notes: list[int] # resolved MIDI note numbers 100 velocity: int # 0-127 101 note: int | None = None # chromatic note (0-11, None for rest) 102 chord_type: str | None = None 103 is_chord: bool = False 104 legato: bool | None = None # None=keep state, True=on, False=off 105 octave_shift: int = 0 # per-note octave offset 106 107 108 # ── Header parser ──────────────────────────────────────────────────── 109 110 def parse_header(path: str | Path) -> VoiceHeader: 111 """Parse a .json header file.""" 112 with open(path) as f: 113 data = json.load(f) 114 115 header = VoiceHeader( 116 project=data.get("project", ""), 117 bpm=data.get("bpm", 120), 118 key=data.get("key", 0), 119 octave=data.get("octave", 4), 120 mode=data.get("mode", "ionian"), 121 channels=data.get("channels", []), 122 ) 123 return header 124 125 126 # ── Duration parser ────────────────────────────────────────────────── 127 128 _DURATION_RE = re.compile( 129 r'^(\d+)?(?:b)?(?:/(\d+))?$' 130 # Matches: "b", "2b", "b/2", "b/3", "1b/2", "" 131 ) 132 133 def _parse_duration(text: str) -> float: 134 """Parse duration string to beats. 135 136 "b" -> 1.0 (one beat) 137 "2b" -> 2.0 (two beats) 138 "b/2" -> 0.5 (half beat) 139 "b/3" -> 0.333... (triplet eighth) 140 "b/4" -> 0.25 141 "b/8" -> 0.125 142 "" -> default handled by caller 143 """ 144 if not text: 145 return 0.0 # caller will fill default 146 147 text = text.strip().lstrip(':') 148 149 # "b" alone = 1 beat 150 if text == 'b': 151 return 1.0 152 153 m = _DURATION_RE.match(text) 154 if not m: 155 return 1.0 # fallback 156 157 numerator = int(m.group(1)) if m.group(1) else 1 158 denominator = int(m.group(2)) if m.group(2) else 1 159 160 return numerator / denominator 161 162 163 # ── Velocity parser ────────────────────────────────────────────────── 164 165 def _parse_velocity(text: str, default: int = 100) -> int: 166 """Extract velocity from (v:NN) notation.""" 167 m = re.search(r'\(v:(\d+)\)', text) 168 if m: 169 return int(m.group(1)) 170 return default 171 172 173 # ── Cell parser ────────────────────────────────────────────────────── 174 175 _CELL_RE = re.compile( 176 r'^' 177 r'(?P<note>[0-9]|1[01])?' # chromatic note (0-11) 178 r'(?:x(?P<chord>[A-Za-z0-9]+))?' # optional x + chord type 179 r'(?::(?P<duration>[\w/]+))?' # optional :duration 180 r'(?:\s+\(v:(?P<velocity>\d+)\))?' # optional velocity 181 r'$' 182 ) 183 184 def _parse_cell(text: str, default_vel: int = 100, 185 default_duration: float = 0.25) -> tuple[list[int], float, int, int | None, str | None, bool, bool | None, int]: 186 """Parse a single cell into (midi_notes, duration, velocity, note, chord_type, is_chord, legato, octave_shift). 187 188 Notes are chromatic semitones (0-11). Multiple notes for chords. 189 """ 190 text = text.strip() 191 192 # Extract legato state 193 legato = None 194 if '(legato: on)' in text: 195 legato = True 196 text = text.replace('(legato: on)', '').strip() 197 elif '(legato: off)' in text: 198 legato = False 199 text = text.replace('(legato: off)', '').strip() 200 201 # Extract octave shift 202 octave_shift = 0 203 oct_match = re.search(r'\(oct\s*([+-]?\d+)\)', text) 204 if oct_match: 205 octave_shift = int(oct_match.group(1)) 206 text = text.replace(oct_match.group(0), '').strip() 207 208 # Empty or rest 209 if not text or text == 'x': 210 return [], 0.0, 0, None, None, False, legato, octave_shift 211 212 # Melodic: full syntax (now handles 0-11) 213 m = _CELL_RE.match(text) 214 if not m: 215 return [], 0.0, 0, None, None, False, legato, octave_shift 216 217 note_str = m.group('note') 218 chord_type = m.group('chord') 219 dur_str = m.group('duration') 220 vel_str = m.group('velocity') 221 222 if note_str is None: 223 return [], 0.0, 0, None, None, False, legato, octave_shift 224 225 note = int(note_str) 226 is_chord = chord_type is not None 227 duration = _parse_duration(dur_str) if dur_str else 0.0 228 if duration == 0.0: 229 duration = default_duration 230 velocity = int(vel_str) if vel_str else default_vel 231 232 # midi_notes will be resolved later (needs octave context) 233 midi_notes = [] # placeholder 234 235 return midi_notes, duration, velocity, note, chord_type, is_chord, legato, octave_shift 236 237 238 # ── Note → MIDI resolution ────────────────────────────────────────── 239 240 def _resolve_note_to_midi(note: int, chord_type: str | None, is_chord: bool, 241 octave: int, octave_shift: int = 0) -> list[int]: 242 """Resolve a chromatic note (0-11) to MIDI note numbers. 243 244 Args: 245 note: Chromatic semitone (0=C, 1=C#, ..., 11=B) 246 chord_type: Chord type string (e.g., "M", "m7m") or None 247 is_chord: Whether to play full chord voicing 248 octave: Base octave (0 = C at MIDI 12) 249 octave_shift: Per-note octave offset 250 """ 251 # MIDI note = (octave + 1) * 12 + note 252 # Example: octave=4, note=0 → C4 = MIDI 60 253 base_midi = (octave + 1 + octave_shift) * 12 + note 254 255 if is_chord and chord_type: 256 intervals = CHORDS.get(chord_type, [0]) 257 return [base_midi + interval for interval in intervals] 258 else: 259 return [base_midi] 260 261 262 # ── Main parser ────────────────────────────────────────────────────── 263 264 def parse_gondry(path: str | Path, header: VoiceHeader) -> list[GondryEvent]: 265 """Parse a .flsp file into a list of GondryEvents. 266 267 Each `---` line is a beat boundary. 268 Lines between dividers are subdivisions of that beat. 269 """ 270 with open(path) as f: 271 lines = f.readlines() 272 273 events: list[GondryEvent] = [] 274 beat_position = 0.0 275 current_section: list[str] = [] # lines between dividers 276 277 def _process_section(section_lines: list[str], beat_pos: float): 278 """Process lines that fall within one beat.""" 279 # Empty section = one full beat of rest (don't skip!) 280 if not section_lines: 281 events.append(GondryEvent( 282 beat_position=beat_pos, 283 duration_beats=1.0, 284 midi_notes=[], 285 velocity=0, 286 )) 287 return 288 289 subdivision = len(section_lines) # how many parts this beat is split into 290 ticks_per_subdivision = 1.0 / subdivision # in beats 291 292 for i, line in enumerate(section_lines): 293 sub_beat_pos = beat_pos + (i * ticks_per_subdivision) 294 295 parsed_notes, duration, velocity, note, chord_type, is_chord, legato, octave_shift = _parse_cell( 296 line, 297 default_vel=100, 298 default_duration=ticks_per_subdivision, 299 ) 300 301 if note is not None: 302 # Use per-channel octave if specified, otherwise header octave 303 octave = header.channel_octave 304 midi_notes = _resolve_note_to_midi(note, chord_type, is_chord, octave, octave_shift) 305 else: 306 # rest — but still emit legato state changes 307 if legato is not None: 308 events.append(GondryEvent( 309 beat_position=sub_beat_pos, 310 duration_beats=0.0, 311 midi_notes=[], 312 velocity=0, 313 legato=legato, 314 )) 315 continue 316 317 events.append(GondryEvent( 318 beat_position=sub_beat_pos, 319 duration_beats=duration, 320 midi_notes=midi_notes, 321 velocity=velocity, 322 note=note, 323 chord_type=chord_type, 324 is_chord=is_chord, 325 legato=legato, 326 octave_shift=octave_shift, 327 )) 328 329 for line in lines: 330 stripped = line.strip() 331 332 # Skip empty lines and line comments 333 if not stripped or stripped.startswith('#'): 334 continue 335 336 # Beat divider 337 if stripped.startswith('---'): 338 _process_section(current_section, beat_position) 339 current_section = [] 340 beat_position += 1.0 341 continue 342 343 # Strip leading "- " for subdivision lines 344 if stripped.startswith('- '): 345 stripped = stripped[2:].strip() 346 347 # Inline comments with // 348 if '//' in stripped: 349 stripped = stripped.split('//')[0].strip() 350 351 if stripped: 352 current_section.append(stripped) 353 354 # Don't forget the last section 355 _process_section(current_section, beat_position) 356 357 # Sort by beat position 358 events.sort(key=lambda e: e.beat_position) 359 360 return events