import re import numpy as np import gradio as gr import pretty_midi import subprocess import random from datasets import load_dataset # ========================================== # 1. DATA PREPARATION (RANDOM SAMPLED) # ========================================== MAX_PROGRESSIONS = 2000 print(f"Downloading and shuffling dataset... targeting {MAX_PROGRESSIONS} random progressions per genre.") # The magic happens here: .shuffle(buffer_size=10000) mixes the stream on the fly! dataset = load_dataset( "ailsntua/Chordonomicon", split="train", streaming=True ).shuffle(seed=random.randint(1, 1000), buffer_size=10000) target_genres = ["pop", "rock", "jazz", "metal", "country", "blues", "r&b", "folk", "electronic"] corpus_by_genre = {genre: set() for genre in target_genres} pattern = re.compile(r'<([^>]+)>\s*([^<]+)') for row in dataset: # Stop processing once EVERY genre has hit the max cap if all(len(progressions) >= MAX_PROGRESSIONS for progressions in corpus_by_genre.values()): break main_genre = str(row.get('main_genre', '')).lower() genres_str = str(row.get('genres', '')).lower() combined_genres = main_genre + " " + genres_str matched_genre = None for g in target_genres: if g in combined_genres and len(corpus_by_genre[g]) < MAX_PROGRESSIONS: matched_genre = g break if not matched_genre: continue chord_string = row.get('chords', '') if not chord_string: continue matches = pattern.findall(chord_string) for tag, chords in matches: tag = tag.lower().strip() chords = " ".join(chords.split()) if chords and ('verse' in tag or 'chorus' in tag): corpus_by_genre[matched_genre].add(chords) corpus_by_genre = {g: list(chords) for g, chords in corpus_by_genre.items()} print("Randomized dataset loaded successfully!") # ========================================== # 2. MARKOV CHAIN LOGIC # ========================================== def train_markov_model(corpus, order=1): markov_model = {} art_start = "*S*" art_end = "*E*" for progression in corpus: chords = progression.split() if not chords: continue current_state = tuple([art_start] * order) for chord in chords: if current_state not in markov_model: markov_model[current_state] = {} if chord not in markov_model[current_state]: markov_model[current_state][chord] = 0 markov_model[current_state][chord] += 1 current_state = tuple(list(current_state)[1:] + [chord]) if current_state not in markov_model: markov_model[current_state] = {} if art_end not in markov_model[current_state]: markov_model[current_state][art_end] = 0 markov_model[current_state][art_end] += 1 return markov_model def get_next_chord(current_state, markov_model): if current_state not in markov_model: return "*E*" transitions = markov_model[current_state] next_chords = list(transitions.keys()) counts = list(transitions.values()) total = sum(counts) probs = [c / total for c in counts] return np.random.choice(next_chords, p=probs) def generate_progression(markov_model, target_length, order=1): art_start = "*S*" art_end = "*E*" current_state = tuple([art_start] * order) progression = [] max_attempts = target_length * 5 attempts = 0 while len(progression) < target_length and attempts < max_attempts: attempts += 1 next_chord = get_next_chord(current_state, markov_model) if next_chord == art_end: current_state = tuple([art_start] * order) continue progression.append(next_chord) current_state = tuple(list(current_state)[1:] + [next_chord]) return " ".join(progression) # ========================================== # 3. AUDIO SYNTHESIS & VOICING LOGIC # ========================================== NOTE_TO_MIDI = {'C': 60, 'Cs': 61, 'Db': 61, 'D': 62, 'Ds': 63, 'Eb': 63, 'E': 64, 'F': 65, 'Fs': 66, 'Gb': 66, 'G': 67, 'Gs': 68, 'Ab': 68, 'A': 69, 'As': 70, 'Bb': 70, 'B': 71} MIDI_TO_NOTE = {60: 'C', 61: 'Db', 62: 'D', 63: 'Eb', 64: 'E', 65: 'F', 66: 'Gb', 67: 'G', 68: 'Ab', 69: 'A', 70: 'Bb', 71: 'B'} # 1. Expanded Dictionary with 7ths, 9ths, and extended chords CHORD_INTERVALS = { # --- 13ths --- 'maj13': [0, 4, 7, 11, 14, 21], # Root, 3rd, 5th, Maj7, 9th, 13th 'min13': [0, 3, 7, 10, 14, 21], '13': [0, 4, 7, 10, 14, 21], # Dominant 13 'add13': [0, 4, 7, 21], 'madd13': [0, 3, 7, 21], # --- 11ths --- 'maj11': [0, 4, 7, 11, 14, 17], # Root, 3rd, 5th, Maj7, 9th, 11th 'min11': [0, 3, 7, 10, 14, 17], '11': [0, 4, 7, 10, 14, 17], # Dominant 11 '7#11': [0, 4, 7, 10, 18], # Lydian Dominant flavor 'm711': [0, 3, 7, 10, 17], # Min7 add 11 # --- 9ths --- 'maj9': [0, 4, 7, 11, 14], 'min9': [0, 3, 7, 10, 14], '9': [0, 4, 7, 10, 14], # Dominant 9 'add9': [0, 4, 7, 14], 'madd9': [0, 3, 7, 14], '7b9': [0, 4, 7, 10, 13], # Altered Dominant (flat 9) '7#9': [0, 4, 7, 10, 15], # The "Hendrix" Chord (sharp 9) # --- 7ths --- 'maj7': [0, 4, 7, 11], 'min7': [0, 3, 7, 10], '7': [0, 4, 7, 10], # Dominant 7 'dim7': [0, 3, 6, 9], # Fully diminished 7th 'm7b5': [0, 3, 6, 10], # Half-diminished 7th 'aug7': [0, 4, 8, 10], # Augmented 7th 'mmaj7': [0, 3, 7, 11], # Minor-Major 7th (James Bond chord) '7sus4': [0, 5, 7, 10], # Dominant 7 suspended 4th # --- 6ths --- '6': [0, 4, 7, 9], # Major 6th 'm6': [0, 3, 7, 9], # Minor 6th # --- Sus & Altered Triads --- 'sus4': [0, 5, 7], # Suspended 4th (replaces 3rd) 'sus2': [0, 2, 7], # Suspended 2nd (replaces 3rd) 'aug': [0, 4, 8], # Augmented triad 'dim': [0, 3, 6], # Diminished triad # --- Standard Triads & Power Chords --- 'maj': [0, 4, 7], 'min': [0, 3, 7], 'no3d': [0, 7], # Power chord (from your dataset) '5': [0, 7] # Standard power chord notation } # Pre-sort keys by length (longest first) to prevent the "greedy" bug SORTED_QUALITIES = sorted(CHORD_INTERVALS.keys(), key=len, reverse=True) def parse_chord_to_midi(chord_string): if not chord_string or chord_string == 'N': return [], "" # 1. Check for a slash chord bass note! bass_note_str = None if '/' in chord_string: parts = chord_string.split('/') chord_string = parts[0] # The main chord (e.g., 'Amin') bass_note_str = parts[1] # The bass note (e.g., 'E') # 2. Parse the main chord's root note root_note = chord_string[0] remainder = chord_string[1:] if remainder and remainder[0] in ['s', 'b']: root_note += remainder[0] remainder = remainder[1:] root_midi = NOTE_TO_MIDI.get(root_note, 60) # 3. Find the chord quality quality = 'maj' intervals = CHORD_INTERVALS['maj'] for q in SORTED_QUALITIES: if remainder.startswith(q): intervals = CHORD_INTERVALS[q] quality = q break pitches = [root_midi + i for i in intervals] # 4. Inject the custom bass note if bass_note_str: # Parse the bass note (checking for sharps/flats) b_root = bass_note_str[0] b_rem = bass_note_str[1:] if b_rem and b_rem[0] in ['s', 'b']: b_root += b_rem[0] bass_midi = NOTE_TO_MIDI.get(b_root, 60) # Force the bass note to sit below our root note while bass_midi >= root_midi: bass_midi -= 12 # Drop it one more octave for a deep, rich foundation bass_midi -= 12 pitches.append(bass_midi) # Update the display name so it shows the slash in the final output! quality += "/" + bass_note_str return pitches, quality # General MIDI Patch Numbers (0-indexed) INSTRUMENT_MAP = { "Acoustic Grand Piano": 0, "Electric Piano (Rhodes)": 4, "Drawbar Organ": 16, "Acoustic Guitar (Nylon)": 24, "Electric Guitar (Clean)": 27, "Electric Guitar (Distortion)": 30, "Synth Pad 1 (New Age)": 88, "Synth Pad 2 (Warm)": 89, "Synth Pad 3 (Polysynth)": 90, "Synth Pad 4 (Choir)": 91, "Synth Pad 7 (Halo)": 94, "Synth Pad 8 (Sweep)": 95, "Sci-Fi / Atmosphere": 103 } def apply_voicing(pitches, voicing_type): if not pitches: return pitches pitches = sorted(pitches) if voicing_type == "First Inversion" and len(pitches) > 1: pitches[0] += 12 elif voicing_type == "Second Inversion" and len(pitches) > 2: pitches[0] += 12 pitches[1] += 12 elif voicing_type == "Random Voice Leading": choice = random.choice([0, 1, 2]) if choice == 1 and len(pitches) > 1: pitches[0] += 12 if choice == 2 and len(pitches) > 2: pitches[0] += 12; pitches[1] += 12 elif voicing_type == "Open / Spread" and len(pitches) >= 3: # Drop the bass note down an octave for a huge foundation pitches[0] -= 12 # Push the 3rd (index 1) up an octave to clear room in the middle pitches[1] += 12 # If it's a 4+ note chord (like a 7th or 9th), keep the top notes clustered # Re-sort to ensure MIDI plays them in the correct vertical order return sorted(pitches) if voicing_type != "Open / Spread" else pitches def generate_audio_file(progression_string, instrument_name, transpose_semitones, voicing_type): if not progression_string.strip(): return None, None, "" # Look up the correct MIDI program number from our dictionary prog_num = INSTRUMENT_MAP.get(instrument_name, 0) # Give guitars and synths a slightly higher velocity so they cut through velocity = 100 if prog_num > 20 else 85 midi = pretty_midi.PrettyMIDI(initial_tempo=120) inst = pretty_midi.Instrument(program=prog_num) current_time = 0.0 transposed_chord_names = [] for chord in progression_string.split(): pitches, quality = parse_chord_to_midi(chord) if not pitches: continue # Transpose pitches = [p + transpose_semitones for p in pitches] normalized_root = ((pitches[0] - 60) % 12) + 60 transposed_chord_names.append(MIDI_TO_NOTE.get(normalized_root, "C") + quality) # Drop the octave if it's a distorted metal guitar if instrument_name == "Electric Guitar (Distortion)": pitches = [p - 12 for p in pitches] pitches = apply_voicing(pitches, voicing_type) for pitch in pitches: note = pretty_midi.Note(velocity=velocity, pitch=pitch, start=current_time, end=current_time + 0.5) inst.notes.append(note) current_time += 0.5 midi.instruments.append(inst) midi_path = 'generated_progression.mid' wav_path = 'generated_progression.wav' midi.write(midi_path) subprocess.run(['fluidsynth', '-ni', '/usr/share/sounds/sf2/FluidR3_GM.sf2', midi_path, '-F', wav_path, '-r', '44100'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return wav_path, midi_path, " ".join(transposed_chord_names) # ========================================== # 4. GRADIO INTERFACE # ========================================== def app_logic(genre, order, length, instrument, transpose, voicing): corpus = corpus_by_genre.get(genre, []) if not corpus: return f"Error: No chords found for {genre}. Wait for the dataset to finish loading in the console.", "", None, None model = train_markov_model(corpus, order=int(order)) raw_chords = generate_progression(model, target_length=int(length), order=int(order)) if not raw_chords.strip(): return "(Generation stopped. The Markov chain hit an early dead end. Try again or lower the Order.)", "", None, None audio_path, midi_path, final_transposed_chords = generate_audio_file(raw_chords, instrument, int(transpose), voicing) return raw_chords, final_transposed_chords, audio_path, midi_path with gr.Blocks(theme=gr.themes.Monochrome()) as demo: gr.Markdown("# Markhords: Markov Model Chord Progression Generator") with gr.Row(): with gr.Column(scale=1): gr.Markdown(f"### 1. Training Data (Up to {MAX_PROGRESSIONS} songs per genre)") genre_dropdown = gr.Dropdown( choices=[g.capitalize() for g in target_genres], value="Pop", label="Dataset Genre" ) gr.Markdown("### 2. Generation Settings") order_slider = gr.Slider(minimum=1, maximum=3, step=1, value=1, label="Markov Chain Order") length_slider = gr.Slider(minimum=2, maximum=16, step=1, value=8, label="Target Length (Chords)") gr.Markdown("### 3. Post-Processing") transpose_slider = gr.Slider(minimum=-12, maximum=12, step=1, value=0, label="Transpose (Semitones)") voicing_dropdown = gr.Dropdown( choices=["Root Position", "First Inversion", "Second Inversion", "Open / Spread", "Random Voice Leading"], value="Open / Spread", # Open spread sounds incredible on synth pads! label="Chord Voicings" ) # Feed the dictionary keys into the dropdown instrument_dropdown = gr.Dropdown( choices=list(INSTRUMENT_MAP.keys()), value="Synth Pad 2 (Warm)", label="Instrument" ) generate_btn = gr.Button("Generate Chords", variant="primary") with gr.Column(scale=1): gr.Markdown("### Output") output_raw_text = gr.Textbox(label="Original Generated Progression", lines=2, interactive=False) output_final_text = gr.Textbox(label="Final Progression (After Transposition)", lines=2, interactive=False) output_audio = gr.Audio(label="Playback", type="filepath", autoplay=True) output_midi = gr.File(label="Download MIDI", interactive=False) generate_btn.click( fn=lambda g, o, l, i, t, v: app_logic(g.lower(), o, l, i, t, v), inputs=[genre_dropdown, order_slider, length_slider, instrument_dropdown, transpose_slider, voicing_dropdown], outputs=[output_raw_text, output_final_text, output_audio, output_midi] ) demo.launch()