harmonic-catalyst / harmonic_engine.py
RAM2118's picture
Upload 4 files
79ce1f6 verified
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