commit 4d941f3f90401374e3dd2ac2487942aa2325e4b1 Author: renato97 Date: Sat May 2 21:40:18 2026 -0300 feat: reggaeton production system with intelligent sample selection and FLP generation diff --git a/.atl/skill-registry.md b/.atl/skill-registry.md new file mode 100644 index 0000000..8b9c128 --- /dev/null +++ b/.atl/skill-registry.md @@ -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` diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..08d09f3 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..481e139 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f27b075 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "flstudio-mcp"] + path = flstudio-mcp + url = https://github.com/ohhalim/flstudio-mcp.git diff --git a/.sdd/changes/reggaeton-composer/ARCHIVE.md b/.sdd/changes/reggaeton-composer/ARCHIVE.md new file mode 100644 index 0000000..e67f213 --- /dev/null +++ b/.sdd/changes/reggaeton-composer/ARCHIVE.md @@ -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 diff --git a/.sdd/design.md b/.sdd/design.md new file mode 100644 index 0000000..6f119d8 --- /dev/null +++ b/.sdd/design.md @@ -0,0 +1,205 @@ +# Design: reggaeton-composer + +## Technical Approach + +Extend the existing `SongDefinition → FLPBuilder` pipeline with a new selection + +melodic-generation layer. `SampleSelector` wraps `sample_index.json`; melodic +generators produce `{notes, sample_path}` dicts; `SongDefinition` gains a +`melodic_tracks` field; `FLPBuilder` appends audio-clip channels after existing +drum channels. + +--- + +## Architecture Decisions + +| Decision | Choice | Rejected | Rationale | +|----------|--------|----------|-----------| +| Selector load strategy | Load full index once at `__init__` | Lazy / streaming | 862 entries ≈ 50 MB max; random access needed for scoring | +| Note format | Reuse `{"pos","len","key","vel"}` from `rhythm.py` | New format | Already converted by `_convert_rhythm_notes`; zero friction | +| Schema extension | Add `melodic_tracks: list[MelodicTrack]` optional field | Separate class | Single source of truth; existing `validate()` extended, not replaced | +| Channel numbering | Melodic starts at ch 17 | Dynamic | `skeleton.py` already defines `EMPTY_SAMPLER_CHANNELS = {17,18,19}`; expanding naturally | +| Melodic channel type | `ChType = 21` → value `0` (sampler) | AudioClip type 1 | Reference FLP uses sampler channels for .wav; `skeleton.py` already patches them via `melodic_map` param (already present in `load()` signature) | +| Builder integration | `_build_melodic_channels()` inserted between channel_bytes and arrangement | New Builder subclass | Minimal diff; builder is not subclassed anywhere | +| CLI | `scripts/compose_track.py` using `argparse` | Click | Existing scripts use plain argparse | + +--- + +## Data Flow + +``` +data/sample_index.json + │ + ▼ + SampleSelector.select(role, key, bpm, character, is_tonal) + │ list[SampleEntry] + ▼ + melodic.py generators + generate_bass / generate_lead / generate_chords / generate_pad + │ MelodicTrackDef {role, sample_path, notes[], channel_hint} + ▼ + SongDefinition (extended) + .melodic_tracks: list[MelodicTrack] + │ + ▼ + FLPBuilder.build(song) + ├── _build_header (unchanged) + ├── _build_all_patterns (unchanged) + ├── ChannelSkeletonLoader (pass melodic_map → already supported) + ├── _build_melodic_notes() (NEW — PatNotes for ch 17+) + └── _build_arrangement (unchanged) + │ + ▼ + output/my_track.flp +``` + +--- + +## File Changes + +| File | Action | Description | +|------|--------|-------------| +| `src/selector/__init__.py` | Create | `SampleSelector` class | +| `src/composer/melodic.py` | Create | `generate_bass/lead/chords/pad` | +| `src/flp_builder/schema.py` | Modify | Add `MelodicTrack`, `Note` dataclasses; extend `SongDefinition`; extend `validate()` and `from_json()` | +| `src/flp_builder/builder.py` | Modify | Add `_build_melodic_notes()`; pass `melodic_map` to `ChannelSkeletonLoader.load()` | +| `scripts/compose_track.py` | Create | CLI entry point | + +--- + +## Interfaces / Contracts + +```python +# src/selector/__init__.py +@dataclass +class SampleEntry: + path: str # original_path (absolute) + role: str + key: str | None + bpm: float # 0 = unknown + character: str + is_tonal: bool + score: float = 0.0 + +class SampleSelector: + def __init__(self, index_path: str | Path): ... + def select( + self, + role: str, + key: str | None = None, + bpm: float | None = None, + character: str | None = None, + is_tonal: bool | None = None, + limit: int = 10, + ) -> list[SampleEntry]: ... + +# src/composer/melodic.py +@dataclass +class MelodicTrackDef: + role: str # "bass" | "lead" | "chords" | "pad" + sample_path: str + notes: list[dict] # {"pos","len","key","vel"} + channel_hint: int # suggested channel index (17+) + +def generate_bass(key, scale, bpm, bars, kick_pattern=None, density=0.7) -> MelodicTrackDef +def generate_lead(key, scale, bpm, bars, density=0.5) -> MelodicTrackDef +def generate_chords(key, scale, bpm, bars, progression=None, voicing="closed") -> MelodicTrackDef +def generate_pad(key, scale, bpm, bars) -> MelodicTrackDef + +# src/flp_builder/schema.py (additions) +_KNOWN_ROLES = frozenset({"bass","lead","chords","pad","arp","fx"}) + +@dataclass +class Note: # alias for PatternNote — same fields + pos: float + len: float + key: int + vel: int + +@dataclass +class MelodicTrack: + role: str + sample_path: str + notes: list[Note] + channel_index: int # >= 17 + volume: float = 0.78 # 0.0–1.0 + pan: float = 0.0 # -1.0–1.0 + +# SongDefinition gets: +melodic_tracks: list[MelodicTrack] = field(default_factory=list) +``` + +--- + +## Key Scoring Logic + +```python +# Key compatibility (circle-of-fifths aware) +COMPAT = { + "exact": 1.0, + "relative": 0.8, # Am ↔ C, Gm ↔ Bb + "dominant": 0.6, # Am → Em + "subdominant": 0.6, # Am → Dm + "parallel": 0.5, # Am ↔ A +} + +# Character groups (fuzzy matching) +CHAR_GROUPS = [ + {"warm","soft","lush"}, + {"boomy","deep"}, + {"sharp","crisp","bright"}, + {"aggressive","dark"}, +] + +# Combined score = key_score * 0.5 + bpm_score * 0.3 + char_score * 0.2 +``` + +--- + +## Tresillo / Dembow Bass Pattern + +``` +Bar positions (8 bars × 4 beats = 32 beats): +Tresillo: [0, 0.75, 1.5] per bar (3+3+2 in 8th-note grid) +Kick-avoidance: skip notes within ±0.125 beats of a kick hit +``` + +--- + +## Validation Extensions + +`SongDefinition.validate()` additions: +1. `melodic_track.role in _KNOWN_ROLES` +2. `Path(melodic_track.sample_path).exists()` +3. `melodic_track.channel_index >= 17` +4. No duplicate `channel_index` across melodic tracks + +--- + +## Error Handling Strategy + +| Layer | Strategy | +|-------|----------| +| `SampleSelector.select()` | Returns `[]` on no match — callers must check; never raises on empty | +| `generate_*` functions | Raise `ValueError` if `selector.select()` returns empty (required sample missing) | +| `SongDefinition.validate()` | Accumulate all errors, raise `ValueError` with full list | +| `FLPBuilder.build()` | Propagates `FileNotFoundError` if `sample_path` missing at write time | +| CLI | `sys.exit(1)` with human-readable message on any `ValueError` / `FileNotFoundError` | + +--- + +## Testing Strategy + +| Layer | What | Approach | +|-------|------|----------| +| Unit | `SampleSelector` scoring | Fixture with 5 hand-crafted entries; assert rank order | +| Unit | `generate_bass` tresillo | Assert note positions match `[0, 0.75, 1.5]` per bar | +| Unit | `MelodicTrack` schema validation | `validate()` returns expected errors for bad inputs | +| Integration | `FLPBuilder` with melodic tracks | Build to bytes, parse header, assert `num_channels == 17 + n` | +| Smoke | CLI end-to-end | `compose_track.py --key Am --bpm 95 --output /tmp/test.flp`; assert file exists and size > 0 | + +--- + +## Open Questions + +- [ ] `skeleton.py` `EMPTY_SAMPLER_CHANNELS` includes `{17,18,19}` — need to confirm that adding melodic channels beyond 19 doesn't require reference FLP changes or if we expand the sampler clone range. +- [ ] `sample_index.json` entries use `original_path` (absolute Windows paths). CLI on other machines will break. Decision needed: embed relative paths or make selector rebase paths against a configurable `library_root`. diff --git a/1_ANALIZAR.bat b/1_ANALIZAR.bat new file mode 100644 index 0000000..e619210 --- /dev/null +++ b/1_ANALIZAR.bat @@ -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 diff --git a/2_RENOMBRAR.bat b/2_RENOMBRAR.bat new file mode 100644 index 0000000..c8ebf6c --- /dev/null +++ b/2_RENOMBRAR.bat @@ -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 diff --git a/3_ESTADISTICAS.bat b/3_ESTADISTICAS.bat new file mode 100644 index 0000000..37ac432 --- /dev/null +++ b/3_ESTADISTICAS.bat @@ -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 diff --git a/COMPONER.bat b/COMPONER.bat new file mode 100644 index 0000000..c47545d --- /dev/null +++ b/COMPONER.bat @@ -0,0 +1,3 @@ +@echo off +python scripts\compose_track.py --key Am --bpm 95 --bars 8 --output output\reggaeton.flp +pause \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..29ccfee --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..64221d7 --- /dev/null +++ b/README.md @@ -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. diff --git a/flstudio-mcp b/flstudio-mcp new file mode 160000 index 0000000..d518dec --- /dev/null +++ b/flstudio-mcp @@ -0,0 +1 @@ +Subproject commit d518dec361700d886ffc74eb94d388636a2eeed8 diff --git a/knowledge/genres/reggaeton_2009.json b/knowledge/genres/reggaeton_2009.json new file mode 100644 index 0000000..b35f650 --- /dev/null +++ b/knowledge/genres/reggaeton_2009.json @@ -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" + ] +} diff --git a/knowledge/songs/reggaeton_template.json b/knowledge/songs/reggaeton_template.json new file mode 100644 index 0000000..4aa035f --- /dev/null +++ b/knowledge/songs/reggaeton_template.json @@ -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" +} diff --git a/mcp/__init__.py b/mcp/__init__.py new file mode 100644 index 0000000..242706f --- /dev/null +++ b/mcp/__init__.py @@ -0,0 +1,5 @@ +"""FL Studio MCP Server — nibble-encoded SysEx over MIDI loopback.""" + +from __future__ import annotations + +__version__ = "0.1.0" diff --git a/mcp/protocol/__init__.py b/mcp/protocol/__init__.py new file mode 100644 index 0000000..75c0963 --- /dev/null +++ b/mcp/protocol/__init__.py @@ -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", +] diff --git a/mcp/protocol/sysex.py b/mcp/protocol/sysex.py new file mode 100644 index 0000000..fe9659b --- /dev/null +++ b/mcp/protocol/sysex.py @@ -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")) diff --git a/mcp/protocol/transport.py b/mcp/protocol/transport.py new file mode 100644 index 0000000..71424ac --- /dev/null +++ b/mcp/protocol/transport.py @@ -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()} diff --git a/mcp/run.py b/mcp/run.py new file mode 100644 index 0000000..ea95ae8 --- /dev/null +++ b/mcp/run.py @@ -0,0 +1,5 @@ +"""FL Studio MCP Server entry point.""" +from server import mcp + +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/mcp/server.py b/mcp/server.py new file mode 100644 index 0000000..3533424 --- /dev/null +++ b/mcp/server.py @@ -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") diff --git a/mcp/tests/quick_test.py b/mcp/tests/quick_test.py new file mode 100644 index 0000000..772f757 --- /dev/null +++ b/mcp/tests/quick_test.py @@ -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!") diff --git a/mcp/tests/send_ping.py b/mcp/tests/send_ping.py new file mode 100644 index 0000000..f078961 --- /dev/null +++ b/mcp/tests/send_ping.py @@ -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.") diff --git a/mcp/tests/test_integration.py b/mcp/tests/test_integration.py new file mode 100644 index 0000000..dd30f67 --- /dev/null +++ b/mcp/tests/test_integration.py @@ -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!") diff --git a/mcp/tests/test_sysex_loopback.py b/mcp/tests/test_sysex_loopback.py new file mode 100644 index 0000000..2c07851 --- /dev/null +++ b/mcp/tests/test_sysex_loopback.py @@ -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) diff --git a/output/flstudio_sampler_template.bin b/output/flstudio_sampler_template.bin new file mode 100644 index 0000000..175bc6b Binary files /dev/null and b/output/flstudio_sampler_template.bin differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a3f0e0b --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/batch_generate.py b/scripts/batch_generate.py new file mode 100644 index 0000000..11dd346 --- /dev/null +++ b/scripts/batch_generate.py @@ -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() diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..712e717 --- /dev/null +++ b/scripts/build.py @@ -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() diff --git a/scripts/build_complete_reggaeton.py b/scripts/build_complete_reggaeton.py new file mode 100644 index 0000000..2145195 --- /dev/null +++ b/scripts/build_complete_reggaeton.py @@ -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(" 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() diff --git a/scripts/build_from_json.py b/scripts/build_from_json.py new file mode 100644 index 0000000..aa98116 --- /dev/null +++ b/scripts/build_from_json.py @@ -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 [--out ] +""" +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() diff --git a/scripts/build_reggaeton_fuego.py b/scripts/build_reggaeton_fuego.py new file mode 100644 index 0000000..a936624 --- /dev/null +++ b/scripts/build_reggaeton_fuego.py @@ -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(" 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(" 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() \ No newline at end of file diff --git a/scripts/inventory.py b/scripts/inventory.py new file mode 100644 index 0000000..3ae5977 --- /dev/null +++ b/scripts/inventory.py @@ -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() diff --git a/src/analyzer/__init__.py b/src/analyzer/__init__.py new file mode 100644 index 0000000..b1c2a29 --- /dev/null +++ b/src/analyzer/__init__.py @@ -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 diff --git a/src/analyzer/forensic_classify.py b/src/analyzer/forensic_classify.py new file mode 100644 index 0000000..37e3822 --- /dev/null +++ b/src/analyzer/forensic_classify.py @@ -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}") diff --git a/src/analyzer/forensic_filenames.py b/src/analyzer/forensic_filenames.py new file mode 100644 index 0000000..a46c04a --- /dev/null +++ b/src/analyzer/forensic_filenames.py @@ -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}") diff --git a/src/analyzer/run_batch.py b/src/analyzer/run_batch.py new file mode 100644 index 0000000..a61c0ce --- /dev/null +++ b/src/analyzer/run_batch.py @@ -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...") diff --git a/src/analyzer/run_rename.py b/src/analyzer/run_rename.py new file mode 100644 index 0000000..90a90ef --- /dev/null +++ b/src/analyzer/run_rename.py @@ -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...") diff --git a/src/analyzer/show_stats.py b/src/analyzer/show_stats.py new file mode 100644 index 0000000..63652c5 --- /dev/null +++ b/src/analyzer/show_stats.py @@ -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() diff --git a/src/composer/__init__.py b/src/composer/__init__.py new file mode 100644 index 0000000..3f9e114 --- /dev/null +++ b/src/composer/__init__.py @@ -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 diff --git a/src/composer/melodic.py b/src/composer/melodic.py new file mode 100644 index 0000000..e034127 --- /dev/null +++ b/src/composer/melodic.py @@ -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 \ No newline at end of file diff --git a/src/composer/rhythm.py b/src/composer/rhythm.py new file mode 100644 index 0000000..bf33270 --- /dev/null +++ b/src/composer/rhythm.py @@ -0,0 +1,311 @@ +"""Reggaeton rhythm generators — pure functions returning note dicts per channel.""" + +# --------------------------------------------------------------------------- +# Channel constants — match SAMPLE_MAP in channel_skeleton.py +# --------------------------------------------------------------------------- +CH_P1 = 10 # perc1.wav +CH_K = 11 # kick.wav +CH_S = 12 # snare.wav +CH_R = 13 # rim.wav +CH_P2 = 14 # perc2.wav +CH_H = 15 # hihat.wav +CH_CL = 16 # clap.wav + +# Note dict format: {"pos": float, "len": float, "key": int, "vel": int} +# pos — in BEATS from start of bar 0 (bar 2 beat 3 → 2*4 + 2 = 10.0) +# len — in beats (0.25 = 16th note at 4/4) +# key — always 60 for drum samples (pitch irrelevant, sample just plays) +# vel — 1–127 after applying velocity_mult + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _clamp_vel(vel: int) -> int: + """Clamp velocity to valid MIDI range [1, 127].""" + return max(1, min(127, vel)) + + +def _apply_vel(base_vel: int, velocity_mult: float) -> int: + """Multiply base velocity by velocity_mult and clamp.""" + return _clamp_vel(int(base_vel * velocity_mult)) + + +def _note(pos: float, length: float, vel: int) -> dict: + """Create a note dict with key=60.""" + return {"pos": pos, "len": length, "key": 60, "vel": vel} + + +# --------------------------------------------------------------------------- +# Kick generators +# --------------------------------------------------------------------------- + +def kick_main_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, +) -> dict[int, list[dict]]: + """Dembow kick: beat 1 (hard, vel 115) + beat 2-and (the dembow hit, vel 105). + + Positions per bar: 0.0 and 1.5 (the classic "one — &-two" reggaeton kick). + Returns {CH_K: [notes...]}. + """ + notes: list[dict] = [] + for b in range(bars): + o = b * 4.0 + notes.append(_note(o, 0.25, _apply_vel(115, velocity_mult))) + notes.append(_note(o + 1.5, 0.25, _apply_vel(105, velocity_mult))) + return {CH_K: notes} + + +def kick_sparse_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, +) -> dict[int, list[dict]]: + """Sparse intro/outro kick: just beat 1 per bar (vel 110). + + Returns {CH_K: [notes...]}. + """ + notes: list[dict] = [] + for b in range(bars): + o = b * 4.0 + notes.append(_note(o, 0.25, _apply_vel(110, velocity_mult))) + return {CH_K: notes} + + +def kick_outro_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, +) -> dict[int, list[dict]]: + """Outro kick: dembow pattern with 0.75 baseline softness. + + Delegates to kick_main_notes with an additional 0.75 velocity scaling. + Returns {CH_K: [notes...]}. + """ + return kick_main_notes(bars, velocity_mult=velocity_mult * 0.75, density=density) + + +# --------------------------------------------------------------------------- +# Snare generators +# --------------------------------------------------------------------------- + +def snare_verse_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, +) -> dict[int, list[dict]]: + """Reggaeton snare: beats 2, 3, 3-and, 4 per bar. + + Positions: 1.0 (vel 100), 2.0 (vel 95), 2.5 (vel 110), 3.0 (vel 90). + Returns {CH_S: [notes...]}. + """ + _PATTERN = [(1.0, 100), (2.0, 95), (2.5, 110), (3.0, 90)] + notes: list[dict] = [] + for b in range(bars): + o = b * 4.0 + for p, v in _PATTERN: + notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult))) + return {CH_S: notes} + + +def snare_fill_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, +) -> dict[int, list[dict]]: + """Busier snare with 16th-note fills: adds positions 2.25 and 3.75. + + Verse base (1.0, 2.0, 2.5, 3.0) plus 16th fills at 2.25 and 3.75. + Returns {CH_S: [notes...]}. + """ + _PATTERN = [ + (1.0, 100), + (2.0, 95), + (2.25, 80), # 16th fill + (2.5, 110), + (3.0, 90), + (3.75, 85), # 16th fill + ] + notes: list[dict] = [] + for b in range(bars): + o = b * 4.0 + for p, v in _PATTERN: + notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult))) + return {CH_S: notes} + + +def snare_outro_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, +) -> dict[int, list[dict]]: + """Softer outro snare (velocity_mult on top of 0.7 baseline). + + Delegates to snare_verse_notes with an additional 0.7 velocity scaling. + Returns {CH_S: [notes...]}. + """ + return snare_verse_notes(bars, velocity_mult=velocity_mult * 0.7, density=density) + + +# --------------------------------------------------------------------------- +# Hihat generators +# --------------------------------------------------------------------------- + +def hihat_16th_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, +) -> dict[int, list[dict]]: + """16th-note hihat with three-tier accent mapping. + + Accented on quarter notes (vel 85), medium on 8ths (vel 60), soft on + off-8ths (vel 40). density=1.0 → all 16ths; density=0.5 → every other. + Returns {CH_H: [notes...]}. + """ + notes: list[dict] = [] + step = max(1, round(1.0 / density)) if density > 0 else 1 + for b in range(bars): + o = b * 4.0 + for i in range(0, 16, step): + beat_frac = i * 0.25 # position within bar in beats + if beat_frac % 1.0 == 0.0: # quarter note position + base_vel = 85 + elif beat_frac % 0.5 == 0.0: # 8th note position + base_vel = 60 + else: # 16th note position + base_vel = 40 + notes.append(_note(o + beat_frac, 0.1, _apply_vel(base_vel, velocity_mult))) + return {CH_H: notes} + + +def hihat_8th_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, +) -> dict[int, list[dict]]: + """8th-note hihat for intro/breakdown. + + Accented on beats (vel 70), off-beats softer (vel 50). + Returns {CH_H: [notes...]}. + """ + notes: list[dict] = [] + for b in range(bars): + o = b * 4.0 + for i in range(8): + base_vel = 70 if i % 2 == 0 else 50 + notes.append(_note(o + i * 0.5, 0.1, _apply_vel(base_vel, velocity_mult))) + return {CH_H: notes} + + +# --------------------------------------------------------------------------- +# Clap generator +# --------------------------------------------------------------------------- + +def clap_24_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, +) -> dict[int, list[dict]]: + """Classic reggaeton clap: beats 2 and 4 → positions 1.0 and 3.0 per bar. + + Hard clap (vel 120). + Returns {CH_CL: [notes...]}. + """ + notes: list[dict] = [] + for b in range(bars): + o = b * 4.0 + notes.append(_note(o + 1.0, 0.15, _apply_vel(120, velocity_mult))) + notes.append(_note(o + 3.0, 0.15, _apply_vel(120, velocity_mult))) + return {CH_CL: notes} + + +# --------------------------------------------------------------------------- +# Percussion generators +# --------------------------------------------------------------------------- + +def perc_combo_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, +) -> dict[int, list[dict]]: + """Perc1 + Perc2 offbeat combo (tumba feel). + + perc2 (CH_P2): positions 0.75 (vel 85) and 2.75 (vel 80). + perc1 (CH_P1): positions 1.5 (vel 70) and 3.5 (vel 65). + Returns {CH_P1: [...], CH_P2: [...]}. + """ + p2_notes: list[dict] = [] + p1_notes: list[dict] = [] + for b in range(bars): + o = b * 4.0 + p2_notes.append(_note(o + 0.75, 0.1, _apply_vel(85, velocity_mult))) + p2_notes.append(_note(o + 2.75, 0.1, _apply_vel(80, velocity_mult))) + p1_notes.append(_note(o + 1.5, 0.1, _apply_vel(70, velocity_mult))) + p1_notes.append(_note(o + 3.5, 0.1, _apply_vel(65, velocity_mult))) + return {CH_P1: p1_notes, CH_P2: p2_notes} + + +def rim_build_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, +) -> dict[int, list[dict]]: + """Rim roll that builds intensity across bars (4-bar cycle). + + Bar N%4=0: 16th indices 0,2,8,14 (sparse opening) + Bar N%4=1: indices 0,2,4,8,10,14 (filling in) + Bar N%4=2: indices 0,2,4,6,8,10,12,14 (every other 16th) + Bar N%4=3: all 16 indices (full roll) + + Velocity ramps: 50 → 65 → 80 → 100 across the 4-bar cycle. + Returns {CH_R: [notes...]}. + """ + _PATTERNS = [ + [0, 2, 8, 14], + [0, 2, 4, 8, 10, 14], + [0, 2, 4, 6, 8, 10, 12, 14], + list(range(16)), + ] + _BASE_VELS = [50, 65, 80, 100] + + notes: list[dict] = [] + for b in range(bars): + cycle = b % 4 + o = b * 4.0 + base_vel = _BASE_VELS[cycle] + vel = _apply_vel(base_vel, velocity_mult) + for idx in _PATTERNS[cycle]: + notes.append(_note(o + idx * 0.25, 0.1, vel)) + return {CH_R: notes} + + +# --------------------------------------------------------------------------- +# Registry & dispatcher +# --------------------------------------------------------------------------- + +GENERATORS: dict[str, callable] = { + "kick_main_notes": kick_main_notes, + "kick_sparse_notes": kick_sparse_notes, + "kick_outro_notes": kick_outro_notes, + "snare_verse_notes": snare_verse_notes, + "snare_fill_notes": snare_fill_notes, + "snare_outro_notes": snare_outro_notes, + "hihat_16th_notes": hihat_16th_notes, + "hihat_8th_notes": hihat_8th_notes, + "clap_24_notes": clap_24_notes, + "perc_combo_notes": perc_combo_notes, + "rim_build_notes": rim_build_notes, +} + + +def get_notes( + generator_name: str, + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, +) -> dict[int, list[dict]]: + """Dispatch to the named generator. Raises KeyError if not found.""" + gen = GENERATORS[generator_name] + return gen(bars, velocity_mult, density) diff --git a/src/composer/variation.py b/src/composer/variation.py new file mode 100644 index 0000000..3d74b19 --- /dev/null +++ b/src/composer/variation.py @@ -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 diff --git a/src/flp_builder/__init__.py b/src/flp_builder/__init__.py new file mode 100644 index 0000000..608ff9c --- /dev/null +++ b/src/flp_builder/__init__.py @@ -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", +] diff --git a/src/flp_builder/arrangement.py b/src/flp_builder/arrangement.py new file mode 100644 index 0000000..b73eb89 --- /dev/null +++ b/src/flp_builder/arrangement.py @@ -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( + " 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(" 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) diff --git a/src/flp_builder/builder.py b/src/flp_builder/builder.py new file mode 100644 index 0000000..d73cd69 --- /dev/null +++ b/src/flp_builder/builder.py @@ -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(" 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(" 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(" 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 diff --git a/src/flp_builder/events.py b/src/flp_builder/events.py new file mode 100644 index 0000000..b5932d7 --- /dev/null +++ b/src/flp_builder/events.py @@ -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(" bytes: + return bytes([id_]) + struct.pack(" 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( + " 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) diff --git a/src/flp_builder/project.py b/src/flp_builder/project.py new file mode 100644 index 0000000..4e5192e --- /dev/null +++ b/src/flp_builder/project.py @@ -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 diff --git a/src/flp_builder/schema.py b/src/flp_builder/schema.py new file mode 100644 index 0000000..d7c6aca --- /dev/null +++ b/src/flp_builder/schema.py @@ -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 # 20–999 + 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 0–127 + + +@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 (10–16) + 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 0–127 + + +@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.0–1.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 20–999 + 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 20–999, 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) diff --git a/src/flp_builder/skeleton.py b/src/flp_builder/skeleton.py new file mode 100644 index 0000000..4efb2aa --- /dev/null +++ b/src/flp_builder/skeleton.py @@ -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 ".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(" 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(" 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(" 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(" 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) diff --git a/src/flp_builder/writer.py b/src/flp_builder/writer.py new file mode 100644 index 0000000..65a96d5 --- /dev/null +++ b/src/flp_builder/writer.py @@ -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(" bytes: + # Minimal native plugin state + stub = struct.pack(" 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(" 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)) diff --git a/src/selector/__init__.py b/src/selector/__init__.py new file mode 100644 index 0000000..5a2f59a --- /dev/null +++ b/src/selector/__init__.py @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/analyzer/__init__.py b/tests/analyzer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/analyzer/test_batch.py b/tests/analyzer/test_batch.py new file mode 100644 index 0000000..a5091bc --- /dev/null +++ b/tests/analyzer/test_batch.py @@ -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!'}") diff --git a/tests/analyzer/test_classifier_v2.py b/tests/analyzer/test_classifier_v2.py new file mode 100644 index 0000000..9f67cd3 --- /dev/null +++ b/tests/analyzer/test_classifier_v2.py @@ -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}") diff --git a/tests/analyzer/test_ml_paths.py b/tests/analyzer/test_ml_paths.py new file mode 100644 index 0000000..cf30aa3 --- /dev/null +++ b/tests/analyzer/test_ml_paths.py @@ -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}") diff --git a/tests/analyzer/test_quick.py b/tests/analyzer/test_quick.py new file mode 100644 index 0000000..37edfef --- /dev/null +++ b/tests/analyzer/test_quick.py @@ -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}') diff --git a/tests/selector b/tests/selector new file mode 100644 index 0000000..bb4cdfd --- /dev/null +++ b/tests/selector @@ -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()}")