feat: reggaeton production system with intelligent sample selection and FLP generation
This commit is contained in:
67
.sdd/changes/reggaeton-composer/ARCHIVE.md
Normal file
67
.sdd/changes/reggaeton-composer/ARCHIVE.md
Normal 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
205
.sdd/design.md
Normal 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.0–1.0
|
||||
pan: float = 0.0 # -1.0–1.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`.
|
||||
Reference in New Issue
Block a user