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

35
.atl/skill-registry.md Normal file
View File

@@ -0,0 +1,35 @@
# Skill Registry — fl_control
Generated: 2026-05-02
## Project Conventions
| File | Type | Notes |
|------|------|-------|
| `~/.config/opencode/AGENTS.md` | Agent config | User-level persona, rules, language, tone |
No project-level AGENTS.md found.
## Available Skills
| Skill | Trigger |
|-------|---------|
| `fl-control` | Create music, compose a song, make beats, reggaeton/trap/house |
| `sdd-init` | Initialize SDD in a project, "sdd init", "iniciar sdd" |
| `sdd-explore` | Think through a feature, investigate codebase, clarify requirements |
| `sdd-propose` | Create or update a proposal for a change |
| `sdd-spec` | Write or update specs for a change |
| `sdd-design` | Write or update technical design for a change |
| `sdd-tasks` | Create or update task breakdown for a change |
| `sdd-apply` | Implement tasks from a change |
| `sdd-verify` | Verify completed or partial implementation against specs |
| `sdd-archive` | Archive a completed change |
| `sdd-onboard` | Guided walkthrough of the SDD workflow |
| `branch-pr` | Create a pull request |
| `issue-creation` | Create a GitHub issue |
| `judgment-day` | Adversarial dual review: "judgment day", "dual review", "juzgar" |
| `skill-creator` | Create a new AI skill |
| `skill-registry` | Update skill registry |
| `go-testing` | Go tests, Bubbletea TUI testing |
Source: `~/.config/opencode/skills/*/SKILL.md`

17
.gitattributes vendored Normal file
View File

@@ -0,0 +1,17 @@
# Normalize line endings to LF in the repo, checkout as-is on Windows
* text=auto eol=lf
# Python
*.py text eol=lf
# Batch files need CRLF on Windows
*.bat text eol=crlf
# Binaries
*.flp binary
*.wav binary
*.mp3 binary
*.bin binary
*.npy binary
*.faiss binary
*.pkl binary

58
.gitignore vendored Normal file
View File

