fluidstudio

Programmable music notation — write music as text, compile to MIDI + WAV stems + mix
Log | Files | Refs | README

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