RAM2118 commited on
Commit
79ce1f6
·
verified ·
1 Parent(s): 06ffc04

Upload 4 files

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