@@ -0,0 +1,58 @@
# ── Python ──────────────────────────────────────────
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.eggs/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# ── Datos generados (regenerables) ──────────────────
data/sample_index.json
data/analysis_checkpoint.jsonl
data/rename_log.json
data/rename_plan.json
# ── Samples de audio (binarios masivos / licenciados)
libreria/
librerias/analyzed_samples/
librerias/organized_samples/
librerias/reggaeton/
librerias/all_tracks/
librerias/vector_store/
# ── Proyectos personales de FL Studio ───────────────
my space ryt/
my space ryt.zip
# ── Outputs generados ───────────────────────────────
output/*.flp
output/*.wav
output/*.mp3
# ── Binarios y modelos ML ───────────────────────────
*.npy
*.faiss
*.pkl
# ── Artefactos de FL Studio / MCP ───────────────────
flstudio-mcp/midi_rag_database.pkl
flstudio-mcp/user_preferences.pkl
# ── IDEs y OS ───────────────────────────────────────
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
desktop.ini
# ── Referencias externas (repo de terceros) ──────────
references/

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "flstudio-mcp"]
path = flstudio-mcp
url = https://github.com/ohhalim/flstudio-mcp.git

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`.

22
1_ANALIZAR.bat Normal file
View File

@@ -0,0 +1,22 @@
@echo off
chcp 65001 >nul 2>&1
title Analizador Forense de Samples - Batch 862 archivos
cd /d "C:\Users\Administrator\Documents\fl_control"
echo ============================================================
echo ANALIZADOR FORENSE DE SAMPLES - 4 CAPAS
echo Layer 1: Signal (FFT, spectral, RMS, ZCR, attack)
echo Layer 2: Perceptual (MFCC, chroma, onset, tempo, LUFS)
echo Layer 3: Musical (Key Krumhansl-Schmuckler, tonal/atonal)
echo Layer 4: Timbre (Mel band stats, spectral contrast, tonnetz)
echo ============================================================
echo.
echo Usando 16 threads (70%% CPU de 24 cores)
echo Presiona Ctrl+C para cancelar
echo.
pause
python "src\analyzer\run_batch.py"
echo.
echo ============================================================
echo ANALISIS COMPLETADO - revisa data\sample_index.json
echo ============================================================
pause

19
2_RENOMBRAR.bat Normal file
View File

@@ -0,0 +1,19 @@
@echo off
chcp 65001 >nul 2>&1
title Renombrar Samples con Nombres Estandarizados
cd /d "C:\Users\Administrator\Documents\fl_control"
echo ============================================================
echo RENOMBRADOR DE SAMPLES
echo Lee data\rename_plan.json y copia los archivos
echo con nombres estandarizados a librerias\analyzed_samples\
echo.
echo Formato: {role}_{key}_{bpm}_{character}_{id}.wav
echo ============================================================
echo.
echo ADVERTENCIA: Esto COPIARA archivos a una nueva carpeta.
echo Los originales NO se modifican.
echo.
pause
python "src\analyzer\run_rename.py"
echo.
pause

6
3_ESTADISTICAS.bat Normal file
View File

@@ -0,0 +1,6 @@
@echo off
chcp 65001 >nul 2>&1
title Ver Estadisticas del Analisis
cd /d "C:\Users\Administrator\Documents\fl_control"
python "src\analyzer\show_stats.py"
pause

3
COMPONER.bat Normal file
View File

@@ -0,0 +1,3 @@
@echo off
python scripts\compose_track.py --key Am --bpm 95 --bars 8 --output output\reggaeton.flp
pause

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 renato97
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

58
README.md Normal file
View File

@@ -0,0 +1,58 @@
# FL Control — Reggaeton Production System
Python system for generating complete reggaeton `.flp` projects for FL Studio from the command line, using intelligent sample selection and algorithmic composition.
## Features
- **Forensic Sample Analyzer** — 4-layer audio analysis (signal, perceptual, musical, timbre) using aubio
- **Intelligent Sample Selector** — scores samples by key compatibility (circle of fifths), BPM proximity, and character
- **Melodic Generators** — reggaeton-idiomatic patterns: bass tresillo, melodic hooks, chord blocks, sustained pads
- **FLP Builder** — assembles valid FL Studio project files from a JSON song definition
- **MCP Server** — 28-tool Model Context Protocol server for AI-assisted production
## Quick Start
```bash
pip install -r requirements.txt
# Analyze your sample library
1_ANALIZAR.bat # or: python src/analyzer/__init__.py
# Compose a track
python scripts/compose_track.py --key Am --bpm 95 --bars 8 --output output/track.flp
# or double-click:
COMPONER.bat
```
## Project Structure
```
fl_control/
├── src/
│ ├── analyzer/ # Forensic audio feature extraction
│ ├── composer/ # Pattern generators (rhythm + melodic)
│ ├── flp_builder/ # FL Studio .flp binary assembly
│ └── selector/ # Intelligent sample scoring & selection
├── mcp/ # MCP server (28 tools for AI integration)
├── scripts/ # CLI entry points
├── knowledge/ # Musical domain knowledge (progressions, templates)
├── data/ # Generated indexes (gitignored)
├── .sdd/ # Spec-Driven Development artifacts
└── COMPONER.bat # Quick-compose launcher
```
## System Requirements
- Python 3.10+
- FL Studio (for opening generated `.flp` files)
- ~4GB disk space for sample library (not included)
## Workflow
1. Drop your sample library into `librerias/`
2. Run `1_ANALIZAR.bat` to build the sample index
3. Run `COMPONER.bat` to generate a track
## Architecture
The system uses a JSON `SongDefinition` as the single source of truth decoupling composition logic from FLP binary rendering. See `.sdd/` for full technical specs and design documents.

1
flstudio-mcp Submodule

Submodule flstudio-mcp added at d518dec361

View File

@@ -0,0 +1,156 @@
{
"genre": "reggaeton",
"era": "2009",
"display_name": "Reggaeton 2009 (Era de Oro)",
"description": "Reggaeton comercial 2006-2010. Daddy Yankee, Wisin y Yandel, Don Omar, Tito El Bambino, Hector El Father. Beat dembow con 808, piano stabs, brass hits.",
"bpm": {
"min": 88,
"max": 102,
"default": 96
},
"keys": ["Am", "Dm", "Gm", "Cm", "Em", "Fm", "Bbm"],
"time_signature": [4, 4],
"ppq": 96,
"structure": {
"template": "intro-verse-chorus-verse-chorus-outro",
"sections": [
{"name": "intro", "bars": 4, "energy": 0.3},
{"name": "verse", "bars": 8, "energy": 0.6},
{"name": "chorus", "bars": 8, "energy": 0.9},
{"name": "verse2", "bars": 8, "energy": 0.6},
{"name": "chorus2", "bars": 8, "energy": 1.0},
{"name": "bridge", "bars": 4, "energy": 0.5},
{"name": "chorus3", "bars": 8, "energy": 1.0},
{"name": "outro", "bars": 4, "energy": 0.4}
]
},
"roles": {
"drums": {
"description": "Patron dembow - kick en 1 y 2.5, snare en 2 y 4, hi-hats en corcheas",
"pattern_type": "dembow",
"preferred_plugins": ["FPC", "Fruity DrumSynth Live", "DirectWave", "Kontakt 7"],
"midi_channel": 0,
"mixer_slot": 0,
"notes_template": "dembow"
},
"bass": {
"description": "808 sub bass que sigue al kick. Sostenido, octave 2.",
"pattern_type": "808_follow_kick",
"preferred_plugins": ["Serum 2", "Transistor Bass", "Sytrus", "3x Osc", "ravity(S)"],
"midi_channel": 1,
"mixer_slot": 1,
"octave": 2,
"notes_template": "bass_808"
},
"harmony": {
"description": "Piano stabs en offbeats. Closed triads.",
"pattern_type": "piano_stabs",
"preferred_plugins": ["FL Keys", "Nexus2", "Kontakt 7", "Sakura", "Pigments"],
"midi_channel": 2,
"mixer_slot": 2,
"notes_template": "piano_stabs"
},
"lead": {
"description": "Brass hit o melodia sintetizada. Hook del coro.",
"pattern_type": "brass_hook",
"preferred_plugins": ["Serum 2", "Omnisphere", "Harmor", "Electra", "ravity(S)"],
"midi_channel": 3,
"mixer_slot": 3,
"notes_template": "lead_hook"
},
"pad": {
"description": "Pad atmosferico sutil para llenar el fondo.",
"pattern_type": "sustained_pad",
"preferred_plugins": ["Harmor", "Serum 2", "Omnisphere", "FLEX", "Pigments"],
"midi_channel": 4,
"mixer_slot": 4,
"notes_template": "pad_sustained"
},
"perc": {
"description": "Percusion adicional: shaker, congas, timbales.",
"pattern_type": "latin_perc",
"preferred_plugins": ["FPC", "Fruity DrumSynth Live", "DirectWave"],
"midi_channel": 5,
"mixer_slot": 5,
"notes_template": "latin_perc"
}
},
"chord_progressions": [
{
"name": "clasica_menor",
"chords": ["Am", "G", "F", "G"],
"beats_per_chord": 4,
"popularity": 0.9
},
{
"name": "tensión",
"chords": ["Am", "F", "C", "G"],
"beats_per_chord": 4,
"popularity": 0.7
},
{
"name": "oscura",
"chords": ["Dm", "C", "Bb", "C"],
"beats_per_chord": 4,
"popularity": 0.6
},
{
"name": "romantica",
"chords": ["Am", "Dm", "G", "C"],
"beats_per_chord": 4,
"popularity": 0.75
},
{
"name": "nocturna",
"chords": ["Em", "Am", "Dm", "G"],
"beats_per_chord": 4,
"popularity": 0.5
}
],
"mix": {
"bass_heavy": true,
"sidechain_bass_to_kick": true,
"reverb_on_snare": true,
"reverb_on_lead": true,
"typical_lufs": -8,
"master_chain": ["Pro-Q 3", "Pro-C 2", "Pro-L 2"],
"per_role": {
"drums": {
"effects": ["Fruity Parametric EQ 2", "Fruity Compressor"],
"volume_db": -3,
"stereo": 0
},
"bass": {
"effects": ["Fruity Parametric EQ 2", "Saturn 2"],
"volume_db": -2,
"stereo": 0,
"hp_filter_hz": 20,
"lp_filter_hz": 200
},
"harmony": {
"effects": ["Pro-Q 3", "Pro-C 2"],
"volume_db": -6,
"stereo": -10
},
"lead": {
"effects": ["Pro-Q 3", "Pro-R 2", "Saturn 2"],
"volume_db": -5,
"stereo": 15
},
"pad": {
"effects": ["Pro-Q 3", "Pro-R 2"],
"volume_db": -10,
"stereo": 30
}
}
},
"reference_tracks": [
"Gasolina - Daddy Yankee",
"Rakata - Wisin y Yandel",
"Danza Kuduro - Don Omar",
"El Amor - Tito El Bambino",
"Rumor de Guerra - Hector El Father",
"Pose - Daddy Yankee",
"Llamé Pa Verte - Wisin y Yandel"
]
}

View File

@@ -0,0 +1,184 @@
{
"meta": {
"bpm": 95,
"key": "Am",
"title": "Reggaeton Template",
"ppq": 96,
"time_sig_num": 4,
"time_sig_den": 4
},
"samples": {
"kick": "kick.wav",
"snare": "snare.wav",
"rim": "rim.wav",
"perc1": "perc1.wav",
"perc2": "perc2.wav",
"hihat": "hihat.wav",
"clap": "clap.wav"
},
"patterns": [
{
"id": 1,
"name": "Kick Main",
"instrument": "kick",
"channel": 11,
"bars": 8,
"generator": "kick_main_notes",
"velocity_mult": 1.0,
"density": 1.0
},
{
"id": 2,
"name": "Snare Verse",
"instrument": "snare",
"channel": 12,
"bars": 8,
"generator": "snare_verse_notes",
"velocity_mult": 1.0,
"density": 1.0
},
{
"id": 3,
"name": "Hihat 16th",
"instrument": "hihat",
"channel": 15,
"bars": 8,
"generator": "hihat_16th_notes",
"velocity_mult": 1.0,
"density": 1.0
},
{
"id": 4,
"name": "Clap 2-4",
"instrument": "clap",
"channel": 16,
"bars": 8,
"generator": "clap_24_notes",
"velocity_mult": 1.0,
"density": 1.0
},
{
"id": 5,
"name": "Perc Combo",
"instrument": "perc1",
"channel": 14,
"bars": 8,
"generator": "perc_combo_notes",
"velocity_mult": 1.0,
"density": 1.0
},
{
"id": 6,
"name": "Kick Sparse",
"instrument": "kick",
"channel": 11,
"bars": 8,
"generator": "kick_sparse_notes",
"velocity_mult": 0.7,
"density": 0.5
},
{
"id": 7,
"name": "Hihat 8th",
"instrument": "hihat",
"channel": 15,
"bars": 8,
"generator": "hihat_8th_notes",
"velocity_mult": 0.6,
"density": 0.5
},
{
"id": 8,
"name": "Rim Build",
"instrument": "rim",
"channel": 13,
"bars": 4,
"generator": "rim_build_notes",
"velocity_mult": 1.2,
"density": 1.0
},
{
"id": 9,
"name": "Kick Outro",
"instrument": "kick",
"channel": 11,
"bars": 8,
"generator": "kick_outro_notes",
"velocity_mult": 0.8,
"density": 0.7
}
],
"tracks": [
{ "index": 1, "name": "Kick" },
{ "index": 2, "name": "Snare" },
{ "index": 3, "name": "Hihat" },
{ "index": 4, "name": "Clap/Rim" },
{ "index": 5, "name": "Perc" }
],
"items": [
{ "pattern": 7, "bar": 0, "bars": 8, "track": 3 },
{ "pattern": 6, "bar": 0, "bars": 8, "track": 1 },
{ "pattern": 3, "bar": 8, "bars": 8, "track": 3 },
{ "pattern": 3, "bar": 16, "bars": 8, "track": 3 },
{ "pattern": 1, "bar": 8, "bars": 8, "track": 1 },
{ "pattern": 1, "bar": 16, "bars": 8, "track": 1 },
{ "pattern": 2, "bar": 8, "bars": 8, "track": 2 },
{ "pattern": 2, "bar": 16, "bars": 8, "track": 2 },
{ "pattern": 5, "bar": 8, "bars": 8, "track": 5 },
{ "pattern": 5, "bar": 16, "bars": 8, "track": 5 },
{ "pattern": 3, "bar": 24, "bars": 4, "track": 3 },
{ "pattern": 1, "bar": 24, "bars": 4, "track": 1 },
{ "pattern": 2, "bar": 24, "bars": 4, "track": 2 },
{ "pattern": 5, "bar": 24, "bars": 4, "track": 5 },
{ "pattern": 8, "bar": 24, "bars": 4, "track": 4 },
{ "pattern": 3, "bar": 28, "bars": 8, "track": 3 },
{ "pattern": 3, "bar": 36, "bars": 8, "track": 3 },
{ "pattern": 1, "bar": 28, "bars": 8, "track": 1 },
{ "pattern": 1, "bar": 36, "bars": 8, "track": 1 },
{ "pattern": 2, "bar": 28, "bars": 8, "track": 2 },
{ "pattern": 2, "bar": 36, "bars": 8, "track": 2 },
{ "pattern": 4, "bar": 28, "bars": 8, "track": 4 },
{ "pattern": 4, "bar": 36, "bars": 8, "track": 4 },
{ "pattern": 5, "bar": 28, "bars": 8, "track": 5 },
{ "pattern": 5, "bar": 36, "bars": 8, "track": 5 },
{ "pattern": 3, "bar": 44, "bars": 8, "track": 3 },
{ "pattern": 3, "bar": 52, "bars": 8, "track": 3 },
{ "pattern": 1, "bar": 44, "bars": 8, "track": 1 },
{ "pattern": 1, "bar": 52, "bars": 8, "track": 1 },
{ "pattern": 2, "bar": 44, "bars": 8, "track": 2 },
{ "pattern": 2, "bar": 52, "bars": 8, "track": 2 },
{ "pattern": 5, "bar": 44, "bars": 8, "track": 5 },
{ "pattern": 5, "bar": 52, "bars": 8, "track": 5 },
{ "pattern": 3, "bar": 60, "bars": 4, "track": 3 },
{ "pattern": 1, "bar": 60, "bars": 4, "track": 1 },
{ "pattern": 2, "bar": 60, "bars": 4, "track": 2 },
{ "pattern": 5, "bar": 60, "bars": 4, "track": 5 },
{ "pattern": 8, "bar": 60, "bars": 4, "track": 4 },
{ "pattern": 3, "bar": 64, "bars": 8, "track": 3 },
{ "pattern": 3, "bar": 72, "bars": 8, "track": 3 },
{ "pattern": 1, "bar": 64, "bars": 8, "track": 1 },
{ "pattern": 1, "bar": 72, "bars": 8, "track": 1 },
{ "pattern": 2, "bar": 64, "bars": 8, "track": 2 },
{ "pattern": 2, "bar": 72, "bars": 8, "track": 2 },
{ "pattern": 4, "bar": 64, "bars": 8, "track": 4 },
{ "pattern": 4, "bar": 72, "bars": 8, "track": 4 },
{ "pattern": 5, "bar": 64, "bars": 8, "track": 5 },
{ "pattern": 5, "bar": 72, "bars": 8, "track": 5 },
{ "pattern": 7, "bar": 80, "bars": 8, "track": 3 },
{ "pattern": 6, "bar": 80, "bars": 8, "track": 1 },
{ "pattern": 3, "bar": 88, "bars": 8, "track": 3 },
{ "pattern": 9, "bar": 88, "bars": 8, "track": 1 },
{ "pattern": 2, "bar": 88, "bars": 8, "track": 2 },
{ "pattern": 4, "bar": 88, "bars": 8, "track": 4 }
],
"progression_name": "clasica_menor",
"section_template": "standard"
}

5
mcp/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""FL Studio MCP Server — nibble-encoded SysEx over MIDI loopback."""
from __future__ import annotations
__version__ = "0.1.0"

15
mcp/protocol/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
"""FL-MCP Protocol — SysEx encoding/decoding and MIDI transport."""
from __future__ import annotations
from .sysex import encode_command, decode_command, nibble_encode, nibble_decode, SYSEX_ID
from .transport import MidiTransport
__all__ = [
"nibble_encode",
"nibble_decode",
"encode_command",
"decode_command",
"SYSEX_ID",
"MidiTransport",
]

65
mcp/protocol/sysex.py Normal file
View File

@@ -0,0 +1,65 @@
"""
FL-MCP Protocol — SysEx encoding/decoding.
Protocol: F0 7D [nibble-encoded UTF-8 JSON] F7
"""
from __future__ import annotations
SYSEX_ID = 0x7D
def nibble_encode(data: bytes) -> list[int]:
"""Split each byte into two nibbles (high, low) for MIDI data byte compliance (< 0x80).
Each byte 0x00-0xFF is split into two MIDI-safe bytes:
- high nibble: (byte >> 4) & 0x0F
- low nibble: byte & 0x0F
"""
result: list[int] = []
for byte in data:
result.append((byte >> 4) & 0x0F)
result.append(byte & 0x0F)
return result
def nibble_decode(nibbles: list[int]) -> bytes:
"""Reconstruct bytes from nibble pairs.
Each pair (high, low) reconstructs one byte:
byte = (high << 4) | low
"""
result = bytearray()
for i in range(0, len(nibbles), 2):
if i + 1 < len(nibbles):
result.append(((nibbles[i] & 0x0F) << 4) | (nibbles[i + 1] & 0x0F))
return bytes(result)
def encode_command(cmd: str, params: dict | None = None) -> list[int]:
"""Encode a JSON command as a complete SysEx message: F0 7D [nibbles...] F7."""
import json
payload = json.dumps({"cmd": cmd, "params": params or {}})
nibbles = nibble_encode(payload.encode("utf-8"))
return [0xF0, SYSEX_ID] + nibbles + [0xF7]
def decode_command(data: list[int]) -> dict | None:
"""Decode a SysEx message back to JSON dict.
Returns None if the message is not a valid FL-MCP SysEx message.
"""
if not data or len(data) < 4 or data[0] != 0xF0 or data[-1] != 0xF7:
return None
if data[1] != SYSEX_ID:
return None
import json
nibbles = data[2:-1]
if not nibbles:
return None
raw = nibble_decode(nibbles)
if not raw:
return None
return json.loads(raw.decode("utf-8"))

67
mcp/protocol/transport.py Normal file
View File

@@ -0,0 +1,67 @@
"""FL-MCP MIDI Transport using mido."""
from __future__ import annotations
import mido
from mido import Message
from .sysex import encode_command
class MidiTransport:
"""MIDI transport for sending SysEx commands to FL Studio via loopback."""
def __init__(self, port_name: str = "FL_MCP"):
self.port_name = port_name
self._output: mido.ports.Port | None = None
self._input: mido.ports.Port | None = None
def connect(self) -> bool:
"""Find and open the FL_MCP output port."""
ports = mido.get_output_names()
# Try exact match first, then partial
match: str | None = None
for p in ports:
if self.port_name in p:
match = p
break
if not match:
raise ConnectionError(
f"Port '{self.port_name}' not found. Available: {ports}"
)
self._output = mido.open_output(match)
return True
def send_command(self, cmd: str, params: dict | None = None) -> None:
"""Send a SysEx command to FL Studio."""
if not self._output:
self.connect()
data = encode_command(cmd, params)
# mido.Message('sysex', data=...) automatically wraps with F0/F7
# data[1:-1] skips the F0/SYSEX_ID/F7 wrapper since mido adds them
msg = Message("sysex", data=bytes(data[1:-1]))
self._output.send(msg)
def receive(self, timeout: float = 1.0) -> mido.Message | None:
"""Receive a MIDI message (blocking with timeout)."""
if not self._input:
self._input = mido.open_input(
next((p for p in mido.get_input_names() if self.port_name in p), None)
)
if self._input:
return self._input.receive(timeout=timeout)
return None
def close(self) -> None:
"""Close all MIDI ports."""
if self._output:
self._output.close()
self._output = None
if self._input:
self._input.close()
self._input = None
@staticmethod
def list_ports() -> dict[str, list[str]]:
"""List all available MIDI input and output ports."""
return {"inputs": mido.get_input_names(), "outputs": mido.get_output_names()}

5
mcp/run.py Normal file
View File

@@ -0,0 +1,5 @@
"""FL Studio MCP Server entry point."""
from server import mcp
if __name__ == "__main__":
mcp.run(transport="stdio")

356
mcp/server.py Normal file
View File

@@ -0,0 +1,356 @@
"""
FL Studio MCP Server — FastMCP server with MIDI SysEx transport.
Sends nibble-encoded JSON commands to FL Studio via Windows MIDI Services loopback.
FL Studio controller script receives via OnSysEx() and calls FL Studio API.
"""
from __future__ import annotations
import sys
from mcp.server.fastmcp import FastMCP
from protocol.transport import MidiTransport
mcp = FastMCP("fl-studio-mcp")
transport = MidiTransport()
def _log(msg: str) -> None:
"""Print a log message to stderr."""
print(f"[fl-studio-mcp] {msg}", file=sys.stderr)
# ─── Transport Tools ────────────────────────────────────────────────────────────
@mcp.tool()
def play() -> str:
"""Start FL Studio playback."""
try:
transport.send_command("start_playback")
return "Playback started"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def stop() -> str:
"""Stop FL Studio playback."""
try:
transport.send_command("stop_playback")
return "Playback stopped"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def record() -> str:
"""Start recording in FL Studio."""
try:
transport.send_command("start_recording")
return "Recording started"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def set_tempo(bpm: float) -> str:
"""Set FL Studio tempo (40-999 BPM)."""
try:
transport.send_command("set_tempo", {"tempo": float(bpm)})
return f"Tempo set to {bpm} BPM"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def set_time_signature(numerator: int = 4, denominator: int = 4) -> str:
"""Set time signature."""
try:
transport.send_command("set_time_signature", {
"numerator": int(numerator),
"denominator": int(denominator),
})
return f"Time signature set to {numerator}/{denominator}"
except Exception as e:
return f"Error: {e}"
# ─── Channel Tools ────────────────────────────────────────────────────────────
@mcp.tool()
def select_channel(channel_index: int) -> str:
"""Select a channel in the channel rack (0-based index)."""
try:
transport.send_command("select_channel", {"channel_index": int(channel_index)})
return f"Channel {channel_index} selected"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def mute_channel(channel_index: int, muted: bool = True) -> str:
"""Mute or unmute a channel."""
try:
transport.send_command("mute_channel", {
"channel_index": int(channel_index),
"muted": bool(muted),
})
return f"Channel {channel_index} muted={muted}"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def solo_channel(channel_index: int) -> str:
"""Solo a channel."""
try:
transport.send_command("solo_channel", {"channel_index": int(channel_index)})
return f"Channel {channel_index} soloed"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def set_channel_volume(channel_index: int, volume: float) -> str:
"""Set channel volume (0.0-1.0)."""
try:
transport.send_command("set_channel_volume", {
"channel_index": int(channel_index),
"volume": float(volume),
})
return f"Channel {channel_index} volume={volume}"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def set_channel_pan(channel_index: int, pan: float) -> str:
"""Set channel pan (-1.0 to 1.0)."""
try:
transport.send_command("set_channel_pan", {
"channel_index": int(channel_index),
"pan": float(pan),
})
return f"Channel {channel_index} pan={pan}"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def note_on(channel_index: int, note: int, velocity: int = 100) -> str:
"""Send a note on event."""
try:
transport.send_command("note_on", {
"channel_index": int(channel_index),
"note": int(note),
"velocity": int(velocity),
})
return f"Note on: ch={channel_index} note={note} vel={velocity}"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def note_off(channel_index: int, note: int) -> str:
"""Send a note off event."""
try:
transport.send_command("note_off", {
"channel_index": int(channel_index),
"note": int(note),
})
return f"Note off: ch={channel_index} note={note}"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def stop_all_notes() -> str:
"""Stop all playing notes (panic)."""
try:
transport.send_command("stop_all_notes")
return "All notes stopped"
except Exception as e:
return f"Error: {e}"
# ─── Mixer Tools ──────────────────────────────────────────────────────────────
@mcp.tool()
def set_mixer_volume(track_index: int, volume: float) -> str:
"""Set mixer track volume (0.0-1.25)."""
try:
transport.send_command("set_mixer_volume", {
"track_index": int(track_index),
"volume": float(volume),
})
return f"Mixer track {track_index} volume={volume}"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def set_mixer_pan(track_index: int, pan: float) -> str:
"""Set mixer track pan (-1.0 to 1.0)."""
try:
transport.send_command("set_mixer_pan", {
"track_index": int(track_index),
"pan": float(pan),
})
return f"Mixer track {track_index} pan={pan}"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def mute_mixer_track(track_index: int, muted: bool = True) -> str:
"""Mute or unmute a mixer track."""
try:
transport.send_command("mute_mixer_track", {
"track_index": int(track_index),
"muted": bool(muted),
})
return f"Mixer track {track_index} muted={muted}"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def solo_mixer_track(track_index: int) -> str:
"""Solo a mixer track."""
try:
transport.send_command("solo_mixer_track", {"track_index": int(track_index)})
return f"Mixer track {track_index} soloed"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def rename_mixer_track(track_index: int, name: str) -> str:
"""Rename a mixer track."""
try:
transport.send_command("set_mixer_track_name", {
"track_index": int(track_index),
"name": str(name),
})
return f"Mixer track {track_index} renamed to '{name}'"
except Exception as e:
return f"Error: {e}"
# ─── Pattern Tools ────────────────────────────────────────────────────────────
@mcp.tool()
def select_pattern(pattern_index: int) -> str:
"""Select a pattern by index."""
try:
transport.send_command("select_pattern", {"pattern_index": int(pattern_index)})
return f"Pattern {pattern_index} selected"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def create_pattern(name: str) -> str:
"""Create a new pattern with the given name."""
try:
transport.send_command("set_pattern_name", {
"pattern_index": 0, # Will create new
"name": str(name),
})
return f"Pattern '{name}' created"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def rename_pattern(pattern_index: int, name: str) -> str:
"""Rename a pattern."""
try:
transport.send_command("set_pattern_name", {
"pattern_index": int(pattern_index),
"name": str(name),
})
return f"Pattern {pattern_index} renamed to '{name}'"
except Exception as e:
return f"Error: {e}"
# ─── UI Tools ─────────────────────────────────────────────────────────────────
@mcp.tool()
def show_channel_rack() -> str:
"""Show the FL Studio channel rack window."""
try:
transport.send_command("show_channel_rack")
return "Channel rack shown"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def show_mixer() -> str:
"""Show the FL Studio mixer window."""
try:
transport.send_command("show_mixer")
return "Mixer shown"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def show_piano_roll() -> str:
"""Show the FL Studio piano roll window."""
try:
transport.send_command("show_piano_roll")
return "Piano roll shown"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def show_playlist() -> str:
"""Show the FL Studio playlist window."""
try:
transport.send_command("show_playlist")
return "Playlist shown"
except Exception as e:
return f"Error: {e}"
# ─── Meta Tools ────────────────────────────────────────────────────────────────
@mcp.tool()
def ping() -> str:
"""Ping the FL Studio MCP server to verify connectivity."""
try:
transport.send_command("ping", {"ts": 1})
return "pong"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def list_ports() -> dict[str, list[str]]:
"""List all available MIDI input and output ports."""
return MidiTransport.list_ports()
@mcp.tool()
def get_session_info() -> dict:
"""Get FL Studio session information (transport state)."""
return {
"server": "fl-studio-mcp",
"transport": "midi_sysex",
"loopback": "FL_MCP",
}
if __name__ == "__main__":
mcp.run(transport="stdio")

26
mcp/tests/quick_test.py Normal file
View File

@@ -0,0 +1,26 @@
"""Quick validation of the protocol module."""
import sys
sys.path.insert(0, "C:\\Users\\Administrator\\Documents\\fl_control\\mcp")
from protocol.sysex import nibble_encode, nibble_decode, encode_command, decode_command, SYSEX_ID
# Test 1: nibble roundtrip
for original in [b"Hello", b"", b"\x00\x7f", b'{"cmd":"ping"}']:
encoded = nibble_encode(original)
decoded = nibble_decode(encoded)
assert decoded == original, f"Roundtrip failed for {original}"
print("PASS: nibble roundtrip")
# Test 2: encode/decode command
result = encode_command("ping", {"ts": 1})
assert result[0] == 0xF0 and result[1] == 0x7D and result[-1] == 0xF7
decoded = decode_command(result)
assert decoded["cmd"] == "ping"
assert decoded["params"] == {"ts": 1}
print("PASS: encode/decode command")
# Test 3: SYSEX_ID
assert SYSEX_ID == 0x7D
print("PASS: SYSEX_ID == 0x7D")
print("All unit tests passed!")

46
mcp/tests/send_ping.py Normal file
View File

@@ -0,0 +1,46 @@
"""Send a SysEx ping to FL Studio and check if it arrives."""
import mido
import json
import sys
import time
print("=== FL Studio MCP — Send Ping Test ===")
# List ports
print(f"Inputs: {mido.get_input_names()}")
print(f"Outputs: {mido.get_output_names()}")
# Open FL_MCP 1 output
out = mido.open_output("FL_MCP 1")
print("Opened FL_MCP 1 output")
# Encode ping command
payload = json.dumps({"cmd": "ping", "params": {"ts": 1}}).encode("utf-8")
nibbles = []
for b in payload:
nibbles.append((b >> 4) & 0x0F)
nibbles.append(b & 0x0F)
# mido adds F0/F7 automatically, we provide [SYSEX_ID + nibble data]
msg = mido.Message("sysex", data=bytes([0x7D] + nibbles))
hex_str = " ".join(f"{b:02X}" for b in msg.bytes())
print(f"Sending SysEx ({len(msg.bytes())} bytes): {hex_str}")
out.send(msg)
print("Sent! Check FL Studio script console for: [FL-MCP] Ping received")
# Also try a play command
time.sleep(0.5)
payload2 = json.dumps({"cmd": "start_playback", "params": {}}).encode("utf-8")
nibbles2 = []
for b in payload2:
nibbles2.append((b >> 4) & 0x0F)
nibbles2.append(b & 0x0F)
msg2 = mido.Message("sysex", data=bytes([0x7D] + nibbles2))
hex_str2 = " ".join(f"{b:02X}" for b in msg2.bytes())
print(f"\nSending PLAY SysEx ({len(msg2.bytes())} bytes): {hex_str2}")
out.send(msg2)
print("Sent! FL Studio should start playing if controller is loaded.")
time.sleep(0.5)
out.close()
print("\nDone. Check FL Studio console and transport state.")

View File

@@ -0,0 +1,125 @@
"""
Integration tests for FL Studio MCP server.
Tests nibble encode/decode roundtrip, command encoding, and tool registration.
"""
from __future__ import annotations
import json
import sys
sys.path.insert(0, "C:\\Users\\Administrator\\Documents\\fl_control\\mcp")
from protocol.sysex import nibble_encode, nibble_decode, encode_command, decode_command, SYSEX_ID
from protocol.transport import MidiTransport
def test_nibble_encode_decode_roundtrip():
"""Test that nibble_encode and nibble_decode are perfect inverses."""
test_cases = [
b"Hello",
b"",
b"\x00\x7f\x80\xff",
b'{"cmd":"ping","params":{"ts":1}}',
b"\xff\xfe\xfd\xfc\xfb",
b"A" * 256, # stress test
]
for original in test_cases:
encoded = nibble_encode(original)
# Each byte becomes 2 nibbles
assert len(encoded) == len(original) * 2, f"Length mismatch for {original!r}"
decoded = nibble_decode(encoded)
assert decoded == original, f"Roundtrip failed for {original!r}: got {decoded!r}"
print("PASS: nibble_encode/decode roundtrip")
def test_encode_command_format():
"""Test that encode_command produces valid SysEx format."""
# Test 1: Basic command
result = encode_command("ping", {"ts": 1})
assert result[0] == 0xF0, "Must start with F0"
assert result[1] == SYSEX_ID, "Must have SYSEX_ID=0x7D"
assert result[-1] == 0xF7, "Must end with F7"
print("PASS: encode_command format (basic)")
# Test 2: Empty params
result2 = encode_command("play")
assert result2[0] == 0xF0
assert result2[1] == SYSEX_ID
assert result2[-1] == 0xF7
print("PASS: encode_command format (no params)")
# Test 3: Command decodes back to original JSON
original_cmd = "set_tempo"
original_params = {"tempo": 140.0}
encoded = encode_command(original_cmd, original_params)
decoded = decode_command(encoded)
assert decoded is not None
assert decoded["cmd"] == original_cmd
assert decoded["params"] == original_params
print("PASS: encode_command → decode_command roundtrip")
def test_decode_command_invalid():
"""Test that decode_command returns None for invalid input."""
assert decode_command([]) is None
assert decode_command([0xF0]) is None # too short
assert decode_command([0xF0, 0x7D]) is None # too short
assert decode_command([0xF0, 0x00, 0xF7]) is None # wrong ID
assert decode_command([0xF0, 0x7D, 0xF7]) is None # empty payload
print("PASS: decode_command rejects invalid input")
def test_sysex_id():
"""Verify SYSEX_ID is the correct non-commercial experimental ID."""
assert SYSEX_ID == 0x7D
print("PASS: SYSEX_ID == 0x7D")
def test_miditransport_list_ports():
"""Test that MidiTransport.list_ports() works without crashing."""
ports = MidiTransport.list_ports()
assert "inputs" in ports
assert "outputs" in ports
assert isinstance(ports["inputs"], list)
assert isinstance(ports["outputs"], list)
print(f"PASS: list_ports — inputs={ports['inputs']}, outputs={ports['outputs']}")
def test_sysex_protocol_complete_roundtrip():
"""Full end-to-end: encode → send模拟 → receive → decode."""
# Simulate a complete conversation
commands = [
("ping", {"ts": 1}),
("set_tempo", {"tempo": 92.0}),
("select_channel", {"channel_index": 3}),
("note_on", {"channel_index": 0, "note": 60, "velocity": 100}),
("stop_all_notes", {}),
]
for cmd, params in commands:
encoded = encode_command(cmd, params)
# Verify format
assert encoded[0] == 0xF0
assert encoded[1] == SYSEX_ID
assert encoded[-1] == 0xF7
# Verify decode
decoded = decode_command(encoded)
assert decoded is not None
assert decoded["cmd"] == cmd
assert decoded["params"] == params
print("PASS: complete command roundtrip")
if __name__ == "__main__":
print("FL Studio MCP — Integration Tests")
print("==================================\n")
test_sysex_id()
test_nibble_encode_decode_roundtrip()
test_encode_command_format()
test_decode_command_invalid()
test_miditransport_list_ports()
test_sysex_protocol_complete_roundtrip()
print("\nAll tests passed!")

View File

@@ -0,0 +1,199 @@
"""
Phase 0: SysEx Loopback Validation Test.
Tests whether MIDI SysEx messages can travel through the Windows MIDI Services
loopback port "FL_MCP" from the MCP server to the FL Studio controller script.
Usage:
python test_sysex_loopback.py
Requires:
- FL Studio running with FL_MCP controller script loaded
- Windows MIDI Services loopback ports "FL_MCP 0" (input) and "FL_MCP 1" (output)
"""
from __future__ import annotations
import sys
import time
try:
import mido
except ImportError:
print("ERROR: mido not installed. Run: pip install mido")
sys.exit(1)
def send_sysex_raw(output, data):
"""Send raw SysEx bytes via mido."""
msg = mido.Message("sysex", data=bytes(data))
output.send(msg)
def test_simple_sysex():
"""Test 1: Send F0 7D 48 69 F7 ("Hi") and expect it back."""
print("\n=== Test 1: Simple SysEx Echo ===")
output_name = None
input_name = None
for name in mido.get_output_names():
if "FL_MCP" in name and "1" in name:
output_name = name
break
for name in mido.get_input_names():
if "FL_MCP" in name and "0" in name:
input_name = name
break
if not output_name:
print("FAIL: FL_MCP output port (FL_MCP 1) not found")
print("Available outputs:", mido.get_output_names())
return False
if not input_name:
print("FAIL: FL_MCP input port (FL_MCP 0) not found")
print("Available inputs:", mido.get_input_names())
return False
print(f"Output: {output_name}")
print(f"Input: {input_name}")
try:
output = mido.open_output(output_name)
input_port = mido.open_input(input_name)
except Exception as e:
print(f"FAIL: Cannot open ports: {e}")
return False
# Send "Hi" ping: F0 7D 48 69 F7
# (0x48='H', 0x69='i')
ping_data = [0xF0, 0x7D, 0x48, 0x69, 0xF7]
print(f"Sending: {' '.join(f'{b:02X}' for b in ping_data)}")
send_sysex_raw(output, ping_data[1:-1]) # mido adds F0/F7
print("Waiting 5s for echo...")
timeout = 5.0
start = time.time()
received = None
while time.time() - start < timeout:
msg = input_port.receive(timeout=0.1)
if msg and msg.type == "sysex":
received = list(msg.bytes())
break
output.close()
input_port.close()
if received:
print(f"Received: {' '.join(f'{b:02X}' for b in received)}")
if received[0] == 0xF0 and received[-1] == 0xF7 and received[1] == 0x7D:
print("PASS: Simple SysEx loopback works!")
return True
print(f"UNKNOWN: Received but unexpected bytes: {received}")
return False
else:
print("FAIL: No message received within timeout")
return False
def test_json_command():
"""Test 2: Send a proper JSON command via nibble encoding."""
print("\n=== Test 2: JSON Command SysEx ===")
import json
output_name = None
input_name = None
for name in mido.get_output_names():
if "FL_MCP" in name and "1" in name:
output_name = name
break
for name in mido.get_input_names():
if "FL_MCP" in name and "0" in name:
input_name = name
break
if not output_name or not input_name:
print("SKIP: FL_MCP ports not available for JSON test")
return False
try:
output = mido.open_output(output_name)
input_port = mido.open_input(input_name)
except Exception as e:
print(f"SKIP: Cannot open ports: {e}")
return False
# Encode ping command: {"cmd":"ping","params":{"ts":1}}
payload = json.dumps({"cmd": "ping", "params": {"ts": 1}}).encode("utf-8")
print(f"JSON payload: {payload}")
# Nibble encode
nibbles = []
for byte in payload:
nibbles.append((byte >> 4) & 0x0F)
nibbles.append(byte & 0x0F)
sysex_data = [0xF0, 0x7D] + nibbles + [0xF7]
print(f"SysEx: {' '.join(f'{b:02X}' for b in sysex_data)}")
send_sysex_raw(output, sysex_data[1:-1])
print("Waiting 5s for response...")
timeout = 5.0
start = time.time()
received = None
while time.time() - start < timeout:
msg = input_port.receive(timeout=0.1)
if msg and msg.type == "sysex":
received = list(msg.bytes())
break
output.close()
input_port.close()
if received:
print(f"Received: {' '.join(f'{b:02X}' for b in received)}")
# Decode nibbles
if received[1] == 0x7D:
nibble_data = received[2:-1]
result = bytearray()
for i in range(0, len(nibble_data), 2):
if i + 1 < len(nibble_data):
result.append(((nibble_data[i] & 0x0F) << 4) | (nibble_data[i + 1] & 0x0F))
decoded = result.decode("utf-8")
print(f"Decoded JSON: {decoded}")
print("PASS: JSON command roundtrip works!")
return True
else:
print("FAIL: No response within timeout")
return False
def list_ports():
"""List all available MIDI ports."""
print("\n=== Available MIDI Ports ===")
print("Inputs:")
for p in mido.get_input_names():
print(f" {p}")
print("Outputs:")
for p in mido.get_output_names():
print(f" {p}")
if __name__ == "__main__":
print("FL Studio MCP — SysEx Loopback Validation")
print("=========================================")
list_ports()
test1 = test_simple_sysex()
test2 = test_json_command()
print("\n=== Summary ===")
print(f"Simple SysEx: {'PASS' if test1 else 'FAIL'}")
print(f"JSON Command: {'PASS' if test2 else 'FAIL'}")
if test1 and test2:
print("\nSysEx loopback is functional. Phase 1-4 can proceed.")
sys.exit(0)
else:
print("\nSysEx loopback FAILED. Phase 1-4 ABORTED — pivot required.")
sys.exit(1)

Binary file not shown.

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
aubio>=0.1.1
librosa>=0.10.0
numpy>=1.24.0
scipy>=1.11.0
soundfile>=0.12.0
mido>=1.3.0
fastmcp>=0.1.0

122
scripts/batch_generate.py Normal file
View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Batch FLP generator — produces 50 unique reggaeton FLP+JSON pairs.
Usage:
python scripts/batch_generate.py [--count 50] [--out-dir output/batch]
Output structure:
output/batch_{timestamp}/
reggaeton_000_95bpm_Am_i-VII-VI-VII.json
reggaeton_000_95bpm_Am_i-VII-VI-VII.flp
reggaeton_001_90bpm_Dm_i-iv-VII-III.json
...
manifest.json ← list of all generated songs with metadata
"""
import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))
from src.composer.variation import generate_batch
from src.flp_builder.builder import FLPBuilder
from src.flp_builder.schema import SongDefinition
# ---------------------------------------------------------------------------
# Filename helpers
# ---------------------------------------------------------------------------
_UNSAFE_RE = re.compile(r'[^\w\-]')
def sanitize_filename(s: str) -> str:
"""Replace unsafe filename chars with _."""
return _UNSAFE_RE.sub('_', s)
def make_filename(idx: int, song: SongDefinition) -> str:
"""Build stem like ``reggaeton_000_95bpm_Am_i_VII_VI_VII`` (no extension)."""
prog_safe = sanitize_filename(song.progression_name)
return f"reggaeton_{idx:03d}_{song.meta.bpm}bpm_{song.meta.key}_{prog_safe}"
# ---------------------------------------------------------------------------
# Manifest
# ---------------------------------------------------------------------------
def build_manifest(songs: list[SongDefinition], filenames: list[str]) -> dict:
"""Build manifest dict with per-song metadata."""
entries = []
for idx, (song, stem) in enumerate(zip(songs, filenames)):
bar_count = int(max(item.bar + item.bars for item in song.items))
entries.append({
"idx": idx,
"filename": stem,
"bpm": song.meta.bpm,
"key": song.meta.key,
"progression": song.progression_name,
"title": song.meta.title,
"bars": bar_count,
})
return {
"generated_at": datetime.now().isoformat(),
"count": len(songs),
"songs": entries,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Batch FLP generator")
parser.add_argument("--count", type=int, default=50,
help="Number of songs to generate (default: 50)")
parser.add_argument("--out-dir", default="",
help="Output directory (default: output/batch_{timestamp})")
args = parser.parse_args()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = Path(args.out_dir) if args.out_dir else Path("output") / f"batch_{timestamp}"
out_dir.mkdir(parents=True, exist_ok=True)
print(f"Generating {args.count} songs -> {out_dir}")
songs = generate_batch(args.count)
builder = FLPBuilder()
filenames: list[str] = []
for idx, song in enumerate(songs):
stem = make_filename(idx, song)
filenames.append(stem)
# Write JSON
json_path = out_dir / f"{stem}.json"
json_path.write_text(song.to_json(), encoding="utf-8")
# Write FLP
flp_path = out_dir / f"{stem}.flp"
flp_bytes = builder.build(song)
flp_path.write_bytes(flp_bytes)
bar_count = int(max(item.bar + item.bars for item in song.items))
print(f" [{idx+1:>3}/{args.count}] {stem}.flp {len(flp_bytes):>9,}b {bar_count}bars")
# Write manifest
manifest = build_manifest(songs, filenames)
(out_dir / "manifest.json").write_text(
json.dumps(manifest, indent=2), encoding="utf-8"
)
total_size = sum((out_dir / f"{f}.flp").stat().st_size for f in filenames)
print(f"\nDone. {args.count} FLPs in {out_dir}")
print(f" Total size: {total_size:,} bytes")
if __name__ == "__main__":
main()

160
scripts/build.py Normal file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python
"""Build an FL Studio project from a composition plan JSON."""
import sys
import os
import json
import argparse
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from src.flp_builder.project import FLPProject, Note
from src.flp_builder.writer import FLPWriter
PLUGIN_NAME_MAP = {
"Serum 2": "Serum2VST3",
"Omnisphere": "Omnisphere",
"Kontakt 7": "Kontakt 7",
"Diva": "Diva",
"Electra": "Electra",
"Pigments": "Pigments",
"ravity(S)": "ravity(S)",
"FL Keys": "FL Keys",
"FPC": "FPC",
"FLEX": "FLEX",
"Sytrus": "Sytrus",
"Harmor": "Harmor",
"3x Osc": "3x Osc",
"DirectWave": "DirectWave",
"Fruity DrumSynth Live": "Fruity DrumSynth Live",
"Transistor Bass": "Transistor Bass",
"Sakura": "Sakura",
"Sawer": "Sawer",
"Toxic Biohazard": "Toxic Biohazard",
"Harmless": "Harmless",
"GMS": "GMS",
"Minisynth": "Minisynth",
"Morphine": "Morphine",
"Soundfont Player": "Soundfont Player",
}
OUTPUT_DIR = Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) / "output"
def resolve_plugin(preferred_list):
for name in preferred_list:
if name in PLUGIN_NAME_MAP:
internal = PLUGIN_NAME_MAP[name]
is_vst = name in [
"Serum 2", "Omnisphere", "Kontakt 7", "Diva",
"Electra", "Pigments", "ravity(S)",
]
return {
"internal_name": "Fruity Wrapper" if is_vst else internal,
"display_name": name,
"is_vst": is_vst,
}
return {
"internal_name": "MIDI Out",
"display_name": "MIDI Out",
"is_vst": False,
}
def build_project(composition: dict) -> FLPProject:
meta = composition["meta"]
tracks = composition["tracks"]
project = FLPProject(
tempo=meta["bpm"],
title=meta.get("title", f"{meta.get('genre', 'Untitled')} - {meta.get('key', 'C')}"),
genre=meta.get("genre", ""),
fl_version="24.7.1.73",
ppq=meta.get("ppq", 96),
)
channel_map = {}
for i, track in enumerate(tracks):
role = track["role"]
plugin_info = resolve_plugin(track.get("preferred_plugins", []))
ch = project.add_channel(
name=f"{role}_{plugin_info['display_name']}",
plugin_internal_name=plugin_info["internal_name"],
plugin_display_name=plugin_info["display_name"],
mixer_track=track.get("mixer_slot", i),
channel_type=2,
)
channel_map[role] = ch.index
bars = meta.get("bars", 8)
ppq = meta.get("ppq", 96)
beats_per_chord = meta.get("beats_per_chord", 4)
for section_idx, track in enumerate(tracks):
role = track["role"]
ch_idx = channel_map.get(role, 0)
raw_notes = track.get("notes", [])
if not raw_notes:
continue
pat = project.add_pattern(name=f"{role}")
for n in raw_notes:
note = Note(
position=n["position"],
length=n["length"],
key=n.get("key", 60),
velocity=n.get("velocity", 100),
pan=n.get("pan", 0),
mod_x=n.get("mod_x", 0),
mod_y=n.get("mod_y", 0),
)
pat.add_note(ch_idx, note)
return project
def main():
parser = argparse.ArgumentParser(description="Build FL Studio project from composition plan")
parser.add_argument("plan", help="Path to composition plan JSON")
parser.add_argument("--output", "-o", help="Output .flp file path", default=None)
args = parser.parse_args()
with open(args.plan, "r", encoding="utf-8") as f:
composition = json.load(f)
project = build_project(composition)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
if args.output:
output_path = args.output
else:
genre = composition["meta"].get("genre", "track")
key = composition["meta"].get("key", "C")
bpm = composition["meta"].get("bpm", 140)
output_path = str(OUTPUT_DIR / f"{genre}_{key}_{bpm}bpm.flp")
writer = FLPWriter(project)
writer.write(output_path)
result = {
"status": "ok",
"output": output_path,
"tempo": project.tempo,
"channels": len(project.channels),
"patterns": len(project.patterns),
"channel_names": [ch.name for ch in project.channels],
"pattern_names": [p.name for p in project.patterns],
"total_notes": sum(
len(notes)
for pat in project.patterns
for notes in pat.notes.values()
),
}
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,436 @@
"""
Build a COMPLETE reggaeton FLP with drums + melodic MIDI patterns.
Strategy:
1. Load 20 sampler channels from reference FLP (ChannelSkeletonLoader)
2. Melodic MIDI notes go on existing EMPTY channels (3, 4, 8, 17)
which are empty samplers — user assigns VST plugins in FL Studio.
3. Build 14 patterns with drum generators + inline melodic generators
4. Build 36-bar arrangement (~1:31 at 95 BPM)
5. Assemble identically to proven v15 builder — 20 channels, no VST hacks.
Output: output/reggaeton_completo.flp
"""
import struct
import sys
import os
# ── Paths ──────────────────────────────────────────────────────────────────────
BASE = r"C:\Users\Administrator\Documents\fl_control"
SAMPLES_DIR = os.path.join(BASE, "output", "samples")
CH11_TMPL = os.path.join(BASE, "output", "ch11_kick_template.bin")
REF_FLP = os.path.join(BASE, r"my space ryt\my space ryt.flp")
FLP_OUT = os.path.join(BASE, "output", "reggaeton_completo.flp")
sys.path.insert(0, BASE)
from src.flp_builder.events import (
EventID,
encode_text_event,
encode_word_event,
encode_data_event,
encode_byte_event,
encode_notes_block,
)
from src.flp_builder.skeleton import ChannelSkeletonLoader
from src.flp_builder.arrangement import (
ArrangementItem,
build_arrangement_section,
build_track_data_template,
)
from src.composer.rhythm import get_notes
# ── Constants ──────────────────────────────────────────────────────────────────
BPM = 95
PPQ = 96
# Channel indices — drums (from rhythm.py)
CH_P1 = 10; CH_K = 11; CH_S = 12; CH_R = 13
CH_P2 = 14; CH_H = 15; CH_CL = 16
# Channel indices — melodic (reuse empty sampler channels from reference)
# Ch 3, 4, 8, 17 are empty samplers (no sample loaded, cloned from ch11 tmpl)
# MIDI notes go here — user assigns VSTs manually in FL Studio
CH_808 = 3
CH_PIANO = 4
CH_LEAD = 8
CH_PAD = 17
# ── Chord Progression: Am → G → F → G (each chord = 2 bars = 8 beats) ──────
PROGRESSION = [
{
"name": "Am",
"bass_root": 45, # A2
"chord": [57, 60, 64], # A3, C4, E4
"pad": [45, 48, 52], # A2, C3, E3
"lead_root": 69, # A4
},
{
"name": "G",
"bass_root": 43, # G2
"chord": [55, 59, 62], # G3, B3, D4
"pad": [43, 47, 50], # G2, B2, D3
"lead_root": 67, # G4
},
{
"name": "F",
"bass_root": 41, # F2
"chord": [53, 57, 60], # F3, A3, C4
"pad": [41, 45, 48], # F2, A2, C3
"lead_root": 65, # F4
},
{
"name": "G",
"bass_root": 43,
"chord": [55, 59, 62],
"pad": [43, 47, 50],
"lead_root": 67,
},
]
BEATS_PER_CHORD = 8 # 2 bars per chord
# ══════════════════════════════════════════════════════════════════════════════
# MELODIC GENERATORS (inline)
# ══════════════════════════════════════════════════════════════════════════════
def _note(pos, length, key, vel):
return {"pos": pos, "len": length, "key": key, "vel": vel}
def bass_808_notes(bars):
"""808 bass following root notes. Pattern per chord (2 bars):
Beat 0: root vel110 dur3 | Beat 3.5: root vel90 dur1.5
Beat 5: root vel100 dur2 | Beat 7.5: root vel85 dur0.5
"""
notes = []
total_beats = bars * 4
chords_needed = total_beats // BEATS_PER_CHORD
for ci in range(chords_needed):
ch = PROGRESSION[ci % len(PROGRESSION)]
base = ci * BEATS_PER_CHORD
root = ch["bass_root"]
notes.append(_note(base + 0.0, 3.0, root, 110))
notes.append(_note(base + 3.5, 1.5, root, 90))
notes.append(_note(base + 5.0, 2.0, root, 100))
notes.append(_note(base + 7.5, 0.5, root, 85))
return {CH_808: notes}
def piano_stabs_notes(bars):
"""Offbeat piano stabs: beats 1.5, 2.5, 3.5, 5.5, 6.5, 7.5 per chord.
3-note triads, vel 80-90."""
notes = []
total_beats = bars * 4
chords_needed = total_beats // BEATS_PER_CHORD
stab_positions = [1.5, 2.5, 3.5, 5.5, 6.5, 7.5]
for ci in range(chords_needed):
ch = PROGRESSION[ci % len(PROGRESSION)]
base = ci * BEATS_PER_CHORD
for sp in stab_positions:
vel = 80 + (hash((ci, sp)) % 11)
for pitch in ch["chord"]:
notes.append(_note(base + sp, 0.15, pitch, vel))
return {CH_PIANO: notes}
def piano_sparse_notes(bars):
"""Sparse piano for intro/breakdown: beats 2.5 and 6.5 only, vel 65-70."""
notes = []
total_beats = bars * 4
chords_needed = total_beats // BEATS_PER_CHORD
for ci in range(chords_needed):
ch = PROGRESSION[ci % len(PROGRESSION)]
base = ci * BEATS_PER_CHORD
for sp in [2.5, 6.5]:
vel = 65 + (hash((ci, sp)) % 6)
for pitch in ch["chord"]:
notes.append(_note(base + sp, 0.15, pitch, vel))
return {CH_PIANO: notes}
def lead_hook_notes(bars):
"""Melodic hook emphasizing chord tones per 2-bar cycle."""
notes = []
total_beats = bars * 4
chords_needed = total_beats // BEATS_PER_CHORD
for ci in range(chords_needed):
ch = PROGRESSION[ci % len(PROGRESSION)]
base = ci * BEATS_PER_CHORD
lr = ch["lead_root"]
notes.append(_note(base + 0.0, 1.0, ch["chord"][0], 100))
notes.append(_note(base + 1.0, 0.5, ch["chord"][2], 95))
notes.append(_note(base + 2.0, 0.75, ch["chord"][1], 90))
notes.append(_note(base + 3.5, 0.25, lr, 85))
notes.append(_note(base + 5.0, 0.5, ch["chord"][2], 95))
notes.append(_note(base + 5.5, 1.0, ch["chord"][0], 100))
notes.append(_note(base + 6.5, 0.5, lr + 2, 80))
return {CH_LEAD: notes}
def pad_sustained_notes(bars):
"""Long sustained pad chords. 3 notes per chord, vel 65, dur 7.5 beats."""
notes = []
total_beats = bars * 4
chords_needed = total_beats // BEATS_PER_CHORD
for ci in range(chords_needed):
ch = PROGRESSION[ci % len(PROGRESSION)]
base = ci * BEATS_PER_CHORD
for pitch in ch["pad"]:
notes.append(_note(base + 0.0, 7.5, pitch, 65))
return {CH_PAD: notes}
MELODIC_GENERATORS = {
"bass_808_notes": bass_808_notes,
"piano_stabs_notes": piano_stabs_notes,
"piano_sparse_notes": piano_sparse_notes,
"lead_hook_notes": lead_hook_notes,
"pad_sustained_notes": pad_sustained_notes,
}
# ══════════════════════════════════════════════════════════════════════════════
# PATTERN DEFINITIONS
# ══════════════════════════════════════════════════════════════════════════════
PATTERNS = [
{"id": 1, "name": "Kick Main", "generator": "kick_main_notes", "bars": 8},
{"id": 2, "name": "Kick Sparse", "generator": "kick_sparse_notes", "bars": 8},
{"id": 3, "name": "Snare Verse", "generator": "snare_verse_notes", "bars": 8},
{"id": 4, "name": "Hihat 16th", "generator": "hihat_16th_notes", "bars": 8},
{"id": 5, "name": "Hihat 8th", "generator": "hihat_8th_notes", "bars": 8},
{"id": 6, "name": "Clap 24", "generator": "clap_24_notes", "bars": 8},
{"id": 7, "name": "Rim Build", "generator": "rim_build_notes", "bars": 4},
{"id": 8, "name": "Perc Combo", "generator": "perc_combo_notes", "bars": 8},
{"id": 9, "name": "Kick Outro", "generator": "kick_outro_notes", "bars": 8},
{"id": 10, "name": "808 Bass", "generator": "bass_808_notes", "bars": 8, "melodic": True},
{"id": 11, "name": "Piano Stabs", "generator": "piano_stabs_notes", "bars": 8, "melodic": True},
{"id": 12, "name": "Piano Sparse", "generator": "piano_sparse_notes","bars": 8, "melodic": True},
{"id": 13, "name": "Lead Hook", "generator": "lead_hook_notes", "bars": 8, "melodic": True},
{"id": 14, "name": "Pad Sustained","generator": "pad_sustained_notes","bars": 8, "melodic": True},
]
# ══════════════════════════════════════════════════════════════════════════════
# ARRANGEMENT (36 bars = ~1:31 at 95 BPM)
# 9 arrangement tracks
# ══════════════════════════════════════════════════════════════════════════════
ARRANGEMENT_ITEMS = [
# INTRO (0-4)
{"pattern": 2, "bar": 0, "bars": 4, "track": 0},
{"pattern": 5, "bar": 0, "bars": 4, "track": 2},
{"pattern": 14, "bar": 0, "bars": 4, "track": 8},
{"pattern": 12, "bar": 0, "bars": 4, "track": 6},
# VERSE (4-12)
{"pattern": 1, "bar": 4, "bars": 8, "track": 0},
{"pattern": 3, "bar": 4, "bars": 8, "track": 1},
{"pattern": 4, "bar": 4, "bars": 8, "track": 2},
{"pattern": 8, "bar": 4, "bars": 8, "track": 4},
{"pattern": 10, "bar": 4, "bars": 8, "track": 5},
{"pattern": 11, "bar": 4, "bars": 8, "track": 6},
{"pattern": 14, "bar": 4, "bars": 8, "track": 8},
# PRE-CHORUS (12-16)
{"pattern": 1, "bar": 12, "bars": 4, "track": 0},
{"pattern": 3, "bar": 12, "bars": 4, "track": 1},
{"pattern": 4, "bar": 12, "bars": 4, "track": 2},
{"pattern": 7, "bar": 12, "bars": 4, "track": 3},
{"pattern": 8, "bar": 12, "bars": 4, "track": 4},
{"pattern": 10, "bar": 12, "bars": 4, "track": 5},
{"pattern": 11, "bar": 12, "bars": 4, "track": 6},
{"pattern": 14, "bar": 12, "bars": 4, "track": 8},
# CHORUS (16-24)
{"pattern": 1, "bar": 16, "bars": 8, "track": 0},
{"pattern": 3, "bar": 16, "bars": 8, "track": 1},
{"pattern": 4, "bar": 16, "bars": 8, "track": 2},
{"pattern": 6, "bar": 16, "bars": 8, "track": 3},
{"pattern": 8, "bar": 16, "bars": 8, "track": 4},
{"pattern": 10, "bar": 16, "bars": 8, "track": 5},
{"pattern": 11, "bar": 16, "bars": 8, "track": 6},
{"pattern": 13, "bar": 16, "bars": 8, "track": 7},
{"pattern": 14, "bar": 16, "bars": 8, "track": 8},
# BREAKDOWN (24-28)
{"pattern": 5, "bar": 24, "bars": 4, "track": 2},
{"pattern": 14, "bar": 24, "bars": 4, "track": 8},
{"pattern": 12, "bar": 24, "bars": 4, "track": 6},
# OUTRO (28-36)
{"pattern": 9, "bar": 28, "bars": 8, "track": 0},
{"pattern": 3, "bar": 28, "bars": 8, "track": 1},
{"pattern": 4, "bar": 28, "bars": 8, "track": 2},
{"pattern": 14, "bar": 28, "bars": 8, "track": 8},
]
# ══════════════════════════════════════════════════════════════════════════════
# HEADER BUILDER
# ══════════════════════════════════════════════════════════════════════════════
def _read_ev(data, pos):
s = pos
ib = data[pos]
pos += 1
if ib < 64:
return pos + 1, s, ib, data[s + 1], "byte"
elif ib < 128:
return pos + 2, s, ib, struct.unpack("<H", data[pos:pos + 2])[0], "word"
elif ib < 192:
return pos + 4, s, ib, struct.unpack("<I", data[pos:pos + 4])[0], "dword"
else:
sz = 0; sh = 0
while True:
b = data[pos]; pos += 1
sz |= (b & 0x7F) << sh; sh += 7
if not (b & 0x80): break
return pos + sz, s, ib, data[pos:pos + sz], "data"
def build_header(ref_bytes):
"""Extract header events from reference FLP, patch BPM to 95."""
pos = 22
first_pat = None
while pos < len(ref_bytes):
np, st, ib, val, vt = _read_ev(ref_bytes, pos)
if ib == EventID.PatNew:
first_pat = st
break
pos = np
if first_pat is None:
raise ValueError("No PatNew event found in reference FLP")
header = bytearray(ref_bytes[22:first_pat])
# Patch BPM
p = 0
while p < len(header):
np, _, ib, val, vt = _read_ev(bytes(header), p)
if ib == EventID.Tempo:
struct.pack_into("<I", header, p + 1, BPM * 1000)
break
p = np
return bytes(header)
# ══════════════════════════════════════════════════════════════════════════════
# PATTERN BUILDER
# ══════════════════════════════════════════════════════════════════════════════
def _convert_rhythm_notes(notes):
return [
{"position": n["pos"], "length": n["len"], "key": n["key"], "velocity": n["vel"]}
for n in notes
]
def build_all_patterns():
buf = bytearray()
for pat_def in PATTERNS:
pat_id = pat_def["id"]
gen_name = pat_def["generator"]
bars = pat_def["bars"]
is_melodic = pat_def.get("melodic", False)
buf += encode_word_event(EventID.PatNew, pat_id - 1)
buf += encode_text_event(EventID.PatName, pat_def["name"])
if is_melodic:
notes_by_channel = MELODIC_GENERATORS[gen_name](bars)
else:
notes_by_channel = get_notes(gen_name, bars)
for ch_idx, raw_notes in notes_by_channel.items():
if not raw_notes:
continue
converted = _convert_rhythm_notes(raw_notes)
buf += encode_data_event(
EventID.PatNotes,
encode_notes_block(ch_idx, converted, PPQ),
)
return bytes(buf)
# ══════════════════════════════════════════════════════════════════════════════
# MAIN BUILD — identical assembly to proven v15 builder
# ══════════════════════════════════════════════════════════════════════════════
def build_complete_reggaeton():
print("=" * 60)
print("Building COMPLETE reggaeton FLP (drums + melodic MIDI)")
print("=" * 60)
for p in [REF_FLP, CH11_TMPL]:
assert os.path.isfile(p), f"MISSING: {p}"
ref_bytes = open(REF_FLP, "rb").read()
num_channels = struct.unpack("<H", ref_bytes[10:12])[0]
print(f"Reference FLP: {len(ref_bytes):,} bytes, {num_channels} channels")
# 1. Load sampler channels (identical to v15)
print("\n[1/4] Loading sampler channels...")
loader = ChannelSkeletonLoader(REF_FLP, CH11_TMPL, SAMPLES_DIR)
sample_map = {
"perc1": "perc1.wav", "kick": "kick.wav", "snare": "snare.wav",
"rim": "rim.wav", "perc2": "perc2.wav", "hihat": "hihat.wav",
"clap": "clap.wav",
}
channel_bytes = loader.load(sample_map)
print(f" Channels: {len(channel_bytes):,} bytes ({num_channels} channels)")
# 2. Build header + patterns
print("\n[2/4] Building header + patterns...")
header_bytes = build_header(ref_bytes)
pattern_bytes = build_all_patterns()
print(f" Header: {len(header_bytes):,} bytes")
print(f" Patterns: {len(pattern_bytes):,} bytes ({len(PATTERNS)} patterns)")
# 3. Build arrangement
print("\n[3/4] Building arrangement...")
track_data_template = build_track_data_template(ref_bytes)
items = [
ArrangementItem(
pattern_id=it["pattern"], bar=it["bar"],
num_bars=it["bars"], track_index=it["track"],
)
for it in ARRANGEMENT_ITEMS
]
arrangement_bytes = build_arrangement_section(items, track_data_template, ppq=PPQ)
print(f" Arrangement: {len(arrangement_bytes):,} bytes ({len(items)} items)")
# 4. Assemble — identical to v15 builder
print("\n[4/4] Assembling FLP...")
body = header_bytes + pattern_bytes + channel_bytes + arrangement_bytes
flp = (
struct.pack("<4sIhHH", b"FLhd", 6, 0, num_channels, PPQ)
+ b"FLdt" + struct.pack("<I", len(body))
+ body
)
os.makedirs(os.path.dirname(FLP_OUT), exist_ok=True)
with open(FLP_OUT, "wb") as f:
f.write(flp)
duration = (36 * 4 / BPM) * 60
print(f"\n{'=' * 60}")
print(f"Output: {FLP_OUT}")
print(f"Size: {len(flp):,} bytes")
print(f"Duration: ~{duration:.0f}s (36 bars at {BPM} BPM)")
print(f"Channels: {num_channels} (unchanged from reference)")
print(f"Patterns: {len(PATTERNS)} (9 drums + 5 melodic)")
print(f"{'=' * 60}")
print()
print("MELODIC CHANNELS (assign VSTs manually in FL Studio):")
print(f" Ch {CH_808}: 808 Bass -> Serum2")
print(f" Ch {CH_PIANO}: Piano -> Pigments")
print(f" Ch {CH_LEAD}: Lead -> Serum2")
print(f" Ch {CH_PAD}: Pad -> Omnisphere")
return flp
if __name__ == "__main__":
build_complete_reggaeton()

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""CLI: build a single FLP from a JSON song definition.
Usage:
python scripts/build_from_json.py <song.json> [--out <output.flp>]
"""
import argparse
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parents[1]))
from src.flp_builder.schema import load_song_json
from src.flp_builder.builder import FLPBuilder
def main():
parser = argparse.ArgumentParser(
description="Build FLP from JSON song definition"
)
parser.add_argument("song_json", help="Path to song .json file")
parser.add_argument(
"--out", help="Output .flp path (default: same name as JSON)"
)
args = parser.parse_args()
json_path = Path(args.song_json)
out_path = (
Path(args.out) if args.out else json_path.with_suffix(".flp")
)
song = load_song_json(json_path)
builder = FLPBuilder()
flp = builder.build(song)
out_path.write_bytes(flp)
print(f"Built {out_path} ({len(flp):,} bytes)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,610 @@
"""
Build a PROFESSIONAL reggaeton FLP with REAL SAMPLES from the user's library.
Key facts:
- Only Ch10-19 are sampler channels in the reference FLP (Ch0-9 are VST/plugin)
- Each sampler channel loads a real WAV from libreria/reggaeton/
- MIDI notes trigger those real samples
- 10 channels = kick, snare, hihat, 808, bell, lead, pad, clap, perc, rim
Sample selection (professional reggaeton):
Ch10: kick nes 1 — classic reggaeton kick
Ch11: snare nes 1 — clean reggaeton snare
Ch12: hi-hat 1 — tight hihat
Ch13: Bass Reventado — deep 808 bass (dastin.prod)
Ch14: bell 4 — bell tone for chords
Ch15: lead 3 — melodic lead
Ch16: pad 1 — sustained pad
Ch17: clap — reggaeton clap (using snap from perc loop)
Ch18: perc 1 — perc one shot
Ch19: rim — rim/rimshot
Output: output/reggaeton_fuego.flp
"""
import struct
import sys
import os
# ── Paths ──────────────────────────────────────────────────────────────────────
BASE = r"C:\Users\Administrator\Documents\fl_control"
CH11_TMPL = os.path.join(BASE, "output", "ch11_kick_template.bin")
REF_FLP = os.path.join(BASE, r"my space ryt\my space ryt.flp")
FLP_OUT = os.path.join(BASE, "output", "reggaeton_fuego.flp")
# All samples copied here — clean names, no special chars
SAMPLES_DIR = os.path.join(BASE, "output", "fuego_samples")
sys.path.insert(0, BASE)
from src.flp_builder.events import (
EventID,
encode_text_event,
encode_word_event,
encode_data_event,
encode_notes_block,
)
from src.flp_builder.skeleton import ChannelSkeletonLoader
from src.flp_builder.arrangement import (
ArrangementItem,
build_arrangement_section,
build_track_data_template,
)
# ── Constants ──────────────────────────────────────────────────────────────────
BPM = 95
PPQ = 96
# Channel indices — ALL sampler channels (10-19)
CH_KICK = 10
CH_SNARE = 11
CH_HH = 12
CH_808 = 13
CH_BELL = 14
CH_LEAD = 15
CH_PAD = 16
CH_CLAP = 17
CH_PERC = 18
CH_RIM = 19
# Sample assignment: ch_idx → (samples_dir, wav_filename)
# All samples in fuego_samples/ with clean names
SAMPLE_ASSIGNMENT = {
CH_KICK: (SAMPLES_DIR, "kick.wav"),
CH_SNARE: (SAMPLES_DIR, "snare.wav"),
CH_HH: (SAMPLES_DIR, "hihat.wav"),
CH_808: (SAMPLES_DIR, "bass_808.wav"),
CH_BELL: (SAMPLES_DIR, "bell.wav"),
CH_LEAD: (SAMPLES_DIR, "lead.wav"),
CH_PAD: (SAMPLES_DIR, "pad.wav"),
CH_CLAP: (SAMPLES_DIR, "clap.wav"),
CH_PERC: (SAMPLES_DIR, "perc.wav"),
CH_RIM: (SAMPLES_DIR, "rim.wav"),
}
# ══════════════════════════════════════════════════════════════════════════════
# FUEGO CHORD PROGRESSION: Am → Dm → F → E
# ══════════════════════════════════════════════════════════════════════════════
PROGRESSION = [
{"name": "Am", "bass": 33, "chord": [45,48,52,57], "triad": [57,60,64], "root": 69},
{"name": "Dm", "bass": 38, "chord": [50,53,57,62], "triad": [62,65,69], "root": 74},
{"name": "F", "bass": 41, "chord": [53,57,60,65], "triad": [65,69,72], "root": 77},
{"name": "E", "bass": 40, "chord": [52,56,59,64], "triad": [64,68,71], "root": 76},
]
BEATS_PER_CHORD = 8
# ══════════════════════════════════════════════════════════════════════════════
# DRUM GENERATORS — using correct channel indices
# ══════════════════════════════════════════════════════════════════════════════
def _n(pos, length, ch, vel):
return {"pos": pos, "len": length, "key": 60, "vel": max(1, min(127, vel))}
def dembow_kick(bars, vel_mult=1.0):
"""REAL dembow: 0.0, 2.0, 3.25"""
notes = []
for b in range(bars):
o = b * 4.0
notes.append(_n(o, 0.25, CH_KICK, int(120 * vel_mult)))
notes.append(_n(o + 2.0, 0.25, CH_KICK, int(110 * vel_mult)))
notes.append(_n(o + 3.25, 0.15, CH_KICK, int(90 * vel_mult)))
return {CH_KICK: notes}
def perreador_kick(bars, vel_mult=1.0):
"""Perreador: every beat + offbeat ghosts."""
notes = []
for b in range(bars):
o = b * 4.0
for beat in range(4):
notes.append(_n(o + beat, 0.25, CH_KICK, int(115 * vel_mult)))
notes.append(_n(o + beat + 0.5, 0.15, CH_KICK, int(80 * vel_mult)))
return {CH_KICK: notes}
def sparse_kick(bars, vel_mult=1.0):
notes = []
for b in range(bars):
notes.append(_n(b * 4.0, 0.25, CH_KICK, int(100 * vel_mult)))
return {CH_KICK: notes}
def snare_standard(bars, vel_mult=1.0):
"""Snare: beats 2, 3-and (positions 1.25, 3.0)."""
notes = []
for b in range(bars):
o = b * 4.0
notes.append(_n(o + 1.25, 0.15, CH_SNARE, int(105 * vel_mult)))
notes.append(_n(o + 3.0, 0.15, CH_SNARE, int(100 * vel_mult)))
return {CH_SNARE: notes}
def snare_intense(bars, vel_mult=1.0):
"""Intense snare with ghost hits."""
notes = []
for b in range(bars):
o = b * 4.0
notes.append(_n(o + 1.25, 0.15, CH_SNARE, int(110 * vel_mult)))
notes.append(_n(o + 1.75, 0.10, CH_SNARE, int(70 * vel_mult)))
notes.append(_n(o + 3.0, 0.15, CH_SNARE, int(105 * vel_mult)))
notes.append(_n(o + 3.5, 0.10, CH_SNARE, int(65 * vel_mult)))
return {CH_SNARE: notes}
def hihat_offbeat(bars, vel_mult=1.0):
notes = []
for b in range(bars):
o = b * 4.0
for i in range(4):
notes.append(_n(o + i + 0.5, 0.1, CH_HH, int(55 * vel_mult)))
return {CH_HH: notes}
def hihat_8th(bars, vel_mult=1.0):
notes = []
for b in range(bars):
o = b * 4.0
for i in range(8):
v = 70 if i % 2 == 0 else 50
notes.append(_n(o + i * 0.5, 0.1, CH_HH, int(v * vel_mult)))
return {CH_HH: notes}
def hihat_16th(bars, vel_mult=1.0):
"""Full 16ths with accents and open hats."""
notes = []
for b in range(bars):
o = b * 4.0
for i in range(16):
p = i * 0.25
if p % 1.0 == 0.0:
v, l = 90, 0.1
elif p % 0.5 == 0.0:
v, l = 65, 0.1
else:
v, l = 40, 0.08
if i in [5, 10]:
l = 0.2; v = int(v * 1.2)
notes.append(_n(o + p, l, CH_HH, int(v * vel_mult)))
return {CH_HH: notes}
def clap_standard(bars, vel_mult=1.0):
notes = []
for b in range(bars):
o = b * 4.0
notes.append(_n(o + 1.0, 0.15, CH_CLAP, int(120 * vel_mult)))
notes.append(_n(o + 3.0, 0.15, CH_CLAP, int(115 * vel_mult)))
return {CH_CLAP: notes}
def clap_soft(bars, vel_mult=1.0):
notes = []
for b in range(bars):
o = b * 4.0
notes.append(_n(o + 1.0, 0.15, CH_CLAP, int(80 * vel_mult)))
notes.append(_n(o + 3.0, 0.15, CH_CLAP, int(75 * vel_mult)))
return {CH_CLAP: notes}
def perc_offbeat(bars, vel_mult=1.0):
notes = []
for b in range(bars):
o = b * 4.0
notes.append(_n(o + 0.75, 0.1, CH_PERC, int(85 * vel_mult)))
notes.append(_n(o + 2.75, 0.1, CH_PERC, int(80 * vel_mult)))
return {CH_PERC: notes}
def rim_build(bars, vel_mult=1.0):
"""Rim roll building intensity."""
PATTERNS = [[0,2,8,14], [0,2,4,8,10,14], [0,2,4,6,8,10,12,14], list(range(16))]
VELS = [50, 65, 80, 100]
notes = []
for b in range(bars):
o = b * 4.0
v = int(VELS[b % 4] * vel_mult)
for idx in PATTERNS[b % 4]:
notes.append(_n(o + idx * 0.25, 0.1, CH_RIM, v))
return {CH_RIM: notes}
# ══════════════════════════════════════════════════════════════════════════════
# MELODIC GENERATORS
# ══════════════════════════════════════════════════════════════════════════════
def _mn(pos, length, key, vel):
"""Melodic note — pitch matters."""
return {"pos": pos, "len": length, "key": key, "vel": max(1, min(127, vel))}
def bass_808_full(bars, vel_mult=1.0):
"""808 bass with chord-root movement + fifth variation."""
notes = []
total = bars * 4
chords = total // BEATS_PER_CHORD
for ci in range(chords):
ch = PROGRESSION[ci % 4]
base = ci * BEATS_PER_CHORD
r = ch["bass"]
f = r + 7
v = vel_mult
notes.append(_mn(base + 0.0, 2.5, r, int(110*v)))
notes.append(_mn(base + 2.5, 0.5, f, int(80*v)))
notes.append(_mn(base + 3.0, 2.0, r, int(105*v)))
notes.append(_mn(base + 5.0, 1.0, r, int(90*v)))
notes.append(_mn(base + 6.0, 0.5, f, int(75*v)))
notes.append(_mn(base + 6.5, 1.5, r, int(100*v)))
return {CH_808: notes}
def bass_808_sparse(bars, vel_mult=1.0):
"""Sparse 808 for intro — just root, long sustain."""
notes = []
total = bars * 4
chords = total // BEATS_PER_CHORD
for ci in range(chords):
ch = PROGRESSION[ci % 4]
notes.append(_mn(ci * BEATS_PER_CHORD, 7.5, ch["bass"], int(60 * vel_mult)))
return {CH_808: notes}
def bell_chords(bars, vel_mult=1.0):
"""Bell playing offbeat chord stabs — 4-note voicings."""
notes = []
total = bars * 4
chords = total // BEATS_PER_CHORD
stabs = [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5]
for ci in range(chords):
ch = PROGRESSION[ci % 4]
base = ci * BEATS_PER_CHORD
for sp in stabs:
v = int((85 + (hash((ci, sp)) % 10)) * vel_mult)
for pitch in ch["triad"]:
notes.append(_mn(base + sp, 0.12, pitch, v))
return {CH_BELL: notes}
def bell_sparse(bars, vel_mult=1.0):
"""Sparse bell for intro — 4-note voicings, beats 2.5 and 6.5."""
notes = []
total = bars * 4
chords = total // BEATS_PER_CHORD
for ci in range(chords):
ch = PROGRESSION[ci % 4]
base = ci * BEATS_PER_CHORD
for sp in [2.5, 6.5]:
v = int(60 * vel_mult)
for pitch in ch["chord"]:
notes.append(_mn(base + sp, 0.15, pitch, v))
return {CH_BELL: notes}
def lead_hook(bars, vel_mult=1.0):
"""Lead melody — arch contour, chord tones on strong beats."""
notes = []
total = bars * 4
chords = total // BEATS_PER_CHORD
for ci in range(chords):
ch = PROGRESSION[ci % 4]
base = ci * BEATS_PER_CHORD
lr = ch["root"]
c = ch["triad"]
v = vel_mult
notes.append(_mn(base + 0.0, 1.0, c[0], int(95*v)))
notes.append(_mn(base + 1.0, 0.5, c[1], int(85*v)))
notes.append(_mn(base + 1.5, 0.5, c[2], int(100*v)))
notes.append(_mn(base + 2.0, 1.5, lr, int(105*v)))
notes.append(_mn(base + 3.5, 0.5, c[2], int(90*v)))
notes.append(_mn(base + 4.0, 0.5, c[1], int(80*v)))
notes.append(_mn(base + 4.5, 1.5, c[0], int(95*v)))
notes.append(_mn(base + 6.0, 0.5, lr-2, int(75*v)))
notes.append(_mn(base + 6.5, 1.5, c[0], int(90*v)))
return {CH_LEAD: notes}
def pad_sustained(bars, vel_mult=1.0):
"""Sustained pad — 4-note voicings."""
notes = []
total = bars * 4
chords = total // BEATS_PER_CHORD
for ci in range(chords):
ch = PROGRESSION[ci % 4]
base = ci * BEATS_PER_CHORD
for pitch in ch["chord"]:
notes.append(_mn(base, 7.5, pitch, int(60 * vel_mult)))
return {CH_PAD: notes}
def pad_swell(bars, vel_mult=1.0):
"""Pad swell for pre-chorus — crescendo within chord."""
notes = []
total = bars * 4
chords = total // BEATS_PER_CHORD
for ci in range(chords):
ch = PROGRESSION[ci % 4]
base = ci * BEATS_PER_CHORD
for pitch in ch["chord"]:
notes.append(_mn(base, 4.0, pitch, int(45 * vel_mult)))
notes.append(_mn(base + 4, 3.5, pitch, int(70 * vel_mult)))
return {CH_PAD: notes}
# ══════════════════════════════════════════════════════════════════════════════
# PATTERN DEFINITIONS — 20 patterns
# ══════════════════════════════════════════════════════════════════════════════
# All generators return {ch_idx: [notes]}
ALL_GENERATORS = {
"dembow_kick": dembow_kick,
"perreador_kick": perreador_kick,
"sparse_kick": sparse_kick,
"snare_std": snare_standard,
"snare_intense": snare_intense,
"hh_offbeat": hihat_offbeat,
"hh_8th": hihat_8th,
"hh_16th": hihat_16th,
"clap_std": clap_standard,
"clap_soft": clap_soft,
"perc_offbeat": perc_offbeat,
"rim_build": rim_build,
"bass_full": bass_808_full,
"bass_sparse": bass_808_sparse,
"bell_chords": bell_chords,
"bell_sparse": bell_sparse,
"lead_hook": lead_hook,
"pad_sustained": pad_sustained,
"pad_swell": pad_swell,
}
PATTERNS = [
{"id": 1, "name": "Kick Dembow", "gen": "dembow_kick", "bars": 8},
{"id": 2, "name": "Kick Perreador", "gen": "perreador_kick","bars": 8},
{"id": 3, "name": "Kick Sparse", "gen": "sparse_kick", "bars": 8},
{"id": 4, "name": "Snare Standard", "gen": "snare_std", "bars": 8},
{"id": 5, "name": "Snare Intense", "gen": "snare_intense", "bars": 8},
{"id": 6, "name": "HH Offbeat", "gen": "hh_offbeat", "bars": 8},
{"id": 7, "name": "HH 8th", "gen": "hh_8th", "bars": 8},
{"id": 8, "name": "HH 16th Full", "gen": "hh_16th", "bars": 8},
{"id": 9, "name": "Clap Standard", "gen": "clap_std", "bars": 8},
{"id": 10, "name": "Perc Offbeat", "gen": "perc_offbeat", "bars": 8},
{"id": 11, "name": "Rim Build", "gen": "rim_build", "bars": 4},
{"id": 12, "name": "808 Bass Full", "gen": "bass_full", "bars": 8},
{"id": 13, "name": "808 Bass Sparse", "gen": "bass_sparse", "bars": 8},
{"id": 14, "name": "Bell Chords", "gen": "bell_chords", "bars": 8},
{"id": 15, "name": "Bell Sparse", "gen": "bell_sparse", "bars": 8},
{"id": 16, "name": "Lead Hook", "gen": "lead_hook", "bars": 8},
{"id": 17, "name": "Pad Sustained", "gen": "pad_sustained", "bars": 8},
{"id": 18, "name": "Pad Swell", "gen": "pad_swell", "bars": 8},
]
# ══════════════════════════════════════════════════════════════════════════════
# ARRANGEMENT — 48 bars, 7 sections
# 10 tracks (one per sampler channel Ch10-19)
# Track index in arrangement: 0=kick, 1=snare, 2=hh, 3=808, 4=bell,
# 5=lead, 6=pad, 7=clap, 8=perc, 9=rim
# ══════════════════════════════════════════════════════════════════════════════
ARRANGEMENT_ITEMS = [
# INTRO (0-4): ghostly, sparse
{"pattern": 3, "bar": 0, "bars": 4, "track": 0}, # sparse kick
{"pattern": 6, "bar": 0, "bars": 4, "track": 2}, # offbeat HH
{"pattern": 13, "bar": 0, "bars": 4, "track": 3}, # sparse 808
{"pattern": 15, "bar": 0, "bars": 4, "track": 4}, # sparse bell
{"pattern": 17, "bar": 0, "bars": 4, "track": 6}, # pad sustained
# VERSE 1 (4-12): warming up
{"pattern": 1, "bar": 4, "bars": 8, "track": 0}, # dembow kick
{"pattern": 4, "bar": 4, "bars": 8, "track": 1}, # snare std
{"pattern": 7, "bar": 4, "bars": 8, "track": 2}, # HH 8th
{"pattern": 12, "bar": 4, "bars": 8, "track": 3}, # 808 full
{"pattern": 15, "bar": 4, "bars": 8, "track": 4}, # sparse bell
{"pattern": 17, "bar": 4, "bars": 8, "track": 6}, # pad
# PRE-CHORUS (12-16): building tension
{"pattern": 1, "bar": 12, "bars": 4, "track": 0}, # dembow kick
{"pattern": 5, "bar": 12, "bars": 4, "track": 1}, # snare intense
{"pattern": 11, "bar": 12, "bars": 4, "track": 9}, # rim build
{"pattern": 7, "bar": 12, "bars": 4, "track": 2}, # HH 8th
{"pattern": 12, "bar": 12, "bars": 4, "track": 3}, # 808 full
{"pattern": 14, "bar": 12, "bars": 4, "track": 4}, # bell chords
{"pattern": 18, "bar": 12, "bars": 4, "track": 6}, # pad swell
# CHORUS (16-24): FULL ENERGY
{"pattern": 2, "bar": 16, "bars": 8, "track": 0}, # perreador kick!
{"pattern": 5, "bar": 16, "bars": 8, "track": 1}, # snare intense
{"pattern": 8, "bar": 16, "bars": 8, "track": 2}, # HH 16th
{"pattern": 9, "bar": 16, "bars": 8, "track": 7}, # clap
{"pattern": 10, "bar": 16, "bars": 8, "track": 8}, # perc offbeat
{"pattern": 12, "bar": 16, "bars": 8, "track": 3}, # 808 full
{"pattern": 14, "bar": 16, "bars": 8, "track": 4}, # bell chords
{"pattern": 16, "bar": 16, "bars": 8, "track": 5}, # lead hook
{"pattern": 17, "bar": 16, "bars": 8, "track": 6}, # pad
# VERSE 2 (24-32): energy maintained, no lead
{"pattern": 1, "bar": 24, "bars": 8, "track": 0}, # dembow kick
{"pattern": 4, "bar": 24, "bars": 8, "track": 1}, # snare std
{"pattern": 7, "bar": 24, "bars": 8, "track": 2}, # HH 8th
{"pattern": 9, "bar": 24, "bars": 8, "track": 7}, # clap
{"pattern": 12, "bar": 24, "bars": 8, "track": 3}, # 808 full
{"pattern": 14, "bar": 24, "bars": 8, "track": 4}, # bell chords
{"pattern": 17, "bar": 24, "bars": 8, "track": 6}, # pad
# BREAKDOWN (32-36): stripped
{"pattern": 3, "bar": 32, "bars": 4, "track": 0}, # sparse kick
{"pattern": 6, "bar": 32, "bars": 4, "track": 2}, # offbeat HH
{"pattern": 13, "bar": 32, "bars": 4, "track": 3}, # sparse 808
{"pattern": 15, "bar": 32, "bars": 4, "track": 4}, # sparse bell
{"pattern": 17, "bar": 32, "bars": 4, "track": 6}, # pad
# OUTRO (36-48): fading
{"pattern": 1, "bar": 36, "bars": 12, "track": 0}, # dembow kick
{"pattern": 4, "bar": 36, "bars": 12, "track": 1}, # snare std
{"pattern": 7, "bar": 36, "bars": 12, "track": 2}, # HH 8th
{"pattern": 17, "bar": 36, "bars": 12, "track": 6}, # pad
]
# ══════════════════════════════════════════════════════════════════════════════
# HEADER BUILDER
# ══════════════════════════════════════════════════════════════════════════════
def _read_ev(data, pos):
s = pos
ib = data[pos]; pos += 1
if ib < 64: return pos + 1, s, ib, data[s + 1], "byte"
elif ib < 128: return pos + 2, s, ib, struct.unpack("<H", data[pos:pos + 2])[0], "word"
elif ib < 192: return pos + 4, s, ib, struct.unpack("<I", data[pos:pos + 4])[0], "dword"
else:
sz = 0; sh = 0
while True:
b = data[pos]; pos += 1
sz |= (b & 0x7F) << sh; sh += 7
if not (b & 0x80): break
return pos + sz, s, ib, data[pos:pos + sz], "data"
def build_header(ref_bytes):
pos = 22
first_pat = None
while pos < len(ref_bytes):
np, st, ib, val, vt = _read_ev(ref_bytes, pos)
if ib == EventID.PatNew:
first_pat = st
break
pos = np
if first_pat is None:
raise ValueError("No PatNew found")
header = bytearray(ref_bytes[22:first_pat])
p = 0
while p < len(header):
np, _, ib, val, vt = _read_ev(bytes(header), p)
if ib == EventID.Tempo:
struct.pack_into("<I", header, p + 1, BPM * 1000)
break
p = np
return bytes(header)
# ══════════════════════════════════════════════════════════════════════════════
# PATTERN BUILDER
# ══════════════════════════════════════════════════════════════════════════════
def _conv(notes):
return [{"position": n["pos"], "length": n["len"], "key": n["key"], "velocity": n["vel"]} for n in notes]
def build_all_patterns():
buf = bytearray()
for pat_def in PATTERNS:
buf += encode_word_event(EventID.PatNew, pat_def["id"] - 1)
buf += encode_text_event(EventID.PatName, pat_def["name"])
notes_by_ch = ALL_GENERATORS[pat_def["gen"]](pat_def["bars"])
for ch_idx, raw_notes in notes_by_ch.items():
if not raw_notes:
continue
buf += encode_data_event(EventID.PatNotes, encode_notes_block(ch_idx, _conv(raw_notes), PPQ))
return bytes(buf)
# ══════════════════════════════════════════════════════════════════════════════
# MAIN BUILD
# ══════════════════════════════════════════════════════════════════════════════
def build_fuego():
print("=" * 60)
print("FUEGO reggaeton — REAL SAMPLES from library")
print("=" * 60)
print(f"Progression: Am -> Dm -> F -> E")
print(f"Samples from: libreria/reggaeton/")
print(f"Channels: Ch10-19 (all sampler)")
print(f"Arrangement: 48 bars, 7 sections")
print("=" * 60)
assert os.path.isfile(REF_FLP), f"MISSING: {REF_FLP}"
ref_bytes = open(REF_FLP, "rb").read()
num_channels = struct.unpack("<H", ref_bytes[10:12])[0]
print(f"\nReference: {len(ref_bytes):,} bytes, {num_channels} channels")
# 1. Load channels — ALL 10 samplers get real samples from fuego_samples/
print("\n[1/4] Loading channels with real samples...")
loader = ChannelSkeletonLoader(REF_FLP, CH11_TMPL, SAMPLES_DIR)
# All channels use samples from fuego_samples/
channel_bytes = loader.load(melodic_map=SAMPLE_ASSIGNMENT)
print(f" Channels: {len(channel_bytes):,} bytes")
for ch_idx in sorted(SAMPLE_ASSIGNMENT.keys()):
_, w = SAMPLE_ASSIGNMENT[ch_idx]
print(f" Ch{ch_idx}: {w}")
# 2. Build header + patterns
print("\n[2/4] Building header + patterns...")
header_bytes = build_header(ref_bytes)
pattern_bytes = build_all_patterns()
print(f" Header: {len(header_bytes):,} bytes")
print(f" Patterns: {len(pattern_bytes):,} bytes ({len(PATTERNS)} patterns)")
# 3. Build arrangement
print("\n[3/4] Building arrangement...")
track_data_template = build_track_data_template(ref_bytes)
items = [
ArrangementItem(
pattern_id=it["pattern"], bar=it["bar"],
num_bars=it["bars"], track_index=it["track"],
)
for it in ARRANGEMENT_ITEMS
]
arrangement_bytes = build_arrangement_section(items, track_data_template, ppq=PPQ)
print(f" Arrangement: {len(arrangement_bytes):,} bytes ({len(items)} items)")
# 4. Assemble
print("\n[4/4] Assembling FLP...")
body = header_bytes + pattern_bytes + channel_bytes + arrangement_bytes
flp = (
struct.pack("<4sIhHH", b"FLhd", 6, 0, num_channels, PPQ)
+ b"FLdt" + struct.pack("<I", len(body))
+ body
)
with open(FLP_OUT, "wb") as f:
f.write(flp)
duration = (48 * 4 / BPM) * 60
print(f"\n{'=' * 60}")
print(f" Output: {FLP_OUT}")
print(f" Size: {len(flp):,} bytes")
print(f" Duration: ~{duration:.0f}s (48 bars @ {BPM} BPM)")
print(f" Channels: {num_channels} (Ch0-9 plugin, Ch10-19 sampler)")
print(f" Patterns: {len(PATTERNS)}")
print(f" Sections: INTRO -> VERSE1 -> PRE-CHORUS -> CHORUS -> VERSE2 -> BREAKDOWN -> OUTRO")
print(f"{'=' * 60}")
return flp
if __name__ == "__main__":
build_fuego()

71
scripts/compose.py Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python
"""Compose and build in one step from genre knowledge base."""
import sys
import os
import json
import argparse
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from src.composer import compose_from_genre
from scripts.build import build_project
from src.flp_builder.writer import FLPWriter
KNOWLEDGE_DIR = Path(__file__).parent.parent / "knowledge" / "genres"
OUTPUT_DIR = Path(__file__).parent.parent / "output"
def main():
parser = argparse.ArgumentParser(description="Compose and build from genre")
parser.add_argument("genre", help="Genre filename (e.g. reggaeton_2009)")
parser.add_argument("--key", "-k", default=None, help="Override key (e.g. Am)")
parser.add_argument("--bpm", "-b", type=float, default=None, help="Override BPM")
parser.add_argument("--bars", type=int, default=None, help="Override bar count")
parser.add_argument("--output", "-o", default=None, help="Output .flp path")
args = parser.parse_args()
genre_file = KNOWLEDGE_DIR / f"{args.genre}.json"
if not genre_file.exists():
print(json.dumps({"error": f"Genre not found: {genre_file}", "available": [p.stem for p in KNOWLEDGE_DIR.glob("*.json")]}))
sys.exit(1)
overrides = {}
if args.key:
overrides["keys"] = [args.key]
if args.bpm:
overrides["bpm"] = {"default": args.bpm}
if args.bars:
overrides["structure"] = {"sections": [{"bars": args.bars}]}
composition = compose_from_genre(str(genre_file), overrides if overrides else None)
project = build_project(composition)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
output_path = args.output or str(
OUTPUT_DIR / f"{args.genre}_{composition['meta']['key']}_{composition['meta']['bpm']}bpm.flp"
)
writer = FLPWriter(project)
writer.write(output_path)
result = {
"status": "ok",
"output": output_path,
"genre": args.genre,
"key": composition["meta"]["key"],
"bpm": composition["meta"]["bpm"],
"chord_progression": composition["meta"]["chord_progression"],
"tracks": [
{"role": t["role"], "notes": len(t.get("notes", []))}
for t in composition["tracks"]
],
"channel_names": [ch.name for ch in project.channels],
"total_notes": sum(len(n) for t in composition["tracks"] for n in t.get("notes", [])),
}
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()

251
scripts/compose_track.py Normal file
View File

@@ -0,0 +1,251 @@
#!/usr/bin/env python
"""compose_track.py — CLI para generar un .flp reggaeton completo desde cero."""
import argparse
import sys
from pathlib import Path
# Agregar project root al path
sys.path.insert(0, str(Path(__file__).parents[1]))
from src.selector import SampleSelector
from src.composer.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain
from src.composer.rhythm import get_notes
from src.flp_builder.schema import (
SongDefinition, SongMeta, PatternDef, ArrangementTrack,
ArrangementItemDef, MelodicTrack, MelodicNote
)
from src.flp_builder.builder import FLPBuilder
# ---------------------------------------------------------------------------
# Drum track configuration
# ---------------------------------------------------------------------------
DRUM_SAMPLE_KEYS = ["channel10", "channel11", "channel12", "channel13",
"channel14", "channel15", "channel16"]
DRUM_ROLES = ["perc", "kick", "snare", "rim",
"perc", "hihat", "clap"]
DRUM_CHANNELS = [10, 11, 12, 13,
14, 15, 16 ]
# Pattern definitions for drum tracks (1 pattern per relevant drum instrument)
DRUM_PATTERNS = [
# id, name, instrument, channel, generator
(1, "Kick Main", "kick", 11, "kick_main_notes"),
(2, "Snare Basic", "snare", 12, "snare_verse_notes"),
(3, "Hihat Straight","hihat", 15, "hihat_16th_notes"),
(4, "Clap On2and4", "clap", 16, "clap_24_notes"),
(5, "Perc Sparse", "perc", 10, "perc_combo_notes"),
]
# Melodic track configuration: (role, channel_index, volume, pan, generator_fn)
MELODIC_CONFIG = [
("bass", 17, 0.85, 0.0, bass_tresillo),
("lead", 18, 0.75, 0.1, lead_hook),
("pad", 19, 0.60, 0.0, pad_sustain),
("pluck", 20, 0.70, -0.15, lead_hook), # lead_hook with octave=5
]
# ---------------------------------------------------------------------------
# Sampler template — extract once from reference FLP
# ---------------------------------------------------------------------------
def _ensure_sampler_template() -> Path:
"""Extract sampler channel template from reference FLP if not cached."""
project = Path(__file__).parents[1]
template_path = project / "output" / "flstudio_sampler_template.bin"
if template_path.exists():
return template_path
ref_flp = project / "my space ryt" / "my space ryt.flp"
ch11_path = project / "output" / "ch11_kick_template.bin"
# Try ch11_kick_template.bin first (legacy name)
if ch11_path.exists():
return ch11_path
# Extract channel 11 from reference FLP
from src.flp_builder.skeleton import ChannelSkeletonLoader
loader = ChannelSkeletonLoader(str(ref_flp), str(ch11_path), str(project / "output" / "samples"))
segments = loader._extract_channels_raw()
if 11 in segments:
template_path.parent.mkdir(parents=True, exist_ok=True)
template_path.write_bytes(segments[11])
print(f" [OK] Sampler template extracted -> {template_path}")
return template_path
raise FileNotFoundError(
"No sampler template found. "
"Please ensure output/ch11_kick_template.bin exists, "
"or the reference FLP contains channel 11."
)
def _build_sample_path(sample: dict) -> str:
"""Build absolute path to a sample file.
The sample dict has ``original_path`` pointing to the source file.
We map it to the analyzed library path:
librerias/reggaeton/.../role/name.wav →
librerias/analyzed_samples/{role}/{new_name}
"""
role = sample.get("role", "")
new_name = sample.get("new_name", "")
project = Path(__file__).parents[1]
analyzed = project / "librerias" / "analyzed_samples" / role / new_name
if analyzed.exists():
return str(analyzed)
# Fallback: try original_path if it still exists
orig = sample.get("original_path", "")
if orig and Path(orig).exists():
return orig
# Last resort: return analyzed path even if missing (let FLPBuilder handle it)
return str(analyzed)
def main():
parser = argparse.ArgumentParser(
description="Genera un archivo .flp reggaeton completo con drums, bass, lead y pads."
)
parser.add_argument("--key", default="Am", help="Tonalidad (e.g. Am, Dm, Gm)")
parser.add_argument("--bpm", type=float, default=95, help="Tempo BPM")
parser.add_argument("--bars", type=int, default=8, help="Duración en bars")
parser.add_argument("--output", default="output/composed.flp", help="Ruta del .flp de salida")
parser.add_argument("--title", default="Reggaeton Track", help="Título del song")
args = parser.parse_args()
# ---------------------------------------------------------------------------
# 1. Sample selection
# ---------------------------------------------------------------------------
sel = SampleSelector()
samples: dict[str, str] = {}
for key_name, role, ch in zip(DRUM_SAMPLE_KEYS, DRUM_ROLES, DRUM_CHANNELS):
match = sel.select_one(role=role, bpm=args.bpm)
if match:
samples[key_name] = match["new_name"]
else:
samples[key_name] = f"{role}.wav"
# ---------------------------------------------------------------------------
# 2. Drum patterns
# ---------------------------------------------------------------------------
patterns: list[PatternDef] = []
for pid, name, instrument, channel, generator in DRUM_PATTERNS:
patterns.append(PatternDef(
id=pid,
name=name,
instrument=instrument,
channel=channel,
bars=args.bars,
generator=generator,
velocity_mult=1.0,
density=1.0,
))
# ---------------------------------------------------------------------------
# 3. Melodic tracks with sample selection
# ---------------------------------------------------------------------------
melodic_tracks: list[MelodicTrack] = []
for role, ch_idx, vol, pan, generator_fn in MELODIC_CONFIG:
match = sel.select_one(role=role, key=args.key, bpm=args.bpm)
if match is None:
print(f" [WARN] No sample found for role '{role}', skipping.")
continue
# Build notes using the generator
if role == "pluck":
raw_notes = generator_fn(args.key, bars=args.bars, octave=5)
else:
raw_notes = generator_fn(args.key, bars=args.bars)
notes = [
MelodicNote(pos=n["pos"], len=n["len"], key=n["key"], vel=n["vel"])
for n in raw_notes
]
sample_path = _build_sample_path(match)
melodic_tracks.append(MelodicTrack(
role=role,
sample_path=sample_path,
notes=notes,
channel_index=ch_idx,
volume=vol,
pan=pan,
))
# ---------------------------------------------------------------------------
# 4. Arrangement tracks and items
# ---------------------------------------------------------------------------
# Tracks: 1 drum track + 1 per melodic track
tracks: list[ArrangementTrack] = [
ArrangementTrack(index=1, name="Drums"),
]
for i, mt in enumerate(melodic_tracks):
tracks.append(ArrangementTrack(index=2 + i, name=mt.role.capitalize()))
# Items: each drum pattern placed at bar 0
items: list[ArrangementItemDef] = []
for p in patterns:
items.append(ArrangementItemDef(
pattern=p.id,
bar=0.0,
bars=float(args.bars),
track=1, # all drum patterns on the Drums track
muted=False,
))
# Melodic items are added by FLPBuilder._build_arrangement (auto-added at bar 0)
# ---------------------------------------------------------------------------
# Build and save
# ---------------------------------------------------------------------------
meta = SongMeta(
bpm=args.bpm,
key=args.key,
title=args.title,
ppq=96,
time_sig_num=4,
time_sig_den=4,
)
song = SongDefinition(
meta=meta,
samples=samples,
patterns=patterns,
tracks=tracks,
items=items,
melodic_tracks=melodic_tracks,
)
errors = song.validate()
if errors:
print("Validation errors:")
for e in errors:
print(f" - {e}")
sys.exit(1)
# Ensure sampler template exists before building
template_path = _ensure_sampler_template()
project = Path(__file__).parents[1]
builder = FLPBuilder(
ref_flp=str(project / "my space ryt" / "my space ryt.flp"),
ch11_template=str(template_path),
samples_dir=str(project / "librerias" / "analyzed_samples"),
)
flp_bytes = builder.build(song)
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(flp_bytes)
print(f"[OK] FLP generado: {out_path} ({len(flp_bytes):,} bytes)")
print(f" Key: {args.key} | BPM: {args.bpm} | Bars: {args.bars}")
print(f" Patterns: {len(patterns)} drum + {len(melodic_tracks)} melodic")
print(f" Tracks: {len(tracks)}")
if __name__ == "__main__":
main()

47
scripts/inventory.py Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python
"""Inventory scanner - outputs JSON of all available resources."""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from src.scanner import full_inventory
import json
def main():
inv = full_inventory()
plugins = inv["plugins"]
summary = {
"generators": plugins["generator_names"],
"effects": plugins["effect_names"],
"total_generators": len(plugins["generators"]),
"total_effects": len(plugins["effects"]),
"sample_categories": {
k: len(v) for k, v in inv["samples"]["categories"].items()
},
"total_samples": inv["samples"]["total_files"],
"packs": [
{
"name": p["name"],
"audio": len(p["contents"].get("audio", [])),
"midi": len(p["contents"].get("midi", [])),
}
for p in inv["packs"]["packs"]
],
"vector_store": {
"total": inv["vector_store"]["total"],
"types": inv["vector_store"]["types"],
},
"organized_samples": {},
}
for cat, files in inv["samples"]["categories"].items():
summary["organized_samples"][cat] = [f["name"] for f in files[:20]]
print(json.dumps(summary, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()

827
src/analyzer/__init__.py Normal file
View File

@@ -0,0 +1,827 @@
"""Deep forensic audio sample analyzer.
4-layer analysis pipeline:
Layer 1 - Signal: FFT, spectral centroid, bandwidth, rolloff, flatness, ZCR, RMS, crest factor
Layer 2 - Perceptual: MFCC (20), chromagram (12), onset envelope, tempo, LUFS
Layer 3 - Musical: Key estimation (Krumhansl-Schmuckler), F0 via aubio (C-native), tonal/atonal
Layer 4 - Timbre: Mel band stats, spectral contrast, tonnetz
Architecture: ProcessPoolExecutor with 16 workers for TRUE multi-core parallelism.
aubio for F0 (C-native, ~1ms per file vs pyin ~2s per file).
"""
from __future__ import annotations
import os
import json
import hashlib
from pathlib import Path
from typing import Optional
from concurrent.futures import ProcessPoolExecutor, as_completed
import numpy as np
import librosa
import soundfile as sf
import aubio
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SAMPLE_RATE = 44100
HOP_LENGTH = 512
N_FFT = 2048
N_MFCC = 20
N_CHROMA = 12
MAX_WORKERS = 16 # 70% of 24 cores
AUDIO_EXT = {".wav", ".flac", ".mp3", ".aif", ".aiff"}
# Krumhansl-Schmuckler key profiles
MAJOR_PROFILE = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
MINOR_PROFILE = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
# Character classification thresholds
CHARACTERS = {
"boomy": {"low_ratio_min": 0.6, "centroid_max": 400},
"deep": {"low_ratio_min": 0.5, "centroid_max": 500, "fundamental_max": 150},
"sharp": {"high_ratio_min": 0.4, "centroid_min": 3000, "attack_max": 0.005},
"crisp": {"high_ratio_min": 0.3, "centroid_min": 4000, "duration_max": 0.2},
"warm": {"centroid_min": 300, "centroid_max": 2000, "mid_ratio_min": 0.4},
"bright": {"centroid_min": 3000, "high_ratio_min": 0.3},
"dark": {"centroid_max": 800, "low_ratio_min": 0.4},
"ethereal": {"centroid_min": 1500, "centroid_max": 5000, "rms_std_max": 0.03},
"short": {"duration_max": 0.15},
"impact": {"attack_max": 0.005, "peak_rms_ratio_min": 5.0},
"full": {"duration_min": 1.0, "bandwidth_min": 4000},
"hollow": {"mid_ratio_max": 0.2, "low_ratio_min": 0.3, "high_ratio_min": 0.3},
"tight": {"attack_max": 0.003, "duration_max": 0.3, "centroid_min": 1000},
"lush": {"spectral_flatness_min": 0.1, "mid_ratio_min": 0.3, "duration_min": 0.5},
"aggressive": {"peak_rms_ratio_min": 4.0, "centroid_min": 2000},
"soft": {"peak_rms_ratio_max": 3.0, "attack_min": 0.01},
}
# ---------------------------------------------------------------------------
# Layer 1: Signal Analysis
# ---------------------------------------------------------------------------
def analyze_signal(y: np.ndarray, sr: int) -> dict:
"""Layer 1: Time-domain and spectral signal features."""
duration = len(y) / sr
rms = librosa.feature.rms(y=y, hop_length=HOP_LENGTH)[0]
rms_mean = float(np.mean(rms))
rms_std = float(np.std(rms))
peak = float(np.max(np.abs(y)))
crest_factor = peak / (rms_mean + 1e-10)
peak_rms_ratio = float(np.max(rms) / (np.mean(rms) + 1e-10))
zcr = librosa.feature.zero_crossing_rate(y, hop_length=HOP_LENGTH)[0]
zcr_mean = float(np.mean(zcr))
S = np.abs(librosa.stft(y, n_fft=N_FFT, hop_length=HOP_LENGTH))
S_power = S ** 2
spectral_centroid = librosa.feature.spectral_centroid(S=S_power, sr=sr, hop_length=HOP_LENGTH)[0]
spectral_bandwidth = librosa.feature.spectral_bandwidth(S=S_power, sr=sr, hop_length=HOP_LENGTH)[0]
spectral_rolloff = librosa.feature.spectral_rolloff(S=S_power, sr=sr, hop_length=HOP_LENGTH)[0]
spectral_flatness = librosa.feature.spectral_flatness(S=S_power)[0]
freqs = librosa.fft_frequencies(sr=sr, n_fft=N_FFT)
low_mask = freqs < 300
mid_mask = (freqs >= 300) & (freqs < 3000)
high_mask = freqs >= 3000
band_energy = np.mean(S_power, axis=1)
total_energy = np.sum(band_energy) + 1e-10
low_ratio = float(np.sum(band_energy[low_mask]) / total_energy)
mid_ratio = float(np.sum(band_energy[mid_mask]) / total_energy)
high_ratio = float(np.sum(band_energy[high_mask]) / total_energy)
rms_peak_idx = int(np.argmax(rms))
attack_time = float(rms_peak_idx * HOP_LENGTH / sr)
return {
"duration": round(duration, 4),
"rms_mean": round(rms_mean, 6),
"rms_std": round(rms_std, 6),
"peak_amplitude": round(peak, 6),
"crest_factor": round(crest_factor, 2),
"peak_rms_ratio": round(peak_rms_ratio, 2),
"zcr_mean": round(zcr_mean, 4),
"spectral_centroid_mean": round(float(np.mean(spectral_centroid)), 2),
"spectral_centroid_std": round(float(np.std(spectral_centroid)), 2),
"spectral_centroid_max": round(float(np.max(spectral_centroid)), 2),
"spectral_bandwidth_mean": round(float(np.mean(spectral_bandwidth)), 2),
"spectral_rolloff_mean": round(float(np.mean(spectral_rolloff)), 2),
"spectral_flatness_mean": round(float(np.mean(spectral_flatness)), 6),
"low_energy_ratio": round(low_ratio, 4),
"mid_energy_ratio": round(mid_ratio, 4),
"high_energy_ratio": round(high_ratio, 4),
"attack_time": round(attack_time, 4),
}
# ---------------------------------------------------------------------------
# Layer 2: Perceptual Analysis
# ---------------------------------------------------------------------------
def analyze_perceptual(y: np.ndarray, sr: int) -> dict:
"""Layer 2: MFCC, chromagram, onset, tempo."""
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=N_MFCC, hop_length=HOP_LENGTH)
mfcc_means = [round(float(np.mean(mfcc[i])), 4) for i in range(N_MFCC)]
mfcc_stds = [round(float(np.std(mfcc[i])), 4) for i in range(N_MFCC)]
chroma = librosa.feature.chroma_cqt(y=y, sr=sr, hop_length=HOP_LENGTH)
chroma_mean = np.mean(chroma, axis=1)
onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=HOP_LENGTH)
onset_frames = librosa.onset.onset_detect(onset_envelope=onset_env, sr=sr, hop_length=HOP_LENGTH)
onset_times = librosa.frames_to_time(onset_frames, sr=sr, hop_length=HOP_LENGTH)
onset_count = len(onset_times)
tempo = 0.0
if len(onset_env) > 0:
tempo_vals = librosa.beat.tempo(onset_envelope=onset_env, sr=sr, hop_length=HOP_LENGTH)
if len(tempo_vals) > 0:
tempo = float(tempo_vals[0])
lufs = _compute_lufs(y, sr)
return {
"mfcc_means": mfcc_means,
"mfcc_stds": mfcc_stds,
"chroma_mean": [round(float(v), 4) for v in chroma_mean],
"onset_count": onset_count,
"onset_density": round(onset_count / max(len(y) / sr, 0.01), 2),
"tempo": round(tempo, 2),
"lufs": round(lufs, 2),
}
def _compute_lufs(y: np.ndarray, sr: int) -> float:
"""Simplified LUFS (integrated loudness) approximation."""
try:
from scipy.signal import butter, sosfilt
sos_hp = butter(2, 60, btype='high', fs=sr, output='sos')
y_filtered = sosfilt(sos_hp, y)
sos_hs = butter(1, 1500, btype='high', fs=sr, output='sos')
y_filtered = sosfilt(sos_hs, y_filtered)
block_size = int(0.4 * sr)
hop = int(0.1 * sr)
if len(y_filtered) < block_size:
block_size = len(y_filtered)
hop = max(1, block_size // 4)
blocks = []
for i in range(0, len(y_filtered) - block_size + 1, hop):
block = y_filtered[i:i + block_size]
rms = np.sqrt(np.mean(block ** 2))
if rms > 1e-10:
blocks.append(rms)
if not blocks:
return -70.0
mean_rms = np.mean(blocks)
lufs = -0.691 + 10 * np.log10(mean_rms ** 2 + 1e-20)
return max(lufs, -70.0)
except Exception:
return -70.0
# ---------------------------------------------------------------------------
# F0 Detection via aubio (C-native, ~1ms per file)
# ---------------------------------------------------------------------------
def _fast_f0(y: np.ndarray, sr: int) -> float:
"""Estimate fundamental frequency using aubio's YIN implementation.
This is C-native code running at ~1ms per file, vs librosa.pyin at ~2s."""
try:
# aubio pitch detector
win_s = N_FFT
hop_s = HOP_LENGTH
pitch_o = aubio.pitch("yin", win_s, hop_s, sr)
pitch_o.set_unit("Hz")
pitch_o.set_tolerance(0.8) # confidence threshold
# Process in chunks
pitches = []
for i in range(0, len(y) - win_s + 1, hop_s):
chunk = y[i:i + hop_s].astype(np.float32)
if len(chunk) < hop_s:
break
freq = pitch_o(chunk)
if freq[0] > 0:
pitches.append(float(freq[0]))
if pitches:
return float(np.median(pitches))
return 0.0
except Exception:
return 0.0
# ---------------------------------------------------------------------------
# Layer 3: Musical Analysis
# ---------------------------------------------------------------------------
def analyze_musical(signal_features: dict, perceptual_features: dict, y: np.ndarray, sr: int) -> dict:
"""Layer 3: Key estimation, tonal/atonal, F0 via aubio, one-shot vs loop."""
chroma_mean = np.array(perceptual_features["chroma_mean"])
key_name, key_correlation, mode = _estimate_key(chroma_mean)
chroma_max = float(np.max(chroma_mean))
chroma_std = float(np.std(chroma_mean))
is_tonal = chroma_std > 0.05 and chroma_max > 0.15
duration = signal_features["duration"]
onset_count = perceptual_features["onset_count"]
is_oneshot = duration < 2.0 and onset_count <= 2
is_loop = duration > 1.5 and onset_count >= 4
f0 = 0.0
f0_note = "X"
if is_tonal:
f0 = _fast_f0(y, sr)
if f0 > 0:
midi_note = int(round(12 * np.log2(f0 / 440.0) + 69))
f0_note = _midi_to_note_name(midi_note)
return {
"key": key_name,
"key_correlation": round(key_correlation, 4),
"mode": mode,
"is_tonal": is_tonal,
"is_oneshot": is_oneshot,
"is_loop": is_loop,
"fundamental_freq": round(f0, 2),
"fundamental_note": f0_note,
}
def _estimate_key(chroma_profile: np.ndarray) -> tuple:
"""Krumhansl-Schmuckler key-finding algorithm."""
if np.max(chroma_profile) < 0.01:
return "X", 0.0, "atonal"
chroma_norm = chroma_profile / (np.sum(chroma_profile) + 1e-10)
best_key = "C"
best_corr = -1.0
best_mode = "atonal"
for i in range(12):
rotated = np.roll(chroma_norm, -i)
major_corr = float(np.corrcoef(rotated, MAJOR_PROFILE)[0, 1])
if np.isnan(major_corr):
major_corr = 0.0
minor_corr = float(np.corrcoef(rotated, MINOR_PROFILE)[0, 1])
if np.isnan(minor_corr):
minor_corr = 0.0
if major_corr > best_corr:
best_corr = major_corr
best_key = NOTE_NAMES[i]
best_mode = "major"
if minor_corr > best_corr:
best_corr = minor_corr
best_key = NOTE_NAMES[i]
best_mode = "minor"
if best_corr < 0.3:
return "X", best_corr, "atonal"
if best_mode == "minor":
return f"{best_key}m", best_corr, "minor"
return best_key, best_corr, "major"
def _midi_to_note_name(midi: int) -> str:
if midi < 0 or midi > 127:
return "X"
note = NOTE_NAMES[midi % 12]
octave = midi // 12 - 1
return f"{note}{octave}"
# ---------------------------------------------------------------------------
# Layer 4: Timbre Fingerprint
# ---------------------------------------------------------------------------
def analyze_timbre(y: np.ndarray, sr: int) -> dict:
"""Layer 4: Mel spectrogram statistics for timbre fingerprinting."""
mel_spec = librosa.feature.melspectrogram(y=y, sr=sr, n_mels=128, hop_length=HOP_LENGTH)
mel_spec_db = librosa.power_to_db(mel_spec, ref=np.max)
n_bands = 8
band_size = 128 // n_bands
band_stats = []
for b in range(n_bands):
start = b * band_size
end = start + band_size
band = mel_spec_db[start:end, :]
band_stats.append({
"mean": round(float(np.mean(band)), 2),
"std": round(float(np.std(band)), 2),
"max": round(float(np.max(band)), 2),
})
contrast = librosa.feature.spectral_contrast(y=y, sr=sr, hop_length=HOP_LENGTH)
contrast_mean = [round(float(np.mean(contrast[i])), 4) for i in range(min(7, contrast.shape[0]))]
try:
tonnetz = librosa.feature.tonnetz(y=y, sr=sr, hop_length=HOP_LENGTH)
tonnetz_mean = [round(float(np.mean(tonnetz[i])), 4) for i in range(min(6, tonnetz.shape[0]))]
except Exception:
tonnetz_mean = [0.0] * 6
return {
"mel_band_stats": band_stats,
"spectral_contrast": contrast_mean,
"tonnetz": tonnetz_mean,
}
# ---------------------------------------------------------------------------
# Classification — 3-layer priority: filename → folder → spectral heuristic
# ---------------------------------------------------------------------------
def classify_role(signal: dict, perceptual: dict, musical: dict, folder_hint: str = "") -> str:
"""Classify sample into a production role.
Priority order:
1. FILENAME keywords (most reliable — producers name their files correctly)
2. FOLDER structure (less reliable — "SentimientoLatino" has everything mixed)
3. SPECTRAL heuristics (fallback for unnamed/unknown samples)
"""
filename = folder_hint.lower() # contains parent + current folder names
# ====================================================================
# LAYER 1: Filename keyword extraction (HIGHEST PRIORITY)
# Matches specific patterns like "Lead", "Pad", "Bass" in filenames
# ====================================================================
# Ordered by specificity — more specific keywords first
filename_map = [
# (keyword_pattern, role, require_not) — avoid false positives
(["reese"], "bass", []),
(["808"], "bass", []),
(["kick"], "kick", ["kickdown", "kick drum"]),
(["snare"], "snare", []),
(["hi-hat", "hihat", "hats", "hat "], "hihat", []),
(["shaker"], "perc", []),
(["tambourine", "tambor"], "perc", []),
(["conga", "bongo", "rim"], "perc", []),
(["timbal"], "perc", []),
(["vocal chop", "v.chop", "vox chop"], "vocal", []),
(["vocal", "vox", "vocals"], "vocal", []),
(["pluck"], "pluck", []),
(["bell"], "pluck", []),
(["stab"], "oneshot", []),
(["lead"], "lead", []),
(["arp", "arpeggio"], "arp", []),
(["pad reverse"], "pad", []),
(["pad", "pads"], "pad", []),
(["chord", "chords"], "pad", []),
(["rhodes", "piano", "keys", "key "], "keys", []),
(["guitar"], "guitar", []),
(["string"], "pad", []),
(["brass"], "brass", []),
(["synth"], "synth", []),
(["texture"], "pad", []),
(["riser", "sweep", "impact", "explosion"], "fx", []),
(["loop"], "drumloop", ["vocal loop", "melody loop"]),
(["fill"], "fill", []),
(["drum"], "drumloop", []),
]
for keywords, role, excludes in filename_map:
for kw in keywords:
if kw in filename:
# Check exclusions
excluded = False
for ex in excludes:
if ex in filename:
excluded = True
break
if not excluded:
return role
# ====================================================================
# LAYER 2: Midilatino / SS_RNBL structured filename parsing
# These packs have naming conventions we can extract roles from
# ====================================================================
# Midilatino pattern: "Midilatino_Song_Key_BPM_StemType.wav"
# e.g. "Midilatino_Holanda_F_Min_108BPM_Lead.wav"
# e.g. "Midilatino_Cookie_E_Min_89BPM_Pluck.wav"
parts = filename.replace(".wav", "").replace(".flac", "").replace(".mp3", "").split("_")
if len(parts) >= 2:
# Check last part for stem type
last_parts = " ".join(parts[-2:]).lower()
stem_map = {
"drums": "drumloop", "drum": "drumloop",
"bass": "bass", "reese": "bass",
"lead": "lead", "pluck": "pluck", "pluck fx": "fx",
"pad": "pad", "pad reverse": "pad",
"arp": "arp", "vocal": "vocal", "vocals": "vocal",
"vocal chop": "vocal", "vox": "vocal",
"guitar": "guitar", "rhodes": "keys", "rhode": "keys",
"piano": "keys", "keys": "keys",
"synth": "synth", "texture": "pad", "texture 2": "pad",
"bell chords": "pad", "accent": "oneshot", "accent keys": "keys",
"harp": "pluck", "shaker": "perc",
}
for stem_kw, stem_role in stem_map.items():
if stem_kw in last_parts:
return stem_role
# SS_RNBL pattern: "SS_RNBL_Song_Stem_Type.wav"
# e.g. "SS_RNBL_Amor_One_Shot_Bass_C_.wav"
if "ss_rnbl" in filename or "ss rnbl" in filename:
ss_map = {
"kick": "kick", "snare": "snare", "hats": "hihat", "hat": "hihat",
"perc": "perc", "bass": "bass", "lead": "lead", "pad": "pad",
"fx": "fx", "top": "drumloop", "drum": "drumloop",
"v.chop": "vocal", "phrases": "vocal",
"one shot": "oneshot", "music": "drumloop",
"double": "drumloop", "add": "drumloop",
"gustas": "drumloop", # "Gustas" are full loop sections
}
for kw, role in ss_map.items():
if kw in filename:
return role
# ====================================================================
# LAYER 3: Folder-based hints (MEDIUM PRIORITY)
# Only for folders that are explicitly categorized
# ====================================================================
folder_map = {
"kick": "kick", "kicks": "kick", "8. kicks": "kick",
"snare": "snare", "snares": "snare", "9. snare": "snare",
"hi-hat": "hihat", "hihat": "hihat", "hi-hats": "hihat",
"bass": "bass",
"perc": "perc", "percs": "perc", "10. percs": "perc",
"fx": "fx", "5. fx": "fx",
"drum loops": "drumloop", "4. drum loops": "drumloop", "drumloops": "drumloop",
"vocal": "vocal", "vocals": "vocal", "11. vocals": "vocal",
"fill": "fill", "fills": "fill", "7. fill": "fill",
"3. one shots": "oneshot",
}
for key, role in folder_map.items():
if key in folder_hint.lower():
return role
# ====================================================================
# LAYER 4: Spectral heuristics (LOWEST PRIORITY — fallback only)
# Only used when filename and folder give no signal
# ====================================================================
centroid = signal["spectral_centroid_mean"]
low_r = signal["low_energy_ratio"]
high_r = signal["high_energy_ratio"]
dur = signal["duration"]
onsets = perceptual["onset_count"]
is_tonal = musical["is_tonal"]
attack = signal["attack_time"]
rms_std = signal["rms_std"]
# Percussive one-shots
if centroid < 600 and low_r > 0.5 and dur < 1.0 and attack < 0.01 and onsets <= 3:
return "kick"
if centroid > 5000 and high_r > 0.4 and dur < 0.3:
return "hihat"
if 1000 < centroid < 5000 and attack < 0.005 and onsets <= 2:
return "snare"
if dur < 0.5 and onsets <= 2 and 500 < centroid < 5000:
return "perc"
# Tonal classification (for long tonal samples that didn't match filename)
if is_tonal:
# Sub-bass / bass
if centroid < 200 and low_r > 0.7:
return "bass"
# Pad: sustained, low variance, long
if rms_std < 0.05 and dur > 1.0 and centroid < 4000:
return "pad"
# Pluck: short, tonal
if dur < 0.8 and onsets <= 3:
return "pluck"
# Lead: prominent, mid-high frequency
if 500 < centroid < 6000:
return "lead"
# Keys: mid frequency, moderate dynamics
if 200 < centroid < 2000 and rms_std < 0.1:
return "keys"
# Generic tonal loop
if dur > 2.0 and onsets > 4:
return "drumloop"
return "synth"
# Atonal loops
if dur > 2.0 and onsets >= 4:
return "drumloop"
# Short atonal
if dur < 2.0 and onsets <= 1:
return "oneshot"
return "fx"
def classify_character(signal: dict, perceptual: dict, musical: dict) -> str:
"""Classify the sonic character."""
centroid = signal["spectral_centroid_mean"]
low_r = signal["low_energy_ratio"]
high_r = signal["high_energy_ratio"]
mid_r = signal["mid_energy_ratio"]
dur = signal["duration"]
attack = signal["attack_time"]
peak_rms = signal["peak_rms_ratio"]
flatness = signal["spectral_flatness_mean"]
rms_std = signal["rms_std"]
scores = {}
if low_r >= 0.6 and centroid <= 400:
scores["boomy"] = low_r * 2
if low_r >= 0.5 and centroid <= 500:
scores["deep"] = low_r * 1.5
if high_r >= 0.4 and centroid >= 3000 and attack <= 0.005:
scores["sharp"] = high_r * 2
if high_r >= 0.3 and centroid >= 4000 and dur <= 0.2:
scores["crisp"] = high_r * 1.5
if 300 <= centroid <= 2000 and mid_r >= 0.4:
scores["warm"] = mid_r * 1.5
if centroid >= 3000 and high_r >= 0.3:
scores["bright"] = high_r * 1.5
if centroid <= 800 and low_r >= 0.4:
scores["dark"] = low_r * 1.5
if 1500 <= centroid <= 5000 and rms_std <= 0.03:
scores["ethereal"] = 1.0
if dur <= 0.15:
scores["short"] = 1.0
if attack <= 0.005 and peak_rms >= 5.0:
scores["impact"] = peak_rms / 5.0
if dur >= 1.0 and signal["spectral_bandwidth_mean"] >= 4000:
scores["full"] = 1.0
if mid_r <= 0.2 and low_r >= 0.3 and high_r >= 0.3:
scores["hollow"] = 1.0
if attack <= 0.003 and dur <= 0.3 and centroid >= 1000:
scores["tight"] = 1.0
if flatness >= 0.1 and mid_r >= 0.3 and dur >= 0.5:
scores["lush"] = flatness * 5
if peak_rms >= 4.0 and centroid >= 2000:
scores["aggressive"] = peak_rms / 4.0
if peak_rms <= 3.0 and attack >= 0.01:
scores["soft"] = 1.0
return max(scores, key=scores.get) if scores else "neutral"
# ---------------------------------------------------------------------------
# Full Analysis Pipeline (single file)
# ---------------------------------------------------------------------------
def analyze_file(filepath: str) -> Optional[dict]:
"""Full 4-layer analysis of a single audio file.
This function is picklable and runs in separate processes."""
try:
y, sr = librosa.load(filepath, sr=SAMPLE_RATE, mono=True, duration=30.0)
if len(y) < 512:
return None
peak = np.max(np.abs(y))
if peak > 1e-6:
y = y / peak
path = Path(filepath)
# Pass BOTH the full filename and folder structure to classifier
classify_hint = f"{path.parent.parent.name} {path.parent.name} {path.stem}"
signal = analyze_signal(y, sr)
perceptual = analyze_perceptual(y, sr)
musical = analyze_musical(signal, perceptual, y, sr)
timbre = analyze_timbre(y, sr)
role = classify_role(signal, perceptual, musical, classify_hint)
character = classify_character(signal, perceptual, musical)
new_name = _generate_name(role, musical, perceptual, character, filepath)
file_hash = _hash_file(filepath)
return {
"original_path": filepath,
"original_name": path.name,
"new_name": new_name,
"file_hash": file_hash,
"file_size": os.path.getsize(filepath),
"role": role,
"character": character,
"musical": musical,
"signal": signal,
"perceptual": {
"mfcc_means": perceptual["mfcc_means"],
"mfcc_stds": perceptual["mfcc_stds"],
"onset_count": perceptual["onset_count"],
"onset_density": perceptual["onset_density"],
"tempo": perceptual["tempo"],
"lufs": perceptual["lufs"],
},
"timbre": timbre,
}
except Exception as e:
return {"original_path": filepath, "error": str(e)}
def _generate_name(role: str, musical: dict, perceptual: dict, character: str, filepath: str) -> str:
key = musical["fundamental_note"] if musical["is_tonal"] else "X"
if key == "X" and musical["key"] != "X":
key = musical["key"]
bpm = int(perceptual["tempo"]) if perceptual["tempo"] > 0 else 0
short_id = hashlib.md5(filepath.encode()).hexdigest()[:6]
ext = Path(filepath).suffix
return f"{role}_{key}_{bpm:03d}_{character}_{short_id}{ext}"
def _hash_file(filepath: str) -> str:
h = hashlib.md5()
size = os.path.getsize(filepath)
with open(filepath, "rb") as f:
h.update(f.read(65536))
if size > 131072:
f.seek(size - 65536)
h.update(f.read(65536))
return h.hexdigest()
# ---------------------------------------------------------------------------
# File Collection
# ---------------------------------------------------------------------------
def collect_audio_files(*directories: str) -> list[str]:
files = []
for d in directories:
base = Path(d)
if not base.exists():
continue
for f in base.rglob("*"):
if f.is_file() and f.suffix.lower() in AUDIO_EXT:
files.append(str(f))
return sorted(files)
# ---------------------------------------------------------------------------
# Batch Analysis (TRUE multiprocessing)
# ---------------------------------------------------------------------------
def batch_analyze(files: list[str], workers: int = MAX_WORKERS, checkpoint_path: Optional[str] = None) -> list[dict]:
"""Analyze all files using ProcessPoolExecutor for real multi-core parallelism.
Each process runs independently — no GIL contention, no shared memory."""
results = []
errors = []
done = 0
total = len(files)
# Resume from checkpoint
completed_paths = set()
if checkpoint_path and os.path.exists(checkpoint_path):
with open(checkpoint_path, "r", encoding="utf-8") as f:
for line in f:
try:
entry = json.loads(line.strip())
completed_paths.add(entry["original_path"])
results.append(entry)
except (json.JSONDecodeError, KeyError):
pass
done = len(results)
print(f"Resumed from checkpoint: {done}/{total}")
remaining = [f for f in files if f not in completed_paths]
if not remaining:
print("All files already analyzed.")
return results
print(f"Analyzing {len(remaining)} files with {workers} PROCESSES (true parallel)...")
with ProcessPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(analyze_file, f): f for f in remaining}
for future in as_completed(futures):
filepath = futures[future]
done += 1
try:
result = future.result()
if result is None:
errors.append(filepath)
continue
if "error" in result:
errors.append(f"{filepath}: {result['error']}")
continue
results.append(result)
if checkpoint_path and done % 50 == 0:
_save_checkpoint(results, checkpoint_path)
if done % 25 == 0 or done == total:
print(f" [{done}/{total}] {result.get('new_name', '?')}")
except Exception as e:
errors.append(f"{filepath}: {e}")
if errors:
print(f"\nErrors ({len(errors)}):")
for e in errors[:10]:
print(f" - {e}")
if len(errors) > 10:
print(f" ... and {len(errors) - 10} more")
return results
def _save_checkpoint(results: list[dict], path: str):
with open(path, "w", encoding="utf-8") as f:
for r in results:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
def save_index(results: list[dict], output_path: str):
roles = {}
keys = {}
characters = {}
for r in results:
if "error" in r:
continue
role = r.get("role", "unknown")
roles[role] = roles.get(role, 0) + 1
key = r.get("musical", {}).get("key", "X")
keys[key] = keys.get(key, 0) + 1
char = r.get("character", "unknown")
characters[char] = characters.get(char, 0) + 1
index = {
"metadata": {
"total_samples": len(results),
"errors": sum(1 for r in results if "error" in r),
"roles": roles,
"keys": keys,
"characters": characters,
},
"samples": results,
}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(index, f, ensure_ascii=False, indent=2)
print(f"Index saved to {output_path}")
print(f" Total: {len(results)} | Roles: {roles} | Keys: {len(keys)} | Characters: {len(characters)}")
# ---------------------------------------------------------------------------
# Rename Engine
# ---------------------------------------------------------------------------
def plan_renames(results: list[dict], output_dir: str) -> list[dict]:
out = Path(output_dir)
renames = []
seen_names = set()
for r in results:
if "error" in r or "new_name" not in r:
continue
old_path = Path(r["original_path"])
role = r["role"]
new_name = r["new_name"]
if new_name in seen_names:
stem = Path(new_name).stem
ext = Path(new_name).suffix
counter = 2
candidate = f"{stem}_{counter}{ext}"
while candidate in seen_names:
counter += 1
candidate = f"{stem}_{counter}{ext}"
new_name = candidate
seen_names.add(new_name)
new_path = out / role / new_name
renames.append({
"old_path": str(old_path),
"new_path": str(new_path),
"new_name": new_name,
"role": role,
"original_name": r["original_name"],
})
return renames
def execute_renames(renames: list[dict], dry_run: bool = True) -> dict:
stats = {"planned": len(renames), "executed": 0, "skipped": 0, "errors": []}
for r in renames:
old = Path(r["old_path"])
new = Path(r["new_path"])
if not old.exists():
stats["skipped"] += 1
continue
if dry_run:
stats["skipped"] += 1
continue
new.parent.mkdir(parents=True, exist_ok=True)
try:
import shutil
shutil.copy2(str(old), str(new))
stats["executed"] += 1
except Exception as e:
stats["errors"].append(f"{old.name} -> {new.name}: {e}")
return stats

View File

@@ -0,0 +1,72 @@
"""Forensic analysis of misclassified samples."""
import json, os
PROJECT = r"C:\Users\Administrator\Documents\fl_control"
with open(os.path.join(PROJECT, "data", "sample_index.json"), "r", encoding="utf-8") as f:
d = json.load(f)
samples = d["samples"]
# --- DRUMLOOPS ---
drumloops = [s for s in samples if s.get("role") == "drumloop"]
print(f"DRUMLOOPS ({len(drumloops)} total)")
print(f"{'Orig filename':<55s} {'Dur':>5s} {'Onset':>5s} {'Centr':>7s} {'Low':>5s} {'Mid':>5s} {'High':>5s} {'Tonal':>5s} {'Key':>5s} {'Char':>10s}")
print("-" * 120)
for s in drumloops[:50]:
orig = s.get("original_name", "?")[:54]
dur = s["signal"]["duration"]
onc = s["perceptual"]["onset_count"]
cent = s["signal"]["spectral_centroid_mean"]
low = s["signal"]["low_energy_ratio"]
mid = s["signal"]["mid_energy_ratio"]
high = s["signal"]["high_energy_ratio"]
ton = s["musical"]["is_tonal"]
key = s["musical"]["key"]
char = s["character"]
print(f"{orig:<55s} {dur:5.1f} {onc:5d} {cent:7.0f} {low:5.2f} {mid:5.2f} {high:5.2f} {str(ton):>5s} {key:>5s} {char:>10s}")
# --- ONESHOTS ---
oneshots = [s for s in samples if s.get("role") == "oneshot"]
print(f"\nONESHOTS ({len(oneshots)} total)")
print(f"{'Orig filename':<55s} {'Dur':>5s} {'Onset':>5s} {'Centr':>7s} {'Low':>5s} {'Mid':>5s} {'High':>5s} {'Tonal':>5s} {'Key':>5s} {'Char':>10s}")
print("-" * 120)
for s in oneshots[:40]:
orig = s.get("original_name", "?")[:54]
dur = s["signal"]["duration"]
onc = s["perceptual"]["onset_count"]
cent = s["signal"]["spectral_centroid_mean"]
low = s["signal"]["low_energy_ratio"]
mid = s["signal"]["mid_energy_ratio"]
high = s["signal"]["high_energy_ratio"]
ton = s["musical"]["is_tonal"]
key = s["musical"]["key"]
char = s["character"]
print(f"{orig:<55s} {dur:5.1f} {onc:5d} {cent:7.0f} {low:5.2f} {mid:5.2f} {high:5.2f} {str(ton):>5s} {key:>5s} {char:>10s}")
# --- Summary: folder source of drumloops ---
print(f"\n\nDRUMLOOP ORIGINS:")
from collections import Counter
origins = Counter()
for s in drumloops:
path = s.get("original_path", "")
parts = path.replace("\\", "/").split("/")
# Find the category folder
for i, p in enumerate(parts):
if "reggaeton" in p.lower() and i+1 < len(parts):
origins[parts[i+1]] += 1
break
for k, v in origins.most_common():
print(f" {k:40s} {v:4d}")
# --- Summary: folder source of oneshots ---
print(f"\nONESHOT ORIGINS:")
origins2 = Counter()
for s in oneshots:
path = s.get("original_path", "")
parts = path.replace("\\", "/").split("/")
for i, p in enumerate(parts):
if "reggaeton" in p.lower() and i+1 < len(parts):
origins2[parts[i+1]] += 1
break
for k, v in origins2.most_common():
print(f" {k:40s} {v:4d}")

View File

@@ -0,0 +1,72 @@
"""Forensic analysis of misclassified samples."""
import json, os, re
from collections import Counter
PROJECT = r"C:\Users\Administrator\Documents\fl_control"
with open(os.path.join(PROJECT, "data", "sample_index.json"), "r", encoding="utf-8") as f:
d = json.load(f)
samples = d["samples"]
# --- Analyze filename patterns in misclassified ---
print("=" * 70)
print(" PATRONES DE NOMBRE EN 'DRUMLOOPS'")
print("=" * 70)
# Extract Midilatino stems
ml_stems = Counter()
for s in samples:
if s.get("role") != "drumloop":
continue
name = s.get("original_name", "")
# Midilatino pattern: Midilatino_Name_Key_Min_BPM_Stem.wav
if "Midilatino" in name or "midilatino" in name:
# Extract the stem type (last part before .wav)
parts = name.replace(".wav", "").replace(".flac", "").replace(".mp3", "")
# Try to find stem keywords
for kw in ["Drums", "Bass", "Lead", "Pad", "Pluck", "Arp", "Vocal",
"Vox", "Guitar", "Rhodes", "Piano", "Synth", "Reese",
"Texture", "Chords", "Reverse", "Fx", "Accent", "Harp",
"Keys", "Bell", "Loop", "Stem", "Snare", "Kick", "Hat",
"Perc", "Shaker", "Hi", "808"]:
if kw.lower() in parts.lower():
ml_stems[kw] += 1
break
else:
# No stem keyword found - it's the full mix
ml_stems["FULL_MIX"] += 1
print("\nMidilatino stem types in 'drumloop':")
for k, v in ml_stems.most_common():
print(f" {k:15s} {v:4d}")
# --- SS_RNBL patterns ---
print("\n\nSentimientoLatino SS_RNBL patterns:")
ss_stems = Counter()
for s in samples:
name = s.get("original_name", "")
if "SS_RNBL" in name:
# Extract type: SS_RNBL_Song_Stem_Type.wav
parts = name.replace(".wav", "").split("_")
if len(parts) >= 4:
stem_type = parts[3] if parts[3] not in ("One", "Shot") else "_".join(parts[3:5])
ss_stems[stem_type] += 1
for k, v in ss_stems.most_common():
print(f" {k:20s} {v:4d}")
# --- All filename keywords ---
print("\n\nAll filename role keywords across library:")
role_keywords = Counter()
for s in samples:
name = s.get("original_name", "").lower()
for kw in ["kick", "snare", "hi-hat", "hihat", "hat", "bass", "808",
"lead", "pad", "pluck", "arp", "vocal", "vox", "fx",
"perc", "drum", "loop", "fill", "guitar", "piano", "rhodes",
"synth", "bell", "brass", "string", "reese", "texture",
"chord", "shaker", "tambourine", "conga", "rim"]:
if kw in name:
role_keywords[kw] += 1
for k, v in role_keywords.most_common(25):
print(f" {k:15s} {v:4d}")

143
src/analyzer/run_batch.py Normal file
View File

@@ -0,0 +1,143 @@
"""
Batch analyzer - STANDALONE for double-click execution.
Uses ProcessPoolExecutor (16 processes) for TRUE multi-core parallelism.
aubio replaces pyin for F0 detection (~1ms vs ~2s per file).
IMPORTANT: The if __name__ == '__main__' guard is REQUIRED on Windows
for ProcessPoolExecutor. Without it, child processes re-import this file
and create infinite process spawning.
"""
from __future__ import annotations
import sys
import os
import time
import json
import warnings
import traceback
import multiprocessing
# CRITICAL: Windows multiprocessing guard - MUST be at top level
multiprocessing.freeze_support()
warnings.filterwarnings("ignore")
PROJECT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.chdir(PROJECT)
if PROJECT not in sys.path:
sys.path.insert(0, PROJECT)
from src.analyzer import (
collect_audio_files,
batch_analyze,
save_index,
plan_renames,
)
def main():
print("=" * 60)
print(" ANALIZADOR FORENSE DE SAMPLES v2.0")
print(" ProcessPoolExecutor + aubio F0 (C-native)")
print(" 4 capas: Signal + Perceptual + Musical + Timbre")
print(" 16 procesos independientes = 16 cores en paralelo")
print("=" * 60)
lib1 = os.path.join(PROJECT, "libreria", "reggaeton")
lib2 = os.path.join(PROJECT, "librerias", "reggaeton")
print("\n[1/4] Colectando archivos de audio...")
files = collect_audio_files(lib1, lib2)
print(f" Encontrados: {len(files)} archivos")
if not files:
print("ERROR: No se encontraron archivos de audio.")
return
data_dir = os.path.join(PROJECT, "data")
os.makedirs(data_dir, exist_ok=True)
checkpoint = os.path.join(data_dir, "analysis_checkpoint.jsonl")
# Delete old checkpoint from failed thread-based run
if os.path.exists(checkpoint):
old_size = os.path.getsize(checkpoint)
if old_size < 1000: # Probably broken from the thread run
os.remove(checkpoint)
print(" (Removed broken checkpoint)")
print(f"\n[2/4] Analizando con 16 PROCESOS (70% CPU)...")
print(f" Cada proceso en su propio core, sin GIL")
print(f" Checkpoint: {checkpoint}")
print(f" (Si se corta, re-ejecuta y continua desde donde quedo)")
print()
start = time.time()
results = batch_analyze(files, workers=16, checkpoint_path=checkpoint)
elapsed = time.time() - start
valid = [r for r in results if "error" not in r]
errors = [r for r in results if "error" in r]
print(f"\n Tiempo: {elapsed:.1f}s ({elapsed / max(len(files), 1):.2f}s/archivo)")
print(f" Exitosos: {len(valid)} | Errores: {len(errors)}")
if errors:
err_path = os.path.join(data_dir, "analysis_errors.json")
with open(err_path, "w", encoding="utf-8") as f:
json.dump(errors, f, ensure_ascii=False, indent=2)
print(f" Errores guardados en: {err_path}")
print(f"\n[3/4] Guardando indice...")
index_path = os.path.join(data_dir, "sample_index.json")
save_index(results, index_path)
print(f"\n[4/4] Plan de renombrado...")
output_dir = os.path.join(PROJECT, "librerias", "analyzed_samples")
renames = plan_renames(results, output_dir)
rename_path = os.path.join(data_dir, "rename_plan.json")
with open(rename_path, "w", encoding="utf-8") as f:
json.dump(renames, f, ensure_ascii=False, indent=2)
print(f" {len(renames)} archivos para renombrar")
print(f" Plan guardado en: {rename_path}")
# Summary
print("\n" + "=" * 60)
print(" RESUMEN")
print("=" * 60)
roles = {}
chars = {}
keys = {}
for r in valid:
role = r.get("role", "?")
roles[role] = roles.get(role, 0) + 1
char = r.get("character", "?")
chars[char] = chars.get(char, 0) + 1
key = r.get("musical", {}).get("key", "X")
keys[key] = keys.get(key, 0) + 1
print(f"\n Roles:")
for role, count in sorted(roles.items(), key=lambda x: -x[1]):
bar = "#" * min(count, 60)
print(f" {role:12s} {count:4d} {bar}")
print(f"\n Caracteres:")
for char, count in sorted(chars.items(), key=lambda x: -x[1]):
bar = "#" * min(count, 50)
print(f" {char:12s} {count:4d} {bar}")
print(f"\n Tonalidades (top 10):")
for key, count in sorted(keys.items(), key=lambda x: -x[1])[:10]:
print(f" {key:5s} {count:4d}")
print(f"\n Proximo paso: ejecuta 2_RENOMBRAR.bat")
print("=" * 60)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"\nFATAL ERROR: {e}")
traceback.print_exc()
input("Presiona Enter para cerrar...")

View File

@@ -0,0 +1,88 @@
"""
Rename executor - Copies files to analyzed_samples/ with standardized names.
Reads from data/rename_plan.json generated by the batch analyzer.
"""
from __future__ import annotations
import sys
import os
import json
import shutil
import warnings
warnings.filterwarnings("ignore")
PROJECT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.chdir(PROJECT)
if PROJECT not in sys.path:
sys.path.insert(0, PROJECT)
from src.analyzer import plan_renames, execute_renames
def main():
rename_path = os.path.join(PROJECT, "data", "rename_plan.json")
index_path = os.path.join(PROJECT, "data", "sample_index.json")
output_dir = os.path.join(PROJECT, "librerias", "analyzed_samples")
# Load rename plan if exists, otherwise generate from index
if os.path.exists(rename_path):
print("Cargando plan de renombrado existente...")
with open(rename_path, "r", encoding="utf-8") as f:
renames = json.load(f)
elif os.path.exists(index_path):
print("Generando plan desde indice...")
with open(index_path, "r", encoding="utf-8") as f:
index = json.load(f)
renames = plan_renames(index["samples"], output_dir)
with open(rename_path, "w", encoding="utf-8") as f:
json.dump(renames, f, ensure_ascii=False, indent=2)
else:
print("ERROR: No existe data/sample_index.json ni data/rename_plan.json")
print(" Ejecuta primero 1_ANALIZAR.bat")
return
print(f"\n{len(renames)} archivos para renombrar")
print(f"Destino: {output_dir}")
print()
# Show sample renames
print("Ejemplos:")
for r in renames[:15]:
print(f" {r['original_name']:50s} -> {r['role']:10s}\\{r['new_name']}")
if len(renames) > 15:
print(f" ... y {len(renames) - 15} mas")
print()
# Confirm
answer = input("Ejecutar renombrado? (s/n): ").strip().lower()
if answer != "s":
print("Cancelado.")
return
# Execute
print("\nCopiando archivos...")
stats = execute_renames(renames, dry_run=False)
print(f"\nResultado: {stats['executed']} copiados, {stats['skipped']} omitidos, {len(stats.get('errors', []))} errores")
if stats.get("errors"):
print("Errores:")
for e in stats["errors"][:10]:
print(f" {e}")
# Save rename log
log_path = os.path.join(PROJECT, "data", "rename_log.json")
with open(log_path, "w", encoding="utf-8") as f:
json.dump({"stats": stats, "renames": renames}, f, ensure_ascii=False, indent=2)
print(f"\nLog guardado en: {log_path}")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"\nFATAL ERROR: {e}")
import traceback
traceback.print_exc()
input("Presiona Enter para cerrar...")

117
src/analyzer/show_stats.py Normal file
View File

@@ -0,0 +1,117 @@
"""
Show statistics from the analysis index.
"""
import sys
import os
import json
PROJECT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.chdir(PROJECT)
def main():
index_path = os.path.join(PROJECT, "data", "sample_index.json")
if not os.path.exists(index_path):
print("ERROR: No existe data/sample_index.json")
print(" Ejecuta primero 1_ANALIZAR.bat")
return
with open(index_path, "r", encoding="utf-8") as f:
index = json.load(f)
samples = index["samples"]
valid = [s for s in samples if "error" not in s]
print("=" * 60)
print(f" ESTADISTICAS DE LA BIBLIOTECA ({len(valid)} samples)")
print("=" * 60)
# Roles
roles = {}
for s in valid:
r = s.get("role", "?")
roles[r] = roles.get(r, 0) + 1
print("\n Roles:")
max_count = max(roles.values()) if roles else 1
for role, count in sorted(roles.items(), key=lambda x: -x[1]):
bar_len = int(40 * count / max_count)
print(f" {role:12s} {count:4d} {'' * bar_len}")
# Characters
chars = {}
for s in valid:
c = s.get("character", "?")
chars[c] = chars.get(c, 0) + 1
print("\n Caracteres sonoros:")
max_count = max(chars.values()) if chars else 1
for char, count in sorted(chars.items(), key=lambda x: -x[1]):
bar_len = int(40 * count / max_count)
print(f" {char:12s} {count:4d} {'' * bar_len}")
# Keys
keys = {}
for s in valid:
k = s.get("musical", {}).get("key", "X")
keys[k] = keys.get(k, 0) + 1
print("\n Tonalidades:")
for key, count in sorted(keys.items(), key=lambda x: -x[1])[:15]:
print(f" {key:5s} {count:4d}")
# Tempo distribution
tempos = [s.get("perceptual", {}).get("tempo", 0) for s in valid]
tempos_nonzero = [t for t in tempos if t > 0]
if tempos_nonzero:
print(f"\n Tempo:")
print(f" Rango: {min(tempos_nonzero):.0f} - {max(tempos_nonzero):.0f} BPM")
print(f" Promedio: {sum(tempos_nonzero) / len(tempos_nonzero):.0f} BPM")
# LUFS distribution
lufs = [s.get("perceptual", {}).get("lufs", 0) for s in valid]
lufs_valid = [l for l in lufs if l > -70]
if lufs_valid:
print(f"\n Loudness (LUFS):")
print(f" Rango: {min(lufs_valid):.1f} a {max(lufs_valid):.1f} LUFS")
print(f" Promedio: {sum(lufs_valid) / len(lufs_valid):.1f} LUFS")
# Tonal vs atonal
tonal = sum(1 for s in valid if s.get("musical", {}).get("is_tonal", False))
atonal = len(valid) - tonal
print(f"\n Tonalidad:")
print(f" Tonal: {tonal} ({100 * tonal / len(valid):.0f}%)")
print(f" Atonal: {atonal} ({100 * atonal / len(valid):.0f}%)")
# One-shot vs loop
oneshot = sum(1 for s in valid if s.get("musical", {}).get("is_oneshot", False))
loops = sum(1 for s in valid if s.get("musical", {}).get("is_loop", False))
print(f"\n Tipo:")
print(f" One-shots: {oneshot}")
print(f" Loops: {loops}")
print(f" Otros: {len(valid) - oneshot - loops}")
print("\n" + "=" * 60)
# Show samples per role for quick reference
print("\n EJEMPLOS POR ROL:")
by_role = {}
for s in valid:
role = s.get("role", "?")
if role not in by_role:
by_role[role] = []
by_role[role].append(s)
for role in sorted(by_role.keys()):
samples_list = by_role[role][:5]
print(f"\n [{role}] ({len(by_role[role])} total)")
for s in samples_list:
key = s.get("musical", {}).get("key", "X")
char = s.get("character", "?")
bpm = s.get("perceptual", {}).get("tempo", 0)
print(f" {s.get('new_name', '?'):50s} key={key:5s} bpm={bpm:5.0f} char={char}")
if __name__ == "__main__":
main()

310
src/composer/__init__.py Normal file
View 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
View 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
View 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 — 1127 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
View 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

View File

@@ -0,0 +1,12 @@
from .writer import FLPWriter
from .writer import FLPWriter
from .project import FLPProject, Note, Channel, Pattern, Plugin
__all__ = [
"FLPWriter",
"FLPProject",
"Note",
"Channel",
"Pattern",
"Plugin",
]

View File

@@ -0,0 +1,222 @@
"""FL Studio arrangement/playlist encoding.
Encodes playlist items (ID233) and track data (ID238) into binary format
matching FL Studio's internal structure. Extracted from the proven v15 builder
(output/build_reggaeton_v15.py, lines 61-90).
Arrangement block sequence:
ArrNew(99) → ArrName(241) → Flag36(36) → Playlist(233)
→ TrackData(238)×N → ArrCurrent(100)
"""
from dataclasses import dataclass
import struct
from .events import encode_byte_event, encode_data_event, encode_word_event
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
PPQ_DEFAULT: int = 96
MAX_TRACKS_DEFAULT: int = 500
PATTERN_BASE: int = 20480
# Arrangement event IDs (not yet in EventID enum — raw constants)
EID_ARR_NEW = 99
EID_ARR_CURRENT = 100
EID_ARR_NAME = 241
EID_FLAG_36 = 36
EID_PLAYLIST = 233
EID_TRACK_DATA = 238
# TrackData template size (bytes), extracted from reference FLP
TRACK_DATA_SIZE = 66
# ---------------------------------------------------------------------------
# ArrangementItem dataclass
# ---------------------------------------------------------------------------
@dataclass
class ArrangementItem:
"""A single playlist item placed on the arrangement timeline.
Args:
pattern_id: Pattern number (1-based).
bar: Start bar (0-based, fractional allowed).
num_bars: Length in bars (fractional allowed).
track_index: Track row index (0-based).
muted: Whether the item is muted in the playlist.
"""
pattern_id: int # pattern number (1-based)
bar: float # start bar (0-based)
num_bars: float # length in bars
track_index: int # 0-based track index
muted: bool = False
def to_bytes(
self,
ppq: int = PPQ_DEFAULT,
max_tracks: int = MAX_TRACKS_DEFAULT,
) -> bytes:
"""Encode as a 32-byte playlist item (ID233 format).
Encoding rules (from reverse-engineered FL Studio format):
position = int(bar × ppq × 4) — ticks, truncated
pattern_base = 20480 — constant
item_index = 20480 + pattern_id
length = int(num_bars × ppq × 4) — ticks, truncated
track_rvidx = (max_tracks - 1) - track_index — REVERSED
flags = 0x2040 if muted else 0x0040
"""
position = int(self.bar * ppq * 4)
item_index = PATTERN_BASE + self.pattern_id
length = int(self.num_bars * ppq * 4)
track_rvidx = (max_tracks - 1) - self.track_index
flags = 0x2040 if self.muted else 0x0040
return struct.pack(
"<IHHIHH HH 4B ff",
position,
PATTERN_BASE,
item_index,
length,
track_rvidx,
0, # group
0x0078,
flags,
64, 100, 128, 128,
-1.0, -1.0,
)
# ---------------------------------------------------------------------------
# TrackData helpers
# ---------------------------------------------------------------------------
def build_track_data_template(reference_flp_bytes: bytes) -> bytes:
"""Extract the 66-byte TrackData template from a reference FLP.
Scans the raw FLP bytes for the first ID238 event and returns its
66-byte payload. This template is then cloned and patched for each
of the *max_tracks* track data entries in the arrangement section.
Args:
reference_flp_bytes: Full contents of a valid .flp file.
Returns:
The 66-byte track-data template.
Raises:
ValueError: If no ID238 event of the expected size is found.
"""
pos = 22 # skip FLhd (14 bytes) + FLdt header (8 bytes)
while pos < len(reference_flp_bytes):
ib = reference_flp_bytes[pos]
pos += 1
if ib < 64:
# Byte event: 1-byte value
pos += 1
elif ib < 128:
# Word event: 2-byte value
pos += 2
elif ib < 192:
# Dword event: 4-byte value
pos += 4
else:
# Data / text event: varint length + payload
size = 0
shift = 0
while True:
b = reference_flp_bytes[pos]
pos += 1
size |= (b & 0x7F) << shift
shift += 7
if not (b & 0x80):
break
if ib == EID_TRACK_DATA and size == TRACK_DATA_SIZE:
return bytes(reference_flp_bytes[pos:pos + size])
pos += size
raise ValueError(
f"No ID{EID_TRACK_DATA} TrackData event ({TRACK_DATA_SIZE} bytes) "
"found in reference FLP"
)
def encode_track_data(iid: int, enabled: int, template: bytes) -> bytes:
"""Clone *template*, patch iid at byte 0 (uint32 LE) and enabled at byte 12.
Args:
iid: Internal track ID (sequential from 1).
enabled: 0 = disabled, 1 = enabled.
template: 66-byte template extracted by :func:`build_track_data_template`.
Returns:
66-byte patched track data.
"""
td = bytearray(template)
struct.pack_into("<I", td, 0, iid)
td[12] = enabled & 0xFF
return bytes(td)
# ---------------------------------------------------------------------------
# Full arrangement section builder
# ---------------------------------------------------------------------------
def build_arrangement_section(
items: list[ArrangementItem],
track_data_template: bytes,
ppq: int = PPQ_DEFAULT,
max_tracks: int = MAX_TRACKS_DEFAULT,
) -> bytes:
"""Build the full post-channel arrangement section bytes.
Produces the exact byte sequence FL Studio expects after the channel
events:
ArrNew(99) → ArrName(241) → Flag36(36) → Playlist(233)
→ TrackData(238) × *max_tracks* → ArrCurrent(100)
Args:
items: Playlist items to encode.
track_data_template: 66-byte template from :func:`build_track_data_template`.
ppq: Pulses-per-quarter-note (default 96).
max_tracks: Total track-data entries to write (default 500).
Returns:
Complete arrangement section as raw bytes.
"""
result = bytearray()
# 1. ArrNew — word event, value = 0
result.extend(encode_word_event(EID_ARR_NEW, 0))
# 2. ArrName — "Arrangement" as UTF-16-LE + null terminator
arr_name = "Arrangement".encode("utf-16-le") + b"\x00\x00"
result.extend(encode_data_event(EID_ARR_NAME, arr_name))
# 3. Flag36 — byte event, value = 0
result.extend(encode_byte_event(EID_FLAG_36, 0))
# 4. Playlist — data event, concatenation of all 32-byte items
pl_data = b"".join(item.to_bytes(ppq, max_tracks) for item in items)
result.extend(encode_data_event(EID_PLAYLIST, pl_data))
# 5. TrackData × max_tracks — first track (iid=1) disabled, rest enabled
for i in range(1, max_tracks + 1):
enabled = 0 if i == 1 else 1
td = encode_track_data(i, enabled, track_data_template)
result.extend(encode_data_event(EID_TRACK_DATA, td))
# 6. ArrCurrent — word event, value = 0
result.extend(encode_word_event(EID_ARR_CURRENT, 0))
return bytes(result)

382
src/flp_builder/builder.py Normal file
View File

@@ -0,0 +1,382 @@
"""JSON->FLP builder - converts SongDefinition to a valid FL Studio FLP file.
Replicates the proven assembly logic from ``output/build_reggaeton_v15.py`` but
driven entirely by a :class:`SongDefinition` object instead of hardcoded values.
Assembly order (matches v15):
FLhd header + FLdt wrapper around:
header_events + pattern_events + channel_events + arrangement_events
Usage::
builder = FLPBuilder()
flp_bytes = builder.build(song)
Path("out.flp").write_bytes(flp_bytes)
"""
import struct
from pathlib import Path
from .schema import SongDefinition, PatternDef, MelodicTrack
from .skeleton import ChannelSkeletonLoader
from .arrangement import ArrangementItem, build_arrangement_section, build_track_data_template
from .events import (
EventID,
encode_text_event,
encode_word_event,
encode_data_event,
encode_notes_block,
)
from ..composer.rhythm import get_notes
# ---------------------------------------------------------------------------
# Default paths (relative to project root)
# ---------------------------------------------------------------------------
REF_FLP = Path(__file__).parents[2] / "my space ryt" / "my space ryt.flp"
CH11_TMPL = Path(__file__).parents[2] / "output" / "ch11_kick_template.bin"
SAMPLES = Path(__file__).parents[2] / "output" / "samples"
# ---------------------------------------------------------------------------
# Note format conversion
# ---------------------------------------------------------------------------
def _convert_rhythm_notes(notes: list[dict]) -> list[dict]:
"""Convert rhythm.py note format to events.py format.
rhythm.py: ``{"pos", "len", "key", "vel"}``
events.py: ``{"position", "length", "key", "velocity"}``
"""
return [
{"position": n["pos"], "length": n["len"], "key": n["key"], "velocity": n["vel"]}
for n in notes
]
def _convert_melodic_notes(notes: list) -> list[dict]:
"""Convert MelodicNote (pos/len/key/vel) to events.py format.
MelodicNote: ``{pos, len, key, vel}``
events.py: ``{"position", "length", "key", "velocity"}``
"""
return [
{"position": n.pos, "length": n.len, "key": n.key, "velocity": n.vel}
for n in notes
]
# ---------------------------------------------------------------------------
# FLPBuilder
# ---------------------------------------------------------------------------
class FLPBuilder:
"""Builds an FLP binary from a :class:`SongDefinition`.
Parameters
----------
ref_flp:
Path to a reference FLP used for header events and channel skeleton.
ch11_template:
Path to the ch11_kick_template.bin for empty sampler channels.
samples_dir:
Directory containing .wav sample files.
"""
def __init__(
self,
ref_flp: str | Path = REF_FLP,
ch11_template: str | Path = CH11_TMPL,
samples_dir: str | Path = SAMPLES,
):
self._ref_flp = Path(ref_flp)
self._ch11 = Path(ch11_template)
self._samples = Path(samples_dir)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def build(self, song: SongDefinition) -> bytes:
"""Convert *song* to raw FLP bytes.
Raises
------
ValueError
If song validation fails or the reference FLP is malformed.
FileNotFoundError
If reference FLP or templates are missing.
"""
# 1. Validate
errors = song.validate()
if errors:
raise ValueError(
"Song validation failed:\n - " + "\n - ".join(errors)
)
# 2. Read reference FLP
ref_bytes = self._ref_flp.read_bytes()
num_channels = struct.unpack("<H", ref_bytes[10:12])[0]
# 3. Build each section
header_bytes = self._build_header(song, ref_bytes)
pattern_bytes = self._build_all_patterns(song)
# 3b. Build melodic map and melodic pattern bytes
melodic_map: dict[int, tuple[str, str]] = {}
melodic_pattern_bytes = b""
if song.melodic_tracks:
for mt in song.melodic_tracks:
wav_dir = str(Path(mt.sample_path).parent)
wav_name = Path(mt.sample_path).name
melodic_map[mt.channel_index] = (wav_dir, wav_name)
# Assign pattern IDs after drum patterns (1-based)
drum_pattern_count = len(song.patterns)
for i, mt in enumerate(song.melodic_tracks):
pattern_id = drum_pattern_count + i + 1
melodic_pattern_bytes += self._build_melodic_pattern(
mt, pattern_id, song.meta.ppq
)
else:
# No melodic tracks: melodic_map stays empty, same as before
pass
loader = ChannelSkeletonLoader(
str(self._ref_flp),
str(self._ch11),
str(self._samples),
)
channel_bytes = loader.load(song.samples, melodic_map=melodic_map)
track_data_template = build_track_data_template(ref_bytes)
arrangement_bytes = self._build_arrangement(song, track_data_template)
# 4. Assemble body: header + patterns + melodic_patterns + channels + arrangement
body = (
header_bytes
+ pattern_bytes
+ melodic_pattern_bytes
+ channel_bytes
+ arrangement_bytes
)
# 5. Wrap with FLhd + FLdt headers (matches v15 line 317-318)
flp = (
struct.pack("<4sIhHH", b"FLhd", 6, 0, num_channels, song.meta.ppq)
+ b"FLdt"
+ struct.pack("<I", len(body))
+ body
)
return flp
# ------------------------------------------------------------------
# Header
# ------------------------------------------------------------------
def _build_header(self, song: SongDefinition, ref_bytes: bytes) -> bytes:
"""Extract header events from reference FLP and patch with song.meta values.
The "header" is everything between offset 22 (after FLhd+FLdt chunk
headers) and the first ``PatNew`` event. This includes version info,
tempo, time-signature, etc. We patch the tempo (BPM) to match the
song definition.
This replicates v15 lines 133-141.
"""
# Find first PatNew event
first_pat = self._find_first_event(ref_bytes, EventID.PatNew)
if first_pat is None:
raise ValueError("No PatNew event found in reference FLP")
# Extract header events (everything before first pattern)
header = bytearray(ref_bytes[22:first_pat])
# Patch BPM — Tempo event (ID 156) is a dword, value = BPM * 1000
p = 0
while p < len(header):
np, _, ib, _v, _vt = self._read_ev(bytes(header), p)
if ib == EventID.Tempo:
struct.pack_into("<I", header, p + 1, int(song.meta.bpm * 1000))
break
p = np
return bytes(header)
# ------------------------------------------------------------------
# Patterns
# ------------------------------------------------------------------
def _build_pattern_bytes(self, pattern: PatternDef, ppq: int) -> bytes:
"""Build all FLP events for one pattern.
Sequence:
1. ``PatNew`` (word event) — value = pattern.id - 1 (0-based)
2. ``PatName`` (text event) — UTF-16-LE pattern name
3. ``PatNotes`` (data event) per channel from ``get_notes()``
Returns raw bytes for this pattern.
"""
buf = bytearray()
# 1. PatNew — word event, 0-based index
buf += encode_word_event(EventID.PatNew, pattern.id - 1)
# 2. PatName — text event (UTF-16-LE + null terminator)
if pattern.name:
buf += encode_text_event(EventID.PatName, pattern.name)
# 3. Generate notes via rhythm.py dispatcher
notes_by_channel = get_notes(
pattern.generator,
pattern.bars,
pattern.velocity_mult,
pattern.density,
)
# 4. Encode notes for each channel
for ch_idx, raw_notes in notes_by_channel.items():
converted = _convert_rhythm_notes(raw_notes)
buf += encode_data_event(
EventID.PatNotes,
encode_notes_block(ch_idx, converted, ppq),
)
return bytes(buf)
def _build_all_patterns(self, song: SongDefinition) -> bytes:
"""Build bytes for all patterns in *song.patterns*."""
buf = bytearray()
for pattern in song.patterns:
buf += self._build_pattern_bytes(pattern, song.meta.ppq)
return bytes(buf)
def _build_melodic_pattern(
self, mt: MelodicTrack, pattern_id: int, ppq: int
) -> bytes:
"""Build FLP events for one melodic track pattern.
Sequence:
1. ``PatNew`` (word event) — value = pattern_id - 1 (0-based)
2. ``PatName`` (text event) — UTF-16-LE with ``mt.role`` as name
3. ``PatNotes`` (data event) with notes for the melodic channel
Returns raw bytes for this melodic pattern.
"""
buf = bytearray()
# 1. PatNew — word event, 0-based index
buf += encode_word_event(EventID.PatNew, pattern_id - 1)
# 2. PatName — text event (UTF-16-LE + null terminator)
if mt.role:
buf += encode_text_event(EventID.PatName, mt.role)
# 3. Convert MelodicNotes to events.py format and encode
converted = _convert_melodic_notes(mt.notes)
buf += encode_data_event(
EventID.PatNotes,
encode_notes_block(mt.channel_index, converted, ppq),
)
return bytes(buf)
# ------------------------------------------------------------------
# Arrangement
# ------------------------------------------------------------------
def _build_arrangement(
self, song: SongDefinition, track_data_template: bytes
) -> bytes:
"""Convert *song.items* to arrangement section bytes.
Each :class:`ArrangementItemDef` (1-based track) is converted to an
:class:`ArrangementItem` (0-based track_index) and fed to
:func:`build_arrangement_section`.
"""
items = [
ArrangementItem(
pattern_id=item.pattern,
bar=item.bar,
num_bars=item.bars,
track_index=item.track - 1, # 1-based -> 0-based
muted=item.muted,
)
for item in song.items
]
# Add melodic track items after drum items
if song.melodic_tracks:
drum_pattern_count = len(song.patterns)
# Determine starting track index (after drum tracks)
max_drum_track = max((item.track for item in song.items), default=1)
for i, mt in enumerate(song.melodic_tracks):
pattern_id = drum_pattern_count + i + 1
track_index = max_drum_track + i # 0-based, after drum tracks
items.append(
ArrangementItem(
pattern_id=pattern_id,
bar=0,
num_bars=4, # default 4 bars
track_index=track_index,
muted=False,
)
)
return build_arrangement_section(
items,
track_data_template,
ppq=song.meta.ppq,
)
# ------------------------------------------------------------------
# Event parsing helpers (minimal, for header scanning)
# ------------------------------------------------------------------
@staticmethod
def _read_ev(data: bytes, pos: int) -> tuple:
"""Read one FLP event from *data* starting at *pos*.
Returns ``(next_pos, start, event_id, value, value_type)``.
"""
start = pos
ib = data[pos]
pos += 1
if ib < 64:
# Byte event: 1 byte ID + 1 byte value
return pos + 1, start, ib, data[start + 1], "byte"
elif ib < 128:
# Word event: 1 byte ID + 2 byte value
return pos + 2, start, ib, struct.unpack("<H", data[pos : pos + 2])[0], "word"
elif ib < 192:
# Dword event: 1 byte ID + 4 byte value
return pos + 4, start, ib, struct.unpack("<I", data[pos : pos + 4])[0], "dword"
else:
# Data/text event: 1 byte ID + varint size + payload
sz = 0
sh = 0
while True:
b = data[pos]
pos += 1
sz |= (b & 0x7F) << sh
sh += 7
if not (b & 0x80):
break
return pos + sz, start, ib, data[pos : pos + sz], "data"
@classmethod
def _find_first_event(cls, data: bytes, event_id: int) -> int | None:
"""Find the byte offset of the first occurrence of *event_id*.
Starts scanning at offset 22 (past FLhd + FLdt chunk headers).
Returns ``None`` if the event is not found.
"""
pos = 22
while pos < len(data):
np, start, ib, _val, _vt = cls._read_ev(data, pos)
if ib == event_id:
return start
pos = np
return None

225
src/flp_builder/events.py Normal file
View File

@@ -0,0 +1,225 @@
import struct
from enum import IntEnum
class EventID(IntEnum):
WORD = 64
DWORD = 128
TEXT = 192
DATA = 208
LoopActive = 9
ShowInfo = 10
Volume = 12
PanLaw = 23
Licensed = 28
TempoCoarse = 66
Pitch = 80
TempoFine = 93
CurGroupId = 146
Tempo = 156
FLBuild = 159
Title = 194
Comments = 195
Url = 197
RTFComments = 198
FLVersion = 199
Licensee = 200
DataPath = 202
Genre = 206
Artists = 207
Timestamp = 237
ChIsEnabled = 0
ChVolByte = 2
ChPanByte = 3
ChZipped = 15
ChType = 21
ChRoutedTo = 22
ChIsLocked = 32
ChNew = 64
ChFreqTilt = 69
ChFXFlags = 70
ChCutoff = 71
ChVolWord = 72
ChPanWord = 73
ChPreamp = 74
ChFadeOut = 75
ChFadeIn = 76
ChResonance = 83
ChStereoDelay = 85
ChPogo = 86
ChTimeShift = 89
ChChildren = 94
ChSwing = 97
ChRingMod = 131
ChCutGroup = 132
ChRootNote = 135
ChDelayModXY = 138
ChReverb = 139
ChStretchTime = 140
ChFineTune = 142
ChSamplerFlags = 143
ChLayerFlags = 144
ChGroupNum = 145
ChAUSampleRate = 153
ChName = 192
ChSamplePath = 196
ChDelay = 209
ChParameters = 215
ChEnvelopeLFO = 218
ChLevels = 219
ChPolyphony = 221
ChTracking = 228
ChLevelAdjusts = 229
ChAutomation = 234
PatLooped = 26
PatNew = 65
PatColor = 150
PatName = 193
PatChannelIID = 160
PatLength = 164
PatControllers = 223
PatNotes = 224
PluginColor = 128
PluginIcon = 155
PluginInternalName = 201
PluginName = 203
PluginWrapper = 212
PluginData = 213
MixerAPDC = 29
MixerParams = 225
def encode_varint(value: int) -> bytes:
result = bytearray()
while True:
byte = value & 0x7F
value >>= 7
if value:
byte |= 0x80
result.append(byte)
if not value:
break
return bytes(result)
def encode_text(text: str, utf16: bool = True) -> bytes:
if utf16:
return text.encode("utf-16-le") + b"\x00\x00"
return text.encode("ascii") + b"\x00"
def encode_byte_event(id_: int, value: int) -> bytes:
return bytes([id_, value & 0xFF])
def encode_word_event(id_: int, value: int) -> bytes:
return bytes([id_]) + struct.pack("<H", value)
def encode_dword_event(id_: int, value: int) -> bytes:
return bytes([id_]) + struct.pack("<I", value)
def encode_text_event(id_: int, text: str) -> bytes:
data = encode_text(text)
return bytes([id_]) + encode_varint(len(data)) + data
def encode_data_event(id_: int, data: bytes) -> bytes:
return bytes([id_]) + encode_varint(len(data)) + data
def encode_note_24(
position: int,
flags: int,
rack_channel: int,
length: int,
key: int,
group: int,
fine_pitch: int,
release: int,
midi_channel: int,
pan: int,
velocity: int,
mod_x: int,
mod_y: int,
) -> bytes:
"""Encode a single note in FL Studio's 24-byte format.
Format (24 bytes, all absolute values):
position: uint32 (4) - absolute position in PPQ ticks
flags: uint16 (2) - note flags (0x4000 = standard note)
rack_channel: uint16 (2) - channel rack index
length: uint32 (4) - duration in PPQ ticks
key: uint16 (2) - MIDI note number (0-127)
group: uint16 (2) - note group
fine_pitch: uint8 (1) - fine pitch (0x78 = 120 = no detune)
_u1: uint8 (1) - unknown (0x40)
release: uint8 (1) - release value
midi_channel: uint8 (1) - MIDI channel
pan: int8 (1) - stereo pan (64 = center)
velocity: uint8 (1) - note velocity
mod_x: uint8 (1) - modulation X (128 = center)
mod_y: uint8 (1) - modulation Y (128 = center)
"""
return struct.pack(
"<IHHIHHBBBBBBBB",
position,
flags,
rack_channel,
length,
key,
group,
fine_pitch,
0x40, # unknown byte, always 0x40 in observed data
release,
midi_channel,
pan,
velocity,
mod_x,
mod_y,
)
def encode_notes_block(
channel_index: int,
notes: list[dict],
ppq: int = 96,
) -> bytes:
"""Encode all notes for a pattern as raw note data (no header).
FL Studio stores notes as a flat array of 24-byte structs.
No header or count prefix needed - the event size determines count.
"""
note_data = bytearray()
for note in notes:
pos = int(note.get("position", 0) * ppq)
length = int(note.get("length", 1) * ppq)
key = note.get("key", 60)
velocity = note.get("velocity", 100)
rack_channel = note.get("rack_channel", channel_index)
note_bytes = encode_note_24(
position=pos,
flags=0x4000,
rack_channel=rack_channel,
length=max(length, 1),
key=key & 0x7F,
group=0,
fine_pitch=120,
release=64,
midi_channel=0,
pan=64,
velocity=velocity & 0x7F,
mod_x=128,
mod_y=128,
)
note_data.extend(note_bytes)
return bytes(note_data)

134
src/flp_builder/project.py Normal file
View File

@@ -0,0 +1,134 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Note:
position: float
length: float
key: int
velocity: int = 100
fine_pitch: int = 0
pan: int = 0
midi_channel: int = 0
slide: bool = False
release: int = 0
mod_x: int = 0
mod_y: int = 0
group: int = 0
def to_dict(self) -> dict:
return {
"position": self.position,
"length": self.length,
"key": self.key,
"velocity": self.velocity,
"fine_pitch": self.fine_pitch,
"pan": self.pan,
"midi_channel": self.midi_channel,
"slide": self.slide,
"release": self.release,
"mod_x": self.mod_x,
"mod_y": self.mod_y,
"group": self.group,
}
@dataclass
class Pattern:
name: str = ""
index: int = 0
notes: dict[int, list[Note]] = field(default_factory=dict)
color: int = 0
length: int = 0
def add_note(self, channel_index: int, note: Note):
if channel_index not in self.notes:
self.notes[channel_index] = []
self.notes[channel_index].append(note)
@dataclass
class Plugin:
internal_name: str = ""
display_name: str = ""
plugin_data: Optional[bytes] = None
color: int = 0
icon: int = 0
@dataclass
class Channel:
name: str = ""
index: int = 0
enabled: bool = True
volume: int = 256
pan: int = 0
plugin: Optional[Plugin] = None
mixer_track: int = 0
color: int = 0
root_note: int = 60
channel_type: int = 0
FL_TYPE_GENERATOR = 2
FL_TYPE_SAMPLER = 0
@dataclass
class MixerTrack:
name: str = ""
index: int = 0
volume: float = 1.0
pan: float = 0.0
muted: bool = False
effects: list[Plugin] = field(default_factory=list)
@dataclass
class FLPProject:
tempo: float = 140.0
title: str = ""
genre: str = ""
artists: str = ""
comments: str = ""
fl_version: str = "24.7.1.73"
ppq: int = 96
channels: list[Channel] = field(default_factory=list)
patterns: list[Pattern] = field(default_factory=list)
mixer_tracks: list[MixerTrack] = field(default_factory=list)
def add_channel(
self,
name: str,
plugin_internal_name: str = "",
plugin_display_name: str = "",
plugin_data: Optional[bytes] = None,
mixer_track: int = -1,
channel_type: int = 2,
volume: int = 256,
) -> Channel:
idx = len(self.channels)
plugin = None
if plugin_internal_name:
plugin = Plugin(
internal_name=plugin_internal_name,
display_name=plugin_display_name or plugin_internal_name,
plugin_data=plugin_data,
)
ch = Channel(
name=name,
index=idx,
plugin=plugin,
mixer_track=mixer_track if mixer_track >= 0 else idx,
channel_type=channel_type,
volume=volume,
)
self.channels.append(ch)
return ch
def add_pattern(self, name: str = "") -> Pattern:
idx = len(self.patterns) + 1
pat = Pattern(name=name, index=idx)
self.patterns.append(pat)
return pat

395
src/flp_builder/schema.py Normal file
View File

@@ -0,0 +1,395 @@
"""Song definition schema for FL Studio FLP generation.
Provides the JSON contract that decouples song composition from FLP rendering.
A SongDefinition is the single source of truth for one ``.flp`` file.
Usage::
song = SongDefinition.load_file("knowledge/songs/reggaeton_template.json")
errors = song.validate()
json_str = song.to_json()
"""
from __future__ import annotations
import json
import re
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Key validation pattern: A-G, optional flat/sharp, optional minor 'm'
# ---------------------------------------------------------------------------
_KEY_RE = re.compile(r"^[A-G][b#]?m?$")
# Allowed top-level keys in the JSON document
_TOP_LEVEL_KEYS = frozenset({
"meta", "samples", "patterns", "tracks", "items",
"melodic_tracks", "progression_name", "section_template",
})
# Allowed keys in nested objects
_META_KEYS = frozenset({
"bpm", "key", "title", "ppq", "time_sig_num", "time_sig_den",
})
_PATTERN_KEYS = frozenset({
"id", "name", "instrument", "channel", "bars", "generator",
"velocity_mult", "density",
})
_TRACK_KEYS = frozenset({"index", "name"})
_ITEM_KEYS = frozenset({"pattern", "bar", "bars", "track", "muted"})
# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------
@dataclass
class SongMeta:
"""Song metadata — tempo, key, time signature."""
bpm: float # 20999
key: str # e.g. "Am", "Dm", "Gm"
title: str # song title
ppq: int = 96 # ticks per quarter note
time_sig_num: int = 4
time_sig_den: int = 4
@dataclass
class PatternNote:
"""A single note within a pattern (used when embedding notes directly)."""
pos: float # beat position (0.0 = beat 1 of bar)
len: float # duration in beats
key: int # MIDI note (60 = C4)
vel: int # velocity 0127
@dataclass
class PatternDef:
"""Pattern definition — recipe for generating note data.
The ``generator`` field names a function in ``composer/rhythm.py``
that produces the actual MIDI notes for this pattern.
"""
id: int # pattern number (1-based)
name: str # human label
instrument: str # "kick", "snare", "hihat", etc.
channel: int # channel rack index (1016)
bars: int # pattern length in bars
generator: str # rhythm.py function name
velocity_mult: float = 1.0 # scales all velocities
density: float = 1.0 # 0.5=sparse, 1.0=full
@dataclass
class ArrangementTrack:
"""A track row in the FL Studio playlist / arrangement."""
index: int # 1-based track index in arrangement
name: str # display name
@dataclass
class ArrangementItemDef:
"""Placement of a pattern on the arrangement timeline."""
pattern: int # pattern id
bar: float # start bar (0-based)
bars: float # duration in bars
track: int # track index (1-based, must exist in tracks[])
muted: bool = False
@dataclass
class MelodicNote:
"""A single note in a melodic track. Unified format: {pos, len, key, vel}."""
pos: float # beat position (0.0 = beat 1 of bar)
len: float # duration in beats
key: int # MIDI note (60 = C4)
vel: int # velocity 0127
@dataclass
class MelodicTrack:
"""A melodic track referencing an audio sample with MIDI note triggers.
The sample is loaded into a sampler channel and notes trigger playback.
"""
role: str # "bass", "lead", "pad", "pluck", etc.
sample_path: str # absolute path to .wav file
notes: list[MelodicNote] # note events
channel_index: int # FL Studio channel (17+ for melodic)
volume: float = 0.85 # 0.01.0
pan: float = 0.0 # -1.0 to 1.0
@dataclass
class SongDefinition:
"""Complete song definition — the single source of truth for one .flp.
Serialization round-trips through ``to_json()`` / ``from_json()``.
Use ``validate()`` to check constraints before rendering.
"""
meta: SongMeta
samples: dict[str, str] # {"kick": "kick.wav", ...}
patterns: list[PatternDef]
tracks: list[ArrangementTrack]
items: list[ArrangementItemDef]
melodic_tracks: list[MelodicTrack] = field(default_factory=list)
# Optional metadata for variation engine
progression_name: str = ""
section_template: str = "standard"
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
def validate(self) -> list[str]:
"""Return list of validation errors (empty list = valid).
Checks:
1. meta.bpm in 20999
2. meta.key matches ``^[A-G][b#]?m?$``
3. meta.ppq == 96
4. All pattern ``id`` values are unique
5. All ``item.pattern`` reference an existing pattern id
6. All ``item.track`` reference an existing track index
"""
errors: list[str] = []
# 1. BPM range
if not (20 <= self.meta.bpm <= 999):
errors.append(
f"meta.bpm must be 20999, got {self.meta.bpm}"
)
# 2. Key format
if not _KEY_RE.match(self.meta.key):
errors.append(
f"meta.key must match ^[A-G][b#]?m?$, got '{self.meta.key}'"
)
# 3. PPQ
if self.meta.ppq != 96:
errors.append(
f"meta.ppq must be 96, got {self.meta.ppq}"
)
# 4. Unique pattern ids
pattern_ids = [p.id for p in self.patterns]
seen: set[int] = set()
for pid in pattern_ids:
if pid in seen:
errors.append(f"Duplicate pattern id: {pid}")
seen.add(pid)
valid_pattern_ids = set(pattern_ids)
# 5. All items reference valid pattern id
for i, item in enumerate(self.items):
if item.pattern not in valid_pattern_ids:
errors.append(
f"items[{i}].pattern={item.pattern} does not reference "
f"an existing pattern id"
)
# 6. All items reference valid track index
valid_track_indices = {t.index for t in self.tracks}
for i, item in enumerate(self.items):
if item.track not in valid_track_indices:
errors.append(
f"items[{i}].track={item.track} does not reference "
f"an existing track index"
)
return errors
# ------------------------------------------------------------------
# Serialization
# ------------------------------------------------------------------
def to_json(self, indent: int = 2) -> str:
"""Serialize to a JSON string."""
return json.dumps(asdict(self), indent=indent, ensure_ascii=False)
@classmethod
def from_json(cls, data: str | dict) -> SongDefinition:
"""Deserialize from a JSON string or dict.
Raises:
ValueError: On unknown keys, missing fields, or validation errors.
"""
if isinstance(data, str):
raw = json.loads(data)
else:
raw = data
if not isinstance(raw, dict):
raise ValueError(f"Expected dict, got {type(raw).__name__}")
# Reject unknown top-level keys
unknown = set(raw.keys()) - _TOP_LEVEL_KEYS
if unknown:
raise ValueError(f"Unknown top-level keys: {sorted(unknown)}")
# --- meta ---
meta_raw = raw.get("meta")
if not isinstance(meta_raw, dict):
raise ValueError("Missing or invalid 'meta' object")
unknown_meta = set(meta_raw.keys()) - _META_KEYS
if unknown_meta:
raise ValueError(f"Unknown meta keys: {sorted(unknown_meta)}")
try:
meta = SongMeta(
bpm=float(meta_raw["bpm"]),
key=str(meta_raw["key"]),
title=str(meta_raw.get("title", "")),
ppq=int(meta_raw.get("ppq", 96)),
time_sig_num=int(meta_raw.get("time_sig_num", 4)),
time_sig_den=int(meta_raw.get("time_sig_den", 4)),
)
except KeyError as exc:
raise ValueError(f"Missing required meta field: {exc}") from exc
# --- samples ---
samples = raw.get("samples")
if not isinstance(samples, dict):
raise ValueError("Missing or invalid 'samples' dict")
# --- patterns ---
patterns_raw = raw.get("patterns")
if not isinstance(patterns_raw, list):
raise ValueError("Missing or invalid 'patterns' list")
patterns: list[PatternDef] = []
for idx, p in enumerate(patterns_raw):
if not isinstance(p, dict):
raise ValueError(f"patterns[{idx}] must be a dict")
unknown_p = set(p.keys()) - _PATTERN_KEYS
if unknown_p:
raise ValueError(
f"patterns[{idx}] unknown keys: {sorted(unknown_p)}"
)
try:
patterns.append(PatternDef(
id=int(p["id"]),
name=str(p["name"]),
instrument=str(p["instrument"]),
channel=int(p["channel"]),
bars=int(p["bars"]),
generator=str(p["generator"]),
velocity_mult=float(p.get("velocity_mult", 1.0)),
density=float(p.get("density", 1.0)),
))
except KeyError as exc:
raise ValueError(
f"patterns[{idx}] missing required field: {exc}"
) from exc
# --- tracks ---
tracks_raw = raw.get("tracks")
if not isinstance(tracks_raw, list):
raise ValueError("Missing or invalid 'tracks' list")
tracks: list[ArrangementTrack] = []
for idx, t in enumerate(tracks_raw):
if not isinstance(t, dict):
raise ValueError(f"tracks[{idx}] must be a dict")
unknown_t = set(t.keys()) - _TRACK_KEYS
if unknown_t:
raise ValueError(
f"tracks[{idx}] unknown keys: {sorted(unknown_t)}"
)
try:
tracks.append(ArrangementTrack(
index=int(t["index"]),
name=str(t["name"]),
))
except KeyError as exc:
raise ValueError(
f"tracks[{idx}] missing required field: {exc}"
) from exc
# --- items ---
items_raw = raw.get("items")
if not isinstance(items_raw, list):
raise ValueError("Missing or invalid 'items' list")
items: list[ArrangementItemDef] = []
for idx, it in enumerate(items_raw):
if not isinstance(it, dict):
raise ValueError(f"items[{idx}] must be a dict")
unknown_it = set(it.keys()) - _ITEM_KEYS
if unknown_it:
raise ValueError(
f"items[{idx}] unknown keys: {sorted(unknown_it)}"
)
try:
items.append(ArrangementItemDef(
pattern=int(it["pattern"]),
bar=float(it["bar"]),
bars=float(it["bars"]),
track=int(it["track"]),
muted=bool(it.get("muted", False)),
))
except KeyError as exc:
raise ValueError(
f"items[{idx}] missing required field: {exc}"
) from exc
song = cls(
meta=meta,
samples=samples,
patterns=patterns,
tracks=tracks,
items=items,
progression_name=str(raw.get("progression_name", "")),
section_template=str(raw.get("section_template", "standard")),
)
# Validate and raise on errors
errors = song.validate()
if errors:
raise ValueError(
"Song validation failed:\n - " + "\n - ".join(errors)
)
return song
@classmethod
def load_file(cls, path: str | Path) -> SongDefinition:
"""Load and validate from a ``.json`` file.
Raises:
FileNotFoundError: If the file does not exist.
ValueError: If validation fails.
"""
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"Song file not found: {p}")
return cls.from_json(p.read_text(encoding="utf-8"))
# ---------------------------------------------------------------------------
# Convenience
# ---------------------------------------------------------------------------
def load_song_json(path: str | Path) -> SongDefinition:
"""Load + validate a song definition from a JSON file.
Raises:
ValueError: If validation fails.
FileNotFoundError: If file does not exist.
"""
return SongDefinition.load_file(path)

382
src/flp_builder/skeleton.py Normal file
View File

@@ -0,0 +1,382 @@
"""Channel skeleton loader — extracts sampler channels from reference FLP and patches sample paths."""
import os
import struct
from pathlib import Path
# Default channel→sample mapping (index: sample_key)
# Only Ch10-19 are sampler channels in the reference FLP
DEFAULT_CHANNEL_MAP = {
10: "channel10",
11: "channel11",
12: "channel12",
13: "channel13",
14: "channel14",
15: "channel15",
16: "channel16",
17: "channel17",
18: "channel18",
19: "channel19",
}
# Channels to replace with empty sampler (non-drum channels from original)
EMPTY_SAMPLER_CHANNELS = {3, 4, 8, 17, 18, 19}
class ChannelSkeletonLoader:
"""Loads sampler channel configuration from a reference FLP binary.
Usage:
loader = ChannelSkeletonLoader(ref_flp_path, ch11_template_path, samples_dir)
channel_bytes = loader.load(sample_map={"kick": "kick.wav", ...})
"""
def __init__(self, ref_flp_path: str, ch11_template_path: str, samples_dir: str):
self.ref_flp_path = ref_flp_path
self.ch11_template_path = ch11_template_path
self.samples_dir = samples_dir
self._cache: bytes | None = None
self._ch11_template: bytes | None = None
def load(
self,
sample_map: dict[str, str] | None = None,
melodic_map: dict[int, tuple[str, str]] | None = None,
) -> bytes:
"""Return assembled channel bytes with sample paths patched.
sample_map: {"kick": "kick.wav", "snare": "snare.wav", ...}
Keys must match DEFAULT_CHANNEL_MAP values.
If None, uses DEFAULT_CHANNEL_MAP with filenames as "<key>.wav"
melodic_map: {ch_idx: (samples_dir, wav_name), ...}
Maps melodic channel indices to their sample file.
These channels get sampler clones with real samples instead of empty.
Returns raw bytes for all channels (stripped of post-channel data).
Caches result — calling load() multiple times returns same bytes.
"""
if self._cache is not None:
return self._cache
# Resolve sample_map: map channel_index → wav filename
if sample_map is None:
ch_to_wav = {ch: f"{key}.wav" for ch, key in DEFAULT_CHANNEL_MAP.items()}
else:
ch_to_wav = {ch: sample_map[key] for ch, key in DEFAULT_CHANNEL_MAP.items() if key in sample_map}
melodic_channels = set(melodic_map.keys()) if melodic_map else set()
extracted = self._extract_channels()
order = extracted["order"]
segments: dict[int, bytearray] = extracted["segments"]
# Replace channels not in drum/melodic maps with empty sampler clones
channels_with_samples = set(ch_to_wav.keys()) | melodic_channels
for ch_idx in list(segments.keys()):
if ch_idx not in channels_with_samples:
segments[ch_idx] = bytearray(self._make_empty_sampler(ch_idx))
# For melodic channels: clone ch11 template and patch with real sample path
if melodic_map:
for ch_idx, (sample_dir, wav_name) in melodic_map.items():
if ch_idx in segments:
segments[ch_idx] = bytearray(
self._make_sampler_with_sample(ch_idx, sample_dir, wav_name)
)
# Patch sample paths for drum channels (skip melodic — already patched)
for ch_idx, wav_name in ch_to_wav.items():
if ch_idx in segments and ch_idx not in melodic_channels:
segments[ch_idx] = bytearray(self._patch_sample_path(bytes(segments[ch_idx]), wav_name))
# Assemble in original order
buf = bytearray()
for ch_idx in order:
buf += segments[ch_idx]
self._cache = bytes(buf)
return self._cache
# ── Event parsing ──────────────────────────────────────────────────────────
def _read_ev(self, data: bytes, pos: int) -> tuple:
"""Read one FLP event. Returns (next_pos, start, event_id, value, value_type)."""
start = pos
ib = data[pos]
pos += 1
if ib < 64:
# Byte event: 1 byte ID + 1 byte value
return pos + 1, start, ib, data[start + 1], "byte"
elif ib < 128:
# Word event: 1 byte ID + 2 byte value
return pos + 2, start, ib, struct.unpack("<H", data[pos : pos + 2])[0], "word"
elif ib < 192:
# Dword event: 1 byte ID + 4 byte value
return pos + 4, start, ib, struct.unpack("<I", data[pos : pos + 4])[0], "dword"
else:
# Data/TEXT event: 1 byte ID + varint size + payload
sz = 0
sh = 0
while True:
b = data[pos]
pos += 1
sz |= (b & 0x7F) << sh
sh += 7
if not (b & 0x80):
break
return pos + sz, start, ib, data[pos : pos + sz], "data"
def _encode_varint(self, n: int) -> bytes:
"""Encode an integer as a varint (LEB128)."""
r = bytearray()
while True:
b = n & 0x7F
n >>= 7
if n:
b |= 0x80
r.append(b)
if not n:
break
return bytes(r)
# ── Channel extraction ─────────────────────────────────────────────────────
def _extract_channels(self) -> dict:
"""Parse reference FLP, extract channel segments, find post-channel boundary.
Returns:
{
'order': [ch_idx, ...], # channels in original order
'segments': {idx: bytes}, # raw bytes per channel
'last_ch': idx, # index of last channel
}
"""
with open(self.ref_flp_path, "rb") as f:
data = f.read()
# Skip FLhd header (6 bytes) + FLdt chunk header (8 bytes) = 14 bytes,
# then the FLhd body. v15 starts scanning at offset 22.
pos = 22
first_ch = None
current_ch = -1
ch_ranges: dict[int, list[int]] = {}
channels_order: list[int] = []
# Import here to avoid circular — events is a leaf module
from src.flp_builder.events import EventID
while pos < len(data):
np, st, ib, val, vt = self._read_ev(data, pos)
if ib == EventID.ChNew:
if first_ch is None:
first_ch = st
if current_ch >= 0:
ch_ranges[current_ch] = (ch_ranges[current_ch][0], st)
current_ch = val
ch_ranges[current_ch] = (st, st)
channels_order.append(current_ch)
pos = np
if current_ch >= 0:
ch_ranges[current_ch] = (ch_ranges[current_ch][0], len(data))
if not channels_order:
raise ValueError("No channels found in reference FLP")
# Find post-channel boundary in last channel segment
# Scan for ID 99 (ArrNew) — everything from there onward is post-channel
last_ch = channels_order[-1]
last_seg_start = ch_ranges[last_ch][0]
last_seg_data = data[last_seg_start:]
p = 0
post_ch_offset = len(last_seg_data)
while p < len(last_seg_data):
np, st, ib, val, vt = self._read_ev(last_seg_data, p)
if ib == 99: # ArrNew
post_ch_offset = st
break
p = np
# Build channel segments, stripping post-channel data from last one
segments: dict[int, bytearray] = {}
for ch_idx in channels_order:
s, e = ch_ranges[ch_idx]
if ch_idx == last_ch:
segments[ch_idx] = bytearray(data[s : s + post_ch_offset])
else:
segments[ch_idx] = bytearray(data[s:e])
return {
"order": channels_order,
"segments": segments,
"last_ch": last_ch,
}
# ── Sampler with real sample ────────────────────────────────────────────────
# Events to strip when cloning: old sample path, old sample name, cached data
STRIP_EVENTS = {0xC4, 0xCB, 0xDA, 0xD7, 0xE4, 0xE5, 0xDD, 0xD1}
def _make_sampler_with_sample(self, ch_idx: int, samples_dir: str, wav_name: str) -> bytes:
"""Clone the FL Studio-created sampler template and patch with real sample.
Uses output/flstudio_sampler_template.bin which was extracted from a
channel that FL Studio itself created (guaranteed correct format).
"""
template_path = os.path.join(
os.path.dirname(self.ref_flp_path), "..", "output", "flstudio_sampler_template.bin"
)
template_path = os.path.normpath(template_path)
if not os.path.isfile(template_path):
# Fallback: extract from debug_sampler.flp
raise FileNotFoundError(f"Sampler template not found: {template_path}")
with open(template_path, "rb") as f:
source = f.read()
# Rebuild: keep non-cached events, patch ChNew index
seg = bytearray()
pos = 0
while pos < len(source):
np, st, ib, val, vt = self._read_ev(source, pos)
if ib in self.STRIP_EVENTS:
pass # Remove stale cached data
elif ib == 0x40 and vt == "word":
seg += struct.pack("<BH", 0x40, ch_idx)
else:
seg += source[st:np]
pos = np
# Add sample name (0xCB)
sample_name = os.path.splitext(wav_name)[0]
encoded_name = sample_name.encode("utf-16-le") + b"\x00\x00"
seg += bytes([0xCB]) + self._encode_varint(len(encoded_name)) + encoded_name
# Add sample path (0xC4) — absolute path, no %USERPROFILE%
full_path = os.path.join(samples_dir, wav_name)
encoded_path = full_path.encode("utf-16-le") + b"\x00\x00"
seg += bytes([0xC4]) + self._encode_varint(len(encoded_path)) + encoded_path
return bytes(seg)
def _extract_channels_raw(self) -> dict[int, bytes]:
"""Extract raw channel segments from reference FLP without caching.
Returns {ch_idx: bytes}."""
with open(self.ref_flp_path, "rb") as f:
data = f.read()
from src.flp_builder.events import EventID
pos = 22
current_ch = -1
ch_ranges: dict[int, tuple[int, int]] = {}
channels_order: list[int] = []
while pos < len(data):
np, st, ib, val, vt = self._read_ev(data, pos)
if ib == EventID.ChNew:
if current_ch >= 0:
ch_ranges[current_ch] = (ch_ranges[current_ch][0], st)
current_ch = val
ch_ranges[current_ch] = (st, st)
channels_order.append(current_ch)
pos = np
if current_ch >= 0:
ch_ranges[current_ch] = (ch_ranges[current_ch][0], len(data))
# Strip post-channel data from last channel
last_ch = channels_order[-1]
last_start = ch_ranges[last_ch][0]
last_data = data[last_start:]
p = 0
post_offset = len(last_data)
while p < len(last_data):
np, st, ib, val, vt = self._read_ev(last_data, p)
if ib == 99:
post_offset = st
break
p = np
segments: dict[int, bytes] = {}
for ch_idx in channels_order:
s, e = ch_ranges[ch_idx]
if ch_idx == last_ch:
segments[ch_idx] = data[s:s + post_offset]
else:
segments[ch_idx] = data[s:e]
return segments
def _patch_chnew_index(self, seg: bytearray, new_idx: int):
"""Find and patch the ChNew word event to a new channel index."""
pos = 0
while pos < len(seg):
np, st, ib, val, vt = self._read_ev(bytes(seg), pos)
if ib == 64 and vt == "word": # ChNew
struct.pack_into("<H", seg, st + 1, new_idx)
return
pos = np
# ── Empty sampler ──────────────────────────────────────────────────────────
def _make_empty_sampler(self, ch_idx: int) -> bytes:
"""Create a minimal empty sampler channel with no sample loaded."""
extracted = self._extract_channels_raw()
source_idx = 10
if source_idx not in extracted:
for alt in [11, 12, 13, 14, 15, 16, 17, 18, 19]:
if alt in extracted:
source_idx = alt
break
seg = bytearray()
source = extracted[source_idx]
pos = 0
while pos < len(source):
np, st, ib, val, vt = self._read_ev(source, pos)
if ib in self.STRIP_EVENTS or ib == 0xC4:
pass # Remove cached data AND old sample path
elif ib == 0x40 and vt == "word":
seg += struct.pack("<BH", 0x40, ch_idx)
else:
seg += source[st:np]
pos = np
# Add empty sample path
seg += bytes([0xC4, 0x02, 0x00, 0x00])
return bytes(seg)
# ── Sample path patching ───────────────────────────────────────────────────
def _patch_sample_path(self, seg: bytes, wav_name: str) -> bytes:
"""Replace 0xC4 (ChSamplePath) event with encoded wav_path.
Uses %USERPROFILE% substitution for portability.
Paths are encoded as UTF-16-LE + null terminator (\\x00\\x00).
"""
seg = bytearray(seg)
# Build full path and substitute USERPROFILE for portability
full_path = os.path.join(self.samples_dir, wav_name)
userprofile = os.environ.get("USERPROFILE", "")
rel_path = full_path.replace(userprofile, "%USERPROFILE%")
encoded_path = rel_path.encode("utf-16-le") + b"\x00\x00"
# Build replacement event: ID byte + varint(size) + encoded path
path_ev = bytes([0xC4]) + self._encode_varint(len(encoded_path)) + encoded_path
# Find all ChSamplePath events
local = 0
replacements: list[tuple[int, int, bytes]] = []
while local < len(seg):
nl, es, ib, v, vt = self._read_ev(bytes(seg), local)
if ib == 0xC4:
replacements.append((es, nl, path_ev))
local = nl
# Apply in reverse to preserve offsets
for es, el, nd in reversed(replacements):
seg[es:el] = nd
return bytes(seg)

145
src/flp_builder/writer.py Normal file
View File

@@ -0,0 +1,145 @@
from __future__ import annotations
import struct
from .events import (
EventID,
encode_byte_event,
encode_word_event,
encode_dword_event,
encode_text_event,
encode_data_event,
encode_varint,
encode_notes_block,
)
from .project import FLPProject, Pattern, Note
class FLPWriter:
def __init__(self, project: FLPProject):
self.project = project
self._events: list[bytes] = []
def build(self) -> bytes:
self._events = []
self._write_project_header()
self._write_patterns()
self._write_channels()
return self._serialize()
def _add_event(self, data: bytes):
self._events.append(data)
def _write_project_header(self):
p = self.project
self._add_event(encode_text_event(EventID.FLVersion, p.fl_version))
self._add_event(encode_dword_event(EventID.FLBuild, 1773))
self._add_event(encode_byte_event(EventID.Licensed, 1))
self._add_event(encode_dword_event(EventID.Tempo, int(p.tempo * 1000)))
self._add_event(encode_byte_event(EventID.LoopActive, 1))
self._add_event(encode_word_event(EventID.Pitch, 0))
self._add_event(encode_byte_event(EventID.PanLaw, 0))
if p.title:
self._add_event(encode_text_event(EventID.Title, p.title))
if p.genre:
self._add_event(encode_text_event(EventID.Genre, p.genre))
if p.artists:
self._add_event(encode_text_event(EventID.Artists, p.artists))
if p.comments:
self._add_event(encode_text_event(EventID.Comments, p.comments))
def _write_patterns(self):
p = self.project
for pat in p.patterns:
self._add_event(encode_word_event(EventID.PatNew, pat.index))
if pat.name:
self._add_event(encode_text_event(EventID.PatName, pat.name))
for ch_idx, notes in pat.notes.items():
if notes:
notes_data = encode_notes_block(
ch_idx,
[n.to_dict() if isinstance(n, Note) else n for n in notes],
ppq=p.ppq,
)
self._add_event(encode_data_event(EventID.PatNotes, notes_data))
def _write_channels(self):
p = self.project
for ch in p.channels:
self._add_event(encode_word_event(EventID.ChNew, ch.index))
self._add_event(encode_byte_event(EventID.ChType, ch.channel_type))
if ch.plugin:
self._add_event(
encode_text_event(EventID.PluginInternalName, ch.plugin.internal_name)
)
if ch.plugin.plugin_data:
self._add_event(
encode_data_event(EventID.PluginData, ch.plugin.plugin_data)
)
elif ch.plugin.internal_name == "Fruity Wrapper":
self._add_event(
encode_text_event(EventID.PluginName, ch.plugin.display_name)
)
wrapper_data = self._build_wrapper_stub(ch.plugin.display_name)
self._add_event(encode_data_event(EventID.PluginData, wrapper_data))
else:
self._add_event(
encode_text_event(EventID.PluginName, ch.plugin.display_name)
)
plugin_data = self._build_native_plugin_stub(ch.plugin.internal_name)
self._add_event(encode_data_event(EventID.PluginData, plugin_data))
if ch.plugin.color:
self._add_event(
encode_dword_event(EventID.PluginColor, ch.plugin.color)
)
self._add_event(encode_text_event(EventID.ChName, ch.name))
self._add_event(encode_byte_event(EventID.ChIsEnabled, 1 if ch.enabled else 0))
self._add_event(encode_byte_event(EventID.ChRoutedTo, ch.mixer_track & 0xFF))
self._add_event(encode_word_event(EventID.ChVolWord, ch.volume))
self._add_event(encode_byte_event(EventID.ChRootNote, ch.root_note))
def _build_wrapper_stub(self, plugin_name: str) -> bytes:
# Minimal VST wrapper state - FL Studio will initialize the plugin fresh
# 10 params with default values
stub = struct.pack("<II", 10, 1) # param_count=10, unknown=1
stub += struct.pack("<II", 20, 0) # version=20, flags=0
stub += b"\xff\xff\xff\xff\xff\xff\xff\xff" # GUID placeholder
stub += b"\x0c\x00\x0c\x00\x0c\x00\x0c\x00" # padding
stub += b"\x00" * 16 # zeros
return stub
def _build_native_plugin_stub(self, internal_name: str) -> bytes:
# Minimal native plugin state
stub = struct.pack("<II", 10, 1)
stub += struct.pack("<II", 20, 0)
stub += b"\xff\xff\xff\xff\xff\xff\xff\xff"
stub += b"\x0c\x00\x0c\x00\x0c\x00\x0c\x00"
stub += b"\x00" * 16
return stub
def _serialize(self) -> bytes:
num_channels = len(self.project.channels)
ppq = self.project.ppq
header = struct.pack(
"<4sIhHH",
b"FLhd",
6,
0,
num_channels,
ppq,
)
all_events = b"".join(self._events)
total_size = len(all_events)
data_header = b"FLdt" + struct.pack("<I", total_size)
return header + data_header + all_events
def write(self, filepath: str):
data = self.build()
with open(filepath, "wb") as f:
f.write(data)
return filepath

194
src/scanner/__init__.py Normal file
View File

@@ -0,0 +1,194 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Optional
FL_USER_DIR = Path(os.path.expanduser("~")) / "Documents" / "Image-Line" / "FL Studio"
PLUGIN_DB_DIR = FL_USER_DIR / "Presets" / "Plugin database" / "Installed"
PROJECT_ROOT = Path(os.path.expanduser("~")) / "Documents" / "fl_control"
def scan_installed_plugins() -> dict:
generators = []
effects = []
gen_dir = PLUGIN_DB_DIR / "Generators"
if gen_dir.exists():
for category_dir in gen_dir.iterdir():
if not category_dir.is_dir():
continue
category = category_dir.name
for fst_file in category_dir.glob("*.fst"):
name = fst_file.stem
generators.append({
"name": name,
"category": category,
"type": "generator",
"format": category,
"fst_path": str(fst_file),
})
fx_dir = PLUGIN_DB_DIR / "Effects"
if fx_dir.exists():
for category_dir in fx_dir.iterdir():
if not category_dir.is_dir():
continue
category = category_dir.name
for fst_file in category_dir.glob("*.fst"):
name = fst_file.stem
effects.append({
"name": name,
"category": category,
"type": "effect",
"format": category,
"fst_path": str(fst_file),
})
return {
"generators": generators,
"effects": effects,
"generator_names": sorted(set(g["name"] for g in generators)),
"effect_names": sorted(set(e["name"] for e in effects)),
}
def scan_samples(base_dir: Optional[Path] = None) -> dict:
if base_dir is None:
base_dir = PROJECT_ROOT / "librerias" / "organized_samples"
categories = {}
if not base_dir.exists():
return {"categories": {}, "total_files": 0}
for cat_dir in base_dir.iterdir():
if not cat_dir.is_dir():
continue
files = []
for f in cat_dir.rglob("*"):
if f.is_file() and f.suffix.lower() in (".wav", ".mp3", ".flac", ".ogg", ".aif", ".aiff"):
files.append({
"name": f.stem,
"path": str(f),
"size": f.stat().st_size,
"ext": f.suffix.lower(),
})
categories[cat_dir.name] = files
total = sum(len(v) for v in categories.values())
return {"categories": categories, "total_files": total}
def scan_library_packs(base_dir: Optional[Path] = None) -> dict:
if base_dir is None:
base_dir = PROJECT_ROOT / "librerias" / "reggaeton"
packs = []
if not base_dir.exists():
return {"packs": packs}
for pack_dir in base_dir.iterdir():
if not pack_dir.is_dir():
continue
pack = {
"name": pack_dir.name,
"path": str(pack_dir),
"contents": {},
}
for sub in pack_dir.rglob("*"):
if sub.is_dir():
continue
ext = sub.suffix.lower()
rel = str(sub.relative_to(pack_dir))
content_type = "other"
if ext in (".wav", ".mp3", ".flac", ".ogg", ".aif", ".aiff"):
content_type = "audio"
elif ext == ".mid":
content_type = "midi"
elif ext in (".fxp", ".fxb", ".fst"):
content_type = "preset"
if content_type not in pack["contents"]:
pack["contents"][content_type] = []
pack["contents"][content_type].append({
"name": sub.stem,
"path": str(sub),
"ext": ext,
"type": content_type,
})
packs.append(pack)
return {"packs": packs}
def scan_vector_store_metadata(vs_dir: Optional[Path] = None) -> dict:
if vs_dir is None:
vs_dir = PROJECT_ROOT / "librerias" / "vector_store"
metadata_path = vs_dir / "metadata.json"
if not metadata_path.exists():
return {"items": [], "total": 0}
with open(metadata_path, "r", encoding="utf-8") as f:
data = json.load(f)
types = {}
for item in data:
t = item.get("type", "unknown")
types[t] = types.get(t, 0) + 1
return {
"total": len(data),
"types": types,
"items_with_key": sum(1 for i in data if i.get("key")),
"items_with_bpm": sum(1 for i in data if i.get("bpm")),
"sample_items": data,
}
def full_inventory() -> dict:
plugins = scan_installed_plugins()
samples = scan_samples()
packs = scan_library_packs()
vector_store = scan_vector_store_metadata()
return {
"plugins": plugins,
"samples": samples,
"packs": packs,
"vector_store": vector_store,
}
if __name__ == "__main__":
import sys
sys.stdout.reconfigure(encoding="utf-8")
inv = full_inventory()
summary = {
"plugins": {
"generators": inv["plugins"]["generator_names"],
"effects": inv["plugins"]["effect_names"],
"total_generators": len(inv["plugins"]["generators"]),
"total_effects": len(inv["plugins"]["effects"]),
},
"samples": {
"categories": {k: len(v) for k, v in inv["samples"]["categories"].items()},
"total_files": inv["samples"]["total_files"],
},
"packs": [
{
"name": p["name"],
"audio_count": len(p["contents"].get("audio", [])),
"midi_count": len(p["contents"].get("midi", [])),
}
for p in inv["packs"]
],
"vector_store": {
"total": inv["vector_store"]["total"],
"types": inv["vector_store"]["types"],
},
}
print(json.dumps(summary, indent=2, ensure_ascii=False))

330
src/selector/__init__.py Normal file
View File

@@ -0,0 +1,330 @@
"""Sample Selector — queries the forensic sample index by musical criteria.
Loads data/sample_index.json and provides scored, ranked queries:
- Role matching (exact)
- Key compatibility (exact, relative major/minor, dominant/subdominant)
- BPM tolerance (±5%, half/double time)
- Character similarity (grouped characters)
- Tonal/atonal filtering
Usage:
selector = SampleSelector()
results = selector.select(role="kick", bpm=95, limit=5)
results = selector.select(role="bass", key="Am", bpm=92, character="deep")
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Optional
from dataclasses import dataclass, field
# ---------------------------------------------------------------------------
# Key Compatibility
# ---------------------------------------------------------------------------
CIRCLE_OF_FIFTHS = ["C", "G", "D", "A", "E", "B", "F#", "C#", "G#", "D#", "A#", "F"]
# Relative major/minor pairs (each minor → its relative major)
RELATIVE_MAJOR = {
"Am": "C", "Em": "G", "Bm": "D", "F#m": "A", "C#m": "E",
"G#m": "B", "D#m": "F#", "A#m": "C#", "Fm": "G#", "Cm": "Eb",
"Gm": "Bb", "Dm": "F",
# Enharmonic equivalents
"Bbm": "Db", "Ebm": "Gb", "Abm": "B", "Bbm": "Cb",
}
# Build reverse: major → relative minor
RELATIVE_MINOR = {v: k for k, v in RELATIVE_MAJOR.items()}
# Dominant (V) and subdominant (IV) relationships
DOMINANT = {"C": "G", "G": "D", "D": "A", "A": "E", "E": "B", "B": "F#",
"F#": "C#", "C#": "G#", "G#": "D#", "D#": "A#", "A#": "F", "F": "C"}
SUBDOMINANT = {v: k for k, v in DOMINANT.items()}
# Character similarity groups
CHARACTER_GROUPS = [
{"warm", "soft", "lush"},
{"boomy", "deep", "dark"},
{"sharp", "crisp", "bright"},
{"aggressive", "tight"},
{"ethereal", "neutral"},
{"impact", "short"},
{"hollow", "full"},
]
# All roles the classifier produces
KNOWN_ROLES = {
"kick", "snare", "hihat", "bass", "lead", "pad", "pluck",
"vocal", "arp", "guitar", "keys", "synth", "brass",
"perc", "drumloop", "fx", "fill", "oneshot",
}
# Roles that are typically atonal (key doesn't matter)
ATONAL_ROLES = {"kick", "snare", "hihat", "perc", "fx", "fill", "oneshot"}
def _normalize_key(key: str) -> str:
"""Normalize key names: Eb→D#, Bb→A#, Db→C#, Gb→F#, Ab→G#."""
enharmonics = {"Eb": "D#", "Bb": "A#", "Db": "C#", "Gb": "F#", "Ab": "G#", "Cb": "B"}
return enharmonics.get(key, key)
def _key_compatibility(query_key: str, sample_key: str) -> float:
"""Score how compatible a sample's key is with the query key.
Returns:
1.0 = exact match
0.9 = same root, different mode (C ↔ Cm)
0.8 = relative major/minor (Am ↔ C)
0.7 = dominant/subdominant (C ↔ G or C ↔ F)
0.5 = compatible (nearby in circle of fifths)
0.0 = atonal or no match
"""
if query_key == "X" or sample_key == "X":
return 0.0 # Atonal, no key compatibility
q = _normalize_key(query_key)
s = _normalize_key(sample_key)
# Exact match
if q == s:
return 1.0
# Separate root and mode
q_root = q.rstrip("m")
q_minor = q.endswith("m")
s_root = s.rstrip("m")
s_minor = s.endswith("m")
# Same root, different mode (C ↔ Cm)
if q_root == s_root:
return 0.9
# Relative major/minor (Am ↔ C)
if q_minor and not s_minor:
rel = RELATIVE_MAJOR.get(q, "")
if s_root == _normalize_key(rel):
return 0.8
if not q_minor and s_minor:
rel = RELATIVE_MINOR.get(q, "")
if s_root == _normalize_key(rel.rstrip("m")):
return 0.8
# Dominant/subdominant
q_root_norm = _normalize_key(q_root)
s_root_norm = _normalize_key(s_root)
if DOMINANT.get(q_root_norm) == s_root_norm or SUBDOMINANT.get(q_root_norm) == s_root_norm:
return 0.7
# Circle of fifths proximity
try:
q_idx = CIRCLE_OF_FIFTHS.index(q_root_norm)
s_idx = CIRCLE_OF_FIFTHS.index(s_root_norm)
distance = min(abs(q_idx - s_idx), 12 - abs(q_idx - s_idx))
if distance <= 2:
return 0.5
except ValueError:
pass
return 0.3
def _bpm_compatibility(query_bpm: float, sample_bpm: float) -> float:
"""Score BPM compatibility. Handles half/double time."""
if query_bpm <= 0 or sample_bpm <= 0:
return 0.5 # Unknown BPM, neutral score
ratio = sample_bpm / query_bpm
tolerance = 0.05 # ±5%
# Direct match
if abs(ratio - 1.0) <= tolerance:
return 1.0
# Half time
if abs(ratio - 0.5) <= tolerance:
return 0.8
# Double time
if abs(ratio - 2.0) <= tolerance:
return 0.8
# Near match (±10%)
if abs(ratio - 1.0) <= 0.10:
return 0.6
return 0.3
def _character_compatibility(query_char: Optional[str], sample_char: str) -> float:
"""Score character compatibility using similarity groups."""
if not query_char:
return 0.5 # No preference
if query_char == sample_char:
return 1.0
# Check if in same group
for group in CHARACTER_GROUPS:
if query_char in group and sample_char in group:
return 0.7
return 0.3
@dataclass
class SampleMatch:
"""A scored sample match from the selector."""
score: float
sample: dict
score_breakdown: dict = field(default_factory=dict)
class SampleSelector:
"""Query the forensic sample index with musical criteria."""
def __init__(self, index_path: Optional[str] = None):
if index_path is None:
project = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
index_path = os.path.join(project, "data", "sample_index.json")
self.index_path = index_path
self._samples: list[dict] = []
self._by_role: dict[str, list[dict]] = {}
self._loaded = False
def _load(self):
"""Lazy-load the index."""
if self._loaded:
return
with open(self.index_path, "r", encoding="utf-8") as f:
data = json.load(f)
self._samples = [s for s in data.get("samples", []) if "error" not in s]
# Index by role for fast lookup
self._by_role = {}
for s in self._samples:
role = s.get("role", "unknown")
if role not in self._by_role:
self._by_role[role] = []
self._by_role[role].append(s)
self._loaded = True
def select(
self,
role: str,
key: Optional[str] = None,
bpm: Optional[float] = None,
character: Optional[str] = None,
is_tonal: Optional[bool] = None,
limit: int = 10,
path_prefix: Optional[str] = None,
) -> list[SampleMatch]:
"""Select samples matching criteria, ranked by compatibility score.
Args:
role: Required. Production role (kick, bass, lead, etc.)
key: Musical key for compatibility (e.g. "Am", "C")
bpm: Target BPM for tempo matching
character: Timbre character preference (e.g. "warm", "boomy")
is_tonal: Filter by tonal/atonal status
limit: Maximum results to return
path_prefix: Filter by file path prefix
Returns:
List of SampleMatch objects sorted by score (descending)
"""
self._load()
if role not in KNOWN_ROLES:
# Try fuzzy match
role_lower = role.lower()
for known in KNOWN_ROLES:
if known in role_lower:
role = known
break
candidates = self._by_role.get(role, [])
if not candidates:
return []
# Score each candidate
matches: list[SampleMatch] = []
for s in candidates:
# Path prefix filter
if path_prefix:
if path_prefix.lower() not in s.get("original_path", "").lower():
continue
# Tonal filter
if is_tonal is not None:
sample_tonal = s.get("musical", {}).get("is_tonal", False)
if sample_tonal != is_tonal:
continue
breakdown = {}
total = 0.0
# Role score (always 1.0 since we filtered by role)
breakdown["role"] = 1.0
total += 1.0
# Key compatibility
if key and role not in ATONAL_ROLES:
sample_key = s.get("musical", {}).get("key", "X")
kc = _key_compatibility(key, sample_key)
breakdown["key"] = kc
total += kc * 2.0 # Weight key heavily
else:
breakdown["key"] = 0.5
# BPM compatibility
if bpm:
sample_bpm = s.get("perceptual", {}).get("tempo", 0)
bc = _bpm_compatibility(bpm, sample_bpm)
breakdown["bpm"] = bc
total += bc * 1.5
else:
breakdown["bpm"] = 0.5
# Character compatibility
cc = _character_compatibility(character, s.get("character", ""))
breakdown["character"] = cc
total += cc * 0.5
# Duration preference: shorter samples get slight bonus for flexibility
dur = s.get("signal", {}).get("duration", 0)
if dur > 0 and dur < 5.0:
total += 0.1 # Short bonus
breakdown["duration"] = dur
matches.append(SampleMatch(
score=round(total, 4),
sample=s,
score_breakdown=breakdown,
))
# Sort by score descending
matches.sort(key=lambda m: m.score, reverse=True)
return matches[:limit]
def select_one(self, role: str, **kwargs) -> Optional[dict]:
"""Select the single best matching sample."""
results = self.select(role=role, limit=1, **kwargs)
return results[0].sample if results else None
def get_roles(self) -> list[str]:
"""Get all available roles and their counts."""
self._load()
return sorted(self._by_role.keys())
def get_stats(self) -> dict[str, int]:
"""Get count per role."""
self._load()
return {role: len(samples) for role, samples in sorted(self._by_role.items())}
def random_sample(self, role: str, **kwargs) -> Optional[dict]:
"""Select a random sample from the top candidates for variation."""
import random
results = self.select(role=role, limit=5, **kwargs)
if not results:
return None
return random.choice(results).sample

0
tests/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,49 @@
"""Quick test: analyze 20 samples to verify everything works before full batch."""
import sys
import os
import time
import warnings
warnings.filterwarnings("ignore")
PROJECT = r"C:\Users\Administrator\Documents\fl_control"
os.chdir(PROJECT)
sys.path.insert(0, PROJECT)
from src.analyzer import collect_audio_files, batch_analyze
lib1 = os.path.join(PROJECT, "libreria", "reggaeton")
lib2 = os.path.join(PROJECT, "librerias", "reggaeton")
files = collect_audio_files(lib1, lib2)
print(f"Total files: {len(files)}")
# Take first 20
test_files = files[:20]
print(f"Testing with {len(test_files)} files...\n")
start = time.time()
results = batch_analyze(test_files, workers=8)
elapsed = time.time() - start
valid = [r for r in results if "error" not in r]
errors = [r for r in results if "error" in r]
print(f"\nDone in {elapsed:.1f}s ({elapsed/len(test_files):.2f}s/file)")
print(f"Valid: {len(valid)} | Errors: {len(errors)}")
for r in valid:
role = r["role"]
char = r["character"]
key = r["musical"]["key"]
bpm = r["perceptual"]["tempo"]
new = r["new_name"]
orig = os.path.basename(r["original_path"])
print(f" {orig:45s} -> {role:10s} {char:12s} {key:5s} {bpm:5.0f}bpm -> {new}")
if errors:
print(f"\nErrors:")
for e in errors:
print(f" {e}")
print(f"\n{'OK - Ready for full batch!' if len(valid) > 15 else 'Too many errors!'}")

View File

@@ -0,0 +1,41 @@
import warnings; warnings.filterwarnings("ignore")
import sys, os
sys.path.insert(0, r"C:\Users\Administrator\Documents\fl_control")
from src.analyzer import analyze_file
tests = [
("MIDILATINO Lead", r"libreria\reggaeton\SentimientoLatino2025\01\full\Midilatino_Gracias_C#_Min_102BPM_ Lead.wav"),
("MIDILATINO Bass", r"libreria\reggaeton\SentimientoLatino2025\01\full\Midilatino_Holanda_F_Min_108BPM_Bass.wav"),
("MIDILATINO Pad", r"libreria\reggaeton\SentimientoLatino2025\01\full\Midilatino_Cielo_F_Min_90BPM_Pad.wav"),
("MIDILATINO Pluck", r"libreria\reggaeton\SentimientoLatino2025\01\full\Midilatino_Cookie_E_Min_89BPM_Pluck.wav"),
("MIDILATINO Vocal", r"libreria\reggaeton\SentimientoLatino2025\01\full\Midilatino_Cookie_E_Min_89BPM_Vocal.wav"),
("MIDILATINO Arp", r"libreria\reggaeton\SentimientoLatino2025\01\full\Midilatino_Classic_G#_Min_105BPM_Arp.wav"),
("MIDILATINO Drums", r"libreria\reggaeton\SentimientoLatino2025\01\full\Midilatino_Anonaki_D#_Min_103BPM_Drums.wav"),
("MIDILATINO Full", r"libreria\reggaeton\SentimientoLatino2025\01\full\Midilatino_Anonaki_D#_Min_103BPM.wav"),
("SS_RNBL Kick", r"libreria\reggaeton\SentimientoLatino2025\02\SS_RNBL_Aqui_One_Shot_Kick.wav"),
("SS_RNBL Snare", r"libreria\reggaeton\SentimientoLatino2025\02\SS_RNBL_Aqui_One_Shot_Snare.wav"),
("SS_RNBL Hats", r"libreria\reggaeton\SentimientoLatino2025\02\SS_RNBL_Aqui_One_Shot_Hats.wav"),
("SS_RNBL Bass", r"libreria\reggaeton\SentimientoLatino2025\02\SS_RNBL_Amor_One_Shot_Bass_C_.wav"),
("SS_RNBL Lead", r"libreria\reggaeton\SentimientoLatino2025\02\SS_RNBL_Enga__o_One_Shot_Lead.wav"),
("ONESHOT LEAD", r"libreria\reggaeton\SentimientoLatino2025\01\LATINOS - ONE SHOTS\Midilatino_LEAD_Amor_C.wav"),
("ONESHOT PAD", r"libreria\reggaeton\SentimientoLatino2025\01\LATINOS - ONE SHOTS\Midilatino_PAD_Elevado_C.wav"),
("ONESHOT PLUCK", r"libreria\reggaeton\SentimientoLatino2025\01\LATINOS - ONE SHOTS\Midilatino_PLUCK_Fish_C.wav"),
("ONESHOT BRASS", r"libreria\reggaeton\SentimientoLatino2025\01\LATINOS - ONE SHOTS\Midilatino_BRASS_Thunder_C.wav"),
("ONESHOT BELL", r"libreria\reggaeton\SentimientoLatino2025\01\LATINOS - ONE SHOTS\Midilatino_BELL_Church_C.wav"),
("ONESHOT SYNTH", r"libreria\reggaeton\SentimientoLatino2025\01\LATINOS - ONE SHOTS\Midilatino_SYNTH_Voice_C.wav"),
]
base = r"C:\Users\Administrator\Documents\fl_control"
for label, path in tests:
full = os.path.join(base, path)
if not os.path.exists(full):
print(f"{label:20s} -> NOT FOUND: {path}")
continue
r = analyze_file(full)
if r and "error" not in r:
role = r["role"]
char = r["character"]
new = r["new_name"]
print(f"{label:20s} -> {role:12s} {char:10s} {new}")
else:
print(f"{label:20s} -> ERROR: {r}")

View File

@@ -0,0 +1,34 @@
import warnings; warnings.filterwarnings("ignore")
import sys, os
sys.path.insert(0, r"C:\Users\Administrator\Documents\fl_control")
from src.analyzer import analyze_file
# Use REAL paths from the library
base = r"C:\Users\Administrator\Documents\fl_control"
spack = r"libreria\reggaeton\SentimientoLatino2025\01\LATINOS - SAMPLE PACK"
tests = [
("ML Lead", os.path.join(base, spack, r"Midilatino_El_Despegue_F#_Min_92BPM\Midilatino_El_Despegue_F#_Min_92BPM_Lead.wav")),
("ML Bass", os.path.join(base, spack, r"Midilatino_Cookie_E_Min_89BPM\Midilatino_Cookie_E_Min_89BPM_Bass.wav")),
("ML Pad", os.path.join(base, spack, r"Midilatino_Cielo_F_Min_90BPM\Midilatino_Cielo_F_Min_90BPM_Pad.wav")),
("ML Pluck", os.path.join(base, spack, r"Midilatino_Get Me_E_Min_104BPM\Midilatino_Get Me_E_Min_104BPM_Pluck.wav")),
("ML Drums", os.path.join(base, spack, r"Midilatino_Anonaki_D#_Min_103BPM @PromoViDo vip Telegram\Midilatino_Anonaki_D#_Min_103BPM_Drums.wav")),
("ML FullMix", os.path.join(base, spack, r"Midilatino_Anonaki_D#_Min_103BPM @PromoViDo vip Telegram\Midilatino_Anonaki_D#_Min_103BPM.wav")),
("ML Arp", os.path.join(base, spack, r"Midilatino_Classic_G#_Min_105BPM\Midilatino_Classic_G#_Min_105BPM_Arp.wav")),
("ML Vocal", os.path.join(base, spack, r"Midilatino_Cookie_E_Min_89BPM\Midilatino_Cookie_E_Min_89BPM_Vocal.wav")),
("ML Guitar", os.path.join(base, spack, r"Midilatino_Get Me_E_Min_104BPM\Midilatino_Get Me_E_Min_104BPM_Guitar.wav")),
("ML Reese", os.path.join(base, spack, r"Midilatino_El_Despegue_F#_Min_92BPM\Midilatino_El_Despegue_F#_Min_92BPM_Reese.wav")),
("ML Synth", os.path.join(base, spack, r"Midilatino_El_Despegue_F#_Min_92BPM\Midilatino_El_Despegue_F#_Min_92BPM_Synth.wav")),
]
for label, full in tests:
if not os.path.exists(full):
print(f"{label:15s} -> NOT FOUND")
continue
r = analyze_file(full)
if r and "error" not in r:
role = r["role"]
char = r["character"]
print(f"{label:15s} -> {role:12s} {char:10s} {r['new_name']}")
else:
print(f"{label:15s} -> ERROR: {r}")

View File

@@ -0,0 +1,35 @@
import sys, os, warnings
warnings.filterwarnings('ignore')
sys.path.insert(0, r'C:\Users\Administrator\Documents\fl_control')
os.chdir(r'C:\Users\Administrator\Documents\fl_control')
from src.analyzer import analyze_file
tests = [
('KICK', r'libreria\reggaeton\kick\kick nes 1.wav'),
('SNARE', r'libreria\reggaeton\snare\snare nes 1.wav'),
('HIHAT', r'librerias\reggaeton\reggaeton 2\hi-hat (para percs normalmente)\hi-hat 1.wav'),
('BASS', r'librerias\reggaeton\3. ONE SHOTS\Bass Reventado (c) @dastin.prod.wav'),
('DRUMLOOP', r'librerias\reggaeton\4. DRUM LOOPS\LOOP 2 90bpm @dastin.prod.wav'),
('FX', r'libreria\reggaeton\fx\impact.wav'),
('PERC', r'librerias\reggaeton\10. PERCS\PERC 1 @dastin.prod.wav'),
('VOCAL', r'librerias\reggaeton\11. VOCALS\AAA.wav'),
]
for label, path in tests:
full = os.path.join(r'C:\Users\Administrator\Documents\fl_control', path)
if not os.path.exists(full):
print(f'{label}: FILE NOT FOUND - {path}')
continue
r = analyze_file(full)
if r and 'error' not in r:
role = r['role']
char = r['character']
key = r['musical']['key']
bpm = r['perceptual']['tempo']
lufs = r['perceptual']['lufs']
dur = r['signal']['duration']
new = r['new_name']
print(f'{label:10s} -> role={role:10s} char={char:12s} key={key:5s} bpm={bpm:6.1f} lufs={lufs:6.1f} dur={dur:6.3f}')
print(f' new: {new}')
else:
print(f'{label}: ERROR - {r}')

42
tests/selector Normal file
View File

@@ -0,0 +1,42 @@
import sys, os, warnings
warnings.filterwarnings("ignore")
sys.path.insert(0, r"C:\Users\Administrator\Documents\fl_control")
from src.selector import SampleSelector
sel = SampleSelector()
print("=== KICKS at 95 BPM ===")
for m in sel.select(role="kick", bpm=95, limit=5):
s = m.sample
nm = s["new_name"]
ky = s["musical"]["key"]
bp = s["perceptual"]["tempo"]
ch = s["character"]
bd = m.score_breakdown
print(f" {m.score:.2f} {nm:45s} key={ky:5s} bpm={bp:5.0f} char={ch:10s} | role={bd.get('role',0):.1f} key={bd.get('key',0):.2f} bpm={bd.get('bpm',0):.2f} char={bd.get('character',0):.2f}")
print("\n=== BASS in Am at 92 BPM ===")
for m in sel.select(role="bass", key="Am", bpm=92, limit=5):
s = m.sample
nm = s["new_name"]
ky = s["musical"]["key"]
bp = s["perceptual"]["tempo"]
ch = s["character"]
print(f" {m.score:.2f} {nm:45s} key={ky:5s} bpm={bp:5.0f} char={ch}")
print("\n=== PAD in C#m ===")
for m in sel.select(role="pad", key="C#m", limit=5):
s = m.sample
print(f" {m.score:.2f} {s['new_name']:45s} key={s['musical']['key']:5s} char={s['character']}")
print("\n=== LEAD warm at 95 BPM in Am ===")
for m in sel.select(role="lead", key="Am", bpm=95, character="warm", limit=5):
s = m.sample
print(f" {m.score:.2f} {s['new_name']:45s} key={s['musical']['key']:5s} bpm={s['perceptual']['tempo']:5.0f} char={s['character']}")
print("\n=== VOCAL in Cm ===")
for m in sel.select(role="vocal", key="Cm", limit=5):
s = m.sample
print(f" {m.score:.2f} {s['new_name']:45s} key={s['musical']['key']:5s} char={s['character']}")
print(f"\nStats: {sel.get_stats()}")