"""Melodic pattern generators for reggaeton production. All generators return list[dict] with format {pos, len, key, vel}. Designed to feed MelodicTrack notes in SongDefinition. """ import random # --------------------------------------------------------------------------- # Scale definitions # --------------------------------------------------------------------------- SCALES = { "minor": [0, 2, 3, 5, 7, 8, 10], # natural minor "major": [0, 2, 4, 5, 7, 9, 11], "phrygian": [0, 1, 3, 5, 7, 8, 10], "dorian": [0, 2, 3, 5, 7, 9, 10], } ROOT_SEMITONE = { "C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3, "E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8, "Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11, } # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _parse_key(key_str: str) -> tuple[int, str]: """Parse a key like 'Am', 'C#m', 'Dm', 'C' into (root_semitone, scale_name).""" if key_str.endswith("m") and key_str != "m": root_str = key_str[:-1] scale_name = "minor" else: root_str = key_str scale_name = "major" root = ROOT_SEMITONE.get(root_str) if root is None: raise ValueError(f"Unknown root: {root_str}") return root, scale_name def _get_scale_notes(root: int, scale: str, octave: int) -> list[int]: """Return MIDI note numbers for all degrees of the scale in given octave.""" intervals = SCALES.get(scale, SCALES["major"]) return [root + octave * 12 + interval for interval in intervals] def _clamp_vel(v: int) -> int: """Clamp velocity to valid MIDI range [1, 127].""" return max(1, min(127, v)) def _apply_humanize(notes, humanize): """Apply humanization (velocity jitter + position nudge) to note list.""" if humanize <= 0: return notes jitter = humanize * 5 nudge = humanize * 0.03 for n in notes: n["vel"] = _clamp_vel(int(n["vel"] + random.uniform(-jitter, jitter))) n["pos"] = max(0, n["pos"] + random.uniform(-nudge, nudge)) return notes # --------------------------------------------------------------------------- # Bass: tresillo # --------------------------------------------------------------------------- def bass_tresillo( key: str, bars: int, octave: int = 3, velocity_mult: float = 1.0, humanize: float = 0.0, ) -> list[dict]: """Reggaeton tresillo bass pattern. 6 notes per bar at positions: 0.0, 0.75, 1.5, 2.25, 3.0, 3.75 Root note on downbeats (0.0, 1.5, 3.0), fifth (7 semitones) on upbeats. Velocity: 110 for downbeats, 85 for upbeats. Default octave=3 gives root in MIDI range 45-52 (A3-E4), within 36-55. """ root, scale = _parse_key(key) scale_notes = _get_scale_notes(root, scale, octave) root_note = scale_notes[0] # degree 0 fifth_note = root_note + 7 # up a perfect fifth notes: list[dict] = [] for b in range(bars): o = b * 4.0 # Positions within the bar positions = [0.0, 0.75, 1.5, 2.25, 3.0, 3.75] for idx, pos in enumerate(positions): if idx % 2 == 0: # downbeats: root key_note = root_note vel = 110 else: # upbeats: fifth key_note = fifth_note vel = 85 vel = _clamp_vel(int(vel * velocity_mult)) notes.append({"pos": o + pos, "len": 0.25, "key": key_note, "vel": vel}) return _apply_humanize(notes, humanize) # --------------------------------------------------------------------------- # Lead: hook # --------------------------------------------------------------------------- def lead_hook( key: str, bars: int, octave: int = 5, density: float = 0.6, velocity_mult: float = 1.0, humanize: float = 0.0, ) -> list[dict]: """Simple melodic hook over 4-8 bars. Uses scalar degrees: [0, 2, 4, 2, 3, 1, 0, 2, 4, 5, 4, 2, 0] Note durations: 0.5 or 1.0 beats. density=1.0 → every slot filled; density=0.5 → half filled. """ root, scale = _parse_key(key) intervals = SCALES.get(scale, SCALES["major"]) # Map scale degrees to MIDI notes (extend to cover octave 5 and 6 for melody) scale_notes_oct5 = _get_scale_notes(root, scale, octave) # 7 notes scale_notes_oct6 = _get_scale_notes(root, scale, octave + 1) # Degree pattern (0-indexed scale degrees) degrees = [0, 2, 4, 2, 3, 1, 0, 2, 4, 5, 4, 2, 0] notes: list[dict] = [] # Step through the pattern at half-beat intervals # density controls whether we actually place a note step = max(1, round(1.0 / density)) if density > 0 else 1 pos = 0.0 degree_idx = 0 while pos < bars * 4.0: slot = int(pos * 2) # 0.5-beat slots if slot % step == 0: # Pick note alternating between octave 5 and 6 for contour use_oct6 = (degree_idx // 2) % 3 == 0 # every few notes go higher midi_note = scale_notes_oct6[degrees[degree_idx] % 7] \ if use_oct6 else scale_notes_oct5[degrees[degree_idx] % 7] # Duration: 1.0 beat on strong beats (quarter), 0.5 elsewhere is_strong = (slot % 4 == 0) length = 1.0 if is_strong else 0.5 vel = 100 if is_strong else 80 vel = _clamp_vel(int(vel * velocity_mult)) notes.append({"pos": pos, "len": length, "key": midi_note, "vel": vel}) # Advance degree index degree_idx = (degree_idx + 1) % len(degrees) if is_strong: pos += 1.0 else: pos += 0.5 else: pos += 0.5 return _apply_humanize(notes, humanize) # --------------------------------------------------------------------------- # Chords: block chords # --------------------------------------------------------------------------- def chords_block( key: str, bars: int, octave: int = 4, velocity_mult: float = 1.0, humanize: float = 0.0, ) -> list[dict]: """Blocked chords every 2 beats (half-bar). Minor progression: i - VII - VI - VII (degrees 0, 6, 5, 6 in natural minor) Major progression: I - V - vi - IV (degrees 0, 4, 5, 3 in major) Each chord: root + third + fifth (3 notes stacked at same position). """ root, scale = _parse_key(key) scale_notes_oct4 = _get_scale_notes(root, scale, octave) if scale == "minor": # i - VII - VI - VII (natural minor) # VII = degree 6 (raised 7th = 10 semitones from root in minor) # In natural minor: degrees 0,6,5,6 # We need to build chords: root, 3rd, 5th chord_degrees = [ [0, 2, 4], # i — degrees 0, 2, 4 in minor [6, 1, 3], # VII — degree 6 wraps to next octave; 1=2nd, 3=4th [5, 0, 2], # VI — degree 5 wraps; 0=root of next octave [6, 1, 3], # VII (repeat) ] # For proper stacking, use only the first 7 scale degrees # Chord VII in minor: root is degree 6 (10 semitones above) # Build using absolute semitones: i = root+0,root+3,root+7 # VII = root+10, root+12 (=0 of next), root+15 (=3 of next) pass # We'll rebuild below # Simpler approach: build chords using semitone intervals from root if scale == "minor": # i (0,3,7), VIIb (10,1,5), VI (8,11,2), VII (10,1,5) chord_intervals = [ (0, 3, 7), # i (10, 1, 5), # VII (raised 7th in harmonic minor: 10 semitones) (8, 0, 4), # VI (10, 1, 5), # VII ] else: # I (0,4,7), V (7,11,2), vi (9,0,4), IV (5,9,0) chord_intervals = [ (0, 4, 7), # I (7, 11, 2), # V (9, 0, 4), # vi (9 = root+9) (5, 9, 0), # IV (5 = root+5) ] notes: list[dict] = [] for b in range(bars): o = b * 4.0 chord_idx = b % 4 intervals = chord_intervals[chord_idx] # Chord positions at half-bar: 0.0 and 2.0 chord_positions = [0.0, 2.0] for cpos in chord_positions: for interval in intervals: midi_note = root + octave * 12 + interval vel = 90 vel = _clamp_vel(int(vel * velocity_mult)) notes.append({ "pos": o + cpos, "len": 1.75, # almost 2 beats (leave gap) "key": midi_note, "vel": vel, }) return _apply_humanize(notes, humanize) # --------------------------------------------------------------------------- # Pad: sustain # --------------------------------------------------------------------------- def pad_sustain( key: str, bars: int, octave: int = 4, velocity_mult: float = 1.0, humanize: float = 0.0, ) -> list[dict]: """Long sustained pad notes, one per bar. Follows chord progression from chords_block. Notes last 3.5 beats to avoid collision with next bar's note. Soft velocity (65-75). """ root, scale = _parse_key(key) if scale == "minor": chord_intervals = [ (0, 3, 7), (10, 1, 5), (8, 0, 4), (10, 1, 5), ] root_notes_per_bar = [0, 10, 8, 10] # root semitone offsets per bar else: chord_intervals = [ (0, 4, 7), (7, 11, 2), (9, 0, 4), (5, 9, 0), ] root_notes_per_bar = [0, 7, 9, 5] notes: list[dict] = [] for b in range(bars): o = b * 4.0 cycle = b % 4 root_interval = root_notes_per_bar[cycle] midi_note = root + octave * 12 + root_interval vel = 70 vel = _clamp_vel(int(vel * velocity_mult)) notes.append({ "pos": o, "len": 3.5, "key": midi_note, "vel": vel, }) return notes