CoolFace
Apppublic

RAM2118/harmonic-catalyst

sourceHugging Facemitupdated 7mo agoView on Hugging Face
0likes
harmonic_engine.py559 linesDownload Raw Back to root
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