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:
217
src/composer/chords.py
Normal file
217
src/composer/chords.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Smart chord progression engine with voice leading and emotion-aware patterns.
|
||||
|
||||
Deterministic: same (key, seed) → same output every time.
|
||||
Uses random.Random(seed) as tiebreaker in voice leading for variation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from src.composer import CHORD_TYPES
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Emotion → progression mapping
|
||||
# Each entry: list of (semitone_offset_from_tonic, chord_quality) tuples.
|
||||
# 4-chord loops designed for reggaeton / Latin pop.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
EMOTION_PROGRESSIONS: dict[str, list[tuple[int, str]]] = {
|
||||
"romantic": [(0, "min"), (8, "maj"), (3, "maj"), (10, "maj")], # i-VI-III-VII
|
||||
"dark": [(0, "min"), (5, "min"), (10, "maj"), (3, "maj")], # i-iv-VII-III
|
||||
"club": [(0, "min"), (8, "maj"), (10, "maj"), (7, "maj")], # i-VI-VII-V
|
||||
"classic": [(0, "min"), (10, "maj"), (8, "maj"), (7, "maj")], # i-VII-VI-V
|
||||
}
|
||||
|
||||
|
||||
class ChordEngine:
|
||||
"""Generates chord progressions with voice leading.
|
||||
|
||||
Deterministc: same (key, seed) produces identical voicing choices.
|
||||
Uses random.Random as a micro-tiebreaker in voice-leading selection
|
||||
so different seeds *can* produce divergent voicings.
|
||||
"""
|
||||
|
||||
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
||||
|
||||
def __init__(self, key: str, seed: int = 42) -> None:
|
||||
self.key = key
|
||||
self._seed = seed
|
||||
|
||||
# Parse key into tonic name + minor flag
|
||||
if key.endswith("m"):
|
||||
self._tonic_name = key[:-1]
|
||||
self._is_minor = True
|
||||
else:
|
||||
self._tonic_name = key
|
||||
self._is_minor = False
|
||||
|
||||
# Tonic MIDI number at octave 3 (ideal for chord voicings)
|
||||
base = self.NOTE_NAMES.index(self._tonic_name)
|
||||
self._tonic = (3 + 1) * 12 + base # octave 3 = 48 + base
|
||||
|
||||
self._rng = random.Random(seed)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def progression(
|
||||
self,
|
||||
bars: int,
|
||||
emotion: str = "classic",
|
||||
beats_per_chord: int = 4,
|
||||
inversion: str = "root",
|
||||
) -> list[list[int]]:
|
||||
"""Generate chord progression with voice leading.
|
||||
|
||||
Args:
|
||||
bars: Total bars. 0 → empty list.
|
||||
emotion: Romantic / dark / club / classic (unknown → classic).
|
||||
beats_per_chord: Duration of each chord in beats (default 4).
|
||||
inversion: Preferred inversion for first chord (root/first/second).
|
||||
|
||||
Returns:
|
||||
List of voicings — each voicing is a list[int] of MIDI notes.
|
||||
"""
|
||||
if bars <= 0:
|
||||
return []
|
||||
|
||||
# Reset RNG for deterministic reproducibility across calls.
|
||||
self._rng = random.Random(self._seed)
|
||||
|
||||
degrees = self._get_degrees(emotion)
|
||||
|
||||
# Build root-position chords from degree offsets + quality.
|
||||
# Wrap root notes to stay within ±6 semitones of the tonic octave
|
||||
# so voice-leading candidates are in the same register.
|
||||
chords: list[list[int]] = []
|
||||
for offset, quality in degrees:
|
||||
root = self._tonic + offset
|
||||
while root > self._tonic + 6:
|
||||
root -= 12
|
||||
while root < self._tonic - 6:
|
||||
root += 12
|
||||
intervals = CHORD_TYPES.get(quality, [0, 4, 7])
|
||||
chords.append([root + iv for iv in intervals])
|
||||
|
||||
total_beats = bars * 4
|
||||
num_chords = total_beats // beats_per_chord
|
||||
|
||||
# Cycle the progression to fill the requested bars.
|
||||
full_sequence: list[list[int]] = []
|
||||
for i in range(num_chords):
|
||||
full_sequence.append(chords[i % len(chords)])
|
||||
|
||||
# Apply voice leading to the full sequence.
|
||||
return self._voice_leading(full_sequence, inversion)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_degrees(self, emotion: str) -> list[tuple[int, str]]:
|
||||
"""Resolve emotion → list of (offset, quality) tuples.
|
||||
|
||||
Unknown emotions silently fall back to "classic".
|
||||
"""
|
||||
return EMOTION_PROGRESSIONS.get(emotion, EMOTION_PROGRESSIONS["classic"])
|
||||
|
||||
def _apply_inversion(self, voicing: list[int], inversion: str) -> list[int]:
|
||||
"""Reorder notes so target pitch is the lowest.
|
||||
|
||||
Args:
|
||||
voicing: Sorted MIDI notes [root, third, fifth, ...].
|
||||
inversion: "root" (identity), "first" (third in bass),
|
||||
"second" (fifth in bass).
|
||||
|
||||
Returns:
|
||||
New list where the target note is the lowest pitch.
|
||||
"""
|
||||
if not voicing or inversion == "root":
|
||||
return list(voicing)
|
||||
|
||||
if inversion == "first":
|
||||
# Third becomes lowest; root goes up an octave.
|
||||
return voicing[1:] + [voicing[0] + 12]
|
||||
|
||||
if inversion == "second":
|
||||
# Fifth becomes lowest; root and third go up an octave.
|
||||
return voicing[2:] + [v + 12 for v in voicing[:2]]
|
||||
|
||||
# Unknown inversion → identity.
|
||||
return list(voicing)
|
||||
|
||||
def _score_voicing(self, prev: list[int], cand: list[int]) -> float:
|
||||
"""Sum of absolute semitone differences + micro-jitter for tiebreaking.
|
||||
|
||||
The micro-jitter (rng × 0.1) lets different seeds produce different
|
||||
voicing choices when two candidates are nearly tied.
|
||||
"""
|
||||
base = sum(abs(c - p) for c, p in zip(cand, prev))
|
||||
return base + self._rng.uniform(0, 0.1)
|
||||
|
||||
def _voice_leading(
|
||||
self, chords: list[list[int]], inversion: str = "root"
|
||||
) -> list[list[int]]:
|
||||
"""Greedy min-score path through chord voicings.
|
||||
|
||||
For each chord:
|
||||
1. Build candidates: root-position + first + second inversions (sorted).
|
||||
2. Score each candidate against the previous voicing.
|
||||
3. Filter candidates where every voice moves ≤ 4 semitones.
|
||||
4. Pick the lowest-scoring candidate (greedy).
|
||||
5. If no candidate passes the filter, keep the root-position voicing.
|
||||
"""
|
||||
if not chords:
|
||||
return []
|
||||
|
||||
voicings: list[list[int]] = []
|
||||
prev: list[int] | None = None
|
||||
|
||||
for chord in chords:
|
||||
# Build all candidates: root + inversions at native octave
|
||||
# AND one octave down — so voice leading can reach smooth paths
|
||||
# even when the next chord's root is far from the tonic.
|
||||
candidates: list[list[int]] = []
|
||||
for oct_shift in (0, -12):
|
||||
shifted = [n + oct_shift for n in chord]
|
||||
candidates.append(sorted(shifted)) # root position
|
||||
if len(chord) >= 3:
|
||||
candidates.append(
|
||||
sorted(self._apply_inversion(shifted, "first"))
|
||||
)
|
||||
candidates.append(
|
||||
sorted(self._apply_inversion(shifted, "second"))
|
||||
)
|
||||
|
||||
if prev is None:
|
||||
# First chord: use the caller's preferred inversion
|
||||
# at the native octave (candidates[0]=root, [1]=first, [2]=second).
|
||||
if inversion == "first" and len(candidates) >= 2:
|
||||
best = candidates[1]
|
||||
elif inversion == "second" and len(candidates) >= 3:
|
||||
best = candidates[2]
|
||||
else:
|
||||
best = candidates[0]
|
||||
else:
|
||||
best = None
|
||||
best_score = float("inf")
|
||||
|
||||
for cand in candidates:
|
||||
# Hard cap: every voice ≤ 4 semitones.
|
||||
if any(abs(c - p) > 4 for c, p in zip(cand, prev)):
|
||||
continue
|
||||
|
||||
score = self._score_voicing(prev, cand)
|
||||
if score < best_score:
|
||||
best_score = score
|
||||
best = cand
|
||||
|
||||
# Fallback: no candidate passed the filter → root, native octave.
|
||||
if best is None:
|
||||
best = candidates[0]
|
||||
|
||||
voicings.append(best)
|
||||
prev = best
|
||||
|
||||
return voicings
|
||||
508
src/composer/melody_engine.py
Normal file
508
src/composer/melody_engine.py
Normal 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
|
||||
@@ -207,12 +207,13 @@ def _make_plugin_template(
|
||||
|
||||
if entry:
|
||||
display_name, filename, uid_guid = entry
|
||||
preset_data = PLUGIN_PRESETS.get(resolved_name) if is_vst2 else PLUGIN_PRESETS.get(resolved_name)
|
||||
preset_data = PLUGIN_PRESETS.get((resolved_name, ""))
|
||||
else:
|
||||
# Unresolved — use name/path as display name with empty GUID
|
||||
display_name = f"VST3: {resolved_name}" if not is_vst2 else f"VST: {resolved_name}"
|
||||
filename = resolved_path
|
||||
uid_guid = ""
|
||||
preset_data = None
|
||||
|
||||
return PluginTemplate(
|
||||
name=resolved_name,
|
||||
@@ -378,7 +379,7 @@ def _parse_vst_block(lines: list[str], start_idx: int) -> tuple[PluginTemplate |
|
||||
or all(pl.strip() in ("0 0", "0", "") for pl in preset_lines)
|
||||
)
|
||||
if is_fake_preset and registry_key:
|
||||
registry_preset = PLUGIN_PRESETS.get(registry_key)
|
||||
registry_preset = PLUGIN_PRESETS.get((registry_key, ""))
|
||||
if registry_preset:
|
||||
preset_lines = registry_preset
|
||||
|
||||
|
||||
Reference in New Issue
Block a user