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(""" """, unsafe_allow_html=True) # ── Header banner ── st.markdown("""
HARMONIC CATALYST PRO
Genre-Aware Chord Voicing Engine  ·  Multi-Section Builder  ·  MIDI Export
""", 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'' svg += f'{label}' 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'' if is_active: name = midi_to_name(midi) svg += f'{name}' 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'' 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("""
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"""
{result['section']} {result['section_genre']}  ·  {result['section_chords']}
""", 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"""
{result['label']}
{energy_pct}% · {result['density']} · {result['role']}
Left Hand
{lh_pills if lh_pills else ''}
Right Hand
{rh_pills if rh_pills else ''}
{spectral_icon} {spectral_line}
""", 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("""
Full Song Preview
""", 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("""
Export MIDI
""", 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("""
Summary
""", 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("""
Harmonic Catalyst PRO · Guide, Don't Control
""", unsafe_allow_html=True)