RAM2118's picture
Upload 4 files
79ce1f6 verified
import streamlit as st
import uuid
from audio_engine import AudioEngine
from emotional_engine import (
SectionNamer,
get_section_types,
EMOTIONAL_PRESETS,
NUMBERED_SECTIONS,
SINGLE_SECTIONS,
EmotionalContext,
EmotionalAdapter
)
from harmonic_engine import (
note_name_to_pc, pc_to_note_name, midi_to_name, midi_to_freq,
parse_chord_symbol, roman_to_chord,
GenreVoicer, VoiceLeader, SpectralAuditor, NegativeHarmony,
MidiExporter, NOTE_NAMES
)
# ═══════════════════════════════════════════════════════════════
# CHORD PRESET LIBRARY
# ═══════════════════════════════════════════════════════════════
CHORD_PRESETS = {
"Popular Progressions": {
"I-V-vi-IV (Axis of Awesome)": "I V vi IV",
"vi-IV-I-V (Emotional Pop)": "vi IV I V",
"I-IV-V (Classic Rock)": "I IV V",
"I-vi-IV-V (50s Progression)": "I vi IV V",
},
"Pop Progressions": {
"I-V-vi-IV": "I V vi IV",
"vi-IV-I-V": "vi IV I V",
"I-IV-vi-V": "I IV vi V",
"I-iii-IV-V": "I iii IV V",
},
"Jazz Progressions": {
"ii-V-I (Turnaround)": "ii7 V7 Imaj7",
"I-vi-ii-V (Rhythm Changes)": "Imaj7 vi7 ii7 V7",
"iii-vi-ii-V-I": "iii7 vi7 ii7 V7 Imaj7",
},
"Gospel Progressions": {
"I-IV-V-IV (Traditional)": "I IV V IV",
"I-V-vi-IV-I (Modern)": "I V vi IV I",
},
"Blues Progressions": {
"12-Bar Blues": "I7 I7 I7 I7 IV7 IV7 I7 I7 V7 IV7 I7 V7",
"8-Bar Blues": "I7 IV7 I7 I7 IV7 IV7 I7 V7",
},
"Bollywood Progressions": {
"I-V-vi-III-IV": "I V vi III IV",
"i-VII-VI-V (Emotional)": "i VII VI V",
},
"Latin Progressions": {
"i-iv-V (Minor Latin)": "i iv V",
"I-IV-V-IV (Salsa)": "I IV V IV",
},
"Funk Progressions": {
"i7-IV7 (Two Chord)": "i7 IV7",
"I7-IV7-V7": "I7 IV7 V7",
},
}
def get_preset_chord_symbols(preset_roman, key_root_pc):
"""Convert Roman numeral preset to chord symbols in given key"""
tokens = preset_roman.split()
chord_symbols = []
for token in tokens:
try:
root_pc, quality = roman_to_chord(token, key_root_pc)
root_name = pc_to_note_name(root_pc)
if quality == 'maj':
symbol = root_name
elif quality == 'min':
symbol = f"{root_name}m"
elif quality == 'maj7':
symbol = f"{root_name}maj7"
elif quality == 'min7':
symbol = f"{root_name}m7"
elif quality == 'dom7':
symbol = f"{root_name}7"
elif quality == 'dim':
symbol = f"{root_name}dim"
else:
symbol = f"{root_name}{quality}"
chord_symbols.append(symbol)
except Exception:
chord_symbols.append(token) # pass through unrecognized tokens as-is
return " ".join(chord_symbols)
# ═══════════════════════════════════════════════════════════════
# PAGE CONFIG & STYLING
# ═══════════════════════════════════════════════════════════════
st.set_page_config(page_title="Harmonic Catalyst PRO", page_icon="🎹", layout="wide")
st.markdown("""
<style>
/* ── Base ── */
.stApp { background-color: #0a0d12; color: #c9d1d9; }
.block-container { padding-top: 1.5rem !important; }
/* ── Sidebar ── */
[data-testid="stSidebar"] {
background: #0d1117;
border-right: 1px solid #1e2530;
}
[data-testid="stSidebar"] .stMarkdown h1,
[data-testid="stSidebar"] .stMarkdown h2,
[data-testid="stSidebar"] .stMarkdown h3 {
color: #00bfa5;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
margin-bottom: 0.5rem;
}
/* ── Buttons ── */
.stButton > button {
border-radius: 6px;
border: 1px solid #1e2530;
background: #161b22;
color: #8b949e;
font-weight: 600;
font-size: 0.82rem;
transition: all 0.15s ease;
}
.stButton > button:hover {
border-color: #00bfa5;
color: #00bfa5;
background: #0d1f1e;
}
/* Primary button */
.stButton > button[kind="primary"],
button[data-testid="baseButton-primary"] {
background: linear-gradient(135deg, #00897b, #00bfa5) !important;
border: none !important;
color: #000 !important;
font-weight: 700 !important;
letter-spacing: 0.04em;
}
.stButton > button[kind="primary"]:hover,
button[data-testid="baseButton-primary"]:hover {
background: linear-gradient(135deg, #00bfa5, #1de9b6) !important;
}
/* ── Inputs ── */
.stTextInput > div > div > input,
.stSelectbox > div > div,
.stRadio > div {
background: #0d1117 !important;
border-color: #1e2530 !important;
color: #c9d1d9 !important;
border-radius: 6px !important;
}
.stTextInput > div > div > input:focus {
border-color: #00bfa5 !important;
box-shadow: 0 0 0 2px rgba(0,191,165,0.15) !important;
}
/* ── Expanders ── */
.streamlit-expanderHeader {
background: #0d1117 !important;
border: 1px solid #1e2530 !important;
border-radius: 6px !important;
color: #8b949e !important;
font-size: 0.83rem !important;
}
.streamlit-expanderContent {
background: #0d1117 !important;
border: 1px solid #1e2530 !important;
border-top: none !important;
}
/* ── Dividers ── */
hr { border: 0; border-top: 1px solid #1e2530; }
/* ── Section card ── */
.section-card {
background: #0d1117;
border: 1px solid #1e2530;
border-radius: 10px;
padding: 1rem 1.2rem 0.6rem;
margin-bottom: 1rem;
position: relative;
overflow: hidden;
}
.section-card::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 4px; height: 100%;
border-radius: 10px 0 0 10px;
}
/* Section type color strips */
.sc-intro::before { background: #4e9eff; }
.sc-verse::before { background: #3fb950; }
.sc-prechorus::before { background: #d29922; }
.sc-chorus::before { background: #f78166; }
.sc-postchorus::before { background: #f78166; }
.sc-bridge::before { background: #bc8cff; }
.sc-breakdown::before { background: #8b949e; }
.sc-drop::before { background: #ff6e6e; }
.sc-outro::before { background: #4e9eff; }
.sc-custom::before { background: #00bfa5; }
/* ── Section type badge ── */
.section-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 20px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.7rem;
}
.badge-intro { background: rgba(78,158,255,0.15); color: #4e9eff; }
.badge-verse { background: rgba(63,185,80,0.15); color: #3fb950; }
.badge-prechorus { background: rgba(210,153,34,0.15); color: #d29922; }
.badge-chorus { background: rgba(247,129,102,0.15); color: #f78166; }
.badge-postchorus { background: rgba(247,129,102,0.15); color: #f78166; }
.badge-bridge { background: rgba(188,140,255,0.15); color: #bc8cff; }
.badge-breakdown { background: rgba(139,148,158,0.15); color: #8b949e; }
.badge-drop { background: rgba(255,110,110,0.15); color: #ff6e6e; }
.badge-outro { background: rgba(78,158,255,0.15); color: #4e9eff; }
.badge-custom { background: rgba(0,191,165,0.15); color: #00bfa5; }
/* ── Chord result card ── */
.chord-result-card {
background: #0d1117;
border: 1px solid #1e2530;
border-left: 3px solid #00bfa5;
border-radius: 8px;
padding: 0.8rem 1rem;
margin-bottom: 0.5rem;
}
.chord-name {
font-size: 1.6rem;
font-weight: 800;
color: #e6edf3;
letter-spacing: -0.02em;
line-height: 1;
font-family: 'Georgia', serif;
}
.chord-energy-badge {
display: inline-flex;
gap: 6px;
align-items: center;
margin-top: 4px;
margin-bottom: 8px;
}
.energy-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
background: #00bfa5;
}
.energy-label {
font-size: 0.7rem;
color: #8b949e;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* ── Note pills ── */
.note-row { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 8px; }
.note-label {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
color: #8b949e;
letter-spacing: 0.06em;
margin-bottom: 2px;
}
.note-pill {
display: inline-block;
background: #161b22;
border: 1px solid #30363d;
border-radius: 20px;
padding: 2px 8px;
font-size: 0.72rem;
font-weight: 600;
font-family: 'Courier New', monospace;
color: #c9d1d9;
}
.note-pill-lh { border-color: #1f6feb; color: #79c0ff; }
.note-pill-rh { border-color: #238636; color: #7ee787; }
/* ── Section result header ── */
.section-result-header {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
margin: 16px 0 8px;
border-bottom: 1px solid #1e2530;
}
.section-result-title {
font-size: 1rem;
font-weight: 700;
color: #e6edf3;
}
.section-result-meta {
font-size: 0.72rem;
color: #8b949e;
}
/* ── Audio/Export bar ── */
.output-section {
background: #0d1117;
border: 1px solid #1e2530;
border-radius: 10px;
padding: 1rem 1.2rem;
margin: 1rem 0;
}
.output-section-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #00bfa5;
margin-bottom: 0.75rem;
}
/* ── Spectral status ── */
.spectral-ok { color: #3fb950; font-size: 0.72rem; }
.spectral-warn { color: #d29922; font-size: 0.72rem; }
/* ── Captions ── */
.stCaption { color: #6e7681 !important; }
/* ── Dataframe ── */
.stDataFrame { border: 1px solid #1e2530 !important; border-radius: 8px !important; }
</style>
""", unsafe_allow_html=True)
# ── Header banner ──
st.markdown("""
<div style="display:flex; align-items:baseline; gap:12px; margin-bottom:4px;">
<span style="font-size:1.9rem; font-weight:900; letter-spacing:-0.03em; color:#e6edf3;">
HARMONIC <span style="color:#00bfa5;">CATALYST</span>
</span>
<span style="font-size:0.7rem; font-weight:700; text-transform:uppercase; letter-spacing:0.1em;
color:#8b949e; padding:3px 8px; border:1px solid #1e2530; border-radius:20px;">
PRO
</span>
</div>
<div style="font-size:0.8rem; color:#6e7681; margin-bottom:1rem; letter-spacing:0.02em;">
Genre-Aware Chord Voicing Engine &nbsp;Β·&nbsp; Multi-Section Builder &nbsp;Β·&nbsp; MIDI Export
</div>
<div style="height:2px; background:linear-gradient(90deg, #00bfa5, transparent); margin-bottom:1.5rem; border-radius:2px;"></div>
""", unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════════
# HELP SECTION
# ═══════════════════════════════════════════════════════════════
with st.expander("πŸ“– Quick Start", expanded=False):
st.markdown("""
1. **Add Section** β†’ choose type (Verse, Chorus, etc.)
2. **Enter Chords** β†’ type symbols like `Cmaj7 Am7 F G` or use a preset
3. **Pick Genre** β†’ sets the voicing style
4. **Generate Full Song** β†’ see voicings, play audio, export MIDI
""")
# ═══════════════════════════════════════════════════════════════
# SESSION STATE
# ═══════════════════════════════════════════════════════════════
if 'song_sections' not in st.session_state:
st.session_state.song_sections = []
if 'section_counter' not in st.session_state:
st.session_state.section_counter = 0
if 'last_results' not in st.session_state:
st.session_state.last_results = []
if 'last_progression_data' not in st.session_state:
st.session_state.last_progression_data = []
if 'last_gen_settings' not in st.session_state:
st.session_state.last_gen_settings = {}
# ═══════════════════════════════════════════════════════════════
# SECTION FUNCTIONS
# ═══════════════════════════════════════════════════════════════
def add_section():
st.session_state.section_counter += 1
section_type = 'Verse'
display_name = SectionNamer.generate_name(section_type, st.session_state.song_sections)
new_section = {
'id': str(uuid.uuid4())[:8],
'number': st.session_state.section_counter,
'section_type': section_type,
'name': display_name,
'chords': '',
'genre': 'Pop',
'lh_octave': 2,
'rh_octave': 4
}
st.session_state.song_sections.append(new_section)
def remove_section(section_id):
st.session_state.song_sections = [
s for s in st.session_state.song_sections if s['id'] != section_id
]
def duplicate_section(section_id):
section = next(s for s in st.session_state.song_sections if s['id'] == section_id)
st.session_state.section_counter += 1
new_section = section.copy()
new_section['id'] = str(uuid.uuid4())[:8]
new_section['number'] = st.session_state.section_counter
new_section['name'] = f"{section['name']} (Copy)"
idx = st.session_state.song_sections.index(section)
st.session_state.song_sections.insert(idx + 1, new_section)
def move_section_up(section_id):
sections = st.session_state.song_sections
idx = next(i for i, s in enumerate(sections) if s['id'] == section_id)
if idx > 0:
sections[idx], sections[idx - 1] = sections[idx - 1], sections[idx]
def move_section_down(section_id):
sections = st.session_state.song_sections
idx = next(i for i, s in enumerate(sections) if s['id'] == section_id)
if idx < len(sections) - 1:
sections[idx], sections[idx + 1] = sections[idx + 1], sections[idx]
# ═══════════════════════════════════════════════════════════════
# SIDEBAR
# ═══════════════════════════════════════════════════════════════
with st.sidebar:
st.markdown("""
<div style="padding:1rem 0 0.5rem; font-size:1rem; font-weight:800; color:#e6edf3; letter-spacing:-0.01em;">
🎹 Settings
</div>
""", unsafe_allow_html=True)
st.markdown("**INPUT**")
input_mode = st.radio(
"Input Mode",
["Chord Symbols (Cmaj7, Am7...)", "Roman Numerals (I, vi, IV, V)"],
label_visibility="collapsed"
)
if "Roman" in input_mode:
key_name = st.selectbox("Key", NOTE_NAMES, index=0)
key_pc = note_name_to_pc(key_name)
else:
key_pc = 0
st.divider()
st.markdown("**MIX CONTEXT**")
mix_col1, mix_col2 = st.columns([4, 1])
with mix_col1:
st.markdown("")
with mix_col2:
mix_info = st.toggle("ℹ️", key="mix_info", label_visibility="collapsed")
context = st.radio("Mix Context", ["Full Band", "Solo Piano"], label_visibility="collapsed")
if mix_info:
if context == "Full Band":
st.info("""
**🎚️ FULL BAND MODE**
**WHAT IT DOES:**
Piano is part of a larger arrangement with other instruments. Tool will:
β€’ Avoid frequency clashes with vocals (300-500Hz)
β€’ Keep bass notes cleaner for bass guitar
β€’ Shift muddy notes automatically
**WHEN TO USE:**
β€’ Recording with band
β€’ Producing full tracks
β€’ Piano + vocals + drums + bass
**EXAMPLE:**
Your track has bass guitar, drums, and vocals. Full Band mode keeps piano out of their frequency zones.
""")
else:
st.info("""
**🎹 SOLO PIANO MODE**
**WHAT IT DOES:**
Piano is the main/only instrument. Tool will:
β€’ Use full frequency range
β€’ Richer bass voicings
β€’ No frequency shifting
**WHEN TO USE:**
β€’ Piano covers
β€’ Piano tutorials
β€’ Solo piano performances
β€’ Piano + vocals only
**EXAMPLE:**
You're making a piano cover of a song. Solo Piano mode gives full, rich voicings.
""")
st.divider()
st.markdown("**VOICE LEADING**")
use_voice_leading = st.checkbox("Connect sections smoothly", value=True)
st.divider()
st.markdown("**NEGATIVE HARMONY**")
neg_col1, neg_col2 = st.columns([4, 1])
with neg_col1:
st.markdown("")
with neg_col2:
neg_info = st.toggle("ℹ️", key="neg_info", label_visibility="collapsed")
use_negative = st.checkbox("Enable Negative Harmony", value=False, label_visibility="collapsed")
if neg_info:
st.info("""
**🌌 NEGATIVE HARMONY**
**WHAT IT DOES:**
Mirrors notes around an axis (root + 5th). Creates "shadow" versions of chords. Made famous by Jacob Collier.
**HOW IT WORKS:**
β€’ C major β†’ F minor (mirror image)
β€’ Happy chords β†’ Sad/mysterious versions
β€’ Bright β†’ Dark transformation
**WHEN TO USE:**
β€’ Create unexpected chord colors
β€’ Add tension to progressions
β€’ Experimental compositions
β€’ "What if this chord was dark?"
**EXAMPLE:**
Original: C - G - Am - F (happy pop)
Negative: Fm - Bbm - Ab - Cm (mysterious, cinematic)
πŸ’‘ **TIP:** Try on chorus for dramatic effect!
""")
if use_negative:
neg_key = st.selectbox(
"Mirror Key",
NOTE_NAMES,
index=0,
help="The axis around which notes are mirrored. Usually set to your song's key."
)
neg_key_pc = note_name_to_pc(neg_key)
st.divider()
st.markdown("**TRANSPOSE & TEMPO**")
transpose_semitones = st.slider("Transpose (semitones)", -12, 12, 0)
bpm = st.slider("Playback BPM", 60, 200, 120, help="Affects audio preview in real time β€” no need to regenerate")
st.divider()
if st.button("πŸ—‘οΈ Clear All Sections"):
st.session_state.song_sections = []
st.session_state.section_counter = 0
st.session_state.last_results = []
st.session_state.last_progression_data = []
st.rerun()
# ═══════════════════════════════════════════════════════════════
# PIANO VISUALIZATION
# ═══════════════════════════════════════════════════════════════
def draw_piano_svg(notes, label, start_midi=36, num_keys=37):
white_key_w, black_key_w = 22, 14
white_key_h, black_key_h = 90, 55
padding = 20
white_pcs = {0, 2, 4, 5, 7, 9, 11}
black_x_offset = {1: 0.65, 3: 1.65, 6: 3.65, 8: 4.65, 10: 5.65}
white_positions = {}
w_idx = 0
for i in range(num_keys):
midi = start_midi + i
if midi % 12 in white_pcs:
white_positions[midi] = w_idx
w_idx += 1
total_width = w_idx * (white_key_w + 1) + padding * 2
total_height = white_key_h + 60
svg = f'<svg width="{total_width}" height="{total_height}" style="background:#0d1117; border-radius:8px; border:1px solid #1e2530;">'
svg += f'<text x="{padding}" y="18" fill="#6e7681" font-family="monospace" font-size="10" font-weight="700" letter-spacing="0.08em">{label}</text>'
y_start = 28
for midi, wx in white_positions.items():
x = padding + wx * (white_key_w + 1)
is_active = midi in notes
fill = "#00bfa5" if is_active else "#d0d7de"
stroke = "#008c7a" if is_active else "#6e7681"
svg += f'<rect x="{x}" y="{y_start}" width="{white_key_w}" height="{white_key_h}" fill="{fill}" stroke="{stroke}" rx="2"/>'
if is_active:
name = midi_to_name(midi)
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>'
for i in range(num_keys):
midi = start_midi + i
pc = midi % 12
if pc in black_x_offset:
octave_offset = (midi - pc - start_midi) // 12
x = padding + (black_x_offset[pc] + (octave_offset * 7)) * (white_key_w + 1)
is_active = midi in notes
fill = "#00897b" if is_active else "#0d1117"
svg += f'<rect x="{x}" y="{y_start}" width="{black_key_w}" height="{black_key_h}" fill="{fill}" rx="1"/>'
svg += '</svg>'
return svg
# ═══════════════════════════════════════════════════════════════
# MAIN CONTENT
# ═══════════════════════════════════════════════════════════════
SECTION_CSS_CLASS = {
'Intro': 'intro', 'Verse': 'verse', 'Pre-Chorus': 'prechorus',
'Chorus': 'chorus', 'Post-Chorus': 'postchorus', 'Bridge': 'bridge',
'Breakdown': 'breakdown', 'Drop': 'drop', 'Outro': 'outro', 'Custom': 'custom'
}
st.markdown("""
<div style="font-size:0.7rem; font-weight:700; text-transform:uppercase;
letter-spacing:0.1em; color:#8b949e; margin-bottom:0.75rem;">
Song Structure
</div>
""", unsafe_allow_html=True)
# Empty state
if len(st.session_state.song_sections) == 0:
st.markdown("""
<div style="text-align:center; padding:2.5rem 1rem; background:#0d1117;
border:1px dashed #1e2530; border-radius:10px; margin-bottom:1rem;">
<div style="font-size:2rem; margin-bottom:0.5rem;">🎹</div>
<div style="color:#6e7681; font-size:0.88rem;">No sections yet β€” add your first section to start building</div>
</div>
""", unsafe_allow_html=True)
if st.button("βž• Add First Section", type="primary", use_container_width=True):
add_section()
st.rerun()
# ═══════════════════════════════════════════════════════════════
# SECTION CARDS
# ═══════════════════════════════════════════════════════════════
for idx, section in enumerate(st.session_state.song_sections):
stype = section.get('section_type', 'Verse')
css_cls = SECTION_CSS_CLASS.get(stype, 'custom')
st.markdown(f'<div class="section-card sc-{css_cls}"><span class="section-badge badge-{css_cls}">{stype}</span></div>', unsafe_allow_html=True)
with st.container():
# Header row
col_num, col_type, col_name, col_controls = st.columns([0.5, 2, 2, 2])
with col_num:
st.markdown(f"**#{idx + 1}**")
with col_type:
section_types = get_section_types()
current_type = section.get('section_type', 'Verse')
if current_type not in section_types:
current_type = 'Verse'
new_type = st.selectbox(
"Type",
section_types,
index=section_types.index(current_type),
key=f"type_{section['id']}",
label_visibility="collapsed",
help="Select section type - auto-applies emotional defaults"
)
if new_type != section.get('section_type'):
section['section_type'] = new_type
if new_type != 'Custom':
section['name'] = SectionNamer.generate_name(
new_type,
[s for s in st.session_state.song_sections if s['id'] != section['id']]
)
with col_name:
if section.get('section_type') == 'Custom':
section['name'] = st.text_input(
"Name",
value=section.get('name', 'Custom'),
key=f"name_{section['id']}",
label_visibility="collapsed",
help="Enter custom section name"
)
else:
auto_name = SectionNamer.generate_name(
section.get('section_type', 'Verse'),
[s for s in st.session_state.song_sections if s['id'] != section['id']]
)
section['name'] = st.text_input(
"Name",
value=section.get('name', auto_name),
key=f"name_{section['id']}",
label_visibility="collapsed",
help="Auto-generated name, you can edit it"
)
with col_controls:
c1, c2, c3, c4 = st.columns(4)
with c1:
if st.button("⬆️", key=f"up_{section['id']}", disabled=(idx == 0), help="Move section up"):
move_section_up(section['id'])
st.rerun()
with c2:
if st.button("⬇️", key=f"down_{section['id']}", disabled=(idx == len(st.session_state.song_sections) - 1), help="Move section down"):
move_section_down(section['id'])
st.rerun()
with c3:
if st.button("πŸ“‹", key=f"dup_{section['id']}", help="Create a copy of this section"):
duplicate_section(section['id'])
st.rerun()
with c4:
if st.button("πŸ—‘οΈ", key=f"del_{section['id']}", help="Remove this section"):
remove_section(section['id'])
st.rerun()
# Chords and Genre row
col_chords, col_genre = st.columns([2, 1])
with col_chords:
placeholder = "e.g., Cmaj7 Am7 F G" if "Chord" in input_mode else "e.g., I vi IV V"
section['chords'] = st.text_input(
"Chord Progression",
value=section['chords'],
key=f"chords_{section['id']}",
placeholder=placeholder,
help="Enter chords separated by spaces (e.g., Cmaj7 Am7 F G). Supports slash chords like C/E"
)
# Inline chord validation
if section['chords'].strip():
tokens = section['chords'].strip().split()
invalid = []
for token in tokens:
try:
if "Roman" in input_mode:
roman_to_chord(token, key_pc)
else:
parse_chord_symbol(token)
except Exception:
invalid.append(token)
if invalid:
st.caption(f"❌ Unrecognized: {', '.join(invalid)}")
else:
st.caption(f"βœ… {len(tokens)} chord(s)")
with col_genre:
genre_list = ["Pop", "Jazz", "Gospel", "Blues", "Classical", "RnB", "Waltz",
"Afrobeats", "Trap", "Bossa Nova", "Hindustani Classical",
"Neo-Soul", "Reggae", "Latin", "K-Pop", "Lo-fi", "Funk"]
current_genre = section.get('genre', 'Pop')
if current_genre not in genre_list:
current_genre = 'Pop'
section['genre'] = st.selectbox(
"Genre/Voicing",
genre_list,
index=genre_list.index(current_genre),
key=f"genre_{section['id']}",
help="How chords will be voiced - affects piano arrangement style"
)
# Chord Presets - Two Dropdowns (Category + Preset)
st.markdown("**🎡 Chord Presets:**")
col_cat, col_preset, col_apply = st.columns([2, 3, 1])
with col_cat:
# Category dropdown (short list)
categories = list(CHORD_PRESETS.keys())
selected_category = st.selectbox(
"Category",
categories,
key=f"preset_cat_{section['id']}",
label_visibility="collapsed",
help="Select progression category (Pop, Jazz, Blues, etc.)"
)
with col_preset:
# Preset dropdown (only shows presets from selected category)
if selected_category:
presets_in_category = list(CHORD_PRESETS[selected_category].keys())
selected_preset = st.selectbox(
"Preset",
["Select..."] + presets_in_category,
key=f"preset_name_{section['id']}",
label_visibility="collapsed",
help="Select chord progression to auto-fill"
)
else:
selected_preset = "Select..."
with col_apply:
# Apply button
if selected_preset != "Select...":
if st.button("✨", key=f"apply_{section['id']}", help="Apply selected preset to chord progression"):
preset_roman = CHORD_PRESETS[selected_category][selected_preset]
if "Roman" in input_mode:
section['chords'] = preset_roman
else:
section['chords'] = get_preset_chord_symbols(preset_roman, key_pc)
st.rerun()
# Emotional Context
with st.expander("🎭 Emotional Context", expanded=False):
# Preset selector
preset_col1, preset_col2 = st.columns([4, 1])
with preset_col1:
emotional_presets = list(EMOTIONAL_PRESETS.keys())
current_preset = section.get('emotional_preset', 'Supportive Verse')
if current_preset not in emotional_presets:
current_preset = 'Supportive Verse'
selected_emotional = st.selectbox(
"Preset",
emotional_presets,
index=emotional_presets.index(current_preset),
key=f"emo_preset_{section['id']}",
help="Quick emotional presets"
)
with preset_col2:
if st.button("✨", key=f"apply_emo_{section['id']}", help="Apply preset"):
preset_values = EMOTIONAL_PRESETS[selected_emotional]
section['emotional_preset'] = selected_emotional
section['energy'] = preset_values['energy']
section['density'] = preset_values['density']
section['role'] = preset_values['role']
section['movement'] = preset_values['movement']
st.rerun()
# Show preset description
if selected_emotional in EMOTIONAL_PRESETS:
st.caption(f"πŸ’‘ {EMOTIONAL_PRESETS[selected_emotional].get('description', '')}")
st.markdown("---")
# Energy slider
section['energy'] = st.slider(
"πŸ”₯ Energy",
0.0, 1.0,
section.get('energy', 0.5),
step=0.1,
key=f"energy_{section['id']}",
help="0.0 = Quiet/Delicate, 1.0 = Loud/Powerful"
)
# Density radio
density_options = ['Sparse', 'Medium', 'Thick']
current_density = section.get('density', 'Medium')
if current_density not in density_options:
current_density = 'Medium'
section['density'] = st.radio(
"🎹 Density",
density_options,
index=density_options.index(current_density),
key=f"density_{section['id']}",
horizontal=True,
help="Sparse = 2-3 notes, Medium = 4-5 notes, Thick = 6-8 notes"
)
# Role radio
role_options = ['Lead', 'Support', 'Ambient']
current_role = section.get('role', 'Support')
if current_role not in role_options:
current_role = 'Support'
section['role'] = st.radio(
"🎚️ Role",
role_options,
index=role_options.index(current_role),
key=f"role_{section['id']}",
horizontal=True,
help="Lead = Front of mix, Support = Behind vocals, Ambient = Background"
)
# Movement radio
movement_options = ['Static', 'Flowing', 'Agitated']
current_movement = section.get('movement', 'Flowing')
if current_movement not in movement_options:
current_movement = 'Flowing'
section['movement'] = st.radio(
"🌊 Movement",
movement_options,
index=movement_options.index(current_movement),
key=f"movement_{section['id']}",
horizontal=True,
help="Static = Block chords, Flowing = Smooth transitions, Agitated = Dramatic"
)
# Advanced settings
with st.expander("βš™οΈ Advanced", expanded=False):
c1, c2 = st.columns(2)
with c1:
section['lh_octave'] = st.slider(
"LH Octave", 1, 4, section.get('lh_octave', 2),
key=f"lh_{section['id']}",
help="Left hand octave - lower = deeper bass"
)
with c2:
section['rh_octave'] = st.slider(
"RH Octave", 3, 6, section.get('rh_octave', 4),
key=f"rh_{section['id']}",
help="Right hand octave - higher = brighter sound"
)
section['beats_per_chord'] = st.select_slider(
"⏱️ Beats per chord",
options=[1, 2, 4, 8],
value=section.get('beats_per_chord', 4),
key=f"bpc_{section['id']}",
help="Duration of each chord in MIDI export (1=quarter note, 4=one bar, 8=two bars)"
)
st.markdown('<div style="height:1px; background:#1e2530; margin:0.5rem 0 0.8rem;"></div>', unsafe_allow_html=True)
# Add Section Button
if len(st.session_state.song_sections) > 0:
if st.button("βž• Add Section", use_container_width=True):
add_section()
st.rerun()
st.markdown('<div style="height:1px; background:#1e2530; margin:1rem 0;"></div>', unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════════
# GENERATE BUTTON
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
# GENERATE β€” processes chords and stores results in session state
# ═══════════════════════════════════════════════════════════════
if st.button("🎹 Generate Full Song", type="primary", use_container_width=True):
if len(st.session_state.song_sections) == 0:
st.error("❌ Add at least one section first!")
else:
all_results = []
progression_data = []
prev_rh = None
for section in st.session_state.song_sections:
if not section['chords'].strip():
st.warning(f"⚠️ '{section['name']}' has no chords. Skipping.")
continue
tokens = section['chords'].strip().split()
for token in tokens:
try:
if "Roman" in input_mode:
root_pc, quality = roman_to_chord(token, key_pc)
chord_label = f"{pc_to_note_name(root_pc)}{quality}"
slash_bass = None
else:
root_pc, quality, slash_bass = parse_chord_symbol(token)
chord_label = token
if transpose_semitones != 0:
root_pc = (root_pc + transpose_semitones) % 12
if slash_bass:
slash_bass_pc = note_name_to_pc(slash_bass)
slash_bass_pc = (slash_bass_pc + transpose_semitones) % 12
slash_bass = pc_to_note_name(slash_bass_pc)
chord_label = f"{pc_to_note_name(root_pc)}{quality if quality != 'maj' else ''}"
lh, rh = GenreVoicer.voice(
root_pc, quality, section['genre'],
octave_lh=section['lh_octave'],
octave_rh=section['rh_octave'],
slash_bass=slash_bass
)
emotional_ctx = EmotionalContext(
energy=section.get('energy', 0.5),
density=section.get('density', 'Medium'),
role=section.get('role', 'Support'),
movement=section.get('movement', 'Flowing')
)
if context == "Solo Piano":
lh, rh = EmotionalAdapter.adapt_solo_piano(lh, rh, emotional_ctx)
else:
lh, rh = EmotionalAdapter.adapt_arrangement(lh, rh, emotional_ctx)
if use_negative:
lh = NegativeHarmony.mirror_in_key(lh, neg_key_pc)
rh = NegativeHarmony.mirror_in_key(rh, neg_key_pc)
voice_settings = EmotionalAdapter.get_voice_leading_settings(
section.get('movement', 'Flowing')
)
if use_voice_leading and prev_rh and voice_settings['enabled']:
rh = VoiceLeader.lead(prev_rh, rh)
has_issues, report, mud = SpectralAuditor.audit(lh, rh, context)
if has_issues and context == "Full Band":
mud_midis = {n for n, f in mud}
shifted = [n for n in rh if n in mud_midis and n < 60]
if shifted:
rh = sorted([n + 12 if n in mud_midis and n < 60 else n for n in rh])
has_issues, report, mud = SpectralAuditor.audit(lh, rh, context)
report = report + f"\nπŸ’‘ Auto-shifted {len(shifted)} note(s) up one octave"
prev_rh = rh
beats = section.get('beats_per_chord', 4)
result = {
'section': section['name'],
'section_genre': section['genre'],
'section_chords': section['chords'],
'label': chord_label,
'lh': lh,
'rh': rh,
'report': report,
'has_issues': has_issues,
'lh_names': [midi_to_name(n) for n in lh],
'rh_names': [midi_to_name(n) for n in rh],
'genre': section['genre'],
'energy': emotional_ctx.energy,
'density': emotional_ctx.density,
'role': emotional_ctx.role,
'beats': beats,
}
all_results.append(result)
progression_data.append({'lh': lh, 'rh': rh, 'beats': beats})
except Exception as e:
st.error(f"❌ Error parsing '{token}': {e}")
# Store in session state β€” display block reads from here on every rerun
st.session_state.last_results = all_results
st.session_state.last_progression_data = progression_data
st.session_state.last_gen_settings = {
'transpose': transpose_semitones,
'context': context,
'key_pc': key_pc,
'input_mode': input_mode,
'use_voice_leading': use_voice_leading,
'use_negative': use_negative,
'neg_key_pc': neg_key_pc if use_negative else None,
}
# ═══════════════════════════════════════════════════════════════
# DISPLAY β€” always runs if results exist, audio uses current BPM
# ═══════════════════════════════════════════════════════════════
if st.session_state.last_results:
# Check if any generation-affecting settings changed since last Generate
current_gen_settings = {
'transpose': transpose_semitones,
'context': context,
'key_pc': key_pc,
'input_mode': input_mode,
'use_voice_leading': use_voice_leading,
'use_negative': use_negative,
'neg_key_pc': neg_key_pc if use_negative else None,
}
if st.session_state.last_gen_settings and current_gen_settings != st.session_state.last_gen_settings:
st.warning("⚠️ Settings changed since last generation β€” click **Generate** to update results.")
current_section = None
for result in st.session_state.last_results:
# Section header when section changes
if result['section'] != current_section:
current_section = result['section']
st.markdown(f"""
<div class="section-result-header">
<span class="section-result-title">{result['section']}</span>
<span class="section-result-meta">{result['section_genre']} &nbsp;Β·&nbsp; {result['section_chords']}</span>
</div>
""", unsafe_allow_html=True)
# Generate audio fresh at current BPM on every rerun
chord_wav = AudioEngine.chord_to_wav(result['lh'], result['rh'], result['beats'], bpm)
# Build note pills HTML
lh_pills = " ".join(f'<span class="note-pill note-pill-lh">{n}</span>' for n in result['lh_names'])
rh_pills = " ".join(f'<span class="note-pill note-pill-rh">{n}</span>' for n in result['rh_names'])
spectral_class = "spectral-warn" if result['has_issues'] else "spectral-ok"
spectral_icon = "⚠" if result['has_issues'] else "βœ“"
spectral_line = result['report'].split('\n')[0]
energy_pct = int(result['energy'] * 100)
cols = st.columns([1.5, 2.5, 2.5])
with cols[0]:
st.markdown(f"""
<div class="chord-result-card">
<div class="chord-name">{result['label']}</div>
<div class="chord-energy-badge">
<span class="energy-dot" style="opacity:{0.3 + result['energy'] * 0.7:.2f};"></span>
<span class="energy-label">{energy_pct}% Β· {result['density']} Β· {result['role']}</span>
</div>
<div class="note-label">Left Hand</div>
<div class="note-row">{lh_pills if lh_pills else '<span style="color:#6e7681;font-size:0.7rem;">β€”</span>'}</div>
<div class="note-label">Right Hand</div>
<div class="note-row">{rh_pills if rh_pills else '<span style="color:#6e7681;font-size:0.7rem;">β€”</span>'}</div>
<div class="{spectral_class}" style="margin-top:6px;">{spectral_icon} {spectral_line}</div>
</div>
""", unsafe_allow_html=True)
st.audio(chord_wav, format='audio/wav')
with cols[1]:
st.markdown(draw_piano_svg(result['lh'], "LEFT HAND", 24, 36), unsafe_allow_html=True)
with cols[2]:
st.markdown(draw_piano_svg(result['rh'], "RIGHT HAND", 48, 37), unsafe_allow_html=True)
progression_data = st.session_state.last_progression_data
# Audio Preview
st.markdown("""
<div style="height:1px; background:#1e2530; margin:1.2rem 0 0.8rem;"></div>
<div class="output-section-title">Full Song Preview</div>
""", unsafe_allow_html=True)
try:
full_wav = AudioEngine.progression_to_wav(progression_data, bpm=bpm)
st.audio(full_wav, format='audio/wav')
st.caption(f"{bpm} BPM Β· {len(progression_data)} chord(s)")
except Exception as e:
st.warning(f"Audio preview unavailable: {e}")
# Export MIDI
st.markdown("""
<div style="height:1px; background:#1e2530; margin:0.8rem 0;"></div>
<div class="output-section-title">Export MIDI</div>
""", unsafe_allow_html=True)
try:
files = MidiExporter.export(progression_data, filename_prefix="full_song")
combined_file = MidiExporter.export_combined(progression_data, filename_prefix="full_song")
with open(combined_file, 'rb') as f:
st.download_button(
"⬇️ Download Complete MIDI",
f,
"song_COMPLETE.mid",
"audio/midi",
use_container_width=True,
type="primary"
)
with st.expander("Download Individual Parts"):
c1, c2 = st.columns(2)
with c1:
with open(files['lh'], 'rb') as f:
st.download_button(
"⬇️ LH / Bass",
f,
"song_LH_Bass.mid",
"audio/midi",
use_container_width=True
)
with c2:
with open(files['rh'], 'rb') as f:
st.download_button(
"⬇️ RH / Chords",
f,
"song_RH_Chords.mid",
"audio/midi",
use_container_width=True
)
except Exception as e:
st.error(f"❌ Export error: {e}")
# Summary
st.markdown("""
<div style="height:1px; background:#1e2530; margin:0.8rem 0;"></div>
<div class="output-section-title">Summary</div>
""", unsafe_allow_html=True)
summary = [{
'Section': r['section'],
'Chord': r['label'],
'LH': ", ".join(r['lh_names']),
'RH': ", ".join(r['rh_names'])
} for r in st.session_state.last_results]
st.dataframe(summary, use_container_width=True)
st.markdown("""
<div style="height:1px; background:#1e2530; margin:2rem 0 0.5rem;"></div>
<div style="text-align:center; font-size:0.65rem; color:#3d4451; letter-spacing:0.1em; text-transform:uppercase;">
Harmonic Catalyst PRO Β· Guide, Don't Control
</div>
""", unsafe_allow_html=True)