feat: reggaeton production system with intelligent sample selection and FLP generation

This commit is contained in:
renato97
2026-05-02 21:40:18 -03:00
commit 4d941f3f90
62 changed files with 8656 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
# Change Archive: reggaeton-composer
**Archived**: 2026-05-02
**Status**: Completed & Verified
---
## Summary
Implemented a complete sample-driven reggaeton production system that generates valid FL Studio .flp projects from natural language prompts via CLI.
---
## Files Changed
| File | Role |
|------|------|
| `src/selector/__init__.py` | SampleSelector with scoring by key/BPM/character |
| `src/composer/melodic.py` | Melodic generators: bass_tresillo, lead_hook, chords_block, pad_sustain |
| `src/flp_builder/schema.py` | Extended with MelodicNote, MelodicTrack, melodic_tracks in SongDefinition |
| `src/flp_builder/builder.py` | FLPBuilder extended for melodic_tracks + melodic patterns |
| `src/flp_builder/skeleton.py` | Bug fix: CACHED_SAMPLE_EVENTS → STRIP_EVENTS |
| `scripts/compose_track.py` | Main CLI entrypoint |
| `COMPONER.bat` | Windows batch launcher |
---
## Verification Result
✅ 6/6 checks passed
**Command**: `python scripts/compose_track.py --key Am --bpm 95 --bars 8 --output output/reggaeton.flp`
**Output**: Valid .flp file, ~52KB
---
## Key Decisions
1. **Sample-first → pattern-first hybrid**: Rather than generating MIDI from scratch, the system selects compatible samples and wraps them in AUdsty pattern loops before building the FLP, ensuring results sound polished even when no samples match perfectly.
2. **Score-weighted selection**: SampleSelector ranks candidates by weighted multi-factor scoring (key match: 40%, BPM proximity: 30%, character tag alignment: 30%).
3. **Bug fix in skeleton.py**: `CACHED_SAMPLE_EVENTS` was an undefined constant — corrected to `STRIP_EVENTS` which correctly prevents duplicate sample events.
4. **CLI over library**: Exposed as a standalone script (`compose_track.py`) rather than a Python API, making it directly usable from shell/bat files.
---
## Architecture
```
scripts/compose_track.py
└─ SampleSelector → selects best samples from data/libreria
└─ MelodicComposer → generates bass/lead/chords/pad patterns
└─ FLPBuilder → assembles .flp from skeleton + tracks + clips
```
---
## SDD Cycle
- **Proposal**: reggaeton-composer
- **Spec**: reggaeton production system specs
- **Design**: technical design with scoring algorithm, pattern injection, FLP schema
- **Tasks**: 6 implementation tasks
- **Verify**: 6/6 checks passed
- **Archive**: This file

205
.sdd/design.md Normal file
View File

