Spaces:
Sleeping
Sleeping
| 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: | |
| 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 | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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: | |
| 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) | |
| 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: | |
| 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: | |
| 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 | |
| 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 | |