Spaces:
Sleeping
Sleeping
| 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 Β· Multi-Section Builder Β· 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']} Β· {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) | |