RAM2118/harmonic-catalyst
0
1import mido2 3NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']4ENHARMONIC = {5 'Db':'C#','Eb':'D#','Fb':'E','Gb':'F#',6 'Ab':'G#','Bb':'A#','Cb':'B'7}8 9def note_name_to_pc(name):10 clean = ENHARMONIC.get(name, name)11 return NOTE_NAMES.index(clean)12 13def pc_to_note_name(pc):14 return NOTE_NAMES[pc % 12]15 16def midi_to_name(midi_num):17 octave = (midi_num // 12) - 118 return f"{NOTE_NAMES[midi_num % 12]}{octave}"19 20def midi_to_freq(n):21 return 440.0 * (2 ** ((n - 69) / 12))22 23MAJOR_SCALE_INTERVALS = [0, 2, 4, 5, 7, 9, 11]24 25MAJOR_SCALE_QUALITIES = {26 1: 'maj', 2: 'min', 3: 'min', 4: 'maj',27 5: 'maj', 6: 'min', 7: 'dim'28}29 30ROMAN_MAP = {31 'I':1, 'II':2, 'III':3, 'IV':4,32 'V':5, 'VI':6, 'VII':7,33 'i':1, 'ii':2, 'iii':3, 'iv':4,34 'v':5, 'vi':6, 'vii':735}36 37CHORD_INTERVALS = {38 'maj': [0, 4, 7],39 'min': [0, 3, 7],40 'dim': [0, 3, 6],41 'aug': [0, 4, 8],42 'maj7': [0, 4, 7, 11],43 'min7': [0, 3, 7, 10],44 'dom7': [0, 4, 7, 10],45 '7': [0, 4, 7, 10],46 'dim7': [0, 3, 6, 9],47 'hdim7': [0, 3, 6, 10],48 'sus2': [0, 2, 7],49 'sus4': [0, 5, 7],50 'add9': [0, 4, 7, 14],51 '6': [0, 4, 7, 9],52 'min6': [0, 3, 7, 9],53 '9': [0, 4, 7, 10, 14],54 'min9': [0, 3, 7, 10, 14],55 'maj9': [0, 4, 7, 11, 14],56 '11': [0, 4, 7, 10, 14, 17],57 '13': [0, 4, 7, 10, 14, 21],58}59 60def parse_chord_symbol(symbol):61 """Parse chord symbols including slash chords like C/E, Fmaj7/A"""62 symbol = symbol.strip()63 64 slash_bass = None65 if '/' in symbol:66 parts = symbol.split('/')67 symbol = parts[0]68 slash_bass = parts[1]69 70 if len(symbol) > 1 and symbol[1] in '#b':71 root_str = symbol[:2]72 quality_str = symbol[2:]73 else:74 root_str = symbol[0]75 quality_str = symbol[1:]76 77 root_pc = note_name_to_pc(root_str)78 79 q = quality_str.lower()80 if q in ('', 'maj', 'major'):81 quality = 'maj'82 elif q in ('m', 'min', 'minor'):83 quality = 'min'84 elif q in ('maj7', 'major7'):85 quality = 'maj7'86 elif q in ('m7', 'min7', 'minor7'):87 quality = 'min7'88 elif q == '7':89 quality = 'dom7'90 elif q in ('dim', 'dim7', 'o', 'o7'):91 quality = 'dim7' if '7' in q else 'dim'92 elif q in ('hdim7', 'm7b5'):93 quality = 'hdim7'94 elif q in ('aug', '+'):95 quality = 'aug'96 elif q == 'sus2':97 quality = 'sus2'98 elif q == 'sus4':99 quality = 'sus4'100 elif q == 'add9':101 quality = 'add9'102 elif q in ('6',):103 quality = '6'104 elif q in ('m6', 'min6'):105 quality = 'min6'106 elif q == '9':107 quality = '9'108 elif q in ('m9', 'min9'):109 quality = 'min9'110 elif q == 'maj9':111 quality = 'maj9'112 elif q == '11':113 quality = '11'114 elif q == '13':115 quality = '13'116 else:117 quality = 'maj'118 119 return root_pc, quality, slash_bass120 121def roman_to_chord(roman_str, key_root_pc):122 roman_str = roman_str.strip()123 124 suffix = ''125 base_roman = roman_str126 for s in ['maj7','min7','m7','7','dim','aug','sus2','sus4','9','11','13']:127 if roman_str.lower().endswith(s):128 suffix = s129 base_roman = roman_str[:-len(s)]130 break131 132 upper = base_roman.upper()133 if upper not in ROMAN_MAP:134 raise ValueError(f"Unknown Roman numeral: {roman_str}")135 136 degree = ROMAN_MAP[upper]137 is_minor_numeral = base_roman == base_roman.lower() and base_roman != base_roman.upper()138 139 root_pc = (key_root_pc + MAJOR_SCALE_INTERVALS[degree - 1]) % 12140 141 if suffix:142 q = suffix.lower()143 if q in ('m7', 'min7'):144 quality = 'min7'145 elif q == 'maj7':146 quality = 'maj7'147 elif q == '7':148 quality = 'dom7'149 elif q == 'dim':150 quality = 'dim'151 elif q == 'aug':152 quality = 'aug'153 else:154 quality = q if q in CHORD_INTERVALS else 'maj'155 else:156 if is_minor_numeral:157 quality = 'min'158 else:159 quality = MAJOR_SCALE_QUALITIES.get(degree, 'maj')160 161 return root_pc, quality162 163class GenreVoicer:164 @staticmethod165 def voice(root_pc, quality, genre, octave_lh=2, octave_rh=4, slash_bass=None):166 lh_base = (octave_lh + 1) * 12167 rh_base = (octave_rh + 1) * 12168 169 root_lh = lh_base + root_pc170 root_rh = rh_base + root_pc171 172 intervals = CHORD_INTERVALS.get(quality, [0, 4, 7])173 174 third = 4 if 4 in intervals else (3 if 3 in intervals else None)175 fifth = 7 if 7 in intervals else (6 if 6 in intervals else (8 if 8 in intervals else None))176 seventh = None177 for s in [11, 10, 9]:178 if s in intervals:179 seventh = s180 break181 182 method_name = f'_voice_{genre.lower().replace(" ", "_").replace("-", "_")}'183 method = getattr(GenreVoicer, method_name, GenreVoicer._voice_pop)184 lh, rh = method(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality)185 186 if slash_bass:187 bass_pc = note_name_to_pc(slash_bass)188 lh = [lh_base + bass_pc]189 190 return lh, rh191 192 @staticmethod193 def _voice_pop(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):194 lh = [root_lh]195 rh = []196 if third is not None:197 rh.append(root_rh + third)198 if fifth is not None:199 rh.append(root_rh + fifth)200 if seventh is not None:201 rh.append(root_rh + seventh)202 if not rh:203 rh = [root_rh + iv for iv in intervals if iv != 0]204 if len(rh) < 3:205 rh.append(root_rh + 12)206 return lh, sorted(rh)207 208 @staticmethod209 def _voice_jazz(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):210 lh = [root_lh]211 if seventh is not None:212 lh.append(root_lh + seventh)213 else:214 lh.append(root_lh + (10 if third == 3 else 11))215 216 rh = []217 if third is not None:218 rh.append(root_rh + third)219 sev = seventh if seventh else (10 if third == 3 else 11)220 rh.append(root_rh + sev)221 rh.append(root_rh + 14)222 if third == 4:223 rh.append(root_rh + 21)224 225 return lh, sorted(rh)226 227 @staticmethod228 def _voice_gospel(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):229 lh = [root_lh]230 if fifth is not None:231 lh.append(root_lh + fifth)232 233 rh = []234 if third is not None:235 rh.append(root_rh + third)236 if fifth is not None:237 rh.append(root_rh + fifth)238 sev = seventh if seventh else 11239 rh.append(root_rh + sev)240 rh.append(root_rh + 14)241 rh.append(root_rh + 12)242 243 return lh, sorted(rh)244 245 @staticmethod246 def _voice_blues(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):247 lh = [root_lh, root_lh + 10]248 249 rh = []250 t = third if third else 4251 rh.append(root_rh + t)252 if fifth:253 rh.append(root_rh + fifth)254 rh.append(root_rh + 10)255 rh.append(root_rh + 14)256 257 return lh, sorted(rh)258 259 @staticmethod260 def _voice_classical(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):261 lh = [root_lh, root_lh + 12]262 rh = [root_rh + iv for iv in intervals if iv != 0]263 if not rh:264 rh = [root_rh + 4, root_rh + 7]265 return lh, sorted(rh)266 267 @staticmethod268 def _voice_rnb(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):269 """RnB: Warm soul voicing, 5th in LH for warmth, compact 3rd+7th+9th RH"""270 lh = [root_lh]271 if fifth:272 lh.append(root_lh + fifth)273 rh = []274 if third:275 rh.append(root_rh + third)276 sev = seventh if seventh else (10 if third == 3 else 11)277 rh.append(root_rh + sev)278 rh.append(root_rh + 14)279 return lh, sorted(rh)280 281 @staticmethod282 def _voice_waltz(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):283 lh = [root_lh]284 rh = []285 if third:286 rh.append(root_rh + third)287 if fifth:288 rh.append(root_rh + fifth)289 if seventh:290 rh.append(root_rh + seventh)291 if not rh:292 rh = [root_rh + 4, root_rh + 7]293 return lh, sorted(rh)294 295 296 @staticmethod297 def _voice_afrobeats(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):298 """Afrobeats: Open 5ths, stacked high, modern & spacious"""299 lh = [root_lh]300 rh = []301 if fifth:302 rh.append(root_rh + fifth)303 if third:304 rh.append(root_rh + 12 + third)305 rh.append(root_rh + 19)306 return lh, sorted(rh)307 308 @staticmethod309 def _voice_trap(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):310 """Trap: Dark, minor feel, extended voicings"""311 lh = [root_lh, root_lh + 7]312 rh = []313 if third:314 rh.append(root_rh + third)315 rh.append(root_rh + 10)316 rh.append(root_rh + 14)317 return lh, sorted(rh)318 319 @staticmethod320 def _voice_bossa_nova(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):321 """Bossa Nova: Jazz harmony, gentle 7ths and 9ths"""322 lh = [root_lh]323 if seventh:324 lh.append(root_lh + seventh)325 else:326 lh.append(root_lh + 11)327 328 rh = []329 if third:330 rh.append(root_rh + third)331 rh.append(root_rh + 9)332 rh.append(root_rh + 14)333 return lh, sorted(rh)334 335 @staticmethod336 def _voice_hindustani_classical(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):337 """Hindustani: Drone bass, melodic emphasis on 3rd"""338 lh = [root_lh, root_lh + 7]339 rh = []340 if third:341 rh.append(root_rh + third)342 rh.append(root_rh + third + 12)343 if fifth:344 rh.append(root_rh + fifth)345 return lh, sorted(rh)346 347 @staticmethod348 def _voice_neo_soul(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):349 """Neo-Soul: Wide open spread voicing — 7th low, 3rd+9th an octave up, no 5th"""350 lh = [root_lh]351 sev = seventh if seventh else 10352 rh = [root_rh + sev] # 7th at base octave (low, open)353 if third:354 rh.append(root_rh + 12 + third) # 3rd an octave up355 rh.append(root_rh + 12 + 14) # 9th an octave up356 if third == 3:357 rh.append(root_rh + 12 + 17) # 11th for minor color358 return lh, sorted(rh)359 360 @staticmethod361 def _voice_reggae(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):362 """Reggae: Upbeat skank, major 3rds, simple"""363 lh = [root_lh]364 rh = []365 if third:366 rh.append(root_rh + third)367 if fifth:368 rh.append(root_rh + fifth)369 rh.append(root_rh + 12)370 return lh, sorted(rh)371 372 @staticmethod373 def _voice_latin(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):374 """Latin: Montuno style, syncopated feel"""375 lh = [root_lh, root_lh + 7]376 rh = []377 if third:378 rh.append(root_rh + third)379 if fifth:380 rh.append(root_rh + fifth)381 sev = seventh if seventh else 10382 rh.append(root_rh + sev)383 return lh, sorted(rh)384 385 @staticmethod386 def _voice_k_pop(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):387 """K-Pop: Bright, add9 chords, modern production"""388 lh = [root_lh]389 rh = []390 if third:391 rh.append(root_rh + third)392 if fifth:393 rh.append(root_rh + fifth)394 rh.append(root_rh + 14)395 rh.append(root_rh + 12)396 return lh, sorted(rh)397 398 @staticmethod399 def _voice_lo_fi(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):400 """Lo-fi: Intimate 7th chords, no 5th, warm 9th on top — simpler than Jazz"""401 lh = [root_lh]402 rh = []403 if third:404 rh.append(root_rh + third)405 sev = seventh if seventh else (10 if third == 3 else 11)406 rh.append(root_rh + sev)407 rh.append(root_rh + 14) # 9th on top — the lo-fi color note408 return lh, sorted(rh)409 410 @staticmethod411 def _voice_funk(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):412 """Funk: Dominant 7ths, tight voicings, rhythmic"""413 lh = [root_lh, root_lh + 10]414 rh = []415 if third:416 rh.append(root_rh + third)417 rh.append(root_rh + 10)418 rh.append(root_rh + 14)419 return lh, sorted(rh)420 421class VoiceLeader:422 @staticmethod423 def lead(prev_rh, current_rh):424 if not prev_rh or not current_rh:425 return current_rh426 427 centroid = sum(prev_rh) / len(prev_rh)428 result = []429 for note in current_rh:430 pc = note % 12431 candidates = [pc + (oct * 12) for oct in range(2, 8) if 36 <= pc + (oct * 12) <= 96]432 if not candidates:433 result.append(note)434 continue435 best = min(candidates, key=lambda x: abs(x - centroid))436 result.append(best)437 438 return sorted(result)439 440class SpectralAuditor:441 MUD_ZONE = (160, 400)442 443 @classmethod444 def audit(cls, lh_notes, rh_notes, context="Full Band"):445 issues = []446 suggestions = []447 448 all_notes = lh_notes + rh_notes449 freqs = [(n, midi_to_freq(n)) for n in all_notes]450 451 mud_notes = [(n, f) for n, f in freqs if cls.MUD_ZONE[0] <= f <= cls.MUD_ZONE[1]]452 453 if context == "Full Band" and len(mud_notes) > 2:454 issues.append(455 f"⚠️ MUD WARNING: {len(mud_notes)} notes in {cls.MUD_ZONE[0]}-{cls.MUD_ZONE[1]}Hz"456 )457 suggestions.append("💡 Shift inner RH notes up one octave")458 459 low_notes = sorted([n for n in all_notes if n < 48])460 for i in range(len(low_notes) - 1):461 if low_notes[i+1] - low_notes[i] < 5:462 issues.append(463 f"⚠️ LOW CLASH: {midi_to_name(low_notes[i])} and {midi_to_name(low_notes[i+1])}"464 )465 466 if not issues:467 status = "✅ MIX CLEAR"468 else:469 status = "\n".join(issues + suggestions)470 471 return len(issues) > 0, status, mud_notes472 473class NegativeHarmony:474 @staticmethod475 def mirror_in_key(notes, key_root_pc):476 root_midi = 60 + key_root_pc477 fifth_midi = root_midi + 7478 axis = (root_midi + fifth_midi) / 2479 return sorted([int(2 * axis - n) for n in notes])480 481class MidiExporter:482 @staticmethod483 def export(progression_data, filename_prefix="session"):484 files = {}485 for lane in ["lh", "rh"]:486 mid = mido.MidiFile(ticks_per_beat=480)487 track = mido.MidiTrack()488 mid.tracks.append(track)489 track.append(mido.MetaMessage('track_name', name=f'{lane.upper()}'))490 491 for chord_data in progression_data:492 notes = chord_data[lane]493 velocity = 70 if lane == "lh" else 85494 duration = chord_data.get('beats', 4) * 480495 496 for n in notes:497 n = max(0, min(127, n))498 track.append(mido.Message('note_on', note=n, velocity=velocity, time=0))499 500 track.append(mido.Message('note_off', note=max(0, min(127, notes[0])), velocity=0, time=duration))501 for n in notes[1:]:502 n = max(0, min(127, n))503 track.append(mido.Message('note_off', note=n, velocity=0, time=0))504 505 fname = f"{filename_prefix}_{lane}.mid"506 mid.save(fname)507 files[lane] = fname508 509 return files510 511 @staticmethod512 def export_combined(progression_data, filename_prefix="session"):513 """Export combined MIDI with both LH and RH as separate tracks"""514 mid = mido.MidiFile(ticks_per_beat=480)515 516 # Track 1: LH/Bass517 track_lh = mido.MidiTrack()518 mid.tracks.append(track_lh)519 track_lh.append(mido.MetaMessage('track_name', name='LH/Bass'))520 521 for chord_data in progression_data:522 notes = chord_data['lh']523 velocity = 70524 duration = chord_data.get('beats', 4) * 480525 526 for n in notes:527 n = max(0, min(127, n))528 track_lh.append(mido.Message('note_on', note=n, velocity=velocity, time=0))529 530 if notes:531 track_lh.append(mido.Message('note_off', note=max(0, min(127, notes[0])), velocity=0, time=duration))532 for n in notes[1:]:533 n = max(0, min(127, n))534 track_lh.append(mido.Message('note_off', note=n, velocity=0, time=0))535 536 # Track 2: RH/Chords537 track_rh = mido.MidiTrack()538 mid.tracks.append(track_rh)539 track_rh.append(mido.MetaMessage('track_name', name='RH/Chords'))540 541 for chord_data in progression_data:542 notes = chord_data['rh']543 velocity = 85544 duration = chord_data.get('beats', 4) * 480545 546 for n in notes:547 n = max(0, min(127, n))548 track_rh.append(mido.Message('note_on', note=n, velocity=velocity, time=0))549 550 if notes:551 track_rh.append(mido.Message('note_off', note=max(0, min(127, notes[0])), velocity=0, time=duration))552 for n in notes[1:]:553 n = max(0, min(127, n))554 track_rh.append(mido.Message('note_off', note=n, velocity=0, time=0))555 556 fname = f"{filename_prefix}_COMPLETE.mid"557 mid.save(fname)558 return fname559 