RAM2118 commited on
Commit
a500926
·
verified ·
1 Parent(s): 860c236

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +386 -0
  2. harmonic_engine.py +365 -0
app.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import uuid
3
+ from harmonic_engine import (
4
+ note_name_to_pc, pc_to_note_name, midi_to_name, midi_to_freq,
5
+ parse_chord_symbol, roman_to_chord,
6
+ GenreVoicer, VoiceLeader, SpectralAuditor, NegativeHarmony,
7
+ MidiExporter, NOTE_NAMES
8
+ )
9
+
10
+ st.set_page_config(page_title="Harmonic Catalyst PRO", page_icon="🎹", layout="wide")
11
+
12
+ st.markdown("""
13
+ <style>
14
+ .stApp {background-color: #0d1117; color: #c9d1d9;}
15
+ .stButton>button {
16
+ width: 100%; border-radius: 8px;
17
+ background: linear-gradient(135deg, #238636, #2ea043);
18
+ color: white; font-weight: bold; font-size: 16px; padding: 10px;
19
+ }
20
+ hr {border: 0; border-top: 1px solid #30363d;}
21
+ .chord-header {
22
+ font-size: 22px; font-weight: bold; color: #f0f6fc;
23
+ border-left: 4px solid #58a6ff; padding-left: 12px; margin-bottom: 8px;
24
+ }
25
+ </style>
26
+ """, unsafe_allow_html=True)
27
+
28
+ st.title("🎹 Harmonic Catalyst PRO")
29
+ st.caption("Genre-Aware Voicing Engine • Multi-Section Builder • Voice Leading • Spectral Analysis")
30
+
31
+ if 'song_sections' not in st.session_state:
32
+ st.session_state.song_sections = []
33
+
34
+ if 'section_counter' not in st.session_state:
35
+ st.session_state.section_counter = 0
36
+
37
+ def add_section():
38
+ st.session_state.section_counter += 1
39
+ new_section = {
40
+ 'id': str(uuid.uuid4())[:8],
41
+ 'number': st.session_state.section_counter,
42
+ 'name': f'Section {st.session_state.section_counter}',
43
+ 'chords': '',
44
+ 'genre': 'Pop',
45
+ 'lh_octave': 2,
46
+ 'rh_octave': 4
47
+ }
48
+ st.session_state.song_sections.append(new_section)
49
+
50
+ def remove_section(section_id):
51
+ st.session_state.song_sections = [
52
+ s for s in st.session_state.song_sections if s['id'] != section_id
53
+ ]
54
+
55
+ def duplicate_section(section_id):
56
+ section = next(s for s in st.session_state.song_sections if s['id'] == section_id)
57
+ st.session_state.section_counter += 1
58
+ new_section = section.copy()
59
+ new_section['id'] = str(uuid.uuid4())[:8]
60
+ new_section['number'] = st.session_state.section_counter
61
+ new_section['name'] = f"{section['name']} (Copy)"
62
+ idx = st.session_state.song_sections.index(section)
63
+ st.session_state.song_sections.insert(idx + 1, new_section)
64
+
65
+ def move_section_up(section_id):
66
+ sections = st.session_state.song_sections
67
+ idx = next(i for i, s in enumerate(sections) if s['id'] == section_id)
68
+ if idx > 0:
69
+ sections[idx], sections[idx - 1] = sections[idx - 1], sections[idx]
70
+
71
+ def move_section_down(section_id):
72
+ sections = st.session_state.song_sections
73
+ idx = next(i for i, s in enumerate(sections) if s['id'] == section_id)
74
+ if idx < len(sections) - 1:
75
+ sections[idx], sections[idx + 1] = sections[idx + 1], sections[idx]
76
+
77
+ with st.sidebar:
78
+ st.header("⚙️ Global Settings")
79
+
80
+ input_mode = st.radio(
81
+ "Input Mode",
82
+ ["Chord Symbols (Cmaj7, Am7...)", "Roman Numerals (I, vi, IV, V)"]
83
+ )
84
+
85
+ if "Roman" in input_mode:
86
+ key_name = st.selectbox("Key", NOTE_NAMES, index=0)
87
+ key_pc = note_name_to_pc(key_name)
88
+ else:
89
+ key_pc = 0
90
+
91
+ st.divider()
92
+ context = st.radio("🎚️ Mix Context", ["Full Band", "Solo Piano"])
93
+
94
+ st.divider()
95
+ st.subheader("🔧 Voice Leading")
96
+ use_voice_leading = st.checkbox("🔗 Connect sections smoothly", value=True)
97
+
98
+ st.divider()
99
+ use_negative = st.checkbox("🌌 Negative Harmony", value=False)
100
+ if use_negative:
101
+ neg_key = st.selectbox("Mirror Key", NOTE_NAMES, index=0)
102
+ neg_key_pc = note_name_to_pc(neg_key)
103
+
104
+ st.divider()
105
+ if st.button("🗑️ Clear All Sections"):
106
+ st.session_state.song_sections = []
107
+ st.session_state.section_counter = 0
108
+ st.rerun()
109
+
110
+ def draw_piano_svg(notes, label, start_midi=36, num_keys=37):
111
+ white_key_w = 22
112
+ black_key_w = 14
113
+ white_key_h = 90
114
+ black_key_h = 55
115
+ padding = 20
116
+ white_pcs = {0, 2, 4, 5, 7, 9, 11}
117
+ black_x_offset = {1: 0.65, 3: 1.65, 6: 3.65, 8: 4.65, 10: 5.65}
118
+
119
+ white_positions = {}
120
+ w_idx = 0
121
+ for i in range(num_keys):
122
+ midi = start_midi + i
123
+ pc = midi % 12
124
+ if pc in white_pcs:
125
+ white_positions[midi] = w_idx
126
+ w_idx += 1
127
+
128
+ total_width = w_idx * (white_key_w + 1) + padding * 2
129
+ total_height = white_key_h + 60
130
+
131
+ svg = f'<svg width="{total_width}" height="{total_height}" style="background:#161b22; border-radius:10px; padding:10px; border:1px solid #30363d;">'
132
+ svg += f'<text x="{padding}" y="18" fill="#8b949e" font-family="monospace" font-size="12" font-weight="bold">{label}</text>'
133
+ y_start = 28
134
+
135
+ for midi, wx in white_positions.items():
136
+ x = padding + wx * (white_key_w + 1)
137
+ is_active = midi in notes
138
+ fill = "#58a6ff" if is_active else "#e6edf3"
139
+ stroke = "#1f6feb" if is_active else "#8b949e"
140
+ svg += f'<rect x="{x}" y="{y_start}" width="{white_key_w}" height="{white_key_h}" fill="{fill}" stroke="{stroke}" stroke-width="{"2" if is_active else "1"}" rx="2"/>'
141
+ if is_active:
142
+ name = midi_to_name(midi)
143
+ svg += f'<text x="{x + white_key_w//2}" y="{y_start + white_key_h - 6}" fill="#0d1117" font-family="monospace" font-size="8" text-anchor="middle" font-weight="bold">{name}</text>'
144
+
145
+ for i in range(num_keys):
146
+ midi = start_midi + i
147
+ pc = midi % 12
148
+ if pc in black_x_offset:
149
+ octave_offset = (midi - pc - start_midi) // 12
150
+ x = padding + (black_x_offset[pc] + (octave_offset * 7)) * (white_key_w + 1)
151
+ is_active = midi in notes
152
+ fill = "#1f6feb" if is_active else "#0d1117"
153
+ stroke = "#58a6ff" if is_active else "#30363d"
154
+ svg += f'<rect x="{x}" y="{y_start}" width="{black_key_w}" height="{black_key_h}" fill="{fill}" stroke="{stroke}" rx="1"/>'
155
+ if is_active:
156
+ name = midi_to_name(midi)
157
+ svg += f'<text x="{x + black_key_w//2}" y="{y_start + black_key_h + 12}" fill="#58a6ff" font-family="monospace" font-size="7" text-anchor="middle">{name}</text>'
158
+
159
+ svg += '</svg>'
160
+ return svg
161
+
162
+ st.markdown("---")
163
+ st.subheader("🎵 Song Structure Builder")
164
+ st.caption("Build your song section by section. Add any section in any order!")
165
+
166
+ if len(st.session_state.song_sections) == 0:
167
+ st.info("👇 Click below to add your first section (Verse, Chorus, Intro, Bridge, etc.)")
168
+ if st.button("➕ Add First Section", type="primary", use_container_width=True):
169
+ add_section()
170
+ st.rerun()
171
+
172
+ for idx, section in enumerate(st.session_state.song_sections):
173
+ with st.container():
174
+ col_num, col_name, col_up, col_down, col_dup, col_del = st.columns([0.5, 3, 0.5, 0.5, 0.5, 0.5])
175
+
176
+ with col_num:
177
+ st.markdown(f"<div style='background: linear-gradient(135deg, #238636, #2ea043); border-radius: 8px; padding: 0.5rem; text-align: center; color: white; font-weight: bold;'>#{idx + 1}</div>", unsafe_allow_html=True)
178
+
179
+ with col_name:
180
+ section['name'] = st.text_input(
181
+ "Section Name",
182
+ value=section['name'],
183
+ key=f"name_{section['id']}",
184
+ label_visibility="collapsed",
185
+ placeholder="e.g., Verse 1, Chorus, Bridge, Drop..."
186
+ )
187
+
188
+ with col_up:
189
+ if st.button("⬆️", key=f"up_{section['id']}", help="Move up", disabled=(idx == 0)):
190
+ move_section_up(section['id'])
191
+ st.rerun()
192
+
193
+ with col_down:
194
+ if st.button("⬇️", key=f"down_{section['id']}", help="Move down", disabled=(idx == len(st.session_state.song_sections) - 1)):
195
+ move_section_down(section['id'])
196
+ st.rerun()
197
+
198
+ with col_dup:
199
+ if st.button("📋", key=f"dup_{section['id']}", help="Duplicate"):
200
+ duplicate_section(section['id'])
201
+ st.rerun()
202
+
203
+ with col_del:
204
+ if st.button("🗑️", key=f"del_{section['id']}", help="Delete"):
205
+ remove_section(section['id'])
206
+ st.rerun()
207
+
208
+ col1, col2 = st.columns([2, 1])
209
+
210
+ with col1:
211
+ if "Roman" in input_mode:
212
+ help_text = "Roman numerals: I ii iii IV V vi vii°"
213
+ else:
214
+ help_text = "e.g., Cmaj7 Am7 Fmaj7 G7"
215
+
216
+ section['chords'] = st.text_input(
217
+ "Chord Progression",
218
+ value=section['chords'],
219
+ key=f"chords_{section['id']}",
220
+ placeholder=help_text,
221
+ help=help_text
222
+ )
223
+
224
+ with col2:
225
+ section['genre'] = st.selectbox(
226
+ "Genre/Style",
227
+ ["Pop", "Jazz", "Gospel", "Blues", "Classical", "RnB", "Waltz"],
228
+ index=["Pop", "Jazz", "Gospel", "Blues", "Classical", "RnB", "Waltz"].index(section['genre']),
229
+ key=f"genre_{section['id']}"
230
+ )
231
+
232
+ with st.expander("⚙️ Advanced Settings", expanded=False):
233
+ col_lh, col_rh = st.columns(2)
234
+ with col_lh:
235
+ section['lh_octave'] = st.slider(
236
+ "Left Hand Octave",
237
+ 1, 4, section['lh_octave'],
238
+ key=f"lh_{section['id']}"
239
+ )
240
+ with col_rh:
241
+ section['rh_octave'] = st.slider(
242
+ "Right Hand Octave",
243
+ 3, 6, section['rh_octave'],
244
+ key=f"rh_{section['id']}"
245
+ )
246
+
247
+ st.markdown("---")
248
+
249
+ col_add, col_space = st.columns([1, 3])
250
+ with col_add:
251
+ if st.button("➕ Add Section", use_container_width=True):
252
+ add_section()
253
+ st.rerun()
254
+
255
+ st.markdown("---")
256
+
257
+ if st.button("🎹 Generate Full Song", type="primary", use_container_width=True):
258
+ if len(st.session_state.song_sections) == 0:
259
+ st.error("❌ Please add at least one section first!")
260
+ else:
261
+ all_results = []
262
+ progression_data = []
263
+ prev_rh = None
264
+
265
+ for section_idx, section in enumerate(st.session_state.song_sections):
266
+ if not section['chords'].strip():
267
+ st.warning(f"⚠️ Section '{section['name']}' has no chords. Skipping.")
268
+ continue
269
+
270
+ st.markdown(f"### 🎵 {section['name']}")
271
+ st.caption(f"**Genre:** {section['genre']} • **Chords:** {section['chords']}")
272
+
273
+ tokens = section['chords'].strip().split()
274
+ section_results = []
275
+
276
+ for token in tokens:
277
+ try:
278
+ if "Roman" in input_mode:
279
+ root_pc, quality = roman_to_chord(token, key_pc)
280
+ chord_label = f"{pc_to_note_name(root_pc)}{quality} ({token})"
281
+ else:
282
+ root_pc, quality = parse_chord_symbol(token)
283
+ chord_label = token
284
+
285
+ lh, rh = GenreVoicer.voice(
286
+ root_pc, quality, section['genre'],
287
+ octave_lh=section['lh_octave'],
288
+ octave_rh=section['rh_octave']
289
+ )
290
+
291
+ if use_negative:
292
+ lh = NegativeHarmony.mirror_in_key(lh, neg_key_pc)
293
+ rh = NegativeHarmony.mirror_in_key(rh, neg_key_pc)
294
+
295
+ if use_voice_leading and prev_rh:
296
+ rh = VoiceLeader.lead(prev_rh, rh)
297
+
298
+ has_issues, report, mud = SpectralAuditor.audit(lh, rh, context)
299
+
300
+ if has_issues and context == "Full Band":
301
+ mud_midis = {n for n, f in mud}
302
+ rh = sorted([n + 12 if n in mud_midis and n < 60 else n for n in rh])
303
+ has_issues, report, mud = SpectralAuditor.audit(lh, rh, context)
304
+
305
+ prev_rh = rh
306
+
307
+ result = {
308
+ 'section': section['name'],
309
+ 'label': chord_label,
310
+ 'lh': lh,
311
+ 'rh': rh,
312
+ 'report': report,
313
+ 'has_issues': has_issues,
314
+ 'lh_names': [midi_to_name(n) for n in lh],
315
+ 'rh_names': [midi_to_name(n) for n in rh],
316
+ 'genre': section['genre']
317
+ }
318
+
319
+ section_results.append(result)
320
+ all_results.append(result)
321
+ progression_data.append({'lh': lh, 'rh': rh})
322
+
323
+ except Exception as e:
324
+ st.error(f"❌ Error parsing '{token}' in {section['name']}: {e}")
325
+
326
+ if section_results:
327
+ for r in section_results:
328
+ cols = st.columns([1.5, 2.5, 2.5])
329
+
330
+ with cols[0]:
331
+ st.markdown(f"**{r['label']}**")
332
+ st.caption(f"🎨 {r['genre']}")
333
+ st.code(f"LH: {', '.join(r['lh_names'])}", language=None)
334
+ st.code(f"RH: {', '.join(r['rh_names'])}", language=None)
335
+ if r['has_issues']:
336
+ st.warning(r['report'])
337
+ else:
338
+ st.success(r['report'])
339
+
340
+ with cols[1]:
341
+ piano_lh = draw_piano_svg(r['lh'], "LEFT HAND", 24, 36)
342
+ st.markdown(piano_lh, unsafe_allow_html=True)
343
+
344
+ with cols[2]:
345
+ piano_rh = draw_piano_svg(r['rh'], "RIGHT HAND", 48, 37)
346
+ st.markdown(piano_rh, unsafe_allow_html=True)
347
+
348
+ st.markdown("---")
349
+
350
+ if progression_data:
351
+ st.subheader("💾 Export Full Song")
352
+
353
+ try:
354
+ files = MidiExporter.export(progression_data, filename_prefix="full_song")
355
+ col1, col2 = st.columns(2)
356
+ with col1:
357
+ with open(files['lh'], 'rb') as f:
358
+ st.download_button(
359
+ "⬇️ Download Left Hand MIDI",
360
+ f, file_name="full_song_LH.mid",
361
+ mime="audio/midi",
362
+ use_container_width=True
363
+ )
364
+ with col2:
365
+ with open(files['rh'], 'rb') as f:
366
+ st.download_button(
367
+ "⬇️ Download Right Hand MIDI",
368
+ f, file_name="full_song_RH.mid",
369
+ mime="audio/midi",
370
+ use_container_width=True
371
+ )
372
+ except Exception as e:
373
+ st.error(f"❌ MIDI export error: {e}")
374
+
375
+ st.subheader("📊 Full Song Summary")
376
+ summary_data = []
377
+ for r in all_results:
378
+ summary_data.append({
379
+ 'Section': r['section'],
380
+ 'Chord': r['label'],
381
+ 'Genre': r['genre'],
382
+ 'LH Notes': ", ".join(r['lh_names']),
383
+ 'RH Notes': ", ".join(r['rh_names']),
384
+ 'Status': '✅ Clear' if not r['has_issues'] else '⚠️ Fixed'
385
+ })
386
+ st.dataframe(summary_data, use_container_width=True)
harmonic_engine.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import mido
2
+
3
+ NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
4
+ ENHARMONIC = {
5
+ 'Db':'C#','Eb':'D#','Fb':'E','Gb':'F#',
6
+ 'Ab':'G#','Bb':'A#','Cb':'B'
7
+ }
8
+
9
+ def note_name_to_pc(name):
10
+ clean = ENHARMONIC.get(name, name)
11
+ return NOTE_NAMES.index(clean)
12
+
13
+ def pc_to_note_name(pc):
14
+ return NOTE_NAMES[pc % 12]
15
+
16
+ def midi_to_name(midi_num):
17
+ octave = (midi_num // 12) - 1
18
+ return f"{NOTE_NAMES[midi_num % 12]}{octave}"
19
+
20
+ def midi_to_freq(n):
21
+ return 440.0 * (2 ** ((n - 69) / 12))
22
+
23
+ MAJOR_SCALE_INTERVALS = [0, 2, 4, 5, 7, 9, 11]
24
+
25
+ MAJOR_SCALE_QUALITIES = {
26
+ 1: 'maj', 2: 'min', 3: 'min', 4: 'maj',
27
+ 5: 'maj', 6: 'min', 7: 'dim'
28
+ }
29
+
30
+ ROMAN_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':7
35
+ }
36
+
37
+ CHORD_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
+
60
+ def parse_chord_symbol(symbol):
61
+ symbol = symbol.strip()
62
+ if len(symbol) > 1 and symbol[1] in '#b':
63
+ root_str = symbol[:2]
64
+ quality_str = symbol[2:]
65
+ else:
66
+ root_str = symbol[0]
67
+ quality_str = symbol[1:]
68
+
69
+ root_pc = note_name_to_pc(root_str)
70
+
71
+ q = quality_str.lower()
72
+ if q in ('', 'maj', 'major'):
73
+ quality = 'maj'
74
+ elif q in ('m', 'min', 'minor'):
75
+ quality = 'min'
76
+ elif q in ('maj7', 'major7'):
77
+ quality = 'maj7'
78
+ elif q in ('m7', 'min7', 'minor7'):
79
+ quality = 'min7'
80
+ elif q == '7':
81
+ quality = 'dom7'
82
+ elif q in ('dim', 'dim7', 'o', 'o7'):
83
+ quality = 'dim7' if '7' in q else 'dim'
84
+ elif q in ('hdim7', 'm7b5', 'ø7', 'ø'):
85
+ quality = 'hdim7'
86
+ elif q in ('aug', '+'):
87
+ quality = 'aug'
88
+ elif q == 'sus2':
89
+ quality = 'sus2'
90
+ elif q == 'sus4':
91
+ quality = 'sus4'
92
+ elif q == 'add9':
93
+ quality = 'add9'
94
+ elif q in ('6',):
95
+ quality = '6'
96
+ elif q in ('m6', 'min6'):
97
+ quality = 'min6'
98
+ elif q == '9':
99
+ quality = '9'
100
+ elif q in ('m9', 'min9'):
101
+ quality = 'min9'
102
+ elif q == 'maj9':
103
+ quality = 'maj9'
104
+ elif q == '11':
105
+ quality = '11'
106
+ elif q == '13':
107
+ quality = '13'
108
+ else:
109
+ quality = 'maj'
110
+
111
+ return root_pc, quality
112
+
113
+ def roman_to_chord(roman_str, key_root_pc):
114
+ roman_str = roman_str.strip()
115
+
116
+ suffix = ''
117
+ base_roman = roman_str
118
+ for s in ['maj7','min7','m7','7','dim','aug','sus2','sus4','9','11','13']:
119
+ if roman_str.lower().endswith(s):
120
+ suffix = s
121
+ base_roman = roman_str[:-len(s)]
122
+ break
123
+
124
+ upper = base_roman.upper()
125
+ if upper not in ROMAN_MAP:
126
+ raise ValueError(f"Unknown Roman numeral: {roman_str}")
127
+
128
+ degree = ROMAN_MAP[upper]
129
+ is_minor_numeral = base_roman == base_roman.lower() and base_roman != base_roman.upper()
130
+
131
+ root_pc = (key_root_pc + MAJOR_SCALE_INTERVALS[degree - 1]) % 12
132
+
133
+ if suffix:
134
+ q = suffix.lower()
135
+ if q in ('m7', 'min7'):
136
+ quality = 'min7'
137
+ elif q == 'maj7':
138
+ quality = 'maj7'
139
+ elif q == '7':
140
+ quality = 'dom7'
141
+ elif q == 'dim':
142
+ quality = 'dim'
143
+ elif q == 'aug':
144
+ quality = 'aug'
145
+ else:
146
+ quality = q if q in CHORD_INTERVALS else 'maj'
147
+ else:
148
+ if is_minor_numeral:
149
+ quality = 'min'
150
+ else:
151
+ quality = MAJOR_SCALE_QUALITIES.get(degree, 'maj')
152
+
153
+ return root_pc, quality
154
+
155
+ class GenreVoicer:
156
+ @staticmethod
157
+ def voice(root_pc, quality, genre, octave_lh=2, octave_rh=4):
158
+ lh_base = (octave_lh + 1) * 12
159
+ rh_base = (octave_rh + 1) * 12
160
+
161
+ root_lh = lh_base + root_pc
162
+ root_rh = rh_base + root_pc
163
+
164
+ intervals = CHORD_INTERVALS.get(quality, [0, 4, 7])
165
+
166
+ third = 4 if 4 in intervals else (3 if 3 in intervals else None)
167
+ fifth = 7 if 7 in intervals else (6 if 6 in intervals else (8 if 8 in intervals else None))
168
+ seventh = None
169
+ for s in [11, 10, 9]:
170
+ if s in intervals:
171
+ seventh = s
172
+ break
173
+
174
+ method = getattr(GenreVoicer, f'_voice_{genre.lower()}', GenreVoicer._voice_pop)
175
+ return method(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality)
176
+
177
+ @staticmethod
178
+ def _voice_pop(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):
179
+ lh = [root_lh]
180
+ rh = []
181
+ if third is not None:
182
+ rh.append(root_rh + third)
183
+ if fifth is not None:
184
+ rh.append(root_rh + fifth)
185
+ if seventh is not None:
186
+ rh.append(root_rh + seventh)
187
+ if not rh:
188
+ rh = [root_rh + iv for iv in intervals if iv != 0]
189
+ if len(rh) < 3:
190
+ rh.append(root_rh + 12)
191
+ return lh, sorted(rh)
192
+
193
+ @staticmethod
194
+ def _voice_jazz(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):
195
+ lh = [root_lh]
196
+ if seventh is not None:
197
+ lh.append(root_lh + seventh)
198
+ else:
199
+ lh.append(root_lh + (10 if third == 3 else 11))
200
+
201
+ rh = []
202
+ if third is not None:
203
+ rh.append(root_rh + third)
204
+ sev = seventh if seventh else (10 if third == 3 else 11)
205
+ rh.append(root_rh + sev)
206
+ rh.append(root_rh + 14)
207
+ if third == 4:
208
+ rh.append(root_rh + 21)
209
+
210
+ return lh, sorted(rh)
211
+
212
+ @staticmethod
213
+ def _voice_gospel(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):
214
+ lh = [root_lh]
215
+ if fifth is not None:
216
+ lh.append(root_lh + fifth)
217
+
218
+ rh = []
219
+ if third is not None:
220
+ rh.append(root_rh + third)
221
+ if fifth is not None:
222
+ rh.append(root_rh + fifth)
223
+ sev = seventh if seventh else 11
224
+ rh.append(root_rh + sev)
225
+ rh.append(root_rh + 14)
226
+ rh.append(root_rh + 12)
227
+
228
+ return lh, sorted(rh)
229
+
230
+ @staticmethod
231
+ def _voice_blues(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):
232
+ lh = [root_lh, root_lh + 10]
233
+
234
+ rh = []
235
+ t = third if third else 4
236
+ rh.append(root_rh + t)
237
+ if fifth:
238
+ rh.append(root_rh + fifth)
239
+ rh.append(root_rh + 10)
240
+ rh.append(root_rh + 14)
241
+
242
+ return lh, sorted(rh)
243
+
244
+ @staticmethod
245
+ def _voice_classical(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):
246
+ lh = [root_lh, root_lh + 12]
247
+ rh = [root_rh + iv for iv in intervals if iv != 0]
248
+ if not rh:
249
+ rh = [root_rh + 4, root_rh + 7]
250
+ return lh, sorted(rh)
251
+
252
+ @staticmethod
253
+ def _voice_rnb(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):
254
+ lh = [root_lh]
255
+ rh = []
256
+ if third:
257
+ rh.append(root_rh + third)
258
+ sev = seventh if seventh else (10 if third == 3 else 11)
259
+ rh.append(root_rh + sev)
260
+ rh.append(root_rh + 14)
261
+ if third == 3:
262
+ rh.append(root_rh + 17)
263
+ else:
264
+ rh.append(root_rh + 21)
265
+ return lh, sorted(rh)
266
+
267
+ @staticmethod
268
+ def _voice_waltz(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):
269
+ lh = [root_lh]
270
+ rh = []
271
+ if third:
272
+ rh.append(root_rh + third)
273
+ if fifth:
274
+ rh.append(root_rh + fifth)
275
+ if seventh:
276
+ rh.append(root_rh + seventh)
277
+ if not rh:
278
+ rh = [root_rh + 4, root_rh + 7]
279
+ return lh, sorted(rh)
280
+
281
+ class VoiceLeader:
282
+ @staticmethod
283
+ def lead(prev_rh, current_rh):
284
+ if not prev_rh or not current_rh:
285
+ return current_rh
286
+
287
+ centroid = sum(prev_rh) / len(prev_rh)
288
+ result = []
289
+ for note in current_rh:
290
+ pc = note % 12
291
+ candidates = [pc + (oct * 12) for oct in range(2, 8)]
292
+ best = min(candidates, key=lambda x: abs(x - centroid))
293
+ result.append(best)
294
+
295
+ return sorted(result)
296
+
297
+ class SpectralAuditor:
298
+ MUD_ZONE = (160, 400)
299
+
300
+ @classmethod
301
+ def audit(cls, lh_notes, rh_notes, context="Full Band"):
302
+ issues = []
303
+ suggestions = []
304
+
305
+ all_notes = lh_notes + rh_notes
306
+ freqs = [(n, midi_to_freq(n)) for n in all_notes]
307
+
308
+ mud_notes = [(n, f) for n, f in freqs if cls.MUD_ZONE[0] <= f <= cls.MUD_ZONE[1]]
309
+
310
+ if context == "Full Band" and len(mud_notes) > 2:
311
+ issues.append(
312
+ f"⚠️ MUD WARNING: {len(mud_notes)} notes in {cls.MUD_ZONE[0]}-{cls.MUD_ZONE[1]}Hz"
313
+ )
314
+ suggestions.append("💡 Shift inner RH notes up one octave")
315
+
316
+ low_notes = sorted([n for n in all_notes if n < 48])
317
+ for i in range(len(low_notes) - 1):
318
+ if low_notes[i+1] - low_notes[i] < 5:
319
+ issues.append(
320
+ f"⚠️ LOW CLASH: {midi_to_name(low_notes[i])} and {midi_to_name(low_notes[i+1])}"
321
+ )
322
+
323
+ if not issues:
324
+ status = "✅ MIX CLEAR"
325
+ else:
326
+ status = "\n".join(issues + suggestions)
327
+
328
+ return len(issues) > 0, status, mud_notes
329
+
330
+ class NegativeHarmony:
331
+ @staticmethod
332
+ def mirror_in_key(notes, key_root_pc):
333
+ root_midi = 60 + key_root_pc
334
+ fifth_midi = root_midi + 7
335
+ axis = (root_midi + fifth_midi) / 2
336
+ return sorted([int(2 * axis - n) for n in notes])
337
+
338
+ class MidiExporter:
339
+ @staticmethod
340
+ def export(progression_data, filename_prefix="session"):
341
+ files = {}
342
+ for lane in ["lh", "rh"]:
343
+ mid = mido.MidiFile(ticks_per_beat=480)
344
+ track = mido.MidiTrack()
345
+ mid.tracks.append(track)
346
+ track.append(mido.MetaMessage('track_name', name=f'{lane.upper()}'))
347
+
348
+ for chord_data in progression_data:
349
+ notes = chord_data[lane]
350
+ velocity = 70 if lane == "lh" else 85
351
+
352
+ for n in notes:
353
+ n = max(0, min(127, n))
354
+ track.append(mido.Message('note_on', note=n, velocity=velocity, time=0))
355
+
356
+ track.append(mido.Message('note_off', note=max(0, min(127, notes[0])), velocity=0, time=1920))
357
+ for n in notes[1:]:
358
+ n = max(0, min(127, n))
359
+ track.append(mido.Message('note_off', note=n, velocity=0, time=0))
360
+
361
+ fname = f"{filename_prefix}_{lane}.mid"
362
+ mid.save(fname)
363
+ files[lane] = fname
364
+
365
+ return files