"""Hook-based reggaeton melody engine — deterministic, chord-aware motif generation. Replaces random pentatonic lead lines with structured repeating motifs using call-and-response phrasing and arch-contour hooks. Design: pure functions, no I/O, no global state. RNG: random.Random(seed) per-call for full determinism. """ from __future__ import annotations import random from src.core.schema import MidiNote # --------------------------------------------------------------------------- # Musical constants # --------------------------------------------------------------------------- _NOTE_TO_MIDI = { "C": 0, "C#": 1, "D": 2, "D#": 3, "E": 4, "F": 5, "F#": 6, "G": 7, "G#": 8, "A": 9, "A#": 10, "B": 11, } # i-VI-III-VII progression (reggaeton standard) — duplicated from compose.py _CHORD_PROGRESSION: list[tuple[int, str]] = [ (0, "minor"), # i (8, "major"), # VI (3, "major"), # III (10, "major"), # VII ] _CHORD_BARS = 2 # each chord lasts 2 bars # Dembow stab grid (beat offsets within each bar) _DEMBOW_POSITIONS: list[float] = [1.0, 2.5, 3.0, 3.5] # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _midi_for_name(note_name: str, octave: int = 4) -> int: """Convert note name (e.g. "A") to MIDI pitch at given octave.""" return (octave + 1) * 12 + _NOTE_TO_MIDI[note_name] def _get_pentatonic(key_root: str, key_minor: bool, octave: int) -> list[int]: """Return pentatonic scale MIDI notes for the given key and octave.""" root_midi = _midi_for_name(key_root, octave) intervals = [0, 3, 5, 7, 10] if key_minor else [0, 2, 4, 7, 9] return [root_midi + i for i in intervals] def _get_diatonic(key_root: str, key_minor: bool, octave: int) -> list[int]: """Return full diatonic scale MIDI notes (7 notes, not pentatonic).""" root_midi = _midi_for_name(key_root, octave) intervals = [0, 2, 3, 5, 7, 8, 10] if key_minor else [0, 2, 4, 5, 7, 9, 11] return [root_midi + i for i in intervals] def _resolve_chord_tones( key_root: str, key_minor: bool, bar: int, octave: int = 4, ) -> set[int]: """Return MIDI pitches for the active chord at a given bar index. Cycles through i-VI-III-VII, each chord lasting _CHORD_BARS. Includes chord tones across ±1 octave range for flexible voicing. """ tonic_midi = _midi_for_name(key_root, octave) chord_idx = (bar // _CHORD_BARS) % len(_CHORD_PROGRESSION) offset, quality = _CHORD_PROGRESSION[chord_idx] root = tonic_midi + offset if quality == "minor": intervals = [0, 3, 7] else: intervals = [0, 4, 7] tones: set[int] = set() # Constrain to single octave (oct_shift=0 only) to keep melodies coherent. # Expanding to ±1 octave creates 2+ octave jumps in the arch contour. for iv in intervals: tones.add(root + iv) return tones def _resolve_tension_notes( key_root: str, key_minor: bool, octave: int = 4, ) -> tuple[int, int]: """Return (V, VII) MIDI pitches for call-resolution scheme.""" tonic_midi = _midi_for_name(key_root, octave) v_pitch = tonic_midi + 7 # perfect fifth # VII: minor = whole step below tonic, major = half step below vii_pitch = tonic_midi + (10 if key_minor else 11) return v_pitch, vii_pitch def _resolve_tonic(key_root: str, octave: int = 4) -> int: """Return tonic MIDI pitch.""" return _midi_for_name(key_root, octave) def _quantize_to_scale(pitch: int, scale_notes: list[int]) -> int: """Snap pitch to the nearest scale note (by semitone distance).""" if not scale_notes: return pitch return min(scale_notes, key=lambda s: abs(s - pitch)) # --------------------------------------------------------------------------- # Style-specific motif builders # --------------------------------------------------------------------------- def _build_hook( key_root: str, key_minor: bool, bars: int, rng: random.Random, ) -> list[MidiNote]: """Hook style: arch contour, chord tones on quarter-beat positions. Each bar gets 4 chord-tone notes forming a low→mid→high→mid arch, plus an optional off-beat ghost note for reggaeton feel. All quarter-position notes are chord tones (100% ratio). Across bars, the peak pitch shifts to follow the chord progression, creating a natural melodic arc. """ scale_oct4 = _get_pentatonic(key_root, key_minor, 4) scale_oct5 = _get_pentatonic(key_root, key_minor, 5) all_scale = sorted(set(scale_oct4 + scale_oct5)) notes: list[MidiNote] = [] for bar in range(bars): chord_tones = _resolve_chord_tones(key_root, key_minor, bar, 4) # Filter to chord tones that align with the scale chord_in_scale = sorted(set( p for p in chord_tones if any(abs(p % 12 - s % 12) == 0 for s in all_scale) )) if len(chord_in_scale) < 4: # Not enough scale-aligned chord tones; use all chord tones chord_in_scale = sorted(chord_tones) bar_start = bar * 4.0 # Build arch: pick 4 distinct chord-tone pitches # low → mid-low → high → mid-high (arch shape) n = len(chord_in_scale) if n >= 4: low = chord_in_scale[0] mid_low = chord_in_scale[n // 3] high = chord_in_scale[-1] mid_high = chord_in_scale[2 * n // 3] elif n == 3: low, mid_low, high = chord_in_scale[0], chord_in_scale[1], chord_in_scale[2] mid_high = chord_in_scale[1] # reuse mid elif n == 2: low = chord_in_scale[0] mid_low = chord_in_scale[1] high = chord_in_scale[0] + 12 # octave up mid_high = chord_in_scale[1] else: low = mid_low = high = mid_high = chord_in_scale[0] # Ensure high > low for true arch shape if high <= low + 2: high = low + 12 if low + 12 in chord_tones else high + 7 if mid_high <= mid_low: mid_high = mid_low + 5 contour = [ (0.0, low, 0.75), # beat 1 — low chord tone (1.0, mid_low, 0.5), # beat 2 — walking up (2.0, high, 0.75), # beat 3 — peak (chord tone) (3.0, mid_high, 0.5), # beat 4 — coming down ] for pos, pitch, dur in contour: velocity = 85 if pos in (0.0, 2.0) else 70 notes.append(MidiNote( pitch=pitch, start=bar_start + pos, duration=dur, velocity=velocity, )) # Ghost note on beat 2.5 (reggaeton syncopation) — deterministically varied if rng.random() < 0.6: ghost_pitch = _quantize_to_scale( mid_low + rng.choice([-2, 0, 2, 5]), all_scale, ) notes.append(MidiNote( pitch=ghost_pitch, start=bar_start + 1.5, duration=0.25, velocity=rng.randint(45, 60), )) # Sort by start time so contour notes come before ghost notes notes.sort(key=lambda n: n.start) return notes def _build_stabs( key_root: str, key_minor: bool, bars: int, rng: random.Random, ) -> list[MidiNote]: """Stabs style: short 16th-duration hits on dembow grid positions. All notes are on [1.0, 2.5, 3.0, 3.5] per bar with duration ≤ 0.25. Strong positions (2.5, 3.5) favor chord tones. """ scale_oct4 = _get_pentatonic(key_root, key_minor, 4) scale_oct5 = _get_pentatonic(key_root, key_minor, 5) all_scale = sorted(set(scale_oct4 + scale_oct5)) notes: list[MidiNote] = [] for bar in range(bars): chord_tones = _resolve_chord_tones(key_root, key_minor, bar, 4) chord_in_scale = sorted(set( p for p in chord_tones if any(abs(p % 12 - s % 12) == 0 for s in all_scale) )) if not chord_in_scale: chord_in_scale = sorted(chord_tones) width = len(chord_in_scale) bar_start = bar * 4.0 for pos_offset in _DEMBOW_POSITIONS: is_strong = pos_offset in (2.5, 3.5) if is_strong and width > 0: idx = rng.randint(0, width - 1) idx = ((bar + int(pos_offset * 4)) * 7 + 3) % width # deterministic pitch = chord_in_scale[idx] else: pitch = rng.choice(all_scale) notes.append(MidiNote( pitch=pitch, start=bar_start + pos_offset, duration=0.25, # 16th note velocity=80 if is_strong else 65, )) return notes def _build_smooth( key_root: str, key_minor: bool, bars: int, rng: random.Random, ) -> list[MidiNote]: """Smooth style: stepwise scalar eighth-note motion. Uses the full diatonic scale (not pentatonic) so consecutive notes differ by ≤ 2 semitones. Melody walks through the scale with occasional direction changes for contour. """ scale_oct4 = _get_diatonic(key_root, key_minor, 4) scale_oct5 = _get_diatonic(key_root, key_minor, 5) scale = sorted(set(scale_oct4 + scale_oct5)) if not scale: return [] notes: list[MidiNote] = [] total_eighths = bars * 8 # Start near tonic with seed-dependent offset for variation tonic = _resolve_tonic(key_root, 4) tonic_candidates = [s for s in scale if abs(s - tonic) <= 4] if tonic_candidates: current = rng.choice(tonic_candidates) if len(tonic_candidates) > 1 else tonic_candidates[0] else: current = tonic direction = 1 # 1 = ascending, -1 = descending for ei in range(total_eighths): pos = ei * 0.5 bar = ei // 8 # Strong beats: bias toward chord tones is_quarter = (ei % 4 == 0) chord_tones = _resolve_chord_tones(key_root, key_minor, bar, 4) # Find candidates: scale notes within ±2 semitones of current candidates = [s for s in scale if abs(s - current) <= 2 and s != current] if not candidates: # No valid stepwise candidate; stay or jump small candidates = [s for s in scale if abs(s - current) <= 3] if not candidates: candidates = [current] # On quarter beats, prefer chord tones that are also stepwise-valid if is_quarter: chord_candidates = [c for c in candidates if c in chord_tones] if chord_candidates: candidates = chord_candidates # Occasionally reverse direction for seed-dependent variation if rng.random() < 0.15: direction *= -1 # Pick: prefer candidates in the current direction if direction > 0: up_candidates = [c for c in candidates if c > current] if up_candidates: next_pitch = rng.choice(up_candidates) if len(up_candidates) > 1 else up_candidates[0] else: down_candidates = [c for c in candidates if c < current] if down_candidates: next_pitch = rng.choice(down_candidates) if len(down_candidates) > 1 else down_candidates[-1] else: next_pitch = rng.choice(candidates) if len(candidates) > 1 else candidates[0] direction = -1 else: down_candidates = [c for c in candidates if c < current] if down_candidates: next_pitch = rng.choice(down_candidates) if len(down_candidates) > 1 else down_candidates[-1] else: up_candidates = [c for c in candidates if c > current] if up_candidates: next_pitch = rng.choice(up_candidates) if len(up_candidates) > 1 else up_candidates[0] else: next_pitch = rng.choice(candidates) if len(candidates) > 1 else candidates[0] direction = 1 # Clamp within overall range (don't go too high or low) if next_pitch > scale[-3]: direction = -1 elif next_pitch < scale[3]: direction = 1 velocity = 75 if is_quarter else 60 notes.append(MidiNote( pitch=next_pitch, start=pos, duration=0.5, velocity=velocity, )) current = next_pitch return notes # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def build_motif( key_root: str, key_minor: bool, style: str, bars: int = 4, seed: int = 42, ) -> list[MidiNote]: """Generate a repeating motif using chord-aware scale selection. Args: key_root: Tonic note name (e.g. "A", "D", "F#"). key_minor: True for minor key, False for major. style: One of "hook", "stabs", "smooth". bars: Number of bars (2-8, clamped if outside). seed: RNG seed for deterministic output. Returns: List of MidiNote objects. Raises: ValueError: If style is not one of "hook", "stabs", "smooth". """ valid = ("hook", "stabs", "smooth") if style not in valid: raise ValueError( f"Invalid style '{style}'. Valid styles: {', '.join(valid)}" ) # Clamp bars to safe range bars = max(2, min(8, bars)) rng = random.Random(seed) if style == "hook": return _build_hook(key_root, key_minor, bars, rng) elif style == "stabs": return _build_stabs(key_root, key_minor, bars, rng) else: return _build_smooth(key_root, key_minor, bars, rng) def apply_variation( motif: list[MidiNote], shift_beats: float = 0.0, transpose_semitones: int = 0, ) -> list[MidiNote]: """Apply rhythmic shift and/or pitch transpose to a motif. Returns a new list; the original motif is unchanged. All inter-onset intervals and durations are preserved. Args: motif: Source notes. shift_beats: Beat offset added to all start times. transpose_semitones: Semitone offset added to all pitches. Returns: New list of MidiNote objects. """ return [ MidiNote( pitch=note.pitch + transpose_semitones, start=note.start + shift_beats, duration=note.duration, velocity=note.velocity, ) for note in motif ] def build_call_response( motif: list[MidiNote], bars: int = 8, key_root: str = "A", key_minor: bool = True, seed: int = 42, ) -> list[MidiNote]: """Build call-and-response structure from a motif. First half (call): motif repeated, last note forced to V or VII (tension). Second half (response): motif repeated, last note forced to tonic i (resolution). The motif is repeated as needed to fill the section. Args: motif: Source motif notes. bars: Total bars for the call-response section. key_root: Tonic note name. key_minor: True for minor key. seed: RNG seed. Returns: List of MidiNote objects spanning `bars` worth of beats. """ if not motif: return [] rng = random.Random(seed) half_bars = bars // 2 total_beats = bars * 4.0 half_beats = half_bars * 4.0 # Determine motif length in beats (end of last note) motif_end = max(n.start + n.duration for n in motif) motif_bars = max(1, int((motif_end + 3.999) / 4.0)) repeats_per_half = max(1, half_bars // motif_bars) v_tonic, vii_tonic = _resolve_tension_notes(key_root, key_minor, 4) tonic = _resolve_tonic(key_root, 4) result: list[MidiNote] = [] # --- Call half --- for r in range(repeats_per_half): offset = r * motif_bars * 4.0 for note in motif: new_start = note.start + offset if new_start >= half_beats: continue # skip notes beyond call half result.append(MidiNote( pitch=note.pitch, start=new_start, duration=note.duration, velocity=note.velocity, )) # Sort by start time so last-by-position = last in time result.sort(key=lambda n: n.start) # Force last note of call half to tension pitch call_notes = [n for n in result if n.start < half_beats] if call_notes: tension_pitch = v_tonic if rng.random() < 0.5 else vii_tonic call_notes[-1].pitch = tension_pitch # --- Response half --- response_start = half_beats for r in range(repeats_per_half): offset = response_start + r * motif_bars * 4.0 for note in motif: new_start = note.start + offset if new_start >= total_beats: continue # skip notes beyond section result.append(MidiNote( pitch=note.pitch, start=new_start, duration=note.duration, velocity=min(127, note.velocity + 5), )) # Sort again after adding response notes result.sort(key=lambda n: n.start) # Force last note to tonic response_notes = [n for n in result if n.start >= response_start] if response_notes: response_notes[-1].pitch = tonic return result