Spaces:
Sleeping
Sleeping
Upload 4 files
Browse files- app.py +535 -145
- audio_engine.py +83 -0
- harmonic_engine.py +31 -28
app.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import streamlit as st
|
| 2 |
import uuid
|
|
|
|
| 3 |
from emotional_engine import (
|
| 4 |
SectionNamer,
|
| 5 |
get_section_types,
|
|
@@ -88,8 +89,8 @@ def get_preset_chord_symbols(preset_roman, key_root_pc):
|
|
| 88 |
symbol = f"{root_name}{quality}"
|
| 89 |
|
| 90 |
chord_symbols.append(symbol)
|
| 91 |
-
except:
|
| 92 |
-
chord_symbols.append(token)
|
| 93 |
|
| 94 |
return " ".join(chord_symbols)
|
| 95 |
|
|
@@ -102,38 +103,275 @@ st.set_page_config(page_title="Harmonic Catalyst PRO", page_icon="🎹", layout=
|
|
| 102 |
|
| 103 |
st.markdown("""
|
| 104 |
<style>
|
| 105 |
-
|
| 106 |
-
.
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
}
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
| 113 |
background: #161b22;
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
border-radius: 8px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
margin: 1rem 0;
|
| 118 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
</style>
|
| 120 |
""", unsafe_allow_html=True)
|
| 121 |
|
| 122 |
-
|
| 123 |
-
st.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
# ═══════════════════════════════════════════════════════════════
|
| 127 |
# HELP SECTION
|
| 128 |
# ═══════════════════════════════════════════════════════════════
|
| 129 |
|
| 130 |
-
with st.expander("📖 Quick Start
|
| 131 |
st.markdown("""
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
4. **Generate** - See voicings and download MIDI
|
| 137 |
""")
|
| 138 |
|
| 139 |
|
|
@@ -147,6 +385,15 @@ if 'song_sections' not in st.session_state:
|
|
| 147 |
if 'section_counter' not in st.session_state:
|
| 148 |
st.session_state.section_counter = 0
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
# ═══════════════════════════════════════════════════════════════
|
| 152 |
# SECTION FUNCTIONS
|
|
@@ -206,25 +453,31 @@ def move_section_down(section_id):
|
|
| 206 |
# ═══════════════════════════════════════════════════════════════
|
| 207 |
|
| 208 |
with st.sidebar:
|
| 209 |
-
st.
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
input_mode = st.radio(
|
| 212 |
"Input Mode",
|
| 213 |
-
["Chord Symbols (Cmaj7, Am7...)", "Roman Numerals (I, vi, IV, V)"]
|
|
|
|
| 214 |
)
|
| 215 |
-
|
| 216 |
if "Roman" in input_mode:
|
| 217 |
key_name = st.selectbox("Key", NOTE_NAMES, index=0)
|
| 218 |
key_pc = note_name_to_pc(key_name)
|
| 219 |
else:
|
| 220 |
key_pc = 0
|
| 221 |
-
|
| 222 |
st.divider()
|
| 223 |
|
| 224 |
-
|
| 225 |
mix_col1, mix_col2 = st.columns([4, 1])
|
| 226 |
with mix_col1:
|
| 227 |
-
st.markdown("
|
| 228 |
with mix_col2:
|
| 229 |
mix_info = st.toggle("ℹ️", key="mix_info", label_visibility="collapsed")
|
| 230 |
|
|
@@ -270,15 +523,16 @@ You're making a piano cover of a song. Solo Piano mode gives full, rich voicings
|
|
| 270 |
""")
|
| 271 |
|
| 272 |
st.divider()
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
|
|
|
| 276 |
st.divider()
|
| 277 |
-
|
| 278 |
-
|
| 279 |
neg_col1, neg_col2 = st.columns([4, 1])
|
| 280 |
with neg_col1:
|
| 281 |
-
st.markdown("
|
| 282 |
with neg_col2:
|
| 283 |
neg_info = st.toggle("ℹ️", key="neg_info", label_visibility="collapsed")
|
| 284 |
|
|
@@ -319,14 +573,18 @@ Negative: Fm - Bbm - Ab - Cm (mysterious, cinematic)
|
|
| 319 |
neg_key_pc = note_name_to_pc(neg_key)
|
| 320 |
|
| 321 |
st.divider()
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
| 325 |
st.divider()
|
| 326 |
-
|
| 327 |
if st.button("🗑️ Clear All Sections"):
|
| 328 |
st.session_state.song_sections = []
|
| 329 |
st.session_state.section_counter = 0
|
|
|
|
|
|
|
| 330 |
st.rerun()
|
| 331 |
|
| 332 |
|
|
@@ -352,20 +610,20 @@ def draw_piano_svg(notes, label, start_midi=36, num_keys=37):
|
|
| 352 |
total_width = w_idx * (white_key_w + 1) + padding * 2
|
| 353 |
total_height = white_key_h + 60
|
| 354 |
|
| 355 |
-
svg = f'<svg width="{total_width}" height="{total_height}" style="background:#
|
| 356 |
-
svg += f'<text x="{padding}" y="18" fill="#
|
| 357 |
y_start = 28
|
| 358 |
-
|
| 359 |
for midi, wx in white_positions.items():
|
| 360 |
x = padding + wx * (white_key_w + 1)
|
| 361 |
is_active = midi in notes
|
| 362 |
-
fill = "#
|
| 363 |
-
stroke = "#
|
| 364 |
svg += f'<rect x="{x}" y="{y_start}" width="{white_key_w}" height="{white_key_h}" fill="{fill}" stroke="{stroke}" rx="2"/>'
|
| 365 |
if is_active:
|
| 366 |
name = midi_to_name(midi)
|
| 367 |
-
svg += f'<text x="{x + white_key_w//2}" y="{y_start + white_key_h - 6}" fill="#
|
| 368 |
-
|
| 369 |
for i in range(num_keys):
|
| 370 |
midi = start_midi + i
|
| 371 |
pc = midi % 12
|
|
@@ -373,7 +631,7 @@ def draw_piano_svg(notes, label, start_midi=36, num_keys=37):
|
|
| 373 |
octave_offset = (midi - pc - start_midi) // 12
|
| 374 |
x = padding + (black_x_offset[pc] + (octave_offset * 7)) * (white_key_w + 1)
|
| 375 |
is_active = midi in notes
|
| 376 |
-
fill = "#
|
| 377 |
svg += f'<rect x="{x}" y="{y_start}" width="{black_key_w}" height="{black_key_h}" fill="{fill}" rx="1"/>'
|
| 378 |
|
| 379 |
svg += '</svg>'
|
|
@@ -384,12 +642,28 @@ def draw_piano_svg(notes, label, start_midi=36, num_keys=37):
|
|
| 384 |
# MAIN CONTENT
|
| 385 |
# ═══════════════════════════════════════════════════════════════
|
| 386 |
|
| 387 |
-
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
|
| 390 |
# Empty state
|
| 391 |
if len(st.session_state.song_sections) == 0:
|
| 392 |
-
st.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
if st.button("➕ Add First Section", type="primary", use_container_width=True):
|
| 394 |
add_section()
|
| 395 |
st.rerun()
|
|
@@ -400,6 +674,9 @@ if len(st.session_state.song_sections) == 0:
|
|
| 400 |
# ═══════════════════════════════════════════════════════════════
|
| 401 |
|
| 402 |
for idx, section in enumerate(st.session_state.song_sections):
|
|
|
|
|
|
|
|
|
|
| 403 |
with st.container():
|
| 404 |
# Header row
|
| 405 |
col_num, col_type, col_name, col_controls = st.columns([0.5, 2, 2, 2])
|
|
@@ -483,6 +760,22 @@ for idx, section in enumerate(st.session_state.song_sections):
|
|
| 483 |
placeholder=placeholder,
|
| 484 |
help="Enter chords separated by spaces (e.g., Cmaj7 Am7 F G). Supports slash chords like C/E"
|
| 485 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
|
| 487 |
with col_genre:
|
| 488 |
genre_list = ["Pop", "Jazz", "Gospel", "Blues", "Classical", "RnB", "Waltz",
|
|
@@ -646,8 +939,15 @@ for idx, section in enumerate(st.session_state.song_sections):
|
|
| 646 |
key=f"rh_{section['id']}",
|
| 647 |
help="Right hand octave - higher = brighter sound"
|
| 648 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
|
| 650 |
-
st.markdown("
|
| 651 |
|
| 652 |
|
| 653 |
# Add Section Button
|
|
@@ -656,13 +956,17 @@ if len(st.session_state.song_sections) > 0:
|
|
| 656 |
add_section()
|
| 657 |
st.rerun()
|
| 658 |
|
| 659 |
-
st.markdown("
|
| 660 |
|
| 661 |
|
| 662 |
# ═══════════════════════════════════════════════════════════════
|
| 663 |
# GENERATE BUTTON
|
| 664 |
# ═══════════════════════════════════════════════════════════════
|
| 665 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
if st.button("🎹 Generate Full Song", type="primary", use_container_width=True):
|
| 667 |
if len(st.session_state.song_sections) == 0:
|
| 668 |
st.error("❌ Add at least one section first!")
|
|
@@ -670,17 +974,14 @@ if st.button("🎹 Generate Full Song", type="primary", use_container_width=True
|
|
| 670 |
all_results = []
|
| 671 |
progression_data = []
|
| 672 |
prev_rh = None
|
| 673 |
-
|
| 674 |
for section in st.session_state.song_sections:
|
| 675 |
if not section['chords'].strip():
|
| 676 |
st.warning(f"⚠️ '{section['name']}' has no chords. Skipping.")
|
| 677 |
continue
|
| 678 |
-
|
| 679 |
-
st.markdown(f"### 🎵 {section['name']}")
|
| 680 |
-
st.caption(f"**Genre/Voicing:** {section['genre']} • **Chords:** {section['chords']}")
|
| 681 |
-
|
| 682 |
tokens = section['chords'].strip().split()
|
| 683 |
-
|
| 684 |
for token in tokens:
|
| 685 |
try:
|
| 686 |
if "Roman" in input_mode:
|
|
@@ -690,8 +991,7 @@ if st.button("🎹 Generate Full Song", type="primary", use_container_width=True
|
|
| 690 |
else:
|
| 691 |
root_pc, quality, slash_bass = parse_chord_symbol(token)
|
| 692 |
chord_label = token
|
| 693 |
-
|
| 694 |
-
# Transpose
|
| 695 |
if transpose_semitones != 0:
|
| 696 |
root_pc = (root_pc + transpose_semitones) % 12
|
| 697 |
if slash_bass:
|
|
@@ -699,50 +999,54 @@ if st.button("🎹 Generate Full Song", type="primary", use_container_width=True
|
|
| 699 |
slash_bass_pc = (slash_bass_pc + transpose_semitones) % 12
|
| 700 |
slash_bass = pc_to_note_name(slash_bass_pc)
|
| 701 |
chord_label = f"{pc_to_note_name(root_pc)}{quality if quality != 'maj' else ''}"
|
| 702 |
-
|
| 703 |
lh, rh = GenreVoicer.voice(
|
| 704 |
root_pc, quality, section['genre'],
|
| 705 |
octave_lh=section['lh_octave'],
|
| 706 |
octave_rh=section['rh_octave'],
|
| 707 |
slash_bass=slash_bass
|
| 708 |
)
|
| 709 |
-
|
| 710 |
-
# Apply emotional adaptation
|
| 711 |
emotional_ctx = EmotionalContext(
|
| 712 |
energy=section.get('energy', 0.5),
|
| 713 |
density=section.get('density', 'Medium'),
|
| 714 |
role=section.get('role', 'Support'),
|
| 715 |
movement=section.get('movement', 'Flowing')
|
| 716 |
)
|
| 717 |
-
|
| 718 |
if context == "Solo Piano":
|
| 719 |
lh, rh = EmotionalAdapter.adapt_solo_piano(lh, rh, emotional_ctx)
|
| 720 |
else:
|
| 721 |
lh, rh = EmotionalAdapter.adapt_arrangement(lh, rh, emotional_ctx)
|
| 722 |
-
|
| 723 |
if use_negative:
|
| 724 |
lh = NegativeHarmony.mirror_in_key(lh, neg_key_pc)
|
| 725 |
rh = NegativeHarmony.mirror_in_key(rh, neg_key_pc)
|
| 726 |
-
|
| 727 |
-
# Apply voice leading based on Movement setting
|
| 728 |
voice_settings = EmotionalAdapter.get_voice_leading_settings(
|
| 729 |
section.get('movement', 'Flowing')
|
| 730 |
)
|
| 731 |
-
|
| 732 |
if use_voice_leading and prev_rh and voice_settings['enabled']:
|
| 733 |
rh = VoiceLeader.lead(prev_rh, rh)
|
| 734 |
-
|
| 735 |
has_issues, report, mud = SpectralAuditor.audit(lh, rh, context)
|
| 736 |
-
|
| 737 |
if has_issues and context == "Full Band":
|
| 738 |
mud_midis = {n for n, f in mud}
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
|
|
|
|
|
|
|
|
|
| 742 |
prev_rh = rh
|
| 743 |
-
|
|
|
|
| 744 |
result = {
|
| 745 |
'section': section['name'],
|
|
|
|
|
|
|
| 746 |
'label': chord_label,
|
| 747 |
'lh': lh,
|
| 748 |
'rh': rh,
|
|
@@ -750,85 +1054,171 @@ if st.button("🎹 Generate Full Song", type="primary", use_container_width=True
|
|
| 750 |
'has_issues': has_issues,
|
| 751 |
'lh_names': [midi_to_name(n) for n in lh],
|
| 752 |
'rh_names': [midi_to_name(n) for n in rh],
|
| 753 |
-
'genre': section['genre']
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
}
|
| 755 |
-
|
| 756 |
all_results.append(result)
|
| 757 |
-
progression_data.append({'lh': lh, 'rh': rh})
|
| 758 |
-
|
| 759 |
-
# Display
|
| 760 |
-
cols = st.columns([1.5, 2.5, 2.5])
|
| 761 |
-
with cols[0]:
|
| 762 |
-
st.markdown(f"**{result['label']}**")
|
| 763 |
-
st.code(f"LH: {', '.join(result['lh_names'])}")
|
| 764 |
-
st.code(f"RH: {', '.join(result['rh_names'])}")
|
| 765 |
-
if result['has_issues']:
|
| 766 |
-
st.warning(result['report'])
|
| 767 |
-
else:
|
| 768 |
-
st.success(result['report'])
|
| 769 |
-
with cols[1]:
|
| 770 |
-
st.markdown(draw_piano_svg(result['lh'], "LEFT HAND", 24, 36), unsafe_allow_html=True)
|
| 771 |
-
with cols[2]:
|
| 772 |
-
st.markdown(draw_piano_svg(result['rh'], "RIGHT HAND", 48, 37), unsafe_allow_html=True)
|
| 773 |
-
|
| 774 |
except Exception as e:
|
| 775 |
st.error(f"❌ Error parsing '{token}': {e}")
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
st.download_button(
|
| 791 |
-
"⬇️
|
| 792 |
f,
|
| 793 |
-
"
|
| 794 |
"audio/midi",
|
| 795 |
-
use_container_width=True
|
| 796 |
-
type="primary"
|
| 797 |
)
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
st.dataframe(summary, use_container_width=True)
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
st.markdown("---")
|
| 834 |
-
st.caption("🎹 Harmonic Catalyst PRO")
|
|
|
|
| 1 |
import streamlit as st
|
| 2 |
import uuid
|
| 3 |
+
from audio_engine import AudioEngine
|
| 4 |
from emotional_engine import (
|
| 5 |
SectionNamer,
|
| 6 |
get_section_types,
|
|
|
|
| 89 |
symbol = f"{root_name}{quality}"
|
| 90 |
|
| 91 |
chord_symbols.append(symbol)
|
| 92 |
+
except Exception:
|
| 93 |
+
chord_symbols.append(token) # pass through unrecognized tokens as-is
|
| 94 |
|
| 95 |
return " ".join(chord_symbols)
|
| 96 |
|
|
|
|
| 103 |
|
| 104 |
st.markdown("""
|
| 105 |
<style>
|
| 106 |
+
/* ── Base ── */
|
| 107 |
+
.stApp { background-color: #0a0d12; color: #c9d1d9; }
|
| 108 |
+
.block-container { padding-top: 1.5rem !important; }
|
| 109 |
+
|
| 110 |
+
/* ── Sidebar ── */
|
| 111 |
+
[data-testid="stSidebar"] {
|
| 112 |
+
background: #0d1117;
|
| 113 |
+
border-right: 1px solid #1e2530;
|
| 114 |
+
}
|
| 115 |
+
[data-testid="stSidebar"] .stMarkdown h1,
|
| 116 |
+
[data-testid="stSidebar"] .stMarkdown h2,
|
| 117 |
+
[data-testid="stSidebar"] .stMarkdown h3 {
|
| 118 |
+
color: #00bfa5;
|
| 119 |
+
font-size: 0.75rem;
|
| 120 |
+
text-transform: uppercase;
|
| 121 |
+
letter-spacing: 0.1em;
|
| 122 |
+
font-weight: 700;
|
| 123 |
+
margin-bottom: 0.5rem;
|
| 124 |
}
|
| 125 |
+
|
| 126 |
+
/* ── Buttons ── */
|
| 127 |
+
.stButton > button {
|
| 128 |
+
border-radius: 6px;
|
| 129 |
+
border: 1px solid #1e2530;
|
| 130 |
background: #161b22;
|
| 131 |
+
color: #8b949e;
|
| 132 |
+
font-weight: 600;
|
| 133 |
+
font-size: 0.82rem;
|
| 134 |
+
transition: all 0.15s ease;
|
| 135 |
+
}
|
| 136 |
+
.stButton > button:hover {
|
| 137 |
+
border-color: #00bfa5;
|
| 138 |
+
color: #00bfa5;
|
| 139 |
+
background: #0d1f1e;
|
| 140 |
+
}
|
| 141 |
+
/* Primary button */
|
| 142 |
+
.stButton > button[kind="primary"],
|
| 143 |
+
button[data-testid="baseButton-primary"] {
|
| 144 |
+
background: linear-gradient(135deg, #00897b, #00bfa5) !important;
|
| 145 |
+
border: none !important;
|
| 146 |
+
color: #000 !important;
|
| 147 |
+
font-weight: 700 !important;
|
| 148 |
+
letter-spacing: 0.04em;
|
| 149 |
+
}
|
| 150 |
+
.stButton > button[kind="primary"]:hover,
|
| 151 |
+
button[data-testid="baseButton-primary"]:hover {
|
| 152 |
+
background: linear-gradient(135deg, #00bfa5, #1de9b6) !important;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/* ── Inputs ── */
|
| 156 |
+
.stTextInput > div > div > input,
|
| 157 |
+
.stSelectbox > div > div,
|
| 158 |
+
.stRadio > div {
|
| 159 |
+
background: #0d1117 !important;
|
| 160 |
+
border-color: #1e2530 !important;
|
| 161 |
+
color: #c9d1d9 !important;
|
| 162 |
+
border-radius: 6px !important;
|
| 163 |
+
}
|
| 164 |
+
.stTextInput > div > div > input:focus {
|
| 165 |
+
border-color: #00bfa5 !important;
|
| 166 |
+
box-shadow: 0 0 0 2px rgba(0,191,165,0.15) !important;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/* ── Expanders ── */
|
| 170 |
+
.streamlit-expanderHeader {
|
| 171 |
+
background: #0d1117 !important;
|
| 172 |
+
border: 1px solid #1e2530 !important;
|
| 173 |
+
border-radius: 6px !important;
|
| 174 |
+
color: #8b949e !important;
|
| 175 |
+
font-size: 0.83rem !important;
|
| 176 |
+
}
|
| 177 |
+
.streamlit-expanderContent {
|
| 178 |
+
background: #0d1117 !important;
|
| 179 |
+
border: 1px solid #1e2530 !important;
|
| 180 |
+
border-top: none !important;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/* ── Dividers ── */
|
| 184 |
+
hr { border: 0; border-top: 1px solid #1e2530; }
|
| 185 |
+
|
| 186 |
+
/* ── Section card ── */
|
| 187 |
+
.section-card {
|
| 188 |
+
background: #0d1117;
|
| 189 |
+
border: 1px solid #1e2530;
|
| 190 |
+
border-radius: 10px;
|
| 191 |
+
padding: 1rem 1.2rem 0.6rem;
|
| 192 |
+
margin-bottom: 1rem;
|
| 193 |
+
position: relative;
|
| 194 |
+
overflow: hidden;
|
| 195 |
+
}
|
| 196 |
+
.section-card::before {
|
| 197 |
+
content: '';
|
| 198 |
+
position: absolute;
|
| 199 |
+
top: 0; left: 0;
|
| 200 |
+
width: 4px; height: 100%;
|
| 201 |
+
border-radius: 10px 0 0 10px;
|
| 202 |
+
}
|
| 203 |
+
/* Section type color strips */
|
| 204 |
+
.sc-intro::before { background: #4e9eff; }
|
| 205 |
+
.sc-verse::before { background: #3fb950; }
|
| 206 |
+
.sc-prechorus::before { background: #d29922; }
|
| 207 |
+
.sc-chorus::before { background: #f78166; }
|
| 208 |
+
.sc-postchorus::before { background: #f78166; }
|
| 209 |
+
.sc-bridge::before { background: #bc8cff; }
|
| 210 |
+
.sc-breakdown::before { background: #8b949e; }
|
| 211 |
+
.sc-drop::before { background: #ff6e6e; }
|
| 212 |
+
.sc-outro::before { background: #4e9eff; }
|
| 213 |
+
.sc-custom::before { background: #00bfa5; }
|
| 214 |
+
|
| 215 |
+
/* ── Section type badge ── */
|
| 216 |
+
.section-badge {
|
| 217 |
+
display: inline-block;
|
| 218 |
+
padding: 2px 10px;
|
| 219 |
+
border-radius: 20px;
|
| 220 |
+
font-size: 0.7rem;
|
| 221 |
+
font-weight: 700;
|
| 222 |
+
text-transform: uppercase;
|
| 223 |
+
letter-spacing: 0.08em;
|
| 224 |
+
margin-bottom: 0.7rem;
|
| 225 |
+
}
|
| 226 |
+
.badge-intro { background: rgba(78,158,255,0.15); color: #4e9eff; }
|
| 227 |
+
.badge-verse { background: rgba(63,185,80,0.15); color: #3fb950; }
|
| 228 |
+
.badge-prechorus { background: rgba(210,153,34,0.15); color: #d29922; }
|
| 229 |
+
.badge-chorus { background: rgba(247,129,102,0.15); color: #f78166; }
|
| 230 |
+
.badge-postchorus { background: rgba(247,129,102,0.15); color: #f78166; }
|
| 231 |
+
.badge-bridge { background: rgba(188,140,255,0.15); color: #bc8cff; }
|
| 232 |
+
.badge-breakdown { background: rgba(139,148,158,0.15); color: #8b949e; }
|
| 233 |
+
.badge-drop { background: rgba(255,110,110,0.15); color: #ff6e6e; }
|
| 234 |
+
.badge-outro { background: rgba(78,158,255,0.15); color: #4e9eff; }
|
| 235 |
+
.badge-custom { background: rgba(0,191,165,0.15); color: #00bfa5; }
|
| 236 |
+
|
| 237 |
+
/* ── Chord result card ── */
|
| 238 |
+
.chord-result-card {
|
| 239 |
+
background: #0d1117;
|
| 240 |
+
border: 1px solid #1e2530;
|
| 241 |
+
border-left: 3px solid #00bfa5;
|
| 242 |
border-radius: 8px;
|
| 243 |
+
padding: 0.8rem 1rem;
|
| 244 |
+
margin-bottom: 0.5rem;
|
| 245 |
+
}
|
| 246 |
+
.chord-name {
|
| 247 |
+
font-size: 1.6rem;
|
| 248 |
+
font-weight: 800;
|
| 249 |
+
color: #e6edf3;
|
| 250 |
+
letter-spacing: -0.02em;
|
| 251 |
+
line-height: 1;
|
| 252 |
+
font-family: 'Georgia', serif;
|
| 253 |
+
}
|
| 254 |
+
.chord-energy-badge {
|
| 255 |
+
display: inline-flex;
|
| 256 |
+
gap: 6px;
|
| 257 |
+
align-items: center;
|
| 258 |
+
margin-top: 4px;
|
| 259 |
+
margin-bottom: 8px;
|
| 260 |
+
}
|
| 261 |
+
.energy-dot {
|
| 262 |
+
display: inline-block;
|
| 263 |
+
width: 8px; height: 8px;
|
| 264 |
+
border-radius: 50%;
|
| 265 |
+
background: #00bfa5;
|
| 266 |
+
}
|
| 267 |
+
.energy-label {
|
| 268 |
+
font-size: 0.7rem;
|
| 269 |
+
color: #8b949e;
|
| 270 |
+
font-weight: 600;
|
| 271 |
+
text-transform: uppercase;
|
| 272 |
+
letter-spacing: 0.06em;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
/* ── Note pills ── */
|
| 276 |
+
.note-row { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 8px; }
|
| 277 |
+
.note-label {
|
| 278 |
+
font-size: 0.65rem;
|
| 279 |
+
font-weight: 700;
|
| 280 |
+
text-transform: uppercase;
|
| 281 |
+
color: #8b949e;
|
| 282 |
+
letter-spacing: 0.06em;
|
| 283 |
+
margin-bottom: 2px;
|
| 284 |
+
}
|
| 285 |
+
.note-pill {
|
| 286 |
+
display: inline-block;
|
| 287 |
+
background: #161b22;
|
| 288 |
+
border: 1px solid #30363d;
|
| 289 |
+
border-radius: 20px;
|
| 290 |
+
padding: 2px 8px;
|
| 291 |
+
font-size: 0.72rem;
|
| 292 |
+
font-weight: 600;
|
| 293 |
+
font-family: 'Courier New', monospace;
|
| 294 |
+
color: #c9d1d9;
|
| 295 |
+
}
|
| 296 |
+
.note-pill-lh { border-color: #1f6feb; color: #79c0ff; }
|
| 297 |
+
.note-pill-rh { border-color: #238636; color: #7ee787; }
|
| 298 |
+
|
| 299 |
+
/* ── Section result header ── */
|
| 300 |
+
.section-result-header {
|
| 301 |
+
display: flex;
|
| 302 |
+
align-items: center;
|
| 303 |
+
gap: 10px;
|
| 304 |
+
padding: 8px 0;
|
| 305 |
+
margin: 16px 0 8px;
|
| 306 |
+
border-bottom: 1px solid #1e2530;
|
| 307 |
+
}
|
| 308 |
+
.section-result-title {
|
| 309 |
+
font-size: 1rem;
|
| 310 |
+
font-weight: 700;
|
| 311 |
+
color: #e6edf3;
|
| 312 |
+
}
|
| 313 |
+
.section-result-meta {
|
| 314 |
+
font-size: 0.72rem;
|
| 315 |
+
color: #8b949e;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
/* ── Audio/Export bar ── */
|
| 319 |
+
.output-section {
|
| 320 |
+
background: #0d1117;
|
| 321 |
+
border: 1px solid #1e2530;
|
| 322 |
+
border-radius: 10px;
|
| 323 |
+
padding: 1rem 1.2rem;
|
| 324 |
margin: 1rem 0;
|
| 325 |
}
|
| 326 |
+
.output-section-title {
|
| 327 |
+
font-size: 0.7rem;
|
| 328 |
+
font-weight: 700;
|
| 329 |
+
text-transform: uppercase;
|
| 330 |
+
letter-spacing: 0.1em;
|
| 331 |
+
color: #00bfa5;
|
| 332 |
+
margin-bottom: 0.75rem;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
/* ── Spectral status ── */
|
| 336 |
+
.spectral-ok { color: #3fb950; font-size: 0.72rem; }
|
| 337 |
+
.spectral-warn { color: #d29922; font-size: 0.72rem; }
|
| 338 |
+
|
| 339 |
+
/* ── Captions ── */
|
| 340 |
+
.stCaption { color: #6e7681 !important; }
|
| 341 |
+
|
| 342 |
+
/* ── Dataframe ── */
|
| 343 |
+
.stDataFrame { border: 1px solid #1e2530 !important; border-radius: 8px !important; }
|
| 344 |
</style>
|
| 345 |
""", unsafe_allow_html=True)
|
| 346 |
|
| 347 |
+
# ── Header banner ──
|
| 348 |
+
st.markdown("""
|
| 349 |
+
<div style="display:flex; align-items:baseline; gap:12px; margin-bottom:4px;">
|
| 350 |
+
<span style="font-size:1.9rem; font-weight:900; letter-spacing:-0.03em; color:#e6edf3;">
|
| 351 |
+
HARMONIC <span style="color:#00bfa5;">CATALYST</span>
|
| 352 |
+
</span>
|
| 353 |
+
<span style="font-size:0.7rem; font-weight:700; text-transform:uppercase; letter-spacing:0.1em;
|
| 354 |
+
color:#8b949e; padding:3px 8px; border:1px solid #1e2530; border-radius:20px;">
|
| 355 |
+
PRO
|
| 356 |
+
</span>
|
| 357 |
+
</div>
|
| 358 |
+
<div style="font-size:0.8rem; color:#6e7681; margin-bottom:1rem; letter-spacing:0.02em;">
|
| 359 |
+
Genre-Aware Chord Voicing Engine · Multi-Section Builder · MIDI Export
|
| 360 |
+
</div>
|
| 361 |
+
<div style="height:2px; background:linear-gradient(90deg, #00bfa5, transparent); margin-bottom:1.5rem; border-radius:2px;"></div>
|
| 362 |
+
""", unsafe_allow_html=True)
|
| 363 |
|
| 364 |
|
| 365 |
# ═══════════════════════════════════════════════════════════════
|
| 366 |
# HELP SECTION
|
| 367 |
# ═══════════════════════════════════════════════════════════════
|
| 368 |
|
| 369 |
+
with st.expander("📖 Quick Start", expanded=False):
|
| 370 |
st.markdown("""
|
| 371 |
+
1. **Add Section** → choose type (Verse, Chorus, etc.)
|
| 372 |
+
2. **Enter Chords** → type symbols like `Cmaj7 Am7 F G` or use a preset
|
| 373 |
+
3. **Pick Genre** → sets the voicing style
|
| 374 |
+
4. **Generate Full Song** → see voicings, play audio, export MIDI
|
|
|
|
| 375 |
""")
|
| 376 |
|
| 377 |
|
|
|
|
| 385 |
if 'section_counter' not in st.session_state:
|
| 386 |
st.session_state.section_counter = 0
|
| 387 |
|
| 388 |
+
if 'last_results' not in st.session_state:
|
| 389 |
+
st.session_state.last_results = []
|
| 390 |
+
|
| 391 |
+
if 'last_progression_data' not in st.session_state:
|
| 392 |
+
st.session_state.last_progression_data = []
|
| 393 |
+
|
| 394 |
+
if 'last_gen_settings' not in st.session_state:
|
| 395 |
+
st.session_state.last_gen_settings = {}
|
| 396 |
+
|
| 397 |
|
| 398 |
# ═══════════════════════════════════════════════════════════════
|
| 399 |
# SECTION FUNCTIONS
|
|
|
|
| 453 |
# ═══════════════════════════════════════════════════════════════
|
| 454 |
|
| 455 |
with st.sidebar:
|
| 456 |
+
st.markdown("""
|
| 457 |
+
<div style="padding:1rem 0 0.5rem; font-size:1rem; font-weight:800; color:#e6edf3; letter-spacing:-0.01em;">
|
| 458 |
+
🎹 Settings
|
| 459 |
+
</div>
|
| 460 |
+
""", unsafe_allow_html=True)
|
| 461 |
+
|
| 462 |
+
st.markdown("**INPUT**")
|
| 463 |
input_mode = st.radio(
|
| 464 |
"Input Mode",
|
| 465 |
+
["Chord Symbols (Cmaj7, Am7...)", "Roman Numerals (I, vi, IV, V)"],
|
| 466 |
+
label_visibility="collapsed"
|
| 467 |
)
|
| 468 |
+
|
| 469 |
if "Roman" in input_mode:
|
| 470 |
key_name = st.selectbox("Key", NOTE_NAMES, index=0)
|
| 471 |
key_pc = note_name_to_pc(key_name)
|
| 472 |
else:
|
| 473 |
key_pc = 0
|
| 474 |
+
|
| 475 |
st.divider()
|
| 476 |
|
| 477 |
+
st.markdown("**MIX CONTEXT**")
|
| 478 |
mix_col1, mix_col2 = st.columns([4, 1])
|
| 479 |
with mix_col1:
|
| 480 |
+
st.markdown("")
|
| 481 |
with mix_col2:
|
| 482 |
mix_info = st.toggle("ℹ️", key="mix_info", label_visibility="collapsed")
|
| 483 |
|
|
|
|
| 523 |
""")
|
| 524 |
|
| 525 |
st.divider()
|
| 526 |
+
|
| 527 |
+
st.markdown("**VOICE LEADING**")
|
| 528 |
+
use_voice_leading = st.checkbox("Connect sections smoothly", value=True)
|
| 529 |
+
|
| 530 |
st.divider()
|
| 531 |
+
|
| 532 |
+
st.markdown("**NEGATIVE HARMONY**")
|
| 533 |
neg_col1, neg_col2 = st.columns([4, 1])
|
| 534 |
with neg_col1:
|
| 535 |
+
st.markdown("")
|
| 536 |
with neg_col2:
|
| 537 |
neg_info = st.toggle("ℹ️", key="neg_info", label_visibility="collapsed")
|
| 538 |
|
|
|
|
| 573 |
neg_key_pc = note_name_to_pc(neg_key)
|
| 574 |
|
| 575 |
st.divider()
|
| 576 |
+
|
| 577 |
+
st.markdown("**TRANSPOSE & TEMPO**")
|
| 578 |
+
transpose_semitones = st.slider("Transpose (semitones)", -12, 12, 0)
|
| 579 |
+
bpm = st.slider("Playback BPM", 60, 200, 120, help="Affects audio preview in real time — no need to regenerate")
|
| 580 |
+
|
| 581 |
st.divider()
|
| 582 |
+
|
| 583 |
if st.button("🗑️ Clear All Sections"):
|
| 584 |
st.session_state.song_sections = []
|
| 585 |
st.session_state.section_counter = 0
|
| 586 |
+
st.session_state.last_results = []
|
| 587 |
+
st.session_state.last_progression_data = []
|
| 588 |
st.rerun()
|
| 589 |
|
| 590 |
|
|
|
|
| 610 |
total_width = w_idx * (white_key_w + 1) + padding * 2
|
| 611 |
total_height = white_key_h + 60
|
| 612 |
|
| 613 |
+
svg = f'<svg width="{total_width}" height="{total_height}" style="background:#0d1117; border-radius:8px; border:1px solid #1e2530;">'
|
| 614 |
+
svg += f'<text x="{padding}" y="18" fill="#6e7681" font-family="monospace" font-size="10" font-weight="700" letter-spacing="0.08em">{label}</text>'
|
| 615 |
y_start = 28
|
| 616 |
+
|
| 617 |
for midi, wx in white_positions.items():
|
| 618 |
x = padding + wx * (white_key_w + 1)
|
| 619 |
is_active = midi in notes
|
| 620 |
+
fill = "#00bfa5" if is_active else "#d0d7de"
|
| 621 |
+
stroke = "#008c7a" if is_active else "#6e7681"
|
| 622 |
svg += f'<rect x="{x}" y="{y_start}" width="{white_key_w}" height="{white_key_h}" fill="{fill}" stroke="{stroke}" rx="2"/>'
|
| 623 |
if is_active:
|
| 624 |
name = midi_to_name(midi)
|
| 625 |
+
svg += f'<text x="{x + white_key_w//2}" y="{y_start + white_key_h - 6}" fill="#0a0d12" font-size="8" text-anchor="middle" font-weight="bold">{name}</text>'
|
| 626 |
+
|
| 627 |
for i in range(num_keys):
|
| 628 |
midi = start_midi + i
|
| 629 |
pc = midi % 12
|
|
|
|
| 631 |
octave_offset = (midi - pc - start_midi) // 12
|
| 632 |
x = padding + (black_x_offset[pc] + (octave_offset * 7)) * (white_key_w + 1)
|
| 633 |
is_active = midi in notes
|
| 634 |
+
fill = "#00897b" if is_active else "#0d1117"
|
| 635 |
svg += f'<rect x="{x}" y="{y_start}" width="{black_key_w}" height="{black_key_h}" fill="{fill}" rx="1"/>'
|
| 636 |
|
| 637 |
svg += '</svg>'
|
|
|
|
| 642 |
# MAIN CONTENT
|
| 643 |
# ═══════════════════════════════════════════════════════════════
|
| 644 |
|
| 645 |
+
SECTION_CSS_CLASS = {
|
| 646 |
+
'Intro': 'intro', 'Verse': 'verse', 'Pre-Chorus': 'prechorus',
|
| 647 |
+
'Chorus': 'chorus', 'Post-Chorus': 'postchorus', 'Bridge': 'bridge',
|
| 648 |
+
'Breakdown': 'breakdown', 'Drop': 'drop', 'Outro': 'outro', 'Custom': 'custom'
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
st.markdown("""
|
| 652 |
+
<div style="font-size:0.7rem; font-weight:700; text-transform:uppercase;
|
| 653 |
+
letter-spacing:0.1em; color:#8b949e; margin-bottom:0.75rem;">
|
| 654 |
+
Song Structure
|
| 655 |
+
</div>
|
| 656 |
+
""", unsafe_allow_html=True)
|
| 657 |
|
| 658 |
# Empty state
|
| 659 |
if len(st.session_state.song_sections) == 0:
|
| 660 |
+
st.markdown("""
|
| 661 |
+
<div style="text-align:center; padding:2.5rem 1rem; background:#0d1117;
|
| 662 |
+
border:1px dashed #1e2530; border-radius:10px; margin-bottom:1rem;">
|
| 663 |
+
<div style="font-size:2rem; margin-bottom:0.5rem;">🎹</div>
|
| 664 |
+
<div style="color:#6e7681; font-size:0.88rem;">No sections yet — add your first section to start building</div>
|
| 665 |
+
</div>
|
| 666 |
+
""", unsafe_allow_html=True)
|
| 667 |
if st.button("➕ Add First Section", type="primary", use_container_width=True):
|
| 668 |
add_section()
|
| 669 |
st.rerun()
|
|
|
|
| 674 |
# ═══════════════════════════════════════════════════════════════
|
| 675 |
|
| 676 |
for idx, section in enumerate(st.session_state.song_sections):
|
| 677 |
+
stype = section.get('section_type', 'Verse')
|
| 678 |
+
css_cls = SECTION_CSS_CLASS.get(stype, 'custom')
|
| 679 |
+
st.markdown(f'<div class="section-card sc-{css_cls}"><span class="section-badge badge-{css_cls}">{stype}</span></div>', unsafe_allow_html=True)
|
| 680 |
with st.container():
|
| 681 |
# Header row
|
| 682 |
col_num, col_type, col_name, col_controls = st.columns([0.5, 2, 2, 2])
|
|
|
|
| 760 |
placeholder=placeholder,
|
| 761 |
help="Enter chords separated by spaces (e.g., Cmaj7 Am7 F G). Supports slash chords like C/E"
|
| 762 |
)
|
| 763 |
+
# Inline chord validation
|
| 764 |
+
if section['chords'].strip():
|
| 765 |
+
tokens = section['chords'].strip().split()
|
| 766 |
+
invalid = []
|
| 767 |
+
for token in tokens:
|
| 768 |
+
try:
|
| 769 |
+
if "Roman" in input_mode:
|
| 770 |
+
roman_to_chord(token, key_pc)
|
| 771 |
+
else:
|
| 772 |
+
parse_chord_symbol(token)
|
| 773 |
+
except Exception:
|
| 774 |
+
invalid.append(token)
|
| 775 |
+
if invalid:
|
| 776 |
+
st.caption(f"❌ Unrecognized: {', '.join(invalid)}")
|
| 777 |
+
else:
|
| 778 |
+
st.caption(f"✅ {len(tokens)} chord(s)")
|
| 779 |
|
| 780 |
with col_genre:
|
| 781 |
genre_list = ["Pop", "Jazz", "Gospel", "Blues", "Classical", "RnB", "Waltz",
|
|
|
|
| 939 |
key=f"rh_{section['id']}",
|
| 940 |
help="Right hand octave - higher = brighter sound"
|
| 941 |
)
|
| 942 |
+
section['beats_per_chord'] = st.select_slider(
|
| 943 |
+
"⏱️ Beats per chord",
|
| 944 |
+
options=[1, 2, 4, 8],
|
| 945 |
+
value=section.get('beats_per_chord', 4),
|
| 946 |
+
key=f"bpc_{section['id']}",
|
| 947 |
+
help="Duration of each chord in MIDI export (1=quarter note, 4=one bar, 8=two bars)"
|
| 948 |
+
)
|
| 949 |
|
| 950 |
+
st.markdown('<div style="height:1px; background:#1e2530; margin:0.5rem 0 0.8rem;"></div>', unsafe_allow_html=True)
|
| 951 |
|
| 952 |
|
| 953 |
# Add Section Button
|
|
|
|
| 956 |
add_section()
|
| 957 |
st.rerun()
|
| 958 |
|
| 959 |
+
st.markdown('<div style="height:1px; background:#1e2530; margin:1rem 0;"></div>', unsafe_allow_html=True)
|
| 960 |
|
| 961 |
|
| 962 |
# ═══════════════════════════════════════════════════════════════
|
| 963 |
# GENERATE BUTTON
|
| 964 |
# ═══════════════════════════════════════════════════════════════
|
| 965 |
|
| 966 |
+
# ═══════════════════════════════════════════════════════════════
|
| 967 |
+
# GENERATE — processes chords and stores results in session state
|
| 968 |
+
# ═══════════════════════════════════════════════════════════════
|
| 969 |
+
|
| 970 |
if st.button("🎹 Generate Full Song", type="primary", use_container_width=True):
|
| 971 |
if len(st.session_state.song_sections) == 0:
|
| 972 |
st.error("❌ Add at least one section first!")
|
|
|
|
| 974 |
all_results = []
|
| 975 |
progression_data = []
|
| 976 |
prev_rh = None
|
| 977 |
+
|
| 978 |
for section in st.session_state.song_sections:
|
| 979 |
if not section['chords'].strip():
|
| 980 |
st.warning(f"⚠️ '{section['name']}' has no chords. Skipping.")
|
| 981 |
continue
|
| 982 |
+
|
|
|
|
|
|
|
|
|
|
| 983 |
tokens = section['chords'].strip().split()
|
| 984 |
+
|
| 985 |
for token in tokens:
|
| 986 |
try:
|
| 987 |
if "Roman" in input_mode:
|
|
|
|
| 991 |
else:
|
| 992 |
root_pc, quality, slash_bass = parse_chord_symbol(token)
|
| 993 |
chord_label = token
|
| 994 |
+
|
|
|
|
| 995 |
if transpose_semitones != 0:
|
| 996 |
root_pc = (root_pc + transpose_semitones) % 12
|
| 997 |
if slash_bass:
|
|
|
|
| 999 |
slash_bass_pc = (slash_bass_pc + transpose_semitones) % 12
|
| 1000 |
slash_bass = pc_to_note_name(slash_bass_pc)
|
| 1001 |
chord_label = f"{pc_to_note_name(root_pc)}{quality if quality != 'maj' else ''}"
|
| 1002 |
+
|
| 1003 |
lh, rh = GenreVoicer.voice(
|
| 1004 |
root_pc, quality, section['genre'],
|
| 1005 |
octave_lh=section['lh_octave'],
|
| 1006 |
octave_rh=section['rh_octave'],
|
| 1007 |
slash_bass=slash_bass
|
| 1008 |
)
|
| 1009 |
+
|
|
|
|
| 1010 |
emotional_ctx = EmotionalContext(
|
| 1011 |
energy=section.get('energy', 0.5),
|
| 1012 |
density=section.get('density', 'Medium'),
|
| 1013 |
role=section.get('role', 'Support'),
|
| 1014 |
movement=section.get('movement', 'Flowing')
|
| 1015 |
)
|
| 1016 |
+
|
| 1017 |
if context == "Solo Piano":
|
| 1018 |
lh, rh = EmotionalAdapter.adapt_solo_piano(lh, rh, emotional_ctx)
|
| 1019 |
else:
|
| 1020 |
lh, rh = EmotionalAdapter.adapt_arrangement(lh, rh, emotional_ctx)
|
| 1021 |
+
|
| 1022 |
if use_negative:
|
| 1023 |
lh = NegativeHarmony.mirror_in_key(lh, neg_key_pc)
|
| 1024 |
rh = NegativeHarmony.mirror_in_key(rh, neg_key_pc)
|
| 1025 |
+
|
|
|
|
| 1026 |
voice_settings = EmotionalAdapter.get_voice_leading_settings(
|
| 1027 |
section.get('movement', 'Flowing')
|
| 1028 |
)
|
| 1029 |
+
|
| 1030 |
if use_voice_leading and prev_rh and voice_settings['enabled']:
|
| 1031 |
rh = VoiceLeader.lead(prev_rh, rh)
|
| 1032 |
+
|
| 1033 |
has_issues, report, mud = SpectralAuditor.audit(lh, rh, context)
|
| 1034 |
+
|
| 1035 |
if has_issues and context == "Full Band":
|
| 1036 |
mud_midis = {n for n, f in mud}
|
| 1037 |
+
shifted = [n for n in rh if n in mud_midis and n < 60]
|
| 1038 |
+
if shifted:
|
| 1039 |
+
rh = sorted([n + 12 if n in mud_midis and n < 60 else n for n in rh])
|
| 1040 |
+
has_issues, report, mud = SpectralAuditor.audit(lh, rh, context)
|
| 1041 |
+
report = report + f"\n💡 Auto-shifted {len(shifted)} note(s) up one octave"
|
| 1042 |
+
|
| 1043 |
prev_rh = rh
|
| 1044 |
+
beats = section.get('beats_per_chord', 4)
|
| 1045 |
+
|
| 1046 |
result = {
|
| 1047 |
'section': section['name'],
|
| 1048 |
+
'section_genre': section['genre'],
|
| 1049 |
+
'section_chords': section['chords'],
|
| 1050 |
'label': chord_label,
|
| 1051 |
'lh': lh,
|
| 1052 |
'rh': rh,
|
|
|
|
| 1054 |
'has_issues': has_issues,
|
| 1055 |
'lh_names': [midi_to_name(n) for n in lh],
|
| 1056 |
'rh_names': [midi_to_name(n) for n in rh],
|
| 1057 |
+
'genre': section['genre'],
|
| 1058 |
+
'energy': emotional_ctx.energy,
|
| 1059 |
+
'density': emotional_ctx.density,
|
| 1060 |
+
'role': emotional_ctx.role,
|
| 1061 |
+
'beats': beats,
|
| 1062 |
}
|
| 1063 |
+
|
| 1064 |
all_results.append(result)
|
| 1065 |
+
progression_data.append({'lh': lh, 'rh': rh, 'beats': beats})
|
| 1066 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1067 |
except Exception as e:
|
| 1068 |
st.error(f"❌ Error parsing '{token}': {e}")
|
| 1069 |
+
|
| 1070 |
+
# Store in session state — display block reads from here on every rerun
|
| 1071 |
+
st.session_state.last_results = all_results
|
| 1072 |
+
st.session_state.last_progression_data = progression_data
|
| 1073 |
+
st.session_state.last_gen_settings = {
|
| 1074 |
+
'transpose': transpose_semitones,
|
| 1075 |
+
'context': context,
|
| 1076 |
+
'key_pc': key_pc,
|
| 1077 |
+
'input_mode': input_mode,
|
| 1078 |
+
'use_voice_leading': use_voice_leading,
|
| 1079 |
+
'use_negative': use_negative,
|
| 1080 |
+
'neg_key_pc': neg_key_pc if use_negative else None,
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
|
| 1084 |
+
# ═══════════════════════════════════════════════════════════════
|
| 1085 |
+
# DISPLAY — always runs if results exist, audio uses current BPM
|
| 1086 |
+
# ═══════════════════════════════════════════════════════════════
|
| 1087 |
+
|
| 1088 |
+
if st.session_state.last_results:
|
| 1089 |
+
# Check if any generation-affecting settings changed since last Generate
|
| 1090 |
+
current_gen_settings = {
|
| 1091 |
+
'transpose': transpose_semitones,
|
| 1092 |
+
'context': context,
|
| 1093 |
+
'key_pc': key_pc,
|
| 1094 |
+
'input_mode': input_mode,
|
| 1095 |
+
'use_voice_leading': use_voice_leading,
|
| 1096 |
+
'use_negative': use_negative,
|
| 1097 |
+
'neg_key_pc': neg_key_pc if use_negative else None,
|
| 1098 |
+
}
|
| 1099 |
+
if st.session_state.last_gen_settings and current_gen_settings != st.session_state.last_gen_settings:
|
| 1100 |
+
st.warning("⚠️ Settings changed since last generation — click **Generate** to update results.")
|
| 1101 |
+
|
| 1102 |
+
current_section = None
|
| 1103 |
+
|
| 1104 |
+
for result in st.session_state.last_results:
|
| 1105 |
+
# Section header when section changes
|
| 1106 |
+
if result['section'] != current_section:
|
| 1107 |
+
current_section = result['section']
|
| 1108 |
+
st.markdown(f"""
|
| 1109 |
+
<div class="section-result-header">
|
| 1110 |
+
<span class="section-result-title">{result['section']}</span>
|
| 1111 |
+
<span class="section-result-meta">{result['section_genre']} · {result['section_chords']}</span>
|
| 1112 |
+
</div>
|
| 1113 |
+
""", unsafe_allow_html=True)
|
| 1114 |
+
|
| 1115 |
+
# Generate audio fresh at current BPM on every rerun
|
| 1116 |
+
chord_wav = AudioEngine.chord_to_wav(result['lh'], result['rh'], result['beats'], bpm)
|
| 1117 |
+
|
| 1118 |
+
# Build note pills HTML
|
| 1119 |
+
lh_pills = " ".join(f'<span class="note-pill note-pill-lh">{n}</span>' for n in result['lh_names'])
|
| 1120 |
+
rh_pills = " ".join(f'<span class="note-pill note-pill-rh">{n}</span>' for n in result['rh_names'])
|
| 1121 |
+
spectral_class = "spectral-warn" if result['has_issues'] else "spectral-ok"
|
| 1122 |
+
spectral_icon = "⚠" if result['has_issues'] else "✓"
|
| 1123 |
+
spectral_line = result['report'].split('\n')[0]
|
| 1124 |
+
|
| 1125 |
+
energy_pct = int(result['energy'] * 100)
|
| 1126 |
+
|
| 1127 |
+
cols = st.columns([1.5, 2.5, 2.5])
|
| 1128 |
+
with cols[0]:
|
| 1129 |
+
st.markdown(f"""
|
| 1130 |
+
<div class="chord-result-card">
|
| 1131 |
+
<div class="chord-name">{result['label']}</div>
|
| 1132 |
+
<div class="chord-energy-badge">
|
| 1133 |
+
<span class="energy-dot" style="opacity:{0.3 + result['energy'] * 0.7:.2f};"></span>
|
| 1134 |
+
<span class="energy-label">{energy_pct}% · {result['density']} · {result['role']}</span>
|
| 1135 |
+
</div>
|
| 1136 |
+
<div class="note-label">Left Hand</div>
|
| 1137 |
+
<div class="note-row">{lh_pills if lh_pills else '<span style="color:#6e7681;font-size:0.7rem;">—</span>'}</div>
|
| 1138 |
+
<div class="note-label">Right Hand</div>
|
| 1139 |
+
<div class="note-row">{rh_pills if rh_pills else '<span style="color:#6e7681;font-size:0.7rem;">—</span>'}</div>
|
| 1140 |
+
<div class="{spectral_class}" style="margin-top:6px;">{spectral_icon} {spectral_line}</div>
|
| 1141 |
+
</div>
|
| 1142 |
+
""", unsafe_allow_html=True)
|
| 1143 |
+
st.audio(chord_wav, format='audio/wav')
|
| 1144 |
+
with cols[1]:
|
| 1145 |
+
st.markdown(draw_piano_svg(result['lh'], "LEFT HAND", 24, 36), unsafe_allow_html=True)
|
| 1146 |
+
with cols[2]:
|
| 1147 |
+
st.markdown(draw_piano_svg(result['rh'], "RIGHT HAND", 48, 37), unsafe_allow_html=True)
|
| 1148 |
+
|
| 1149 |
+
progression_data = st.session_state.last_progression_data
|
| 1150 |
+
|
| 1151 |
+
# Audio Preview
|
| 1152 |
+
st.markdown("""
|
| 1153 |
+
<div style="height:1px; background:#1e2530; margin:1.2rem 0 0.8rem;"></div>
|
| 1154 |
+
<div class="output-section-title">Full Song Preview</div>
|
| 1155 |
+
""", unsafe_allow_html=True)
|
| 1156 |
+
try:
|
| 1157 |
+
full_wav = AudioEngine.progression_to_wav(progression_data, bpm=bpm)
|
| 1158 |
+
st.audio(full_wav, format='audio/wav')
|
| 1159 |
+
st.caption(f"{bpm} BPM · {len(progression_data)} chord(s)")
|
| 1160 |
+
except Exception as e:
|
| 1161 |
+
st.warning(f"Audio preview unavailable: {e}")
|
| 1162 |
+
|
| 1163 |
+
# Export MIDI
|
| 1164 |
+
st.markdown("""
|
| 1165 |
+
<div style="height:1px; background:#1e2530; margin:0.8rem 0;"></div>
|
| 1166 |
+
<div class="output-section-title">Export MIDI</div>
|
| 1167 |
+
""", unsafe_allow_html=True)
|
| 1168 |
+
try:
|
| 1169 |
+
files = MidiExporter.export(progression_data, filename_prefix="full_song")
|
| 1170 |
+
combined_file = MidiExporter.export_combined(progression_data, filename_prefix="full_song")
|
| 1171 |
+
|
| 1172 |
+
with open(combined_file, 'rb') as f:
|
| 1173 |
+
st.download_button(
|
| 1174 |
+
"⬇️ Download Complete MIDI",
|
| 1175 |
+
f,
|
| 1176 |
+
"song_COMPLETE.mid",
|
| 1177 |
+
"audio/midi",
|
| 1178 |
+
use_container_width=True,
|
| 1179 |
+
type="primary"
|
| 1180 |
+
)
|
| 1181 |
+
|
| 1182 |
+
with st.expander("Download Individual Parts"):
|
| 1183 |
+
c1, c2 = st.columns(2)
|
| 1184 |
+
with c1:
|
| 1185 |
+
with open(files['lh'], 'rb') as f:
|
| 1186 |
st.download_button(
|
| 1187 |
+
"⬇️ LH / Bass",
|
| 1188 |
f,
|
| 1189 |
+
"song_LH_Bass.mid",
|
| 1190 |
"audio/midi",
|
| 1191 |
+
use_container_width=True
|
|
|
|
| 1192 |
)
|
| 1193 |
+
with c2:
|
| 1194 |
+
with open(files['rh'], 'rb') as f:
|
| 1195 |
+
st.download_button(
|
| 1196 |
+
"⬇️ RH / Chords",
|
| 1197 |
+
f,
|
| 1198 |
+
"song_RH_Chords.mid",
|
| 1199 |
+
"audio/midi",
|
| 1200 |
+
use_container_width=True
|
| 1201 |
+
)
|
| 1202 |
+
except Exception as e:
|
| 1203 |
+
st.error(f"❌ Export error: {e}")
|
| 1204 |
+
|
| 1205 |
+
# Summary
|
| 1206 |
+
st.markdown("""
|
| 1207 |
+
<div style="height:1px; background:#1e2530; margin:0.8rem 0;"></div>
|
| 1208 |
+
<div class="output-section-title">Summary</div>
|
| 1209 |
+
""", unsafe_allow_html=True)
|
| 1210 |
+
summary = [{
|
| 1211 |
+
'Section': r['section'],
|
| 1212 |
+
'Chord': r['label'],
|
| 1213 |
+
'LH': ", ".join(r['lh_names']),
|
| 1214 |
+
'RH': ", ".join(r['rh_names'])
|
| 1215 |
+
} for r in st.session_state.last_results]
|
| 1216 |
+
st.dataframe(summary, use_container_width=True)
|
| 1217 |
+
|
| 1218 |
+
|
| 1219 |
+
st.markdown("""
|
| 1220 |
+
<div style="height:1px; background:#1e2530; margin:2rem 0 0.5rem;"></div>
|
| 1221 |
+
<div style="text-align:center; font-size:0.65rem; color:#3d4451; letter-spacing:0.1em; text-transform:uppercase;">
|
| 1222 |
+
Harmonic Catalyst PRO · Guide, Don't Control
|
| 1223 |
+
</div>
|
| 1224 |
+
""", unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
audio_engine.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Audio Engine for Harmonic Catalyst
|
| 3 |
+
Synthesizes browser-playable WAV audio from MIDI note data.
|
| 4 |
+
Pure Python — numpy + wave, no external audio libraries required.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import io
|
| 8 |
+
import wave
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class AudioEngine:
|
| 13 |
+
SAMPLE_RATE = 44100
|
| 14 |
+
|
| 15 |
+
@staticmethod
|
| 16 |
+
def _note_to_freq(midi_note):
|
| 17 |
+
return 440.0 * (2 ** ((midi_note - 69) / 12))
|
| 18 |
+
|
| 19 |
+
@staticmethod
|
| 20 |
+
def _synthesize(lh_notes, rh_notes, duration_sec, sample_rate=44100):
|
| 21 |
+
n = int(sample_rate * duration_sec)
|
| 22 |
+
t = np.linspace(0, duration_sec, n, endpoint=False)
|
| 23 |
+
audio = np.zeros(n)
|
| 24 |
+
|
| 25 |
+
# LH: warm, soft, slower decay — bass character
|
| 26 |
+
for note in lh_notes:
|
| 27 |
+
freq = AudioEngine._note_to_freq(note)
|
| 28 |
+
env = 0.55 * np.exp(-1.8 * t)
|
| 29 |
+
audio += env * np.sin(2 * np.pi * freq * t)
|
| 30 |
+
audio += env * 0.25 * np.sin(2 * np.pi * 2 * freq * t)
|
| 31 |
+
|
| 32 |
+
# RH: brighter, more harmonic content, slightly faster decay — chord character
|
| 33 |
+
for note in rh_notes:
|
| 34 |
+
freq = AudioEngine._note_to_freq(note)
|
| 35 |
+
env = np.exp(-2.2 * t)
|
| 36 |
+
audio += env * np.sin(2 * np.pi * freq * t)
|
| 37 |
+
audio += env * 0.35 * np.sin(2 * np.pi * 2 * freq * t)
|
| 38 |
+
audio += env * 0.15 * np.sin(2 * np.pi * 3 * freq * t)
|
| 39 |
+
|
| 40 |
+
return audio
|
| 41 |
+
|
| 42 |
+
@staticmethod
|
| 43 |
+
def _to_wav_bytes(audio, sample_rate=44100):
|
| 44 |
+
peak = np.max(np.abs(audio))
|
| 45 |
+
if peak > 0:
|
| 46 |
+
audio = audio / peak * 0.8
|
| 47 |
+
pcm = (audio * 32767).astype(np.int16)
|
| 48 |
+
buf = io.BytesIO()
|
| 49 |
+
with wave.open(buf, 'wb') as wf:
|
| 50 |
+
wf.setnchannels(1)
|
| 51 |
+
wf.setsampwidth(2)
|
| 52 |
+
wf.setframerate(sample_rate)
|
| 53 |
+
wf.writeframes(pcm.tobytes())
|
| 54 |
+
buf.seek(0)
|
| 55 |
+
return buf.read()
|
| 56 |
+
|
| 57 |
+
@staticmethod
|
| 58 |
+
def chord_to_wav(lh_notes, rh_notes, beats=4, bpm=120):
|
| 59 |
+
"""WAV bytes for a single chord at given beat duration and tempo"""
|
| 60 |
+
duration_sec = (beats / bpm) * 60
|
| 61 |
+
# Cap at 4 seconds for per-chord preview to keep it snappy
|
| 62 |
+
duration_sec = min(duration_sec, 4.0)
|
| 63 |
+
audio = AudioEngine._synthesize(lh_notes, rh_notes, duration_sec)
|
| 64 |
+
return AudioEngine._to_wav_bytes(audio)
|
| 65 |
+
|
| 66 |
+
@staticmethod
|
| 67 |
+
def progression_to_wav(progression_data, bpm=120):
|
| 68 |
+
"""WAV bytes for a full progression — all chords concatenated in sequence"""
|
| 69 |
+
sample_rate = AudioEngine.SAMPLE_RATE
|
| 70 |
+
segments = []
|
| 71 |
+
for chord in progression_data:
|
| 72 |
+
beats = chord.get('beats', 4)
|
| 73 |
+
duration_sec = (beats / bpm) * 60
|
| 74 |
+
seg = AudioEngine._synthesize(
|
| 75 |
+
chord.get('lh', []), chord.get('rh', []),
|
| 76 |
+
duration_sec, sample_rate
|
| 77 |
+
)
|
| 78 |
+
# Small silence gap between chords (20ms)
|
| 79 |
+
gap = np.zeros(int(sample_rate * 0.02))
|
| 80 |
+
segments.append(np.concatenate([seg, gap]))
|
| 81 |
+
|
| 82 |
+
full = np.concatenate(segments) if segments else np.zeros(sample_rate)
|
| 83 |
+
return AudioEngine._to_wav_bytes(full, sample_rate)
|
harmonic_engine.py
CHANGED
|
@@ -179,7 +179,8 @@ class GenreVoicer:
|
|
| 179 |
seventh = s
|
| 180 |
break
|
| 181 |
|
| 182 |
-
|
|
|
|
| 183 |
lh, rh = method(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality)
|
| 184 |
|
| 185 |
if slash_bass:
|
|
@@ -265,17 +266,16 @@ class GenreVoicer:
|
|
| 265 |
|
| 266 |
@staticmethod
|
| 267 |
def _voice_rnb(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):
|
|
|
|
| 268 |
lh = [root_lh]
|
|
|
|
|
|
|
| 269 |
rh = []
|
| 270 |
if third:
|
| 271 |
rh.append(root_rh + third)
|
| 272 |
sev = seventh if seventh else (10 if third == 3 else 11)
|
| 273 |
rh.append(root_rh + sev)
|
| 274 |
rh.append(root_rh + 14)
|
| 275 |
-
if third == 3:
|
| 276 |
-
rh.append(root_rh + 17)
|
| 277 |
-
else:
|
| 278 |
-
rh.append(root_rh + 21)
|
| 279 |
return lh, sorted(rh)
|
| 280 |
|
| 281 |
@staticmethod
|
|
@@ -346,18 +346,15 @@ class GenreVoicer:
|
|
| 346 |
|
| 347 |
@staticmethod
|
| 348 |
def _voice_neo_soul(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):
|
| 349 |
-
"""Neo-Soul:
|
| 350 |
lh = [root_lh]
|
| 351 |
-
rh = []
|
| 352 |
-
if third:
|
| 353 |
-
rh.append(root_rh + third)
|
| 354 |
sev = seventh if seventh else 10
|
| 355 |
-
rh
|
| 356 |
-
|
|
|
|
|
|
|
| 357 |
if third == 3:
|
| 358 |
-
rh.append(root_rh + 17)
|
| 359 |
-
else:
|
| 360 |
-
rh.append(root_rh + 21)
|
| 361 |
return lh, sorted(rh)
|
| 362 |
|
| 363 |
@staticmethod
|
|
@@ -400,14 +397,14 @@ class GenreVoicer:
|
|
| 400 |
|
| 401 |
@staticmethod
|
| 402 |
def _voice_lo_fi(root_pc, root_lh, root_rh, intervals, third, fifth, seventh, quality):
|
| 403 |
-
"""Lo-fi:
|
| 404 |
lh = [root_lh]
|
| 405 |
rh = []
|
| 406 |
if third:
|
| 407 |
rh.append(root_rh + third)
|
| 408 |
-
|
| 409 |
-
sev = seventh if seventh else 11
|
| 410 |
rh.append(root_rh + sev)
|
|
|
|
| 411 |
return lh, sorted(rh)
|
| 412 |
|
| 413 |
@staticmethod
|
|
@@ -431,7 +428,10 @@ class VoiceLeader:
|
|
| 431 |
result = []
|
| 432 |
for note in current_rh:
|
| 433 |
pc = note % 12
|
| 434 |
-
candidates = [pc + (oct * 12) for oct in range(2, 8)]
|
|
|
|
|
|
|
|
|
|
| 435 |
best = min(candidates, key=lambda x: abs(x - centroid))
|
| 436 |
result.append(best)
|
| 437 |
|
|
@@ -491,12 +491,13 @@ class MidiExporter:
|
|
| 491 |
for chord_data in progression_data:
|
| 492 |
notes = chord_data[lane]
|
| 493 |
velocity = 70 if lane == "lh" else 85
|
|
|
|
| 494 |
|
| 495 |
for n in notes:
|
| 496 |
n = max(0, min(127, n))
|
| 497 |
track.append(mido.Message('note_on', note=n, velocity=velocity, time=0))
|
| 498 |
|
| 499 |
-
track.append(mido.Message('note_off', note=max(0, min(127, notes[0])), velocity=0, time=
|
| 500 |
for n in notes[1:]:
|
| 501 |
n = max(0, min(127, n))
|
| 502 |
track.append(mido.Message('note_off', note=n, velocity=0, time=0))
|
|
@@ -520,36 +521,38 @@ class MidiExporter:
|
|
| 520 |
for chord_data in progression_data:
|
| 521 |
notes = chord_data['lh']
|
| 522 |
velocity = 70
|
| 523 |
-
|
|
|
|
| 524 |
for n in notes:
|
| 525 |
n = max(0, min(127, n))
|
| 526 |
track_lh.append(mido.Message('note_on', note=n, velocity=velocity, time=0))
|
| 527 |
-
|
| 528 |
if notes:
|
| 529 |
-
track_lh.append(mido.Message('note_off', note=max(0, min(127, notes[0])), velocity=0, time=
|
| 530 |
for n in notes[1:]:
|
| 531 |
n = max(0, min(127, n))
|
| 532 |
track_lh.append(mido.Message('note_off', note=n, velocity=0, time=0))
|
| 533 |
-
|
| 534 |
# Track 2: RH/Chords
|
| 535 |
track_rh = mido.MidiTrack()
|
| 536 |
mid.tracks.append(track_rh)
|
| 537 |
track_rh.append(mido.MetaMessage('track_name', name='RH/Chords'))
|
| 538 |
-
|
| 539 |
for chord_data in progression_data:
|
| 540 |
notes = chord_data['rh']
|
| 541 |
velocity = 85
|
| 542 |
-
|
|
|
|
| 543 |
for n in notes:
|
| 544 |
n = max(0, min(127, n))
|
| 545 |
track_rh.append(mido.Message('note_on', note=n, velocity=velocity, time=0))
|
| 546 |
-
|
| 547 |
if notes:
|
| 548 |
-
track_rh.append(mido.Message('note_off', note=max(0, min(127, notes[0])), velocity=0, time=
|
| 549 |
for n in notes[1:]:
|
| 550 |
n = max(0, min(127, n))
|
| 551 |
track_rh.append(mido.Message('note_off', note=n, velocity=0, time=0))
|
| 552 |
|
| 553 |
fname = f"{filename_prefix}_COMPLETE.mid"
|
| 554 |
mid.save(fname)
|
| 555 |
-
return fname
|
|
|
|
| 179 |
seventh = s
|
| 180 |
break
|
| 181 |
|
| 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:
|
|
|
|
| 266 |
|
| 267 |
@staticmethod
|
| 268 |
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 |
@staticmethod
|
|
|
|
| 346 |
|
| 347 |
@staticmethod
|
| 348 |
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 10
|
| 352 |
+
rh = [root_rh + sev] # 7th at base octave (low, open)
|
| 353 |
+
if third:
|
| 354 |
+
rh.append(root_rh + 12 + third) # 3rd an octave up
|
| 355 |
+
rh.append(root_rh + 12 + 14) # 9th an octave up
|
| 356 |
if third == 3:
|
| 357 |
+
rh.append(root_rh + 12 + 17) # 11th for minor color
|
|
|
|
|
|
|
| 358 |
return lh, sorted(rh)
|
| 359 |
|
| 360 |
@staticmethod
|
|
|
|
| 397 |
|
| 398 |
@staticmethod
|
| 399 |
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 note
|
| 408 |
return lh, sorted(rh)
|
| 409 |
|
| 410 |
@staticmethod
|
|
|
|
| 428 |
result = []
|
| 429 |
for note in current_rh:
|
| 430 |
pc = note % 12
|
| 431 |
+
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 |
+
continue
|
| 435 |
best = min(candidates, key=lambda x: abs(x - centroid))
|
| 436 |
result.append(best)
|
| 437 |
|
|
|
|
| 491 |
for chord_data in progression_data:
|
| 492 |
notes = chord_data[lane]
|
| 493 |
velocity = 70 if lane == "lh" else 85
|
| 494 |
+
duration = chord_data.get('beats', 4) * 480
|
| 495 |
|
| 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))
|
|
|
|
| 521 |
for chord_data in progression_data:
|
| 522 |
notes = chord_data['lh']
|
| 523 |
velocity = 70
|
| 524 |
+
duration = chord_data.get('beats', 4) * 480
|
| 525 |
+
|
| 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/Chords
|
| 537 |
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 = 85
|
| 544 |
+
duration = chord_data.get('beats', 4) * 480
|
| 545 |
+
|
| 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 fname
|