feat: professional reggaeton production engine — 7 SDD changes, 302 tests

- section-energy: track activity matrix + volume/velocity multipliers per section
- smart-chords: ChordEngine with voice leading, inversions, 4 emotion modes
- hook-melody: melody engine with hook/stabs/smooth styles, call-and-response
- mix-calibration: Calibrator module (LUFS volumes, HPF/LPF, stereo, sends, master)
- transitions-fx: FX track with risers/impacts/sweeps at section boundaries
- sidechain: MIDI CC11 bass ducking on kick hits via DrumLoopAnalyzer
- presets-pack: role-aware plugin presets (Serum/Decapitator/Omnisphere per role)

Full SDD pipeline (propose→spec→design→tasks→apply→verify) for all 7 changes.
302/302 tests passing.
This commit is contained in:
renato97
2026-05-03 23:54:29 -03:00
parent 48bc271afc
commit 014e636889
51 changed files with 11394 additions and 113 deletions

View File

@@ -0,0 +1,508 @@
"""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()
for oct_shift in (-12, 0, 12):
for iv in intervals:
tones.add(root + iv + oct_shift)
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