@@ -0,0 +1,205 @@
# Design: reggaeton-composer
## Technical Approach
Extend the existing `SongDefinition → FLPBuilder` pipeline with a new selection +
melodic-generation layer. `SampleSelector` wraps `sample_index.json`; melodic
generators produce `{notes, sample_path}` dicts; `SongDefinition` gains a
`melodic_tracks` field; `FLPBuilder` appends audio-clip channels after existing
drum channels.
---
## Architecture Decisions
| Decision | Choice | Rejected | Rationale |
|----------|--------|----------|-----------|
| Selector load strategy | Load full index once at `__init__` | Lazy / streaming | 862 entries ≈ 50 MB max; random access needed for scoring |
| Note format | Reuse `{"pos","len","key","vel"}` from `rhythm.py` | New format | Already converted by `_convert_rhythm_notes`; zero friction |
| Schema extension | Add `melodic_tracks: list[MelodicTrack]` optional field | Separate class | Single source of truth; existing `validate()` extended, not replaced |
| Channel numbering | Melodic starts at ch 17 | Dynamic | `skeleton.py` already defines `EMPTY_SAMPLER_CHANNELS = {17,18,19}`; expanding naturally |
| Melodic channel type | `ChType = 21` → value `0` (sampler) | AudioClip type 1 | Reference FLP uses sampler channels for .wav; `skeleton.py` already patches them via `melodic_map` param (already present in `load()` signature) |
| Builder integration | `_build_melodic_channels()` inserted between channel_bytes and arrangement | New Builder subclass | Minimal diff; builder is not subclassed anywhere |
| CLI | `scripts/compose_track.py` using `argparse` | Click | Existing scripts use plain argparse |
---
## Data Flow
```
data/sample_index.json
SampleSelector.select(role, key, bpm, character, is_tonal)
│ list[SampleEntry]
melodic.py generators
generate_bass / generate_lead / generate_chords / generate_pad
│ MelodicTrackDef {role, sample_path, notes[], channel_hint}
SongDefinition (extended)
.melodic_tracks: list[MelodicTrack]
FLPBuilder.build(song)
├── _build_header (unchanged)
├── _build_all_patterns (unchanged)
├── ChannelSkeletonLoader (pass melodic_map → already supported)
├── _build_melodic_notes() (NEW — PatNotes for ch 17+)
└── _build_arrangement (unchanged)
output/my_track.flp
```
---
## File Changes
| File | Action | Description |
|------|--------|-------------|
| `src/selector/__init__.py` | Create | `SampleSelector` class |
| `src/composer/melodic.py` | Create | `generate_bass/lead/chords/pad` |
| `src/flp_builder/schema.py` | Modify | Add `MelodicTrack`, `Note` dataclasses; extend `SongDefinition`; extend `validate()` and `from_json()` |
| `src/flp_builder/builder.py` | Modify | Add `_build_melodic_notes()`; pass `melodic_map` to `ChannelSkeletonLoader.load()` |
| `scripts/compose_track.py` | Create | CLI entry point |
---
## Interfaces / Contracts
```python
# src/selector/__init__.py
@dataclass
class SampleEntry:
path: str # original_path (absolute)
role: str
key: str | None
bpm: float # 0 = unknown
character: str
is_tonal: bool
score: float = 0.0
class SampleSelector:
def __init__(self, index_path: str | Path): ...
def select(
self,
role: str,
key: str | None = None,
bpm: float | None = None,
character: str | None = None,
is_tonal: bool | None = None,
limit: int = 10,
) -> list[SampleEntry]: ...
# src/composer/melodic.py
@dataclass
class MelodicTrackDef:
role: str # "bass" | "lead" | "chords" | "pad"
sample_path: str
notes: list[dict] # {"pos","len","key","vel"}
channel_hint: int # suggested channel index (17+)
def generate_bass(key, scale, bpm, bars, kick_pattern=None, density=0.7) -> MelodicTrackDef
def generate_lead(key, scale, bpm, bars, density=0.5) -> MelodicTrackDef
def generate_chords(key, scale, bpm, bars, progression=None, voicing="closed") -> MelodicTrackDef
def generate_pad(key, scale, bpm, bars) -> MelodicTrackDef
# src/flp_builder/schema.py (additions)
_KNOWN_ROLES = frozenset({"bass","lead","chords","pad","arp","fx"})
@dataclass
class Note: # alias for PatternNote — same fields
pos: float
len: float
key: int
vel: int
@dataclass
class MelodicTrack:
role: str
sample_path: str
notes: list[Note]
channel_index: int # >= 17
volume: float = 0.78 # 0.01.0
pan: float = 0.0 # -1.01.0
# SongDefinition gets:
melodic_tracks: list[MelodicTrack] = field(default_factory=list)
```
---
## Key Scoring Logic
```python
# Key compatibility (circle-of-fifths aware)
COMPAT = {
"exact": 1.0,
"relative": 0.8, # Am ↔ C, Gm ↔ Bb
"dominant": 0.6, # Am → Em
"subdominant": 0.6, # Am → Dm
"parallel": 0.5, # Am ↔ A
}
# Character groups (fuzzy matching)
CHAR_GROUPS = [
{"warm","soft","lush"},
{"boomy","deep"},
{"sharp","crisp","bright"},
{"aggressive","dark"},
]
# Combined score = key_score * 0.5 + bpm_score * 0.3 + char_score * 0.2
```
---
## Tresillo / Dembow Bass Pattern
```
Bar positions (8 bars × 4 beats = 32 beats):
Tresillo: [0, 0.75, 1.5] per bar (3+3+2 in 8th-note grid)
Kick-avoidance: skip notes within ±0.125 beats of a kick hit
```
---
## Validation Extensions
`SongDefinition.validate()` additions:
1. `melodic_track.role in _KNOWN_ROLES`
2. `Path(melodic_track.sample_path).exists()`
3. `melodic_track.channel_index >= 17`
4. No duplicate `channel_index` across melodic tracks
---
## Error Handling Strategy
| Layer | Strategy |
|-------|----------|
| `SampleSelector.select()` | Returns `[]` on no match — callers must check; never raises on empty |
| `generate_*` functions | Raise `ValueError` if `selector.select()` returns empty (required sample missing) |
| `SongDefinition.validate()` | Accumulate all errors, raise `ValueError` with full list |
| `FLPBuilder.build()` | Propagates `FileNotFoundError` if `sample_path` missing at write time |
| CLI | `sys.exit(1)` with human-readable message on any `ValueError` / `FileNotFoundError` |
---
## Testing Strategy
| Layer | What | Approach |
|-------|------|----------|
| Unit | `SampleSelector` scoring | Fixture with 5 hand-crafted entries; assert rank order |
| Unit | `generate_bass` tresillo | Assert note positions match `[0, 0.75, 1.5]` per bar |
| Unit | `MelodicTrack` schema validation | `validate()` returns expected errors for bad inputs |
| Integration | `FLPBuilder` with melodic tracks | Build to bytes, parse header, assert `num_channels == 17 + n` |
| Smoke | CLI end-to-end | `compose_track.py --key Am --bpm 95 --output /tmp/test.flp`; assert file exists and size > 0 |
---
## Open Questions
- [ ] `skeleton.py` `EMPTY_SAMPLER_CHANNELS` includes `{17,18,19}` — need to confirm that adding melodic channels beyond 19 doesn't require reference FLP changes or if we expand the sampler clone range.
- [ ] `sample_index.json` entries use `original_path` (absolute Windows paths). CLI on other machines will break. Decision needed: embed relative paths or make selector rebase paths against a configurable `library_root`.