feat: reggaeton production system with intelligent sample selection and FLP generation
This commit is contained in:
310
src/composer/__init__.py
Normal file
310
src/composer/__init__.py
Normal file
@@ -0,0 +1,310 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
KNOWLEDGE_DIR = Path(__file__).parent.parent.parent / "knowledge"
|
||||
|
||||
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
||||
|
||||
SCALE_INTERVALS = {
|
||||
"major": [0, 2, 4, 5, 7, 9, 11],
|
||||
"minor": [0, 2, 3, 5, 7, 8, 10],
|
||||
"harmonic_minor": [0, 2, 3, 5, 7, 8, 11],
|
||||
"melodic_minor": [0, 2, 3, 5, 7, 9, 11],
|
||||
"dorian": [0, 2, 3, 5, 7, 9, 10],
|
||||
"phrygian": [0, 1, 3, 5, 7, 8, 10],
|
||||
}
|
||||
|
||||
CHORD_TYPES = {
|
||||
"maj": [0, 4, 7],
|
||||
"min": [0, 3, 7],
|
||||
"dim": [0, 3, 6],
|
||||
"aug": [0, 4, 8],
|
||||
"7": [0, 4, 7, 10],
|
||||
"m7": [0, 3, 7, 10],
|
||||
"sus2": [0, 2, 7],
|
||||
"sus4": [0, 5, 7],
|
||||
}
|
||||
|
||||
|
||||
def note_to_midi(note_str: str) -> int:
|
||||
note_str = note_str.strip()
|
||||
if len(note_str) == 1:
|
||||
name = note_str[0]
|
||||
octave = 4
|
||||
elif len(note_str) == 2:
|
||||
if note_str[1] == "#":
|
||||
name = note_str[:2]
|
||||
octave = 4
|
||||
else:
|
||||
name = note_str[0]
|
||||
octave = int(note_str[1])
|
||||
else:
|
||||
name = note_str[:2] if note_str[1] == "#" else note_str[0]
|
||||
octave = int(note_str[-1])
|
||||
|
||||
base = NOTE_NAMES.index(name)
|
||||
return (octave + 1) * 12 + base
|
||||
|
||||
|
||||
def parse_chord_name(chord_str: str) -> tuple[int, list[int]]:
|
||||
chord_str = chord_str.strip()
|
||||
root = chord_str[0]
|
||||
idx = 1
|
||||
if len(chord_str) > 1 and chord_str[1] == "#":
|
||||
root += "#"
|
||||
idx = 2
|
||||
|
||||
suffix = chord_str[idx:]
|
||||
if suffix == "m" or suffix == "min":
|
||||
chord_type = "min"
|
||||
elif suffix == "dim":
|
||||
chord_type = "dim"
|
||||
elif suffix == "7":
|
||||
chord_type = "7"
|
||||
elif suffix == "m7":
|
||||
chord_type = "m7"
|
||||
elif suffix == "sus2":
|
||||
chord_type = "sus2"
|
||||
elif suffix == "sus4":
|
||||
chord_type = "sus4"
|
||||
elif suffix == "":
|
||||
chord_type = "maj"
|
||||
else:
|
||||
chord_type = "maj"
|
||||
|
||||
root_midi = note_to_midi(root + "4")
|
||||
intervals = CHORD_TYPES.get(chord_type, [0, 4, 7])
|
||||
return root_midi, intervals
|
||||
|
||||
|
||||
def generate_dembow(bars: int = 8, ppq: int = 96) -> list[dict]:
|
||||
notes = []
|
||||
for bar in range(bars):
|
||||
offset = bar * 4.0
|
||||
kick_positions = [0.0, 2.5]
|
||||
snare_positions = [2.0, 4.0]
|
||||
hihat_positions = [i * 0.5 for i in range(8)]
|
||||
|
||||
for p in kick_positions:
|
||||
notes.append({"position": offset + p, "length": 0.25, "key": 36, "velocity": 110})
|
||||
for p in snare_positions:
|
||||
notes.append({"position": offset + p, "length": 0.15, "key": 38, "velocity": 105})
|
||||
for p in hihat_positions:
|
||||
notes.append({"position": offset + p, "length": 0.1, "key": 42, "velocity": 75})
|
||||
for i in [1, 3, 5, 7]:
|
||||
notes.append({
|
||||
"position": offset + hihat_positions[i],
|
||||
"length": 0.1,
|
||||
"key": 46,
|
||||
"velocity": 60,
|
||||
})
|
||||
return notes
|
||||
|
||||
|
||||
def generate_bass_808(
|
||||
chord_progression: list[str],
|
||||
beats_per_chord: int = 4,
|
||||
octave: int = 2,
|
||||
bars: int = 8,
|
||||
) -> list[dict]:
|
||||
notes = []
|
||||
pos = 0.0
|
||||
total_beats = bars * 4
|
||||
while pos < total_beats:
|
||||
for chord_name in chord_progression:
|
||||
root_midi, _ = parse_chord_name(chord_name)
|
||||
bass_note = root_midi - (4 - octave) * 12
|
||||
notes.append({
|
||||
"position": pos,
|
||||
"length": min(beats_per_chord - 0.1, total_beats - pos),
|
||||
"key": bass_note,
|
||||
"velocity": 100,
|
||||
})
|
||||
pos += beats_per_chord
|
||||
if pos >= total_beats:
|
||||
break
|
||||
return notes
|
||||
|
||||
|
||||
def generate_piano_stabs(
|
||||
chord_progression: list[str],
|
||||
beats_per_chord: int = 4,
|
||||
bars: int = 8,
|
||||
) -> list[dict]:
|
||||
notes = []
|
||||
pos = 0.0
|
||||
total_beats = bars * 4
|
||||
while pos < total_beats:
|
||||
for chord_name in chord_progression:
|
||||
root_midi, intervals = parse_chord_name(chord_name)
|
||||
chord_notes = [root_midi + iv for iv in intervals]
|
||||
for stab_pos in [0.5, 1.5, 2.5, 3.5]:
|
||||
actual_pos = pos + stab_pos
|
||||
if actual_pos >= total_beats:
|
||||
break
|
||||
for cn in chord_notes:
|
||||
notes.append({
|
||||
"position": actual_pos,
|
||||
"length": 0.2,
|
||||
"key": cn,
|
||||
"velocity": 70,
|
||||
})
|
||||
pos += beats_per_chord
|
||||
if pos >= total_beats:
|
||||
break
|
||||
return notes
|
||||
|
||||
|
||||
def generate_lead_hook(
|
||||
chord_progression: list[str],
|
||||
beats_per_chord: int = 4,
|
||||
bars: int = 8,
|
||||
octave: int = 5,
|
||||
) -> list[dict]:
|
||||
notes = []
|
||||
pos = 0.0
|
||||
total_beats = bars * 4
|
||||
|
||||
hook_patterns = [
|
||||
[0, 0.5, 1.0, 2.0, 3.0],
|
||||
[0, 1.0, 1.5, 2.0, 3.5],
|
||||
[0, 0.25, 0.5, 2.0, 2.5, 3.0],
|
||||
]
|
||||
|
||||
pattern_idx = 0
|
||||
while pos < total_beats:
|
||||
for chord_name in chord_progression:
|
||||
root_midi, intervals = parse_chord_name(chord_name)
|
||||
scale_notes = [root_midi + iv for iv in [0, 2, 3, 5, 7, 8, 10]]
|
||||
target_octave_notes = [n + (octave - 4) * 12 for n in scale_notes]
|
||||
|
||||
pattern = hook_patterns[pattern_idx % len(hook_patterns)]
|
||||
for i, p in enumerate(pattern):
|
||||
actual_pos = pos + p
|
||||
if actual_pos >= total_beats:
|
||||
break
|
||||
note_idx = i % len(target_octave_notes)
|
||||
notes.append({
|
||||
"position": actual_pos,
|
||||
"length": 0.4 if i < len(pattern) - 1 else 0.8,
|
||||
"key": target_octave_notes[note_idx],
|
||||
"velocity": 90 if i % 2 == 0 else 75,
|
||||
})
|
||||
pos += beats_per_chord
|
||||
pattern_idx += 1
|
||||
if pos >= total_beats:
|
||||
break
|
||||
return notes
|
||||
|
||||
|
||||
def generate_pad(
|
||||
chord_progression: list[str],
|
||||
beats_per_chord: int = 4,
|
||||
bars: int = 8,
|
||||
octave: int = 4,
|
||||
) -> list[dict]:
|
||||
notes = []
|
||||
pos = 0.0
|
||||
total_beats = bars * 4
|
||||
while pos < total_beats:
|
||||
for chord_name in chord_progression:
|
||||
root_midi, intervals = parse_chord_name(chord_name)
|
||||
chord_notes = [root_midi + (octave - 4) * 12 + iv for iv in intervals]
|
||||
duration = min(beats_per_chord, total_beats - pos)
|
||||
for cn in chord_notes:
|
||||
notes.append({
|
||||
"position": pos,
|
||||
"length": duration,
|
||||
"key": cn,
|
||||
"velocity": 45,
|
||||
})
|
||||
pos += beats_per_chord
|
||||
if pos >= total_beats:
|
||||
break
|
||||
return notes
|
||||
|
||||
|
||||
def generate_latin_perc(bars: int = 8) -> list[dict]:
|
||||
notes = []
|
||||
for bar in range(bars):
|
||||
offset = bar * 4.0
|
||||
shaker = [(i * 0.25) + 0.125 for i in range(16)]
|
||||
for p in shaker:
|
||||
notes.append({"position": offset + p, "length": 0.1, "key": 50, "velocity": 55})
|
||||
congas = [0.0, 1.0, 2.0, 3.0]
|
||||
for p in congas:
|
||||
notes.append({"position": offset + p, "length": 0.2, "key": 54, "velocity": 65})
|
||||
rim = [0.75, 2.75]
|
||||
for p in rim:
|
||||
notes.append({"position": offset + p, "length": 0.1, "key": 37, "velocity": 50})
|
||||
return notes
|
||||
|
||||
|
||||
def compose_from_genre(
|
||||
genre_path: str | Path,
|
||||
custom_overrides: Optional[dict] = None,
|
||||
) -> dict:
|
||||
with open(genre_path, "r", encoding="utf-8") as f:
|
||||
genre = json.load(f)
|
||||
|
||||
if custom_overrides:
|
||||
genre.update(custom_overrides)
|
||||
|
||||
bpm = genre["bpm"]["default"]
|
||||
ppq = genre.get("ppq", 96)
|
||||
key = genre["keys"][0]
|
||||
progression = genre["chord_progressions"][0]["chords"]
|
||||
beats_per_chord = genre["chord_progressions"][0].get("beats_per_chord", 4)
|
||||
bars = genre["structure"]["sections"][1]["bars"]
|
||||
|
||||
composition = {
|
||||
"meta": {
|
||||
"genre": genre["genre"],
|
||||
"era": genre.get("era", ""),
|
||||
"bpm": bpm,
|
||||
"ppq": ppq,
|
||||
"key": key,
|
||||
"chord_progression": progression,
|
||||
"beats_per_chord": beats_per_chord,
|
||||
"bars": bars,
|
||||
},
|
||||
"tracks": [],
|
||||
}
|
||||
|
||||
for role_name, role_config in genre["roles"].items():
|
||||
track = {
|
||||
"role": role_name,
|
||||
"description": role_config["description"],
|
||||
"preferred_plugins": role_config["preferred_plugins"],
|
||||
"mixer_slot": role_config.get("mixer_slot", 0),
|
||||
}
|
||||
|
||||
if role_name == "drums":
|
||||
track["notes"] = generate_dembow(bars, ppq)
|
||||
elif role_name == "bass":
|
||||
track["notes"] = generate_bass_808(
|
||||
progression, beats_per_chord,
|
||||
octave=role_config.get("octave", 2),
|
||||
bars=bars,
|
||||
)
|
||||
elif role_name == "harmony":
|
||||
track["notes"] = generate_piano_stabs(progression, beats_per_chord, bars)
|
||||
elif role_name == "lead":
|
||||
track["notes"] = generate_lead_hook(
|
||||
progression, beats_per_chord, bars,
|
||||
octave=role_config.get("octave", 5),
|
||||
)
|
||||
elif role_name == "pad":
|
||||
track["notes"] = generate_pad(
|
||||
progression, beats_per_chord, bars,
|
||||
octave=role_config.get("octave", 4),
|
||||
)
|
||||
elif role_name == "perc":
|
||||
track["notes"] = generate_latin_perc(bars)
|
||||
|
||||
composition["tracks"].append(track)
|
||||
|
||||
return composition
|
||||
288
src/composer/melodic.py
Normal file
288
src/composer/melodic.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""Melodic pattern generators for reggaeton production.
|
||||
|
||||
All generators return list[dict] with format {pos, len, key, vel}.
|
||||
Designed to feed MelodicTrack notes in SongDefinition.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bass: tresillo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def bass_tresillo(
|
||||
key: str,
|
||||
bars: int,
|
||||
octave: int = 3,
|
||||
velocity_mult: float = 1.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 notes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lead: hook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def lead_hook(
|
||||
key: str,
|
||||
bars: int,
|
||||
octave: int = 5,
|
||||
density: float = 0.6,
|
||||
velocity_mult: float = 1.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 notes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chords: block chords
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def chords_block(
|
||||
key: str,
|
||||
bars: int,
|
||||
octave: int = 4,
|
||||
velocity_mult: float = 1.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 notes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pad: sustain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pad_sustain(
|
||||
key: str,
|
||||
bars: int,
|
||||
octave: int = 4,
|
||||
velocity_mult: float = 1.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
|
||||
311
src/composer/rhythm.py
Normal file
311
src/composer/rhythm.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""Reggaeton rhythm generators — pure functions returning note dicts per channel."""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Channel constants — match SAMPLE_MAP in channel_skeleton.py
|
||||
# ---------------------------------------------------------------------------
|
||||
CH_P1 = 10 # perc1.wav
|
||||
CH_K = 11 # kick.wav
|
||||
CH_S = 12 # snare.wav
|
||||
CH_R = 13 # rim.wav
|
||||
CH_P2 = 14 # perc2.wav
|
||||
CH_H = 15 # hihat.wav
|
||||
CH_CL = 16 # clap.wav
|
||||
|
||||
# Note dict format: {"pos": float, "len": float, "key": int, "vel": int}
|
||||
# pos — in BEATS from start of bar 0 (bar 2 beat 3 → 2*4 + 2 = 10.0)
|
||||
# len — in beats (0.25 = 16th note at 4/4)
|
||||
# key — always 60 for drum samples (pitch irrelevant, sample just plays)
|
||||
# vel — 1–127 after applying velocity_mult
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _clamp_vel(vel: int) -> int:
|
||||
"""Clamp velocity to valid MIDI range [1, 127]."""
|
||||
return max(1, min(127, vel))
|
||||
|
||||
|
||||
def _apply_vel(base_vel: int, velocity_mult: float) -> int:
|
||||
"""Multiply base velocity by velocity_mult and clamp."""
|
||||
return _clamp_vel(int(base_vel * velocity_mult))
|
||||
|
||||
|
||||
def _note(pos: float, length: float, vel: int) -> dict:
|
||||
"""Create a note dict with key=60."""
|
||||
return {"pos": pos, "len": length, "key": 60, "vel": vel}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kick generators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def kick_main_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Dembow kick: beat 1 (hard, vel 115) + beat 2-and (the dembow hit, vel 105).
|
||||
|
||||
Positions per bar: 0.0 and 1.5 (the classic "one — &-two" reggaeton kick).
|
||||
Returns {CH_K: [notes...]}.
|
||||
"""
|
||||
notes: list[dict] = []
|
||||
for b in range(bars):
|
||||
o = b * 4.0
|
||||
notes.append(_note(o, 0.25, _apply_vel(115, velocity_mult)))
|
||||
notes.append(_note(o + 1.5, 0.25, _apply_vel(105, velocity_mult)))
|
||||
return {CH_K: notes}
|
||||
|
||||
|
||||
def kick_sparse_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Sparse intro/outro kick: just beat 1 per bar (vel 110).
|
||||
|
||||
Returns {CH_K: [notes...]}.
|
||||
"""
|
||||
notes: list[dict] = []
|
||||
for b in range(bars):
|
||||
o = b * 4.0
|
||||
notes.append(_note(o, 0.25, _apply_vel(110, velocity_mult)))
|
||||
return {CH_K: notes}
|
||||
|
||||
|
||||
def kick_outro_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Outro kick: dembow pattern with 0.75 baseline softness.
|
||||
|
||||
Delegates to kick_main_notes with an additional 0.75 velocity scaling.
|
||||
Returns {CH_K: [notes...]}.
|
||||
"""
|
||||
return kick_main_notes(bars, velocity_mult=velocity_mult * 0.75, density=density)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snare generators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def snare_verse_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Reggaeton snare: beats 2, 3, 3-and, 4 per bar.
|
||||
|
||||
Positions: 1.0 (vel 100), 2.0 (vel 95), 2.5 (vel 110), 3.0 (vel 90).
|
||||
Returns {CH_S: [notes...]}.
|
||||
"""
|
||||
_PATTERN = [(1.0, 100), (2.0, 95), (2.5, 110), (3.0, 90)]
|
||||
notes: list[dict] = []
|
||||
for b in range(bars):
|
||||
o = b * 4.0
|
||||
for p, v in _PATTERN:
|
||||
notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult)))
|
||||
return {CH_S: notes}
|
||||
|
||||
|
||||
def snare_fill_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Busier snare with 16th-note fills: adds positions 2.25 and 3.75.
|
||||
|
||||
Verse base (1.0, 2.0, 2.5, 3.0) plus 16th fills at 2.25 and 3.75.
|
||||
Returns {CH_S: [notes...]}.
|
||||
"""
|
||||
_PATTERN = [
|
||||
(1.0, 100),
|
||||
(2.0, 95),
|
||||
(2.25, 80), # 16th fill
|
||||
(2.5, 110),
|
||||
(3.0, 90),
|
||||
(3.75, 85), # 16th fill
|
||||
]
|
||||
notes: list[dict] = []
|
||||
for b in range(bars):
|
||||
o = b * 4.0
|
||||
for p, v in _PATTERN:
|
||||
notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult)))
|
||||
return {CH_S: notes}
|
||||
|
||||
|
||||
def snare_outro_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Softer outro snare (velocity_mult on top of 0.7 baseline).
|
||||
|
||||
Delegates to snare_verse_notes with an additional 0.7 velocity scaling.
|
||||
Returns {CH_S: [notes...]}.
|
||||
"""
|
||||
return snare_verse_notes(bars, velocity_mult=velocity_mult * 0.7, density=density)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hihat generators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def hihat_16th_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""16th-note hihat with three-tier accent mapping.
|
||||
|
||||
Accented on quarter notes (vel 85), medium on 8ths (vel 60), soft on
|
||||
off-8ths (vel 40). density=1.0 → all 16ths; density=0.5 → every other.
|
||||
Returns {CH_H: [notes...]}.
|
||||
"""
|
||||
notes: list[dict] = []
|
||||
step = max(1, round(1.0 / density)) if density > 0 else 1
|
||||
for b in range(bars):
|
||||
o = b * 4.0
|
||||
for i in range(0, 16, step):
|
||||
beat_frac = i * 0.25 # position within bar in beats
|
||||
if beat_frac % 1.0 == 0.0: # quarter note position
|
||||
base_vel = 85
|
||||
elif beat_frac % 0.5 == 0.0: # 8th note position
|
||||
base_vel = 60
|
||||
else: # 16th note position
|
||||
base_vel = 40
|
||||
notes.append(_note(o + beat_frac, 0.1, _apply_vel(base_vel, velocity_mult)))
|
||||
return {CH_H: notes}
|
||||
|
||||
|
||||
def hihat_8th_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""8th-note hihat for intro/breakdown.
|
||||
|
||||
Accented on beats (vel 70), off-beats softer (vel 50).
|
||||
Returns {CH_H: [notes...]}.
|
||||
"""
|
||||
notes: list[dict] = []
|
||||
for b in range(bars):
|
||||
o = b * 4.0
|
||||
for i in range(8):
|
||||
base_vel = 70 if i % 2 == 0 else 50
|
||||
notes.append(_note(o + i * 0.5, 0.1, _apply_vel(base_vel, velocity_mult)))
|
||||
return {CH_H: notes}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Clap generator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def clap_24_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Classic reggaeton clap: beats 2 and 4 → positions 1.0 and 3.0 per bar.
|
||||
|
||||
Hard clap (vel 120).
|
||||
Returns {CH_CL: [notes...]}.
|
||||
"""
|
||||
notes: list[dict] = []
|
||||
for b in range(bars):
|
||||
o = b * 4.0
|
||||
notes.append(_note(o + 1.0, 0.15, _apply_vel(120, velocity_mult)))
|
||||
notes.append(_note(o + 3.0, 0.15, _apply_vel(120, velocity_mult)))
|
||||
return {CH_CL: notes}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Percussion generators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def perc_combo_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Perc1 + Perc2 offbeat combo (tumba feel).
|
||||
|
||||
perc2 (CH_P2): positions 0.75 (vel 85) and 2.75 (vel 80).
|
||||
perc1 (CH_P1): positions 1.5 (vel 70) and 3.5 (vel 65).
|
||||
Returns {CH_P1: [...], CH_P2: [...]}.
|
||||
"""
|
||||
p2_notes: list[dict] = []
|
||||
p1_notes: list[dict] = []
|
||||
for b in range(bars):
|
||||
o = b * 4.0
|
||||
p2_notes.append(_note(o + 0.75, 0.1, _apply_vel(85, velocity_mult)))
|
||||
p2_notes.append(_note(o + 2.75, 0.1, _apply_vel(80, velocity_mult)))
|
||||
p1_notes.append(_note(o + 1.5, 0.1, _apply_vel(70, velocity_mult)))
|
||||
p1_notes.append(_note(o + 3.5, 0.1, _apply_vel(65, velocity_mult)))
|
||||
return {CH_P1: p1_notes, CH_P2: p2_notes}
|
||||
|
||||
|
||||
def rim_build_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Rim roll that builds intensity across bars (4-bar cycle).
|
||||
|
||||
Bar N%4=0: 16th indices 0,2,8,14 (sparse opening)
|
||||
Bar N%4=1: indices 0,2,4,8,10,14 (filling in)
|
||||
Bar N%4=2: indices 0,2,4,6,8,10,12,14 (every other 16th)
|
||||
Bar N%4=3: all 16 indices (full roll)
|
||||
|
||||
Velocity ramps: 50 → 65 → 80 → 100 across the 4-bar cycle.
|
||||
Returns {CH_R: [notes...]}.
|
||||
"""
|
||||
_PATTERNS = [
|
||||
[0, 2, 8, 14],
|
||||
[0, 2, 4, 8, 10, 14],
|
||||
[0, 2, 4, 6, 8, 10, 12, 14],
|
||||
list(range(16)),
|
||||
]
|
||||
_BASE_VELS = [50, 65, 80, 100]
|
||||
|
||||
notes: list[dict] = []
|
||||
for b in range(bars):
|
||||
cycle = b % 4
|
||||
o = b * 4.0
|
||||
base_vel = _BASE_VELS[cycle]
|
||||
vel = _apply_vel(base_vel, velocity_mult)
|
||||
for idx in _PATTERNS[cycle]:
|
||||
notes.append(_note(o + idx * 0.25, 0.1, vel))
|
||||
return {CH_R: notes}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry & dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GENERATORS: dict[str, callable] = {
|
||||
"kick_main_notes": kick_main_notes,
|
||||
"kick_sparse_notes": kick_sparse_notes,
|
||||
"kick_outro_notes": kick_outro_notes,
|
||||
"snare_verse_notes": snare_verse_notes,
|
||||
"snare_fill_notes": snare_fill_notes,
|
||||
"snare_outro_notes": snare_outro_notes,
|
||||
"hihat_16th_notes": hihat_16th_notes,
|
||||
"hihat_8th_notes": hihat_8th_notes,
|
||||
"clap_24_notes": clap_24_notes,
|
||||
"perc_combo_notes": perc_combo_notes,
|
||||
"rim_build_notes": rim_build_notes,
|
||||
}
|
||||
|
||||
|
||||
def get_notes(
|
||||
generator_name: str,
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Dispatch to the named generator. Raises KeyError if not found."""
|
||||
gen = GENERATORS[generator_name]
|
||||
return gen(bars, velocity_mult, density)
|
||||
296
src/composer/variation.py
Normal file
296
src/composer/variation.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""Variation engine — generates unique SongDefinition instances from a seed.
|
||||
|
||||
Pure functions: no file I/O, no print statements. The only side effect is
|
||||
the deterministic randomness from ``random.Random(idx)`` — same seed always
|
||||
produces the same output.
|
||||
|
||||
Usage::
|
||||
|
||||
from src.composer.variation import generate_variant, generate_batch
|
||||
|
||||
one_song = generate_variant(42)
|
||||
fifty = generate_batch(50)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
from ..flp_builder.schema import (
|
||||
ArrangementItemDef,
|
||||
ArrangementTrack,
|
||||
PatternDef,
|
||||
SongDefinition,
|
||||
SongMeta,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Musical constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BPMS: list[int] = [88, 90, 92, 94, 95, 96, 98, 100, 102]
|
||||
|
||||
KEYS_MINOR: list[str] = ["Am", "Dm", "Em", "Gm", "Bm", "Cm", "Fm"]
|
||||
KEYS_MAJOR: list[str] = ["C", "F", "G", "D", "A"]
|
||||
ALL_KEYS: list[str] = KEYS_MINOR + KEYS_MAJOR
|
||||
|
||||
PROGRESSIONS: list[str] = [
|
||||
"i-VII-VI-VII", # Am-G-F-G (classic)
|
||||
"i-iv-VII-III", # Am-Dm-G-C
|
||||
"i-VI-III-VII", # Am-F-C-G
|
||||
"i-VII-III-VI", # Am-G-C-F
|
||||
"I-V-vi-IV", # C-G-Am-F (major mode)
|
||||
"I-IV-V-I", # classic major
|
||||
"i-III-VII-VI", # minor dreamy
|
||||
"i-v-iv-VII", # dark minor
|
||||
"I-vi-IV-V", # 50s progression
|
||||
"i-VII-VI-iv", # modern dark
|
||||
"i-VI-VII-i", # loop
|
||||
"i-iv-i-VII", # minimal
|
||||
"I-II-vi-V", # modern major
|
||||
"i-III-VI-VII", # uplift
|
||||
"vi-IV-I-V", # axis
|
||||
]
|
||||
|
||||
TITLE_PREFIXES: list[str] = [
|
||||
"Zona", "Barrio", "Calle", "Noche", "Fuego",
|
||||
"Ritmo", "Poder", "Flow", "Vibra", "Cuerpo",
|
||||
]
|
||||
TITLE_SUFFIXES: list[str] = [
|
||||
"Caliente", "Oscura", "Sin Fin", "Total", "Real",
|
||||
"Fatal", "Natural", "Del Party", "Con Flow", "Urbano",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variation axis parameters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DENSITY_LEVELS: list[float] = [0.6, 0.75, 1.0]
|
||||
VEL_MULT_LEVELS: list[float] = [0.85, 1.0, 1.1]
|
||||
SECTION_REPEATS: list[int] = [1, 2] # verse/chorus repeat multiplier
|
||||
|
||||
SAMPLES_MAP: dict[str, str] = {
|
||||
"kick": "kick.wav",
|
||||
"snare": "snare.wav",
|
||||
"rim": "rim.wav",
|
||||
"clap": "clap.wav",
|
||||
"hihat": "hihat.wav",
|
||||
"perc1": "perc1.wav",
|
||||
"perc2": "perc2.wav",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_title(rng: random.Random) -> str:
|
||||
"""Combine a random prefix + suffix into a song title."""
|
||||
return f"{rng.choice(TITLE_PREFIXES)} {rng.choice(TITLE_SUFFIXES)}"
|
||||
|
||||
|
||||
def _base_tracks() -> list[ArrangementTrack]:
|
||||
"""Fixed arrangement track layout — 5 tracks, same for all variants."""
|
||||
return [
|
||||
ArrangementTrack(index=1, name="Kick"),
|
||||
ArrangementTrack(index=2, name="Snare"),
|
||||
ArrangementTrack(index=3, name="Hihat"),
|
||||
ArrangementTrack(index=4, name="Clap/Rim"),
|
||||
ArrangementTrack(index=5, name="Perc"),
|
||||
]
|
||||
|
||||
|
||||
def _build_patterns(rng: random.Random) -> list[PatternDef]:
|
||||
"""Build 9 base patterns with per-pattern randomized density and velocity_mult.
|
||||
|
||||
Pattern ids, names, instruments, channels, and generators are fixed —
|
||||
matching the reggaeton_template.json structure. The variation axes
|
||||
(density, velocity_mult) are randomized per pattern.
|
||||
"""
|
||||
# (id, name, instrument, channel, bars, generator)
|
||||
base: list[tuple[int, str, str, int, int, str]] = [
|
||||
(1, "Kick Main", "kick", 11, 8, "kick_main_notes"),
|
||||
(2, "Snare Verse", "snare", 12, 8, "snare_verse_notes"),
|
||||
(3, "Hihat 16th", "hihat", 15, 8, "hihat_16th_notes"),
|
||||
(4, "Clap 2-4", "clap", 16, 8, "clap_24_notes"),
|
||||
(5, "Perc Combo", "perc2", 14, 8, "perc_combo_notes"),
|
||||
(6, "Kick Sparse", "kick", 11, 8, "kick_sparse_notes"),
|
||||
(7, "Hihat 8th", "hihat", 15, 8, "hihat_8th_notes"),
|
||||
(8, "Rim Build", "rim", 13, 4, "rim_build_notes"),
|
||||
(9, "Kick Outro", "kick", 11, 8, "kick_outro_notes"),
|
||||
]
|
||||
patterns: list[PatternDef] = []
|
||||
for pid, name, inst, ch, bars, gen in base:
|
||||
patterns.append(
|
||||
PatternDef(
|
||||
id=pid,
|
||||
name=name,
|
||||
instrument=inst,
|
||||
channel=ch,
|
||||
bars=bars,
|
||||
generator=gen,
|
||||
velocity_mult=rng.choice(VEL_MULT_LEVELS),
|
||||
density=rng.choice(DENSITY_LEVELS),
|
||||
)
|
||||
)
|
||||
return patterns
|
||||
|
||||
|
||||
def _build_arrangement(
|
||||
rng: random.Random,
|
||||
patterns: list[PatternDef], # noqa: ARG001 – reserved for future use
|
||||
) -> list[ArrangementItemDef]:
|
||||
"""Build arrangement items with variable verse/chorus lengths and optional
|
||||
breakdown.
|
||||
|
||||
Structure::
|
||||
|
||||
INTRO (4 bars) — kick_sparse + hihat_8th
|
||||
VERSE (8|16) — kick_main + snare + hihat_16th + perc_combo
|
||||
PRE-CHORUS (4 bars) — above + rim_build
|
||||
CHORUS (8|16) — kick_main + snare + hihat_16th + clap_24 + perc_combo
|
||||
[VERSE 2 + PRE-CHORUS 2 + CHORUS 2]
|
||||
[BREAKDOWN (8 bars, 50% chance)] — hihat_8th + kick_sparse
|
||||
OUTRO (8 bars) — kick_outro + snare + hihat_16th + clap_24
|
||||
"""
|
||||
items: list[ArrangementItemDef] = []
|
||||
cursor: float = 0.0
|
||||
|
||||
verse_bars: int = 8 * rng.choice(SECTION_REPEATS)
|
||||
chorus_bars: int = 8 * rng.choice(SECTION_REPEATS)
|
||||
has_breakdown: bool = rng.random() < 0.5
|
||||
|
||||
def add(pattern_id: int, track: int, length: float) -> None:
|
||||
"""Append one arrangement item at the current cursor (no advance)."""
|
||||
items.append(
|
||||
ArrangementItemDef(
|
||||
pattern=pattern_id,
|
||||
bar=cursor,
|
||||
bars=length,
|
||||
track=track,
|
||||
)
|
||||
)
|
||||
|
||||
# --- INTRO (4 bars) ---
|
||||
add(6, 1, 4) # kick_sparse on Kick
|
||||
add(7, 3, 4) # hihat_8th on Hihat
|
||||
cursor += 4
|
||||
|
||||
# --- VERSE / PRE-CHORUS / CHORUS × 2 ---
|
||||
for _ in range(2):
|
||||
# VERSE
|
||||
add(1, 1, verse_bars) # kick_main
|
||||
add(2, 2, verse_bars) # snare_verse
|
||||
add(3, 3, verse_bars) # hihat_16th
|
||||
add(5, 5, verse_bars) # perc_combo
|
||||
cursor += verse_bars
|
||||
|
||||
# PRE-CHORUS (4 bars)
|
||||
add(1, 1, 4) # kick_main
|
||||
add(2, 2, 4) # snare_verse
|
||||
add(3, 3, 4) # hihat_16th
|
||||
add(5, 5, 4) # perc_combo
|
||||
add(8, 4, 4) # rim_build on Clap/Rim
|
||||
cursor += 4
|
||||
|
||||
# CHORUS
|
||||
add(1, 1, chorus_bars) # kick_main
|
||||
add(2, 2, chorus_bars) # snare_verse
|
||||
add(3, 3, chorus_bars) # hihat_16th
|
||||
add(4, 4, chorus_bars) # clap_24
|
||||
add(5, 5, chorus_bars) # perc_combo
|
||||
cursor += chorus_bars
|
||||
|
||||
# --- BREAKDOWN (optional, 8 bars) ---
|
||||
if has_breakdown:
|
||||
add(6, 1, 8) # kick_sparse
|
||||
add(7, 3, 8) # hihat_8th
|
||||
cursor += 8
|
||||
|
||||
# --- OUTRO (8 bars) ---
|
||||
add(9, 1, 8) # kick_outro on Kick
|
||||
add(2, 2, 8) # snare_verse on Snare
|
||||
add(3, 3, 8) # hihat_16th on Hihat
|
||||
add(4, 4, 8) # clap_24 on Clap/Rim
|
||||
cursor += 8
|
||||
|
||||
return items
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def generate_variant(idx: int) -> SongDefinition:
|
||||
"""Generate a unique ``SongDefinition`` from integer seed *idx*.
|
||||
|
||||
Uses ``random.Random(idx)`` for full reproducibility.
|
||||
Same ``idx`` → same output, always.
|
||||
|
||||
Varies:
|
||||
- BPM (from ``BPMS``)
|
||||
- Key (from ``ALL_KEYS``)
|
||||
- Progression name (from ``PROGRESSIONS``)
|
||||
- Title (random prefix + suffix)
|
||||
- Pattern density (per pattern, from ``DENSITY_LEVELS``)
|
||||
- Pattern velocity_mult (per pattern, from ``VEL_MULT_LEVELS``)
|
||||
- Verse/chorus bar count (8 or 16 bars)
|
||||
- Whether breakdown is included (50 % chance)
|
||||
|
||||
Uniqueness key: ``(bpm, key, progression_name)`` — checked externally
|
||||
by ``generate_batch``.
|
||||
"""
|
||||
rng = random.Random(idx)
|
||||
|
||||
bpm: int = rng.choice(BPMS)
|
||||
key: str = rng.choice(ALL_KEYS)
|
||||
prog: str = rng.choice(PROGRESSIONS)
|
||||
title: str = _make_title(rng)
|
||||
|
||||
meta = SongMeta(bpm=bpm, key=key, title=title)
|
||||
|
||||
patterns: list[PatternDef] = _build_patterns(rng)
|
||||
tracks: list[ArrangementTrack] = _base_tracks()
|
||||
items: list[ArrangementItemDef] = _build_arrangement(rng, patterns)
|
||||
|
||||
return SongDefinition(
|
||||
meta=meta,
|
||||
samples=SAMPLES_MAP.copy(),
|
||||
patterns=patterns,
|
||||
tracks=tracks,
|
||||
items=items,
|
||||
progression_name=prog,
|
||||
section_template="standard",
|
||||
)
|
||||
|
||||
|
||||
def generate_batch(
|
||||
count: int = 50,
|
||||
max_attempts: int = 1000,
|
||||
) -> list[SongDefinition]:
|
||||
"""Generate *count* unique songs (unique on bpm+key+progression triple).
|
||||
|
||||
Iterates seeds 0 … *max_attempts* until *count* unique songs are found.
|
||||
Raises ``RuntimeError`` if not enough unique combos are found.
|
||||
"""
|
||||
seen: set[tuple[int, str, str]] = set()
|
||||
songs: list[SongDefinition] = []
|
||||
|
||||
for seed in range(max_attempts):
|
||||
song = generate_variant(seed)
|
||||
uniq = (song.meta.bpm, song.meta.key, song.progression_name)
|
||||
if uniq not in seen:
|
||||
seen.add(uniq)
|
||||
songs.append(song)
|
||||
if len(songs) >= count:
|
||||
break
|
||||
|
||||
if len(songs) < count:
|
||||
raise RuntimeError(
|
||||
f"Only found {len(songs)} unique songs in {max_attempts} attempts"
|
||||
)
|
||||
|
||||
return songs
|
||||
Reference in New Issue
Block a user