import mido NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'] ENHARMONIC = { 'Db':'C#','Eb':'D#','Fb':'E','Gb':'F#', 'Ab':'G#','Bb':'A#','Cb':'B' } def note_name_to_pc(name): clean = ENHARMONIC.get(name, name) return NOTE_NAMES.index(clean) def pc_to_note_name(pc): return NOTE_NAMES[pc % 12] def midi_to_name(midi_num): octave = (midi_num // 12) - 1 return f"{NOTE_NAMES[midi_num % 12]}{octave}" def midi_to_freq(n): return 440.0 * (2 ** ((n - 69) / 12)) MAJOR_SCALE_INTERVALS = [0, 2, 4, 5, 7, 9, 11] MAJOR_SCALE_QUALITIES = { 1: 'maj', 2: 'min', 3: 'min', 4: 'maj', 5: 'maj', 6: 'min', 7: 'dim' } ROMAN_MAP = { 'I':1, 'II':2, 'III':3, 'IV':4, 'V':5, 'VI':6, 'VII':7, 'i':1, 'ii':2, 'iii':3, 'iv':4, 'v':5, 'vi':6, 'vii':7 } CHORD_INTERVALS = { 'maj': [0, 4, 7], 'min': [0, 3, 7], 'dim': [0, 3, 6], 'aug': [0, 4, 8], 'maj7': [0, 4, 7, 11], 'min7': [0, 3, 7, 10], 'dom7': [0, 4, 7, 10], '7': [0, 4, 7, 10], 'dim7': [0, 3, 6, 9], 'hdim7': [0, 3, 6, 10], 'sus2': [0, 2, 7], 'sus4': [0, 5, 7], 'add9': [0, 4, 7, 14], '6': [0, 4, 7, 9], 'min6': [0, 3, 7, 9], '9': [0, 4, 7, 10, 14], 'min9': [0, 3, 7, 10, 14], 'maj9': [0, 4, 7, 11, 14], '11': [0, 4, 7, 10, 14, 17], '13': [0, 4, 7, 10, 14, 21], } def parse_chord_symbol(symbol): """Parse chord symbols including slash chords like C/E, Fmaj7/A""" symbol = symbol.strip() slash_bass = None if '/' in symbol: parts = symbol.split('/') symbol = parts[0] slash_bass = parts[1] if len(symbol) > 1 and symbol[1] in '#b': root_str = symbol[:2] quality_str = symbol[2:] else: root_str = symbol[0] quality_str = symbol[1:] root_pc = note_name_to_pc(root_str) q = quality_str.lower() if q in ('', 'maj', 'major'): quality = 'maj' elif q in ('m', 'min', 'minor'): quality = 'min' elif q in ('maj7', 'major7'): quality = 'maj7' elif q in ('m7', 'min7', 'minor7'): quality = 'min7' elif q == '7': quality = 'dom7' elif q in ('dim', 'dim7', 'o', 'o7'): quality = 'dim7' if '7' in q else 'dim' elif q in ('hdim7', 'm7b5'): quality = 'hdim7' elif q in ('aug', '+'): quality = 'aug' elif q == 'sus2': quality = 'sus2' elif q == 'sus4': quality = 'sus4' elif q == 'add9': quality = 'add9' elif q in ('6',): quality = '6' elif q in ('m6', 'min6'): quality = 'min6' elif q == '9': quality = '9' elif q in ('m9', 'min9'): quality = 'min9' elif q == 'maj9': quality = 'maj9' elif q == '11': quality = '11' elif q == '13': quality = '13' else: quality = 'maj' return root_pc, quality, slash_bass def roman_to_chord(roman_str, key_root_pc): roman_str = roman_str.strip() suffix = '' base_roman = roman_str for s in ['maj7','min7','m7','7','dim','aug','sus2','sus4','9','11','13']: if roman_str.lower().endswith(s): suffix = s base_roman = roman_str[:-len(s)] break upper = base_roman.upper() if upper not in ROMAN_MAP: raise ValueError(f"Unknown Roman numeral: {roman_str}") degree = ROMAN_MAP[upper] is_minor_numeral = base_roman == base_roman.lower() and base_roman != base_roman.upper() root_pc = (key_root_pc + MAJOR_SCALE_INTERVALS[degree - 1]) % 12 if suffix: q = suffix.lower() if q in ('m7', 'min7'): quality = 'min7' elif q == 'maj7': quality = 'maj7' elif q == '7': quality = 'dom7' elif q == 'dim': quality = 'dim' elif q == 'aug': quality = 'aug' else: quality = q if q in CHORD_INTERVALS else 'maj' else: if is_minor_numeral: quality = 'min' else: quality = MAJOR_SCALE_QUALITIES.get(degree, 'maj') return root_pc, quality class GenreVoicer: @staticmethod def voice(root_pc, quality, genre, octave_lh=2, octave_rh=4, slash_bass=None): lh_base = (octave_lh + 1) * 12 rh_base = (octave_rh + 1) * 12 root_lh = lh_base + root_pc root_rh = rh_base + root_pc intervals = CHORD_INTERVALS.get(quality, [0, 4, 7]) third = 4 if 4 in intervals else (3 if 3 in intervals else None) fifth = 7 if 7 in intervals else (6 if 6 in intervals else (8 if 8 in intervals else None)) seventh = None for s in [11, 10, 9]: if s in intervals: seventh = s break method_name = f'_voice_{genre.lower().replace(" ", "_").replace("-", "_")}' method = getattr(GenreVoicer, method_name, GenreVoicer._voice_pop) lh, rh = method(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality) if slash_bass: bass_pc = note_name_to_pc(slash_bass) lh = [lh_base + bass_pc] return lh, rh @staticmethod def _voice_pop(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): lh = [root_lh] rh = [] if third is not None: rh.append(root_rh + third) if fifth is not None: rh.append(root_rh + fifth) if seventh is not None: rh.append(root_rh + seventh) if not rh: rh = [root_rh + iv for iv in intervals if iv != 0] if len(rh) < 3: rh.append(root_rh + 12) return lh, sorted(rh) @staticmethod def _voice_jazz(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): lh = [root_lh] if seventh is not None: lh.append(root_lh + seventh) else: lh.append(root_lh + (10 if third == 3 else 11)) rh = [] if third is not None: rh.append(root_rh + third) sev = seventh if seventh else (10 if third == 3 else 11) rh.append(root_rh + sev) rh.append(root_rh + 14) if third == 4: rh.append(root_rh + 21) return lh, sorted(rh) @staticmethod def _voice_gospel(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): lh = [root_lh] if fifth is not None: lh.append(root_lh + fifth) rh = [] if third is not None: rh.append(root_rh + third) if fifth is not None: rh.append(root_rh + fifth) sev = seventh if seventh else 11 rh.append(root_rh + sev) rh.append(root_rh + 14) rh.append(root_rh + 12) return lh, sorted(rh) @staticmethod def _voice_blues(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): lh = [root_lh, root_lh + 10] rh = [] t = third if third else 4 rh.append(root_rh + t) if fifth: rh.append(root_rh + fifth) rh.append(root_rh + 10) rh.append(root_rh + 14) return lh, sorted(rh) @staticmethod def _voice_classical(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): lh = [root_lh, root_lh + 12] rh = [root_rh + iv for iv in intervals if iv != 0] if not rh: rh = [root_rh + 4, root_rh + 7] return lh, sorted(rh) @staticmethod def _voice_rnb(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): """RnB: Warm soul voicing, 5th in LH for warmth, compact 3rd+7th+9th RH""" lh = [root_lh] if fifth: lh.append(root_lh + fifth) rh = [] if third: rh.append(root_rh + third) sev = seventh if seventh else (10 if third == 3 else 11) rh.append(root_rh + sev) rh.append(root_rh + 14) return lh, sorted(rh) @staticmethod def _voice_waltz(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): lh = [root_lh] rh = [] if third: rh.append(root_rh + third) if fifth: rh.append(root_rh + fifth) if seventh: rh.append(root_rh + seventh) if not rh: rh = [root_rh + 4, root_rh + 7] return lh, sorted(rh) @staticmethod def _voice_afrobeats(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): """Afrobeats: Open 5ths, stacked high, modern & spacious""" lh = [root_lh] rh = [] if fifth: rh.append(root_rh + fifth) if third: rh.append(root_rh + 12 + third) rh.append(root_rh + 19) return lh, sorted(rh) @staticmethod def _voice_trap(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): """Trap: Dark, minor feel, extended voicings""" lh = [root_lh, root_lh + 7] rh = [] if third: rh.append(root_rh + third) rh.append(root_rh + 10) rh.append(root_rh + 14) return lh, sorted(rh) @staticmethod def _voice_bossa_nova(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): """Bossa Nova: Jazz harmony, gentle 7ths and 9ths""" lh = [root_lh] if seventh: lh.append(root_lh + seventh) else: lh.append(root_lh + 11) rh = [] if third: rh.append(root_rh + third) rh.append(root_rh + 9) rh.append(root_rh + 14) return lh, sorted(rh) @staticmethod def _voice_hindustani_classical(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): """Hindustani: Drone bass, melodic emphasis on 3rd""" lh = [root_lh, root_lh + 7] rh = [] if third: rh.append(root_rh + third) rh.append(root_rh + third + 12) if fifth: rh.append(root_rh + fifth) return lh, sorted(rh) @staticmethod def _voice_neo_soul(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): """Neo-Soul: Wide open spread voicing — 7th low, 3rd+9th an octave up, no 5th""" lh = [root_lh] sev = seventh if seventh else 10 rh = [root_rh + sev] # 7th at base octave (low, open) if third: rh.append(root_rh + 12 + third) # 3rd an octave up rh.append(root_rh + 12 + 14) # 9th an octave up if third == 3: rh.append(root_rh + 12 + 17) # 11th for minor color return lh, sorted(rh) @staticmethod def _voice_reggae(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): """Reggae: Upbeat skank, major 3rds, simple""" lh = [root_lh] rh = [] if third: rh.append(root_rh + third) if fifth: rh.append(root_rh + fifth) rh.append(root_rh + 12) return lh, sorted(rh) @staticmethod def _voice_latin(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): """Latin: Montuno style, syncopated feel""" lh = [root_lh, root_lh + 7] rh = [] if third: rh.append(root_rh + third) if fifth: rh.append(root_rh + fifth) sev = seventh if seventh else 10 rh.append(root_rh + sev) return lh, sorted(rh) @staticmethod def _voice_k_pop(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): """K-Pop: Bright, add9 chords, modern production""" lh = [root_lh] rh = [] if third: rh.append(root_rh + third) if fifth: rh.append(root_rh + fifth) rh.append(root_rh + 14) rh.append(root_rh + 12) return lh, sorted(rh) @staticmethod def _voice_lo_fi(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): """Lo-fi: Intimate 7th chords, no 5th, warm 9th on top — simpler than Jazz""" lh = [root_lh] rh = [] if third: rh.append(root_rh + third) sev = seventh if seventh else (10 if third == 3 else 11) rh.append(root_rh + sev) rh.append(root_rh + 14) # 9th on top — the lo-fi color note return lh, sorted(rh) @staticmethod def _voice_funk(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality): """Funk: Dominant 7ths, tight voicings, rhythmic""" lh = [root_lh, root_lh + 10] rh = [] if third: rh.append(root_rh + third) rh.append(root_rh + 10) rh.append(root_rh + 14) return lh, sorted(rh) class VoiceLeader: @staticmethod def lead(prev_rh, current_rh): if not prev_rh or not current_rh: return current_rh centroid = sum(prev_rh) / len(prev_rh) result = [] for note in current_rh: pc = note % 12 candidates = [pc + (oct * 12) for oct in range(2, 8) if 36 <= pc + (oct * 12) <= 96] if not candidates: result.append(note) continue best = min(candidates, key=lambda x: abs(x - centroid)) result.append(best) return sorted(result) class SpectralAuditor: MUD_ZONE = (160, 400) @classmethod def audit(cls, lh_notes, rh_notes, context="Full Band"): issues = [] suggestions = [] all_notes = lh_notes + rh_notes freqs = [(n, midi_to_freq(n)) for n in all_notes] mud_notes = [(n, f) for n, f in freqs if cls.MUD_ZONE[0] <= f <= cls.MUD_ZONE[1]] if context == "Full Band" and len(mud_notes) > 2: issues.append( f"⚠️ MUD WARNING: {len(mud_notes)} notes in {cls.MUD_ZONE[0]}-{cls.MUD_ZONE[1]}Hz" ) suggestions.append("💡 Shift inner RH notes up one octave") low_notes = sorted([n for n in all_notes if n < 48]) for i in range(len(low_notes) - 1): if low_notes[i+1] - low_notes[i] < 5: issues.append( f"⚠️ LOW CLASH: {midi_to_name(low_notes[i])} and {midi_to_name(low_notes[i+1])}" ) if not issues: status = "✅ MIX CLEAR" else: status = "\n".join(issues + suggestions) return len(issues) > 0, status, mud_notes class NegativeHarmony: @staticmethod def mirror_in_key(notes, key_root_pc): root_midi = 60 + key_root_pc fifth_midi = root_midi + 7 axis = (root_midi + fifth_midi) / 2 return sorted([int(2 * axis - n) for n in notes]) class MidiExporter: @staticmethod def export(progression_data, filename_prefix="session"): files = {} for lane in ["lh", "rh"]: mid = mido.MidiFile(ticks_per_beat=480) track = mido.MidiTrack() mid.tracks.append(track) track.append(mido.MetaMessage('track_name', name=f'{lane.upper()}')) for chord_data in progression_data: notes = chord_data[lane] velocity = 70 if lane == "lh" else 85 duration = chord_data.get('beats', 4) * 480 for n in notes: n = max(0, min(127, n)) track.append(mido.Message('note_on', note=n, velocity=velocity, time=0)) track.append(mido.Message('note_off', note=max(0, min(127, notes[0])), velocity=0, time=duration)) for n in notes[1:]: n = max(0, min(127, n)) track.append(mido.Message('note_off', note=n, velocity=0, time=0)) fname = f"{filename_prefix}_{lane}.mid" mid.save(fname) files[lane] = fname return files @staticmethod def export_combined(progression_data, filename_prefix="session"): """Export combined MIDI with both LH and RH as separate tracks""" mid = mido.MidiFile(ticks_per_beat=480) # Track 1: LH/Bass track_lh = mido.MidiTrack() mid.tracks.append(track_lh) track_lh.append(mido.MetaMessage('track_name', name='LH/Bass')) for chord_data in progression_data: notes = chord_data['lh'] velocity = 70 duration = chord_data.get('beats', 4) * 480 for n in notes: n = max(0, min(127, n)) track_lh.append(mido.Message('note_on', note=n, velocity=velocity, time=0)) if notes: track_lh.append(mido.Message('note_off', note=max(0, min(127, notes[0])), velocity=0, time=duration)) for n in notes[1:]: n = max(0, min(127, n)) track_lh.append(mido.Message('note_off', note=n, velocity=0, time=0)) # Track 2: RH/Chords track_rh = mido.MidiTrack() mid.tracks.append(track_rh) track_rh.append(mido.MetaMessage('track_name', name='RH/Chords')) for chord_data in progression_data: notes = chord_data['rh'] velocity = 85 duration = chord_data.get('beats', 4) * 480 for n in notes: n = max(0, min(127, n)) track_rh.append(mido.Message('note_on', note=n, velocity=velocity, time=0)) if notes: track_rh.append(mido.Message('note_off', note=max(0, min(127, notes[0])), velocity=0, time=duration)) for n in notes[1:]: n = max(0, min(127, n)) track_rh.append(mido.Message('note_off', note=n, velocity=0, time=0)) fname = f"{filename_prefix}_COMPLETE.mid" mid.save(fname) return fname