""", 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("""
🎹 Settings
""", 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''
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("""
Song Structure
""", unsafe_allow_html=True)
# Empty state
if len(st.session_state.song_sections) == 0:
st.markdown("""
🎹
No sections yet — add your first section to start building
""", 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'
{stype}
', 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('', 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('', 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"""
""", 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'{n}' for n in result['lh_names'])
rh_pills = " ".join(f'{n}' 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"""