feat: reggaeton production system with intelligent sample selection and FLP generation
This commit is contained in:
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